diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index 2a1ae8409..9b4921556 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -132,6 +132,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) private final transient List 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 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); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java index a16cea5c2..b79a4c1a8 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java @@ -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; diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java index 9fb9926b7..8479b25d7 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java @@ -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 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 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 latticeOrder(KList pulled) { + KList 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; diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java index e8f8eba76..262ed73be 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java @@ -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; diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index d6c577a27..d4e92e1a5 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -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 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 = 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)); } diff --git a/core/src/main/java/art/arcane/iris/engine/SharedCornerBounds.java b/core/src/main/java/art/arcane/iris/engine/SharedCornerBounds.java new file mode 100644 index 000000000..3241a9e12 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/SharedCornerBounds.java @@ -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); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index f4728a3ff..b7c508ca7 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -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. diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java index d152e8ed2..d3314a512 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java @@ -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 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(); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java index e29f0fe28..e9ac73e8b 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java @@ -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 composedChunks; private final AtomicLong cacheEpoch; private final ThreadLocal localChunkColumns; + private final Executor prefetchExecutor; + private final Set 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 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 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 loadTiles(List keys) { if (keys.size() < 2 || !IrisPlatforms.isBound()) { @@ -174,15 +261,33 @@ public final class HydrologyTileCache implements AutoCloseable { } return List.copyOf(loaded); } - ArrayList> 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> 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 future = CompletableFuture.supplyAsync(() -> get(key), MultiBurst.hydrology); + futures.add(future.thenApply(tile -> { + loaded[slot] = tile; + return tile; + })); } - ArrayList loaded = new ArrayList<>(keys.size()); - for (CompletableFuture future : futures) { - loaded.add(awaitTile(future)); + if (futures != null) { + for (CompletableFuture future : futures) { + awaitTile(future); + } } - return List.copyOf(loaded); + return List.of(loaded); } private static HydrologyTile awaitTile(CompletableFuture future) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java index 2b0da1bba..b59a937f4 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java @@ -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); } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java b/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java index 68b09fe72..10fb954d0 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java @@ -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 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 future = task.future(); long start = System.currentTimeMillis(); boolean interrupted = false; diff --git a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java index eb4f301c1..28fd45de6 100644 --- a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java +++ b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java @@ -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); diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index 533e930db..74f693c01 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -853,7 +853,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun Hunk 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(); } } diff --git a/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java b/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java index 221cec69d..c098009da 100644 --- a/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java +++ b/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java @@ -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)) { diff --git a/core/src/test/java/art/arcane/iris/core/loader/IrisDataEngineRegistryTest.java b/core/src/test/java/art/arcane/iris/core/loader/IrisDataEngineRegistryTest.java index 834dcc1cc..dbc9018ba 100644 --- a/core/src/test/java/art/arcane/iris/core/loader/IrisDataEngineRegistryTest.java +++ b/core/src/test/java/art/arcane/iris/core/loader/IrisDataEngineRegistryTest.java @@ -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()); + } } diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfileTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfileTest.java index 6fe0e0537..454464481 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfileTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfileTest.java @@ -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)); + } } diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskLatticeOrderTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskLatticeOrderTest.java new file mode 100644 index 000000000..8b84e49f3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskLatticeOrderTest.java @@ -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 order = new KList<>(); + PregenTask.iterateRegion(0, 0, (x, z) -> order.add(new Position2(x, z))); + + assertEquals(1024, order.size()); + Set 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 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); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java index 1e5bbea4f..011c02bd7 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java @@ -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 diff --git a/core/src/test/java/art/arcane/iris/engine/SharedCornerBoundsTest.java b/core/src/test/java/art/arcane/iris/engine/SharedCornerBoundsTest.java new file mode 100644 index 000000000..2a908b337 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/SharedCornerBoundsTest.java @@ -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 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()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/EngineModeBurstAroundTest.java b/core/src/test/java/art/arcane/iris/engine/framework/EngineModeBurstAroundTest.java new file mode 100644 index 000000000..eeac725da --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/EngineModeBurstAroundTest.java @@ -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 parallelThreads = ConcurrentHashMap.newKeySet(); + List 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 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 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); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/EngineMulticoreDecisionTest.java b/core/src/test/java/art/arcane/iris/engine/framework/EngineMulticoreDecisionTest.java new file mode 100644 index 000000000..d0c2c745b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/EngineMulticoreDecisionTest.java @@ -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)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java index ff034f4f8..d008a0584 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java @@ -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 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)))); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java index 15dedb50a..00d58f0fe 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java @@ -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 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 lockingChunk() { + MantleChunk chunk = mock(MantleChunk.class); + java.util.concurrent.locks.ReentrantLock lock = new java.util.concurrent.locks.ReentrantLock(); + Set 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 chunk() { MantleChunk 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 secondCompleted = ConcurrentHashMap.newKeySet(); + AtomicReference 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 first = MultiBurst.burst.completeValueAsync(() -> { + generator.generateMatter(0, 0, true, fixture.context); + return null; + }); + await(sharedStarted); + java.util.concurrent.CompletableFuture 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); + } }