Pregeneration throughput

A pregeneration now generates in multicore mode: the matter stage runs
on the calling thread so its mantle window fans out across the burst
pool while biome and terrain run alongside, components are claimed
through the in-flight map so overlapping windows join instead of
blocking, and each region's chunks are ordered in stride-4 lattices so
consecutive chunks never share a window. Hydrology tiles for the whole
area are planned ahead nearest the centre first on a dedicated planning
pool, and the ring around every needed tile stays planned as the front
moves. Height bounds at grid corners are shared between threads and
from planning into generation, the sole engine of a pack is answered
from a snapshot instead of the registry lock, composed chunk keys are
mixed, cached tiles are answered in place, the burst pool grows to two
workers per core for the run, and large chunk-worker pools admit up to
256 requests.
This commit is contained in:
Brian Neumann-Fopiano
2026-09-02 13:56:58 -04:00
parent 88bd8feeeb
commit df34f328e7
23 changed files with 918 additions and 32 deletions
@@ -132,6 +132,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
@Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE)
private final transient List<Engine> engines = new ArrayList<>();
private transient volatile Engine soleEngine;
private IrisData(File dataFolder) {
this(dataFolder, false);
@@ -319,6 +320,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
synchronized (engines) {
int previousSize = engines.size();
removeClosedEngines();
refreshSoleEngine();
removed = previousSize - engines.size();
}
if (removed > 0) {
@@ -335,12 +337,24 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
}
}
// Every noise sample of a thread without a generation context lands here, so the sole
// engine is answered from a snapshot; the registry lock is only taken to refresh it.
Engine sole = soleEngine;
if (sole != null && !sole.isClosed()) {
return sole;
}
synchronized (engines) {
removeClosedEngines();
return engines.size() == 1 ? engines.get(0) : null;
refreshSoleEngine();
return soleEngine;
}
}
/** Must be called with the engines lock held after every change to the registry. */
private void refreshSoleEngine() {
soleEngine = engines.size() == 1 ? engines.get(0) : null;
}
public List<Engine> getEngines() {
synchronized (engines) {
removeClosedEngines();
@@ -381,6 +395,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
}
}
engines.add(engine);
refreshSoleEngine();
}
ENGINE_HOLDERS.add(this);
}
@@ -400,6 +415,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
if (engines.isEmpty()) {
ENGINE_HOLDERS.remove(this);
}
refreshSoleEngine();
}
}
@@ -420,6 +436,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
dump();
synchronized (engines) {
engines.clear();
refreshSoleEngine();
}
ENGINE_HOLDERS.remove(this);
dataLoaders.remove(dataFolder, this);
@@ -27,6 +27,7 @@ import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.iris.util.project.stream.utility.CachedStream2D;
import art.arcane.iris.util.common.parallel.MultiBurst;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -54,6 +55,9 @@ public final class PregenPerformanceProfile {
System.setProperty("iris.cache.fast", "true");
changed = true;
}
if (MultiBurst.burst.raiseParallelism(pregenBurstParallelism(Runtime.getRuntime().availableProcessors()))) {
changed = true;
}
if (JVM_HINT_LOGGED.compareAndSet(false, true) && !fastCacheEnabledBefore) {
IrisLogging.info("For startup-wide cache-fast coverage, set JVM argument: -Diris.cache.fast=true");
@@ -79,6 +83,11 @@ public final class PregenPerformanceProfile {
}
}
/** Two workers per core: enough to keep cores busy while a share of the pool sits in waits. */
static int pregenBurstParallelism(int availableProcessors) {
return Math.max(4, availableProcessors * 2);
}
private static boolean requiresNoiseCacheRefresh(Engine engine) {
if (engine == null) {
return false;
@@ -49,6 +49,7 @@ public class PregenTask {
* hang. A request past the world limit is a bad request, so it fails at construction.
*/
static final int MAX_REGION_SPAN = 117_189;
static final int CHUNK_LATTICE_STRIDE = 4;
private static final int MAX_CACHED_ORDERS = 512;
private static final LinkedHashMap<Long, int[]> ORDERS = new LinkedHashMap<>(64, 0.75f, true) {
@Override
@@ -139,16 +140,37 @@ public class PregenTask {
p.add(new Position2(xx, zz));
}).drain();
p.sort(Comparator.comparing((i) -> i.distance(pull)));
int[] packed = new int[p.size()];
for (int index = 0; index < p.size(); index++) {
Position2 position = p.get(index);
KList<Position2> ordered = latticeOrder(p);
int[] packed = new int[ordered.size()];
for (int index = 0; index < ordered.size(); index++) {
Position2 position = ordered.get(index);
packed[index] = PowerOfTwoCoordinates.packLocal32(position.getX(), position.getZ());
}
return packed;
}
/**
* Regroups a region's chunk order into {@link #CHUNK_LATTICE_STRIDE}-spaced lattices, keeping
* the pull order inside each lattice. Chunks generated back to back then sit a stride apart, so
* their mantle windows do not overlap and their generations never wait on each other; the
* first lattice writes most of the region's mantle and the later ones find it already done.
*/
static KList<Position2> latticeOrder(KList<Position2> pulled) {
KList<Position2> ordered = new KList<>();
for (int classX = 0; classX < CHUNK_LATTICE_STRIDE; classX++) {
for (int classZ = 0; classZ < CHUNK_LATTICE_STRIDE; classZ++) {
for (Position2 position : pulled) {
if (Math.floorMod(position.getX(), CHUNK_LATTICE_STRIDE) == classX
&& Math.floorMod(position.getZ(), CHUNK_LATTICE_STRIDE) == classZ) {
ordered.add(position);
}
}
}
}
return ordered;
}
private static long orderKey(int pullX, int pullZ) {
long high = (long) pullX << 32;
long low = pullZ & 0xFFFFFFFFL;
@@ -280,6 +280,30 @@ public class AsyncPregenMethod implements PregeneratorMethod {
boundsMinRegionZ = minRegionZ;
boundsMaxRegionX = maxRegionX;
boundsMaxRegionZ = maxRegionZ;
prefetchHydrology(minRegionX, minRegionZ, maxRegionX, maxRegionZ);
}
/**
* Plans the hydrology tiles of the whole area up front, nearest the centre first, so the spiral
* finds its tiles ready instead of stalling on each cold plan.
*/
private void prefetchHydrology(int minRegionX, int minRegionZ, int maxRegionX, int maxRegionZ) {
Engine engine = resolveMetricsEngine();
if (engine == null || engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) {
return;
}
int minimumBlockX = minRegionX << 9;
int minimumBlockZ = minRegionZ << 9;
int maximumBlockX = ((maxRegionX + 1) << 9) - 1;
int maximumBlockZ = ((maxRegionZ + 1) << 9) - 1;
engine.getComplex().getHydrologyRuntime().prefetchArea(
minimumBlockX,
minimumBlockZ,
maximumBlockX,
maximumBlockZ,
(minimumBlockX + maximumBlockX) / 2,
(minimumBlockZ + maximumBlockZ) / 2
);
}
private boolean inBounds(int rx, int rz) {
@@ -591,8 +615,10 @@ public class AsyncPregenMethod implements PregeneratorMethod {
return 16;
}
if (recommendedCap > 128) {
return 128;
// A request spends most of its life queued behind Paper's status pipeline, so a large
// worker pool needs well over a hundred requests in flight before its workers stay busy.
if (recommendedCap > 256) {
return 256;
}
return recommendedCap;
@@ -80,12 +80,14 @@ import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BiFunction;
@Data
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
@ToString(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
public class IrisComplex implements DataProvider {
private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D);
private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L);
private static final int GRID_BOUNDS_CACHE_SIZE = 8192;
/** One million corners: about 16 MB, roughly a 4000 by 4000 block area at the 4-block grid. */
private static final int SHARED_CORNER_BOUNDS_CAPACITY = 1 << 20;
private static final int HEIGHT_BOUNDS_GRID = 4;
private static final Comparator<IrisInterpolator> INTERPOLATOR_ORDER = Comparator
.comparing((IrisInterpolator interpolator) -> interpolator.getFunction().name())
@@ -102,6 +104,7 @@ public class IrisComplex implements DataProvider {
};
@Getter(AccessLevel.NONE)
private final transient ThreadLocal<GridBoundsCache> gridBoundsCache = ThreadLocal.withInitial(GridBoundsCache::new);
private transient volatile SharedCornerBounds sharedCornerBounds = new SharedCornerBounds(SHARED_CORNER_BOUNDS_CAPACITY);
@Getter(AccessLevel.NONE)
private final transient IrisInterpolator[] frozenInterpolators;
@Getter(AccessLevel.NONE)
@@ -921,8 +924,21 @@ public class IrisComplex implements DataProvider {
return cache.packed[slot];
}
NoiseBounds bounds = sampleBoundsRaw(cache, engine, interpolator, generators, gx, gz);
long packed = (((long) Float.floatToRawIntBits((float) bounds.min())) << 32) | (Float.floatToRawIntBits((float) bounds.max()) & 0xFFFFFFFFL);
// The per-thread table above catches the walk through one chunk; the shared table
// catches the same corner reached by another thread or by hydrology planning earlier.
SharedCornerBounds shared = sharedCornerBounds();
long sharedKey = SharedCornerBounds.key(gx, gz, interpolatorIndex);
long packed;
if (shared.contains(sharedKey)) {
packed = shared.get(sharedKey);
if (packed == Long.MIN_VALUE) {
packed = computePackedBounds(cache, engine, interpolator, generators, gx, gz);
shared.put(sharedKey, packed);
}
} else {
packed = computePackedBounds(cache, engine, interpolator, generators, gx, gz);
shared.put(sharedKey, packed);
}
cache.gx[slot] = gx;
cache.gz[slot] = gz;
cache.idx[slot] = interpolatorIndex;
@@ -931,6 +947,21 @@ public class IrisComplex implements DataProvider {
return packed;
}
/** The field initializer covers every real complex; an instance built without it gets one here. */
private SharedCornerBounds sharedCornerBounds() {
SharedCornerBounds shared = sharedCornerBounds;
if (shared == null) {
shared = new SharedCornerBounds(SHARED_CORNER_BOUNDS_CAPACITY);
sharedCornerBounds = shared;
}
return shared;
}
private long computePackedBounds(GridBoundsCache cache, Engine engine, IrisInterpolator interpolator, IrisGenerator[] generators, int gx, int gz) {
NoiseBounds bounds = sampleBoundsRaw(cache, engine, interpolator, generators, gx, gz);
return (((long) Float.floatToRawIntBits((float) bounds.min())) << 32) | (Float.floatToRawIntBits((float) bounds.max()) & 0xFFFFFFFFL);
}
private static double boundsLow(long packed) {
return Float.intBitsToFloat((int) (packed >>> 32));
}
@@ -0,0 +1,93 @@
package art.arcane.iris.engine;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
/**
* Direct-mapped, lock-free cache of packed height bounds at grid corners, shared by every thread
* that samples a complex. A corner's bounds depend only on its coordinates and interpolator, so
* one thread's starcast serves all others and survives from hydrology planning into chunk
* generation. Each slot is guarded by a sequence stamp: writers bump it to odd, store, and bump
* it to even; readers reject a slot whose stamp changed or was odd while they read it.
*/
final class SharedCornerBounds {
private static final VarHandle STAMPS = MethodHandles.arrayElementVarHandle(int[].class);
private static final VarHandle LONGS = MethodHandles.arrayElementVarHandle(long[].class);
private static final long PRESENT = 1L << 63;
private static final long COORDINATE_MASK = (1L << 21) - 1;
private final long[] keys;
private final long[] values;
private final int[] stamps;
private final int mask;
SharedCornerBounds(int capacity) {
if (capacity < 2 || Integer.bitCount(capacity) != 1) {
throw new IllegalArgumentException("capacity must be a power of two of at least 2");
}
this.keys = new long[capacity];
this.values = new long[capacity];
this.stamps = new int[capacity];
this.mask = capacity - 1;
}
/** Never zero, so an untouched slot can never match a real corner. */
static long key(int cornerX, int cornerZ, int interpolatorIndex) {
return PRESENT
| ((cornerX & COORDINATE_MASK) << 42)
| ((cornerZ & COORDINATE_MASK) << 21)
| (interpolatorIndex & COORDINATE_MASK);
}
int slot(long key) {
long mixed = (key ^ (key >>> 30)) * 0xBF58476D1CE4E5B9L;
mixed = (mixed ^ (mixed >>> 27)) * 0x94D049BB133111EBL;
return (int) ((mixed ^ (mixed >>> 31)) & mask);
}
boolean contains(long key) {
int slot = slot(key);
int before = (int) STAMPS.getAcquire(stamps, slot);
if ((before & 1) != 0) {
return false;
}
long storedKey = (long) LONGS.getAcquire(keys, slot);
int after = (int) STAMPS.getAcquire(stamps, slot);
return before == after && storedKey == key;
}
/**
* The value stored for the key, or {@link Long#MIN_VALUE} when the slot holds another corner
* or is being written. Callers check {@link #contains(long)} first; a value that legitimately
* equals the sentinel is recomputed, which is only a wasted starcast.
*/
long get(long key) {
int slot = slot(key);
int before = (int) STAMPS.getAcquire(stamps, slot);
if ((before & 1) != 0) {
return Long.MIN_VALUE;
}
long storedKey = (long) LONGS.getAcquire(keys, slot);
long value = (long) LONGS.getAcquire(values, slot);
int after = (int) STAMPS.getAcquire(stamps, slot);
if (before != after || storedKey != key) {
return Long.MIN_VALUE;
}
return value;
}
void put(long key, long value) {
int slot = slot(key);
int stamp = (int) STAMPS.getAcquire(stamps, slot);
if ((stamp & 1) != 0) {
// Another writer owns the slot; the corner is recomputed by whoever misses next.
return;
}
if (!STAMPS.compareAndSet(stamps, slot, stamp, stamp + 1)) {
return;
}
LONGS.setRelease(keys, slot, key);
LONGS.setRelease(values, slot, value);
STAMPS.setRelease(stamps, slot, stamp + 2);
}
}
@@ -94,6 +94,22 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
EnginePlatformHooks getPlatformHooks();
/**
* Whether chunk generation may fan its stages and mantle components out across the burst pool.
* Live worlds generate inline so players keep their cores; a pregeneration owns the machine and
* spreads the work, and the engine service setting forces the same for every world.
*/
static boolean generateMulticore(boolean forceMulticoreWrite, boolean pregeneratorActive) {
return forceMulticoreWrite || pregeneratorActive;
}
default boolean shouldGenerateMulticore() {
return generateMulticore(
IrisSettings.get().getPerformance().getEngineSVC().isForceMulticoreWrite(),
getPlatformHooks().isPregeneratorActive(this)
);
}
/**
* World-space native structure piece bounds overlapping the given XZ rect. The answer is a pure function of the
* seed, the registries and this pack's structure policy, so it never depends on generation order.
@@ -84,6 +84,57 @@ public interface EngineMode extends Staged {
};
}
/**
* Runs {@code parallel} on the burst pool while {@code inline} runs on the calling thread, then
* waits for all of them. Matter generation is the inline stage: on the calling thread its
* mantle window fans out across the pool, and biome and terrain overlap with it instead of
* following it. Without multicore every stage simply runs here, parallel stages first.
*/
default EngineStage burstAround(EngineStage inline, EngineStage... parallel) {
EngineStage fanOut = burst(parallel);
return (x, z, blocks, biomes, multicore, ctx) -> {
if (!multicore) {
fanOut.generate(x, z, blocks, biomes, false, ctx);
inline.generate(x, z, blocks, biomes, false, ctx);
return;
}
java.util.concurrent.CompletableFuture<Void> background = burst().completeValueAsync(() -> {
fanOut.generate(x, z, blocks, biomes, true, ctx);
return null;
});
Throwable inlineFailure = null;
try {
inline.generate(x, z, blocks, biomes, true, ctx);
} catch (Throwable t) {
inlineFailure = t;
}
try {
background.join();
} catch (java.util.concurrent.CompletionException e) {
if (inlineFailure == null) {
Throwable cause = e.getCause() == null ? e : e.getCause();
if (cause instanceof Error error) {
throw error;
}
if (cause instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw new IllegalStateException("Burst stage failure during chunk generation", cause);
}
inlineFailure.addSuppressed(e);
}
if (inlineFailure != null) {
if (inlineFailure instanceof Error error) {
throw error;
}
if (inlineFailure instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw new IllegalStateException("Inline stage failure during chunk generation", inlineFailure);
}
};
}
default IrisComplex getComplex() {
return getEngine().getComplex();
}
@@ -3,6 +3,7 @@ package art.arcane.iris.engine.hydrology;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.volmlib.util.cache.CacheKey;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
@@ -11,8 +12,11 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicLong;
public final class HydrologyTileCache implements AutoCloseable {
@@ -27,13 +31,25 @@ public final class HydrologyTileCache implements AutoCloseable {
private final Cache<Long, ChunkColumns> composedChunks;
private final AtomicLong cacheEpoch;
private final ThreadLocal<LocalChunkColumns> localChunkColumns;
private final Executor prefetchExecutor;
private final Set<HydrologyTileKey> prefetching;
public HydrologyTileCache(HydrologyPlanner planner) {
this(planner, DEFAULT_MAXIMUM_ENTRIES);
}
public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries) {
this(planner, maximumEntries, null);
}
/**
* @param prefetchExecutor runs neighbour tile planning ahead of generation, or null to plan
* tiles only when a chunk needs them
*/
public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries, Executor prefetchExecutor) {
this.planner = Objects.requireNonNull(planner, "planner");
this.prefetchExecutor = prefetchExecutor;
this.prefetching = ConcurrentHashMap.newKeySet();
if (maximumEntries < 1) {
throw new IllegalArgumentException("maximumEntries must be positive.");
}
@@ -121,7 +137,7 @@ public final class HydrologyTileCache implements AutoCloseable {
&& local.columns().chunkZ() == chunkZ) {
return local.columns();
}
long packedChunk = RiverFootprint.pack(chunkX, chunkZ);
long packedChunk = CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ));
ChunkColumns columns = composedChunks.get(
packedChunk,
ignored -> composeChunkColumns(chunkX, chunkZ)
@@ -148,6 +164,7 @@ public final class HydrologyTileCache implements AutoCloseable {
}
}
List<HydrologyTile> relevantTiles = loadTiles(relevantKeys);
prefetchNeighbours(minimumTileX, maximumTileX, minimumTileZ, maximumTileZ);
HydrologyColumnSample[] columns = new HydrologyColumnSample[CHUNK_COLUMN_COUNT];
for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) {
for (int localX = 0; localX < CHUNK_SIZE; localX++) {
@@ -165,6 +182,76 @@ public final class HydrologyTileCache implements AutoCloseable {
return new ChunkColumns(chunkX, chunkZ, columns);
}
/**
* Plans the ring of tiles around the ones a chunk just needed, on the prefetch executor, so a
* generation front walking outward finds its next tiles already planned instead of stalling on
* a cold plan. Tiles already cached or already being prefetched are skipped; a tile a chunk
* needs before its prefetch finishes simply joins that computation.
*/
private void prefetchNeighbours(int minimumTileX, int maximumTileX, int minimumTileZ, int maximumTileZ) {
if (prefetchExecutor == null) {
return;
}
for (int tileZ = minimumTileZ - 1; tileZ <= maximumTileZ + 1; tileZ++) {
for (int tileX = minimumTileX - 1; tileX <= maximumTileX + 1; tileX++) {
if (tileX >= minimumTileX && tileX <= maximumTileX && tileZ >= minimumTileZ && tileZ <= maximumTileZ) {
continue;
}
prefetch(new HydrologyTileKey(tileX, tileZ));
}
}
}
/**
* Plans every tile that touches the block area, nearest to the centre first, on the prefetch
* executor. A pregeneration calls this once up front so its spiral never waits on a cold plan
* once the first tiles are in; nothing happens without a prefetch executor.
*/
public void prefetchArea(int minimumBlockX, int minimumBlockZ, int maximumBlockX, int maximumBlockZ, int centreBlockX, int centreBlockZ) {
if (prefetchExecutor == null) {
return;
}
int tileSize = planner.settings().routing().tileSize();
int publicationRadius = planner.settings().publicationRadius();
int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize);
int maximumTileX = tileCoordinate((long) maximumBlockX + publicationRadius, tileSize);
int minimumTileZ = tileCoordinate((long) minimumBlockZ - publicationRadius, tileSize);
int maximumTileZ = tileCoordinate((long) maximumBlockZ + publicationRadius, tileSize);
int centreTileX = tileCoordinate(centreBlockX, tileSize);
int centreTileZ = tileCoordinate(centreBlockZ, tileSize);
ArrayList<HydrologyTileKey> keys = new ArrayList<>();
for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) {
for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) {
keys.add(new HydrologyTileKey(tileX, tileZ));
}
}
keys.sort(java.util.Comparator.comparingInt((HydrologyTileKey key) -> Math.max(Math.abs(key.tileX() - centreTileX), Math.abs(key.tileZ() - centreTileZ)))
.thenComparingInt(HydrologyTileKey::tileZ)
.thenComparingInt(HydrologyTileKey::tileX));
for (HydrologyTileKey key : keys) {
prefetch(key);
}
}
private void prefetch(HydrologyTileKey key) {
if (tiles.getIfPresent(key) != null || !prefetching.add(key)) {
return;
}
try {
prefetchExecutor.execute(() -> {
try {
get(key);
} catch (RuntimeException failure) {
IrisLogging.reportError(failure);
} finally {
prefetching.remove(key);
}
});
} catch (RuntimeException rejected) {
prefetching.remove(key);
}
}
private List<HydrologyTile> loadTiles(List<HydrologyTileKey> keys) {
if (keys.size() < 2
|| !IrisPlatforms.isBound()) {
@@ -174,15 +261,33 @@ public final class HydrologyTileCache implements AutoCloseable {
}
return List.copyOf(loaded);
}
ArrayList<CompletableFuture<HydrologyTile>> futures = new ArrayList<>(keys.size());
for (HydrologyTileKey key : keys) {
futures.add(CompletableFuture.supplyAsync(() -> get(key), MultiBurst.burst));
// Tiles already planned are answered in place; only a tile that still needs planning is
// worth a round trip through the pool, and most chunks find every tile they need cached.
HydrologyTile[] loaded = new HydrologyTile[keys.size()];
ArrayList<CompletableFuture<HydrologyTile>> futures = null;
for (int index = 0; index < loaded.length; index++) {
HydrologyTileKey key = keys.get(index);
HydrologyTile present = tiles.getIfPresent(key);
if (present != null) {
loaded[index] = present;
continue;
}
if (futures == null) {
futures = new ArrayList<>(loaded.length);
}
int slot = index;
CompletableFuture<HydrologyTile> future = CompletableFuture.supplyAsync(() -> get(key), MultiBurst.hydrology);
futures.add(future.thenApply(tile -> {
loaded[slot] = tile;
return tile;
}));
}
ArrayList<HydrologyTile> loaded = new ArrayList<>(keys.size());
for (CompletableFuture<HydrologyTile> future : futures) {
loaded.add(awaitTile(future));
if (futures != null) {
for (CompletableFuture<HydrologyTile> future : futures) {
awaitTile(future);
}
}
return List.copyOf(loaded);
return List.of(loaded);
}
private static HydrologyTile awaitTile(CompletableFuture<HydrologyTile> future) {
@@ -47,6 +47,8 @@ import art.arcane.iris.engine.object.IrisUndergroundRiverConfig;
import art.arcane.iris.engine.object.IrisUndergroundRiverSourceConfig;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.util.common.parallel.MultiBurst;
import java.util.ArrayList;
import java.util.Collections;
@@ -112,7 +114,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
0,
context.caveViewFactory()
);
this.cache = new HydrologyTileCache(planner, MAXIMUM_CACHE_TILES);
this.cache = new HydrologyTileCache(planner, MAXIMUM_CACHE_TILES, IrisPlatforms.isBound() ? MultiBurst.hydrology : null);
}
public HydrologyPlannerSettings settings() {
@@ -129,6 +131,11 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
return cache.columnAt(blockX, blockZ);
}
/** Plans every tile touching the block area ahead of time, nearest the centre first. */
public void prefetchArea(int minimumBlockX, int minimumBlockZ, int maximumBlockX, int maximumBlockZ, int centreBlockX, int centreBlockZ) {
cache.prefetchArea(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ, centreBlockX, centreBlockZ);
}
public void prepareChunkColumns(int blockX, int blockZ) {
cache.prepareChunkColumns(blockX, blockZ);
}
@@ -19,6 +19,7 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -92,10 +93,12 @@ public interface MatterGenerator {
continue;
}
// A dispatcher thread runs its components inline: dispatching from inside the pool
// only trades a worker for a blocked worker.
boolean asyncComponents = multicore
&& !DISPATCHER.ownsCurrentThread();
// Every multicore generation claims its components through the in-flight map.
// A caller outside the pool spreads its window across the pool; a pool thread
// runs its claims inline. Either way a component another generation already
// owns is joined at the pass barrier instead of blocking on that chunk's lock
// before the caller's own remaining chunks are done.
boolean asyncComponents = multicore;
if (asyncComponents && outstandingTasks == null) {
outstandingTasks = new ArrayList<>();
}
@@ -287,10 +290,11 @@ public interface MatterGenerator {
try {
if (DISPATCHER.ownsCurrentThread()) {
// A pool thread runs its claim inline: a task queued behind pool threads that block
// on unmanaged waits (structure builds, cache locks) could starve the whole pool.
completeComponentTask(future, key, chunk, component, writer, chunkX, chunkZ, context, IrisContext.get());
return new MatterComponentTask(key, future, null);
}
IrisContext callerContext = IrisContext.get();
Future<?> submission = DISPATCHER.submit(() -> completeComponentTask(
future,
@@ -355,6 +359,46 @@ public interface MatterGenerator {
}
private static void awaitComponentTask(MatterComponentTask task) {
CompletableFuture<Void> future = task.future();
if (future.isDone() && !future.isCompletedExceptionally()) {
return;
}
if (DISPATCHER.ownsCurrentThread()) {
// A pool thread parked on another generation's task must not starve the pool that has
// to run that task: managed blocking lets the pool compensate with another worker.
try {
ForkJoinPool.managedBlock(new ComponentTaskBlocker(task));
} catch (InterruptedException interruption) {
Thread.currentThread().interrupt();
}
return;
}
awaitComponentTaskBlocking(task);
}
/**
* Blocks a pool thread on a component task while letting its pool spawn a replacement worker.
*/
final class ComponentTaskBlocker implements ForkJoinPool.ManagedBlocker {
private final MatterComponentTask task;
private ComponentTaskBlocker(MatterComponentTask task) {
this.task = task;
}
@Override
public boolean block() {
awaitComponentTaskBlocking(task);
return true;
}
@Override
public boolean isReleasable() {
return task.future().isDone();
}
}
private static void awaitComponentTaskBlocking(MatterComponentTask task) {
CompletableFuture<Void> future = task.future();
long start = System.currentTimeMillis();
boolean interrupted = false;
@@ -99,9 +99,11 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode {
custom.modify(x, z, k, m, c);
};
registerStage(burst(
sBiome,
// Matter runs on the calling thread so its window fans out across the burst pool (a pool
// thread would run every chunk of the window inline) while biome and terrain run alongside.
registerStage(burstAround(
sGenMatter,
sBiome,
sTerrain
));
registerStage(sCave);
@@ -853,7 +853,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
Hunk<PlatformBiome> biomes = Hunk.viewBiomes(tc);
try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_terrain_stage");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
engine.generate(x << 4, z << 4, blocks, biomes, false);
engine.generate(x << 4, z << 4, blocks, biomes, engine.shouldGenerateMulticore());
blocks.apply();
}
}
@@ -16,6 +16,11 @@ public class MultiBurst extends MultiBurstSupport {
private static final long TIMEOUT = Long.getLong("iris.burst.timeout", 15000);
public static final MultiBurst burst = new MultiBurst();
public static final MultiBurst ioBurst = new MultiBurst("Iris IO", () -> IrisSettings.get().getConcurrency().getIoParallelism());
/**
* Long-running hydrology tile planning. Kept off {@link #burst} so a handful of twenty-second
* plans never pin the workers that chunk generation's short stage and mantle tasks need.
*/
public static final MultiBurst hydrology = new MultiBurst("Iris Hydrology", () -> Math.max(2, IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()) / 3));
public MultiBurst() {
this("Iris");
@@ -33,6 +38,23 @@ public class MultiBurst extends MultiBurstSupport {
super(name, priority, parallelism, IrisSettings::getThreadCount, M::ms, IrisLogging::reportError, IrisLogging::info, IrisLogging::warn, TIMEOUT);
}
/**
* Raises the pool's parallelism to at least {@code target} workers. Pregeneration fans every
* chunk's stages and mantle windows into this pool while some tasks block on unmanaged waits,
* so a pregeneration box wants more workers than cores; the pool never shrinks back.
*/
public boolean raiseParallelism(int target) {
if (!(service() instanceof ForkJoinPool pool) || target <= pool.getParallelism()) {
return false;
}
pool.setParallelism(target);
return true;
}
public int parallelism() {
return service() instanceof ForkJoinPool pool ? pool.getParallelism() : -1;
}
public boolean ownsCurrentThread() {
Thread thread = Thread.currentThread();
if (!(thread instanceof ForkJoinWorkerThread worker)) {
@@ -89,4 +89,21 @@ public class IrisDataEngineRegistryTest {
public void dereference() {
}
}
@Test
public void soleEngineResolvesWithoutAContextAndDropsOnceClosed() throws Exception {
data = IrisData.openRuntime(temporaryFolder.newFolder("pack"));
AtomicBoolean closed = new AtomicBoolean(false);
Engine engine = engine(closed);
data.registerEngine(engine);
assertSame(engine, data.getEngine());
assertSame(engine, data.getEngine());
closed.set(true);
assertNull(data.getEngine());
assertEquals(0, data.getEngines().size());
}
}
@@ -8,6 +8,8 @@ import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.util.project.stream.utility.CachedDoubleStream2D;
import art.arcane.iris.util.project.stream.utility.CachedStream2D;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import art.arcane.iris.util.common.parallel.MultiBurst;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@@ -112,4 +114,19 @@ public class PregenPerformanceProfileTest {
System.setProperty("iris.cache.fast", fastCache);
}
}
@Test
public void pregenerationWantsTwoBurstWorkersPerCore() {
assertEquals(32, PregenPerformanceProfile.pregenBurstParallelism(16));
assertEquals(4, PregenPerformanceProfile.pregenBurstParallelism(1));
}
@Test
public void raisingBurstParallelismOnlyGrowsThePool() {
int before = MultiBurst.burst.parallelism();
assertFalse(MultiBurst.burst.raiseParallelism(before));
assertTrue(MultiBurst.burst.raiseParallelism(before + 1));
assertEquals(before + 1, MultiBurst.burst.parallelism());
assertFalse(MultiBurst.burst.raiseParallelism(before));
}
}
@@ -0,0 +1,50 @@
package art.arcane.iris.core.pregenerator;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.Position2;
import org.junit.Test;
import java.util.HashSet;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class PregenTaskLatticeOrderTest {
@Test
public void regionOrderVisitsEveryChunkOnceInStrideSpacedLattices() {
KList<Position2> order = new KList<>();
PregenTask.iterateRegion(0, 0, (x, z) -> order.add(new Position2(x, z)));
assertEquals(1024, order.size());
Set<Long> unique = new HashSet<>();
for (Position2 position : order) {
assertTrue(unique.add(((long) position.getX() << 32) ^ (position.getZ() & 0xffffffffL)));
}
int stride = PregenTask.CHUNK_LATTICE_STRIDE;
int perLattice = 1024 / (stride * stride);
for (int lattice = 0; lattice < stride * stride; lattice++) {
Position2 first = order.get(lattice * perLattice);
for (int index = lattice * perLattice; index < (lattice + 1) * perLattice; index++) {
Position2 position = order.get(index);
assertEquals(Math.floorMod(first.getX(), stride), Math.floorMod(position.getX(), stride));
assertEquals(Math.floorMod(first.getZ(), stride), Math.floorMod(position.getZ(), stride));
}
}
}
@Test
public void chunksGeneratedBackToBackNeverShareAMantleWindow() {
KList<Position2> order = new KList<>();
PregenTask.iterateRegion(3, -2, (x, z) -> order.add(new Position2(x, z)));
int perLattice = 1024 / (PregenTask.CHUNK_LATTICE_STRIDE * PregenTask.CHUNK_LATTICE_STRIDE);
for (int index = 1; index < perLattice; index++) {
Position2 previous = order.get(index - 1);
Position2 current = order.get(index);
int distance = Math.max(Math.abs(previous.getX() - current.getX()), Math.abs(previous.getZ() - current.getZ()));
assertTrue("consecutive chunks " + previous + " and " + current, distance >= PregenTask.CHUNK_LATTICE_STRIDE);
}
}
}
@@ -21,8 +21,8 @@ public class AsyncPregenMethodConcurrencyCapTest {
assertEquals(16, AsyncPregenMethod.computePaperLikeRecommendedCap(1));
assertEquals(32, AsyncPregenMethod.computePaperLikeRecommendedCap(4));
assertEquals(96, AsyncPregenMethod.computePaperLikeRecommendedCap(12));
assertEquals(128, AsyncPregenMethod.computePaperLikeRecommendedCap(80));
assertEquals(128, AsyncPregenMethod.computePaperLikeRecommendedCap(128));
assertEquals(256, AsyncPregenMethod.computePaperLikeRecommendedCap(80));
assertEquals(256, AsyncPregenMethod.computePaperLikeRecommendedCap(128));
}
@Test
@@ -0,0 +1,89 @@
package art.arcane.iris.engine;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
public class SharedCornerBoundsTest {
@Test
public void storedCornersComeBackAndUnknownCornersMiss() {
SharedCornerBounds cache = new SharedCornerBounds(1 << 10);
long key = SharedCornerBounds.key(12, -40, 1);
assertFalse(cache.contains(key));
cache.put(key, 0x1234_5678_9ABC_DEF0L);
assertTrue(cache.contains(key));
assertEquals(0x1234_5678_9ABC_DEF0L, cache.get(key));
assertFalse(cache.contains(SharedCornerBounds.key(12, -40, 0)));
}
@Test
public void keysSeparateCoordinatesAndInterpolators() {
assertNotEquals(SharedCornerBounds.key(1, 2, 0), SharedCornerBounds.key(2, 1, 0));
assertNotEquals(SharedCornerBounds.key(1, 2, 0), SharedCornerBounds.key(1, 2, 1));
assertNotEquals(SharedCornerBounds.key(-1, 2, 0), SharedCornerBounds.key(1, 2, 0));
assertNotEquals(0L, SharedCornerBounds.key(0, 0, 0));
}
@Test
public void aCollidingCornerReplacesTheOldOne() {
SharedCornerBounds cache = new SharedCornerBounds(2);
long first = SharedCornerBounds.key(0, 0, 0);
long colliding = first;
for (int gx = 1; gx < 100_000; gx++) {
long candidate = SharedCornerBounds.key(gx, 0, 0);
if (cache.slot(candidate) == cache.slot(first)) {
colliding = candidate;
break;
}
}
assertNotEquals(first, colliding);
cache.put(first, 1L);
cache.put(colliding, 2L);
assertFalse(cache.contains(first));
assertEquals(2L, cache.get(colliding));
}
@Test
public void concurrentReadersNeverSeeAnotherCornersValue() throws InterruptedException {
SharedCornerBounds cache = new SharedCornerBounds(1 << 8);
AtomicBoolean torn = new AtomicBoolean();
List<Thread> threads = new ArrayList<>();
for (int worker = 0; worker < 8; worker++) {
int seed = worker;
threads.add(new Thread(() -> {
long state = 0x9E3779B97F4A7C15L * (seed + 1);
for (int step = 0; step < 400_000; step++) {
state ^= state << 13;
state ^= state >>> 7;
state ^= state << 17;
int gx = (int) (state & 1023);
int gz = (int) ((state >>> 10) & 1023);
long key = SharedCornerBounds.key(gx, gz, (int) (state & 1));
if ((state & 2) == 0) {
cache.put(key, key * 31L);
} else if (cache.contains(key) && cache.get(key) != key * 31L) {
torn.set(true);
}
}
}));
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
assertFalse(torn.get());
}
}
@@ -0,0 +1,120 @@
package art.arcane.iris.engine.framework;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.iris.util.project.context.ChunkContext;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformRegistries;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class EngineModeBurstAroundTest {
private static IrisSettings previousSettings;
@BeforeClass
public static void bindPlatform() {
previousSettings = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
IrisPlatforms.unbind();
PlatformRegistries registries = mock(PlatformRegistries.class);
IrisPlatform platform = mock(IrisPlatform.class);
when(registries.block(anyString())).thenReturn(mock(PlatformBlockState.class));
when(platform.registries()).thenReturn(registries);
IrisPlatforms.bind(platform);
}
@AfterClass
public static void unbindPlatform() {
IrisPlatforms.unbind();
IrisSettings.settings = previousSettings;
}
private static EngineMode mode() {
Engine engine = mock(Engine.class);
when(engine.burst()).thenReturn(MultiBurst.burst);
EngineMode mode = mock(EngineMode.class, CALLS_REAL_METHODS);
when(mode.getEngine()).thenReturn(engine);
return mode;
}
@Test
public void multicoreRunsTheInlineStageHereAndTheOthersOnThePoolBeforeReturning() {
EngineMode mode = mode();
ChunkContext context = mock(ChunkContext.class);
Thread caller = Thread.currentThread();
Set<Thread> parallelThreads = ConcurrentHashMap.newKeySet();
List<String> completed = new CopyOnWriteArrayList<>();
EngineStage inline = (x, z, blocks, biomes, multicore, ctx) -> {
assertTrue(multicore);
assertEquals(caller, Thread.currentThread());
completed.add("inline");
};
EngineStage first = (x, z, blocks, biomes, multicore, ctx) -> {
parallelThreads.add(Thread.currentThread());
completed.add("first");
};
EngineStage second = (x, z, blocks, biomes, multicore, ctx) -> {
parallelThreads.add(Thread.currentThread());
completed.add("second");
};
mode.burstAround(inline, first, second).generate(0, 0, null, null, true, context);
assertEquals(3, completed.size());
assertTrue(completed.containsAll(List.of("inline", "first", "second")));
assertFalse(parallelThreads.contains(caller));
}
@Test
public void withoutMulticoreEveryStageRunsOnTheCallerParallelOnesFirst() {
EngineMode mode = mode();
ChunkContext context = mock(ChunkContext.class);
Thread caller = Thread.currentThread();
List<String> order = new CopyOnWriteArrayList<>();
EngineStage inline = (x, z, blocks, biomes, multicore, ctx) -> {
assertEquals(caller, Thread.currentThread());
order.add("inline");
};
EngineStage first = (x, z, blocks, biomes, multicore, ctx) -> {
assertEquals(caller, Thread.currentThread());
order.add("first");
};
mode.burstAround(inline, first).generate(0, 0, null, null, false, context);
assertEquals(List.of("first", "inline"), order);
}
@Test
public void aParallelFailureSurfacesAfterTheInlineStageFinished() {
EngineMode mode = mode();
ChunkContext context = mock(ChunkContext.class);
List<String> completed = new CopyOnWriteArrayList<>();
EngineStage inline = (x, z, blocks, biomes, multicore, ctx) -> completed.add("inline");
EngineStage failing = (x, z, blocks, biomes, multicore, ctx) -> {
throw new IllegalStateException("boom");
};
IllegalStateException failure = org.junit.Assert.assertThrows(IllegalStateException.class,
() -> mode.burstAround(inline, failing).generate(0, 0, null, null, true, context));
assertEquals("boom", failure.getMessage());
assertEquals(List.of("inline"), completed);
}
}
@@ -0,0 +1,20 @@
package art.arcane.iris.engine.framework;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class EngineMulticoreDecisionTest {
@Test
public void liveWorldsGenerateInlineUnlessForced() {
assertFalse(Engine.generateMulticore(false, false));
assertTrue(Engine.generateMulticore(true, false));
}
@Test
public void pregenerationSpreadsStagesAndMantleWorkAcrossCores() {
assertTrue(Engine.generateMulticore(false, true));
assertTrue(Engine.generateMulticore(true, true));
}
}
@@ -372,4 +372,51 @@ public class HydrologyTileCacheTest {
assertTrue(tile.outlets().isEmpty());
assertTrue(tile.columnAt(0, 0).isEmpty());
}
@Test
public void chunkPreparationPrefetchesTheRingOfTilesAroundTheOnesItNeeded() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyPlannerSettings settings = emptySettings();
when(planner.settings()).thenReturn(settings);
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty());
HydrologyTileCache eager = new HydrologyTileCache(planner, 64, Runnable::run);
HydrologyTileCache lazy = new HydrologyTileCache(planner, 64);
eager.prepareChunkColumns(32, 48);
lazy.prepareChunkColumns(32, 48);
int tileSize = settings.routing().tileSize();
int radius = settings.publicationRadius();
int width = Math.floorDiv(32 + 15 + radius, tileSize) - Math.floorDiv(32 - radius, tileSize) + 1;
int height = Math.floorDiv(48 + 15 + radius, tileSize) - Math.floorDiv(48 - radius, tileSize) + 1;
assertEquals(width * height, lazy.size());
assertEquals((width + 2) * (height + 2), eager.size());
}
@Test
public void areaPrefetchPlansEveryTouchingTileNearestTheCentreFirst() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyPlannerSettings settings = emptySettings();
when(planner.settings()).thenReturn(settings);
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
List<HydrologyTileKey> order = new java.util.ArrayList<>();
HydrologyTileCache cache = new HydrologyTileCache(planner, 256, Runnable::run);
org.mockito.Mockito.doAnswer(invocation -> {
order.add(invocation.getArgument(0));
return tile;
}).when(planner).plan(any(HydrologyTileKey.class));
int tileSize = settings.routing().tileSize();
cache.prefetchArea(0, 0, tileSize * 2 - 1, tileSize * 2 - 1, tileSize, tileSize);
int radius = settings.publicationRadius();
int extra = radius > 0 ? 1 : 0;
int span = 2 + 2 * extra;
assertEquals(span * span, order.size());
assertEquals(new HydrologyTileKey(1, 1), order.getFirst());
assertTrue(order.subList(0, 4).containsAll(List.of(new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 0), new HydrologyTileKey(0, 1), new HydrologyTileKey(1, 1))));
}
}
@@ -17,6 +17,7 @@ import art.arcane.volmlib.util.matter.Matter;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import art.arcane.iris.util.common.parallel.MultiBurst;
import java.util.List;
import java.util.Set;
@@ -210,8 +211,17 @@ public class MatterGeneratorConcurrencyTest {
private final Mantle<Matter> mantle;
private final ChunkContext context;
@SuppressWarnings("unchecked")
private GeneratorFixture() {
this(false);
}
/**
* @param lockingChunks true to give every chunk a real per-flag lock and flag state, so a
* generation that reaches a chunk another generation is working on
* blocks exactly like production
*/
@SuppressWarnings("unchecked")
private GeneratorFixture(boolean lockingChunks) {
IrisDimension dimension = mock(IrisDimension.class);
when(dimension.isUseMantle()).thenReturn(true);
EngineMantle engineMantle = mock(EngineMantle.class);
@@ -224,7 +234,7 @@ public class MatterGeneratorConcurrencyTest {
int chunkX = invocation.getArgument(0);
int chunkZ = invocation.getArgument(1);
long key = (((long) chunkX) << 32) ^ (chunkZ & 0xffffffffL);
return chunks.computeIfAbsent(key, ignored -> chunk());
return chunks.computeIfAbsent(key, ignored -> lockingChunks ? lockingChunk() : chunk());
});
context = mock(ChunkContext.class);
when(context.getGenerationSessionId()).thenReturn(91L);
@@ -234,6 +244,30 @@ public class MatterGeneratorConcurrencyTest {
return new TestMatterGenerator(engine, mantle, passes);
}
@SuppressWarnings("unchecked")
private static MantleChunk<Matter> lockingChunk() {
MantleChunk<Matter> chunk = mock(MantleChunk.class);
java.util.concurrent.locks.ReentrantLock lock = new java.util.concurrent.locks.ReentrantLock();
Set<Object> flagged = ConcurrentHashMap.newKeySet();
when(chunk.use()).thenReturn(chunk);
when(chunk.isFlagged(any())).thenAnswer(invocation -> flagged.contains(invocation.getArgument(0)));
doAnswer(invocation -> {
Object flag = invocation.getArgument(0);
Runnable task = invocation.getArgument(1);
lock.lock();
try {
if (!flagged.contains(flag)) {
task.run();
flagged.add(flag);
}
} finally {
lock.unlock();
}
return null;
}).when(chunk).raiseFlagSuspend(any(), any(Runnable.class));
return chunk;
}
@SuppressWarnings("unchecked")
private static MantleChunk<Matter> chunk() {
MantleChunk<Matter> chunk = mock(MantleChunk.class);
@@ -284,4 +318,51 @@ public class MatterGeneratorConcurrencyTest {
return passes;
}
}
@Test
public void dispatcherThreadFinishesItsOwnChunksBeforeJoiningAComponentAnotherGenerationOwns() throws Exception {
GeneratorFixture fixture = new GeneratorFixture(true);
CountDownLatch sharedStarted = new CountDownLatch(1);
CountDownLatch releaseShared = new CountDownLatch(1);
Set<Long> secondCompleted = ConcurrentHashMap.newKeySet();
AtomicReference<Thread> firstOwner = new AtomicReference<>();
RecordingComponent component = new RecordingComponent(ReservedFlag.OBJECT, 0, 16) {
@Override
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
if (x == 1 && z == 0 && firstOwner.compareAndSet(null, Thread.currentThread())) {
sharedStarted.countDown();
await(releaseShared);
return;
}
if (firstOwner.get() != null && Thread.currentThread() != firstOwner.get()) {
secondCompleted.add((((long) x) << 32) ^ (z & 0xffffffffL));
}
}
};
TestMatterGenerator generator = fixture.generator(List.of(new MantlePass(List.of(component), 1, 0)));
java.util.concurrent.CompletableFuture<Void> first = MultiBurst.burst.completeValueAsync(() -> {
generator.generateMatter(0, 0, true, fixture.context);
return null;
});
await(sharedStarted);
java.util.concurrent.CompletableFuture<Void> second = MultiBurst.burst.completeValueAsync(() -> {
generator.generateMatter(2, 0, true, fixture.context);
return null;
});
long deadline = System.currentTimeMillis() + 10_000L;
while (secondCompleted.size() < 7 && System.currentTimeMillis() < deadline) {
Thread.sleep(10L);
}
// The second generation's window is x 1..3, z -1..1. The first generation finished (1,-1)
// before parking on (1,0), so the second one owns the remaining seven chunks and must
// complete all of them while (1,0) is still held instead of blocking on it first.
assertEquals(7, secondCompleted.size());
assertFalse(second.isDone());
releaseShared.countDown();
first.get(10L, TimeUnit.SECONDS);
second.get(10L, TimeUnit.SECONDS);
}
}