Unblock pregen throughput limiters

- Replace the pregen cache global monitor with atomic per-plate and per-region state and a lock-free resident-plate memo
- Serialize plate eviction and its disk write under the residency lock so a reloaded plate can never lose a concurrent bit
- Retry a chunk or region cache write whose plate was retired mid-write
- Delete SynchronizedCache and the isThreadSafe wrapper it existed for
- Replace the AsyncPregenMethod semaphore and permit monitor with PregenAdmissionGate, one condition that signals on release, adaptive-limit change and cancellation
- Attribute the adaptive and permit waits separately from the single gate wait
- Wait on a progress signal instead of sleeping 25ms in PregenMantleBackpressure, signalled by chunk completion
- Wait on a state signal instead of sleeping 50ms and 150ms in IrisPregenerator pause, heap pressure and tectonic reclaim loops
- Restore the burst pool parallelism when the last pregen job finishes
- Add PregenCacheConcurrencyTest, PregenAdmissionGateTest and PregenWaitPolicyContractTest
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-08 04:33:31 -04:00
1 parent e030ac3be5
commit 1be563bd28
14 files changed
+1032 -297

No files matched your search

@@ -35,6 +35,9 @@ import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.scheduling.Looper; import art.arcane.volmlib.util.scheduling.Looper;
import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
@@ -43,6 +46,8 @@ import java.util.concurrent.atomic.AtomicReference;
public class IrisPregenerator { public class IrisPregenerator {
private static final double INVALID = 9223372036854775807d; private static final double INVALID = 9223372036854775807d;
private static final AtomicLong JOB_SEQUENCE = new AtomicLong(0L); private static final AtomicLong JOB_SEQUENCE = new AtomicLong(0L);
private static final long GATE_WAIT_MS = 50L;
private static final long RECLAIM_WAIT_MS = 150L;
private final long jobId; private final long jobId;
private final PregenTask task; private final PregenTask task;
private final PregeneratorMethod generator; private final PregeneratorMethod generator;
@@ -50,6 +55,8 @@ public class IrisPregenerator {
private final Looper ticker; private final Looper ticker;
private final AtomicBoolean paused; private final AtomicBoolean paused;
private final AtomicBoolean shutdown; private final AtomicBoolean shutdown;
private final ReentrantLock stateLock = new ReentrantLock();
private final Condition stateChanged = stateLock.newCondition();
private final PregenRateTracker rateTracker; private final PregenRateTracker rateTracker;
private final KList<Integer> chunksPerSecondHistory; private final KList<Integer> chunksPerSecondHistory;
private final AtomicLong generated; private final AtomicLong generated;
@@ -193,6 +200,7 @@ public class IrisPregenerator {
public void close() { public void close() {
shutdown.set(true); shutdown.set(true);
signalState();
} }
public void start() { public void start() {
@@ -295,6 +303,7 @@ public class IrisPregenerator {
reclaimTectonicPlates(mantle); reclaimTectonicPlates(mantle);
} }
}); });
shutdownStep("parallelism", PregenPerformanceProfile::restore);
shutdownStep("protocol", () -> { shutdownStep("protocol", () -> {
IrisProtocolServer protocolServer = IrisServices.getOrNull(IrisProtocolServer.class); IrisProtocolServer protocolServer = IrisServices.getOrNull(IrisProtocolServer.class);
if (protocolServer != null) { if (protocolServer != null) {
@@ -337,7 +346,7 @@ public class IrisPregenerator {
} }
previousLoaded = loaded; previousLoaded = loaded;
J.sleep(150); awaitStateChange(RECLAIM_WAIT_MS);
} }
int loadedAfter = mantle.getLoadedRegionCount(); int loadedAfter = mantle.getLoadedRegionCount();
@@ -348,7 +357,7 @@ public class IrisPregenerator {
private void visitRegion(int x, int z, boolean regions) { private void visitRegion(int x, int z, boolean regions) {
while (paused.get() && !shutdown.get()) { while (paused.get() && !shutdown.get()) {
J.sleep(50); awaitStateChange(GATE_WAIT_MS);
} }
if (shutdown.get()) { if (shutdown.get()) {
@@ -376,7 +385,7 @@ public class IrisPregenerator {
if (!paused.get()) { if (!paused.get()) {
reclaimHeapPressure(); reclaimHeapPressure();
} }
J.sleep(50); awaitStateChange(GATE_WAIT_MS);
} }
if (shutdown.get()) { if (shutdown.get()) {
@@ -439,10 +448,32 @@ public class IrisPregenerator {
public void pause() { public void pause() {
paused.set(true); paused.set(true);
signalState();
} }
public void resume() { public void resume() {
paused.set(false); paused.set(false);
signalState();
}
private void signalState() {
stateLock.lock();
try {
stateChanged.signalAll();
} finally {
stateLock.unlock();
}
}
private void awaitStateChange(long millis) {
stateLock.lock();
try {
stateChanged.await(millis, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
stateLock.unlock();
}
} }
private PregenListener listenify(PregenListener listener) { private PregenListener listenify(PregenListener listener) {
@@ -0,0 +1,136 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pregenerator;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BooleanSupplier;
import java.util.function.LongSupplier;
public final class PregenAdmissionGate {
public record Wait(long adaptiveMs, long permitMs) {
}
private final int permits;
private final long boundMs;
private final LongSupplier clock;
private final ReentrantLock lock = new ReentrantLock(true);
private final Condition changed = lock.newCondition();
private int free;
private volatile int freeSnapshot;
public PregenAdmissionGate(int permits, long boundMs, LongSupplier clock) {
if (permits < 1) {
throw new IllegalArgumentException("A pregen admission gate needs at least one permit");
}
if (boundMs < 1L) {
throw new IllegalArgumentException("A pregen admission gate needs a positive wait bound");
}
this.permits = permits;
this.boundMs = boundMs;
this.clock = clock;
this.free = permits;
this.freeSnapshot = permits;
}
public int permits() {
return permits;
}
public int availablePermits() {
return freeSnapshot;
}
public Wait admit(BooleanSupplier overAdaptiveLimit, BooleanSupplier cancelled) throws InterruptedException {
long adaptiveMs = 0L;
long permitMs = 0L;
lock.lock();
try {
while (true) {
if (cancelled.getAsBoolean()) {
return null;
}
boolean blockedByLimit = overAdaptiveLimit.getAsBoolean();
if (!blockedByLimit && free > 0) {
free--;
freeSnapshot = free;
return new Wait(adaptiveMs, permitMs);
}
long start = clock.getAsLong();
changed.await(boundMs, TimeUnit.MILLISECONDS);
long waited = Math.max(0L, clock.getAsLong() - start);
if (blockedByLimit) {
adaptiveMs += waited;
} else {
permitMs += waited;
}
}
} finally {
lock.unlock();
}
}
public void release() {
lock.lock();
try {
if (free < permits) {
free++;
freeSnapshot = free;
}
changed.signalAll();
} finally {
lock.unlock();
}
}
public void wake() {
lock.lock();
try {
changed.signalAll();
} finally {
lock.unlock();
}
}
public boolean awaitDrain(long warningInterval, TimeUnit timeUnit, Runnable onWait) {
boolean interrupted = false;
lock.lock();
try {
while (free < permits) {
boolean signalled = false;
try {
signalled = changed.await(warningInterval, timeUnit);
} catch (InterruptedException e) {
interrupted = true;
continue;
}
if (!signalled && free < permits) {
onWait.run();
}
}
} finally {
lock.unlock();
}
return interrupted;
}
}
@@ -22,6 +22,9 @@ import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.math.M;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
import java.util.function.Supplier; import java.util.function.Supplier;
@@ -33,6 +36,8 @@ public final class PregenMantleBackpressure {
private final Runnable onBudgetTimeout; private final Runnable onBudgetTimeout;
private final Supplier<String> diagnostics; private final Supplier<String> diagnostics;
private final BooleanSupplier cancelled; private final BooleanSupplier cancelled;
private final ReentrantLock lock = new ReentrantLock();
private final Condition progressed = lock.newCondition();
public PregenMantleBackpressure(Supplier<Mantle> mantleSupplier, int maxResidentTectonicPlates, int waitMs, long timeoutMs, Runnable onBudgetTimeout, Supplier<String> diagnostics) { public PregenMantleBackpressure(Supplier<Mantle> mantleSupplier, int maxResidentTectonicPlates, int waitMs, long timeoutMs, Runnable onBudgetTimeout, Supplier<String> diagnostics) {
this(mantleSupplier, maxResidentTectonicPlates, waitMs, timeoutMs, onBudgetTimeout, diagnostics, () -> false); this(mantleSupplier, maxResidentTectonicPlates, waitMs, timeoutMs, onBudgetTimeout, diagnostics, () -> false);
@@ -108,10 +113,7 @@ public final class PregenMantleBackpressure {
+ "), freed " + freed + " last pass, waited " + elapsed + "ms."); + "), freed " + freed + " last pass, waited " + elapsed + "ms.");
} }
try { if (!awaitProgress()) {
Thread.sleep(waitMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return; return;
} }
} }
@@ -154,15 +156,34 @@ public final class PregenMantleBackpressure {
+ (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + "."); + (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + ".");
} }
try { if (!awaitProgress()) {
Thread.sleep(waitMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return; return;
} }
} }
} }
public void signalProgress() {
lock.lock();
try {
progressed.signalAll();
} finally {
lock.unlock();
}
}
private boolean awaitProgress() {
lock.lock();
try {
progressed.await(waitMs, TimeUnit.MILLISECONDS);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
lock.unlock();
}
}
private boolean isCancelled() { private boolean isCancelled() {
if (Thread.currentThread().isInterrupted()) { if (Thread.currentThread().isInterrupted()) {
return true; return true;
@@ -28,8 +28,11 @@ import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.iris.util.project.stream.utility.CachedDoubleStream2D; import art.arcane.iris.util.project.stream.utility.CachedDoubleStream2D;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
public final class PregenPerformanceProfile { public final class PregenPerformanceProfile {
private static final AtomicInteger PARALLELISM_HOLDERS = new AtomicInteger();
private PregenPerformanceProfile() { private PregenPerformanceProfile() {
} }
@@ -45,12 +48,33 @@ public final class PregenPerformanceProfile {
} }
if (MultiBurst.burst.raiseParallelism(pregenBurstParallelism(Runtime.getRuntime().availableProcessors()))) { if (MultiBurst.burst.raiseParallelism(pregenBurstParallelism(Runtime.getRuntime().availableProcessors()))) {
PARALLELISM_HOLDERS.incrementAndGet();
changed = true; changed = true;
} }
return changed; return changed;
} }
/**
* Releases one hold on the pregeneration burst parallelism. The pool returns to the parallelism it had
* before the first pregeneration raised it once the last concurrent job releases its hold.
*/
public static boolean restore() {
while (true) {
int holders = PARALLELISM_HOLDERS.get();
if (holders <= 0) {
return false;
}
if (PARALLELISM_HOLDERS.compareAndSet(holders, holders - 1)) {
return holders == 1 && MultiBurst.burst.restoreParallelism();
}
}
}
static int parallelismHolders() {
return PARALLELISM_HOLDERS.get();
}
public static void apply(Engine engine) { public static void apply(Engine engine) {
apply(); apply();
if (engine != null) { if (engine != null) {
@@ -6,10 +6,6 @@ import art.arcane.volmlib.util.documentation.RegionCoordinates;
import java.io.File; import java.io.File;
public interface PregenCache { public interface PregenCache {
default boolean isThreadSafe() {
return false;
}
@ChunkCoordinates @ChunkCoordinates
boolean isChunkCached(int x, int z); boolean isChunkCached(int x, int z);
@@ -32,16 +28,10 @@ public interface PregenCache {
} }
default PregenCache sync() { default PregenCache sync() {
if (isThreadSafe()) return this; return this;
return new SynchronizedCache(this);
} }
PregenCache EMPTY = new PregenCache() { PregenCache EMPTY = new PregenCache() {
@Override
public boolean isThreadSafe() {
return true;
}
@Override @Override
public boolean isChunkCached(int x, int z) { public boolean isChunkCached(int x, int z) {
return false; return false;
@@ -26,6 +26,9 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLongArray;
import java.util.concurrent.atomic.AtomicReferenceArray;
public class PregenCacheImpl implements PregenCache { public class PregenCacheImpl implements PregenCache {
// Daemon threads: this static pool is never shut down, and non-daemon threads here block JVM exit // Daemon threads: this static pool is never shut down, and non-daemon threads here block JVM exit
@@ -36,12 +39,15 @@ public class PregenCacheImpl implements PregenCache {
return thread; return thread;
}); });
private static final AtomicBoolean DISPATCHER_REGISTERED = new AtomicBoolean(); private static final AtomicBoolean DISPATCHER_REGISTERED = new AtomicBoolean();
private static final short SIZE = 1024; private static final int SIZE = 1024;
private static final int RETIRED_WRITE_RETRIES = 4;
private final File directory; private final File directory;
private final int maxSize; private final int maxSize;
private final AtomicBoolean missingStorageReported = new AtomicBoolean(); private final AtomicBoolean missingStorageReported = new AtomicBoolean();
private final Object residencyLock = new Object();
private final Long2ObjectLinkedOpenHashMap<Plate> cache = new Long2ObjectLinkedOpenHashMap<>(); private final Long2ObjectLinkedOpenHashMap<Plate> cache = new Long2ObjectLinkedOpenHashMap<>();
private volatile Plate recent;
public PregenCacheImpl(File directory, int maxSize) { public PregenCacheImpl(File directory, int maxSize) {
this.directory = directory; this.directory = directory;
@@ -78,35 +84,119 @@ public class PregenCacheImpl implements PregenCache {
@Override @Override
@ChunkCoordinates @ChunkCoordinates
public void cacheChunk(int x, int z) { public void cacheChunk(int x, int z) {
getPlate(PowerOfTwoCoordinates.floorDivPow2(x, 10), PowerOfTwoCoordinates.floorDivPow2(z, 10)).cache( int plateX = PowerOfTwoCoordinates.floorDivPow2(x, 10);
PowerOfTwoCoordinates.localMaskPow2(PowerOfTwoCoordinates.floorDivPow2(x, 5), PowerOfTwoCoordinates.REGION_CHUNK_BITS), int plateZ = PowerOfTwoCoordinates.floorDivPow2(z, 10);
PowerOfTwoCoordinates.localMaskPow2(PowerOfTwoCoordinates.floorDivPow2(z, 5), PowerOfTwoCoordinates.REGION_CHUNK_BITS), int regionX = PowerOfTwoCoordinates.localMaskPow2(PowerOfTwoCoordinates.floorDivPow2(x, 5), PowerOfTwoCoordinates.REGION_CHUNK_BITS);
region -> region.cache( int regionZ = PowerOfTwoCoordinates.localMaskPow2(PowerOfTwoCoordinates.floorDivPow2(z, 5), PowerOfTwoCoordinates.REGION_CHUNK_BITS);
PowerOfTwoCoordinates.localMaskPow2(x, PowerOfTwoCoordinates.REGION_CHUNK_BITS), int localX = PowerOfTwoCoordinates.localMaskPow2(x, PowerOfTwoCoordinates.REGION_CHUNK_BITS);
PowerOfTwoCoordinates.localMaskPow2(z, PowerOfTwoCoordinates.REGION_CHUNK_BITS) int localZ = PowerOfTwoCoordinates.localMaskPow2(z, PowerOfTwoCoordinates.REGION_CHUNK_BITS);
)
); for (int attempt = 0; attempt < RETIRED_WRITE_RETRIES; attempt++) {
Plate plate = getPlate(plateX, plateZ);
plate.cache(regionX, regionZ, region -> region.cache(localX, localZ));
if (!plate.retired) {
return;
}
}
} }
@Override @Override
@RegionCoordinates @RegionCoordinates
public void cacheRegion(int x, int z) { public void cacheRegion(int x, int z) {
getPlate(PowerOfTwoCoordinates.floorDivPow2(x, 5), PowerOfTwoCoordinates.floorDivPow2(z, 5)).cache( int plateX = PowerOfTwoCoordinates.floorDivPow2(x, 5);
PowerOfTwoCoordinates.localMaskPow2(x, PowerOfTwoCoordinates.REGION_CHUNK_BITS), int plateZ = PowerOfTwoCoordinates.floorDivPow2(z, 5);
PowerOfTwoCoordinates.localMaskPow2(z, PowerOfTwoCoordinates.REGION_CHUNK_BITS), int regionX = PowerOfTwoCoordinates.localMaskPow2(x, PowerOfTwoCoordinates.REGION_CHUNK_BITS);
Region::cache int regionZ = PowerOfTwoCoordinates.localMaskPow2(z, PowerOfTwoCoordinates.REGION_CHUNK_BITS);
);
for (int attempt = 0; attempt < RETIRED_WRITE_RETRIES; attempt++) {
Plate plate = getPlate(plateX, plateZ);
plate.cache(regionX, regionZ, Region::cache);
if (!plate.retired) {
return;
}
}
} }
@Override @Override
public void write() { public void write() {
if (cache.isEmpty()) { List<Plate> resident;
synchronized (residencyLock) {
if (cache.isEmpty()) {
return;
}
resident = new ArrayList<>(cache.values());
}
writePlates(resident);
}
@Override
public void trim(long unloadDuration) {
synchronized (residencyLock) {
if (cache.isEmpty()) {
return;
}
long threshold = System.currentTimeMillis() - unloadDuration;
List<Plate> resident = new ArrayList<>(cache.size());
Iterator<Plate> iterator = cache.values().iterator();
while (iterator.hasNext()) {
Plate plate = iterator.next();
if (plate.lastAccess < threshold) {
iterator.remove();
plate.retired = true;
}
resident.add(plate);
}
writePlates(resident);
}
}
private Plate getPlate(int x, int z) {
long key = key(x, z);
Plate memo = recent;
if (memo != null && memo.key == key && !memo.retired) {
return memo;
}
return residePlate(key, x, z);
}
private Plate residePlate(long key, int x, int z) {
Plate resolved;
synchronized (residencyLock) {
resolved = cache.getAndMoveToFirst(key);
if (resolved == null) {
resolved = readPlate(key, x, z);
cache.putAndMoveToFirst(key, resolved);
List<Plate> evicted = null;
while (cache.size() > maxSize) {
Plate removed = cache.removeLast();
removed.retired = true;
if (evicted == null) {
evicted = new ArrayList<>(2);
}
evicted.add(removed);
}
if (evicted != null) {
writePlates(evicted);
}
}
}
recent = resolved;
return resolved;
}
private void writePlates(List<Plate> plates) {
if (plates.isEmpty()) {
return; return;
} }
List<CompletableFuture<Void>> futures = new ArrayList<>(cache.size()); List<CompletableFuture<Void>> futures = new ArrayList<>(plates.size());
for (Plate plate : cache.values()) { for (Plate plate : plates) {
if (!plate.dirty) { if (!plate.dirty.get()) {
continue; continue;
} }
futures.add(CompletableFuture.runAsync(() -> writePlate(plate), DISPATCHER)); futures.add(CompletableFuture.runAsync(() -> writePlate(plate), DISPATCHER));
@@ -117,84 +207,41 @@ public class PregenCacheImpl implements PregenCache {
} }
} }
@Override private Plate readPlate(long key, int x, int z) {
public void trim(long unloadDuration) {
if (cache.isEmpty()) {
return;
}
long threshold = System.currentTimeMillis() - unloadDuration;
List<CompletableFuture<Void>> futures = new ArrayList<>(cache.size());
Iterator<Plate> iterator = cache.values().iterator();
while (iterator.hasNext()) {
Plate plate = iterator.next();
if (plate.lastAccess < threshold) {
iterator.remove();
}
futures.add(CompletableFuture.runAsync(() -> writePlate(plate), DISPATCHER));
}
for (CompletableFuture<Void> future : futures) {
future.join();
}
}
private Plate getPlate(int x, int z) {
long key = key(x, z);
Plate plate = cache.getAndMoveToFirst(key);
if (plate != null) {
return plate;
}
Plate loaded = readPlate(x, z);
cache.putAndMoveToFirst(key, loaded);
if (cache.size() > maxSize) {
List<CompletableFuture<Void>> futures = new ArrayList<>(cache.size() - maxSize);
while (cache.size() > maxSize) {
Plate evicted = cache.removeLast();
futures.add(CompletableFuture.runAsync(() -> writePlate(evicted), DISPATCHER));
}
for (CompletableFuture<Void> future : futures) {
future.join();
}
}
return loaded;
}
private Plate readPlate(int x, int z) {
File file = fileForPlate(x, z); File file = fileForPlate(x, z);
if (!file.exists()) { if (!file.exists()) {
return new Plate(x, z); return new Plate(key, x, z);
} }
try (DataInputStream input = new DataInputStream(new LZ4BlockInputStream(new FileInputStream(file)))) { try (DataInputStream input = new DataInputStream(new LZ4BlockInputStream(new FileInputStream(file)))) {
return readPlate(x, z, input); return readPlate(key, x, z, input);
} catch (IOException e) { } catch (IOException e) {
IrisLogging.error("Failed to read pregen cache " + file); IrisLogging.error("Failed to read pregen cache " + file);
IrisLogging.reportError(e); IrisLogging.reportError(e);
} }
return new Plate(x, z); return new Plate(key, x, z);
} }
private void writePlate(Plate plate) { private void writePlate(Plate plate) {
if (!plate.dirty) { synchronized (plate) {
return; if (!plate.dirty.compareAndSet(true, false)) {
} return;
if (!prepareCacheDirectory()) { }
return; if (!prepareCacheDirectory()) {
} plate.dirty.set(true);
return;
}
File file = null; File file = null;
try { try {
file = fileForPlate(plate.x, plate.z); file = fileForPlate(plate.x, plate.z);
IO.write(file, output -> new DataOutputStream(new LZ4BlockOutputStream(output)), plate::write); IO.write(file, output -> new DataOutputStream(new LZ4BlockOutputStream(output)), plate::write);
plate.dirty = false; } catch (Throwable e) {
} catch (Throwable e) { plate.dirty.set(true);
IrisLogging.error("Failed to write pregen cache " + (file != null ? file : "c." + plate.x + "." + plate.z)); IrisLogging.error("Failed to write pregen cache " + (file != null ? file : "c." + plate.x + "." + plate.z));
IrisLogging.reportError(e); IrisLogging.reportError(e);
}
} }
} }
@@ -243,36 +290,48 @@ public class PregenCacheImpl implements PregenCache {
} }
private static class Plate { private static class Plate {
private final long key;
private final int x; private final int x;
private final int z; private final int z;
private short count; private final AtomicInteger count;
private Region[] regions; private final AtomicReferenceArray<Region> regions;
private boolean dirty; private final AtomicBoolean dirty = new AtomicBoolean();
private long lastAccess; private volatile long lastAccess;
private volatile boolean retired;
private Plate(int x, int z) { private Plate(long key, int x, int z) {
this(x, z, (short) 0, new Region[1024]); this(key, x, z, 0, new AtomicReferenceArray<>(SIZE));
} }
private Plate(int x, int z, short count, Region[] regions) { private Plate(long key, int x, int z, int count, AtomicReferenceArray<Region> regions) {
this.key = key;
this.x = x; this.x = x;
this.z = z; this.z = z;
this.count = count; this.count = new AtomicInteger(count);
this.regions = regions; this.regions = regions;
this.lastAccess = System.currentTimeMillis(); this.lastAccess = System.currentTimeMillis();
} }
private int completed() {
return Math.min(count.get(), SIZE);
}
private boolean cache(int x, int z, RegionCacheOp op) { private boolean cache(int x, int z, RegionCacheOp op) {
lastAccess = System.currentTimeMillis(); lastAccess = System.currentTimeMillis();
if (count == SIZE) { if (completed() == SIZE) {
return false;
}
AtomicReferenceArray<Region> current = regions;
if (current == null) {
return false; return false;
} }
int index = PowerOfTwoCoordinates.packLocal32(x, z); int index = PowerOfTwoCoordinates.packLocal32(x, z);
Region region = regions[index]; Region region = current.get(index);
if (region == null) { if (region == null) {
region = new Region(); Region created = new Region();
regions[index] = region; region = current.compareAndSet(index, null, created) ? created : current.get(index);
} }
CacheResult result = op.apply(region); CacheResult result = op.apply(region);
@@ -280,25 +339,26 @@ public class PregenCacheImpl implements PregenCache {
return false; return false;
} }
dirty = true; dirty.set(true);
if (result != CacheResult.COMPLETED) { if (result != CacheResult.COMPLETED) {
return false; return false;
} }
count++; return count.incrementAndGet() >= SIZE;
if (count == SIZE) {
regions = null;
}
return true;
} }
private boolean isCached(int x, int z, RegionPredicate predicate) { private boolean isCached(int x, int z, RegionPredicate predicate) {
lastAccess = System.currentTimeMillis(); lastAccess = System.currentTimeMillis();
if (count == SIZE) { if (completed() == SIZE) {
return true; return true;
} }
Region region = regions[PowerOfTwoCoordinates.packLocal32(x, z)]; AtomicReferenceArray<Region> current = regions;
if (current == null) {
return true;
}
Region region = current.get(PowerOfTwoCoordinates.packLocal32(x, z));
if (region == null) { if (region == null) {
return false; return false;
} }
@@ -306,12 +366,15 @@ public class PregenCacheImpl implements PregenCache {
} }
private void write(DataOutput output) throws IOException { private void write(DataOutput output) throws IOException {
Varint.writeSignedVarInt(count, output); int total = completed();
if (regions == null) { Varint.writeSignedVarInt(total, output);
AtomicReferenceArray<Region> current = regions;
if (total == SIZE || current == null) {
return; return;
} }
for (Region region : regions) { for (int index = 0; index < SIZE; index++) {
Region region = current.get(index);
output.writeBoolean(region == null); output.writeBoolean(region == null);
if (region != null) { if (region != null) {
region.write(output); region.write(output);
@@ -321,33 +384,32 @@ public class PregenCacheImpl implements PregenCache {
} }
private static class Region { private static class Region {
private short count; private final AtomicInteger count;
private long[] words; private final AtomicLongArray words;
private Region() { private Region() {
this((short) 0, new long[64]); this(0, new AtomicLongArray(64));
} }
private Region(short count, long[] words) { private Region(int count, AtomicLongArray words) {
this.count = count; this.count = new AtomicInteger(count);
this.words = words; this.words = words;
} }
private int completed() {
return Math.min(count.get(), SIZE);
}
private CacheResult cache() { private CacheResult cache() {
if (count == SIZE) { return count.getAndSet(SIZE) >= SIZE ? CacheResult.NOOP : CacheResult.COMPLETED;
return CacheResult.NOOP;
}
count = SIZE;
words = null;
return CacheResult.COMPLETED;
} }
private CacheResult cache(int x, int z) { private CacheResult cache(int x, int z) {
if (count == SIZE) { if (completed() == SIZE) {
return CacheResult.NOOP; return CacheResult.NOOP;
} }
long[] value = words; AtomicLongArray value = words;
if (value == null) { if (value == null) {
return CacheResult.NOOP; return CacheResult.NOOP;
} }
@@ -355,73 +417,79 @@ public class PregenCacheImpl implements PregenCache {
int index = PowerOfTwoCoordinates.packLocal32(x, z); int index = PowerOfTwoCoordinates.packLocal32(x, z);
int wordIndex = index >> 6; int wordIndex = index >> 6;
long bit = 1L << (index & 63); long bit = 1L << (index & 63);
boolean current = (value[wordIndex] & bit) != 0L; while (true) {
if (current) { long current = value.get(wordIndex);
return CacheResult.NOOP; if ((current & bit) != 0L) {
return CacheResult.NOOP;
}
if (value.compareAndSet(wordIndex, current, current | bit)) {
break;
}
} }
count++; return count.incrementAndGet() >= SIZE ? CacheResult.COMPLETED : CacheResult.SET;
if (count == SIZE) {
words = null;
return CacheResult.COMPLETED;
}
value[wordIndex] = value[wordIndex] | bit;
return CacheResult.SET;
} }
private boolean isCached() { private boolean isCached() {
return count == SIZE; return completed() == SIZE;
} }
private boolean isCached(int x, int z) { private boolean isCached(int x, int z) {
int index = PowerOfTwoCoordinates.packLocal32(x, z); if (completed() == SIZE) {
if (count == SIZE) {
return true; return true;
} }
return (words[index >> 6] & (1L << (index & 63))) != 0L;
AtomicLongArray value = words;
if (value == null) {
return true;
}
int index = PowerOfTwoCoordinates.packLocal32(x, z);
return (value.get(index >> 6) & (1L << (index & 63))) != 0L;
} }
private void write(DataOutput output) throws IOException { private void write(DataOutput output) throws IOException {
Varint.writeSignedVarInt(count, output); int total = completed();
if (words == null) { Varint.writeSignedVarInt(total, output);
AtomicLongArray value = words;
if (total == SIZE || value == null) {
return; return;
} }
for (long word : words) { for (int index = 0; index < value.length(); index++) {
Varint.writeUnsignedVarLong(word, output); Varint.writeUnsignedVarLong(value.get(index), output);
} }
} }
} }
private static Plate readPlate(int x, int z, DataInput input) throws IOException { private static Plate readPlate(long key, int x, int z, DataInput input) throws IOException {
int count = Varint.readSignedVarInt(input); int count = Varint.readSignedVarInt(input);
if (count == 1024) { if (count == SIZE) {
return new Plate(x, z, SIZE, null); return new Plate(key, x, z, SIZE, null);
} }
Region[] regions = new Region[1024]; AtomicReferenceArray<Region> regions = new AtomicReferenceArray<>(SIZE);
for (int i = 0; i < regions.length; i++) { for (int index = 0; index < SIZE; index++) {
boolean isNull = input.readBoolean(); boolean isNull = input.readBoolean();
if (!isNull) { if (!isNull) {
regions[i] = readRegion(input); regions.set(index, readRegion(input));
} }
} }
return new Plate(x, z, (short) count, regions); return new Plate(key, x, z, count, regions);
} }
private static Region readRegion(DataInput input) throws IOException { private static Region readRegion(DataInput input) throws IOException {
int count = Varint.readSignedVarInt(input); int count = Varint.readSignedVarInt(input);
if (count == 1024) { if (count == SIZE) {
return new Region(SIZE, null); return new Region(SIZE, null);
} }
long[] words = new long[64]; AtomicLongArray words = new AtomicLongArray(64);
for (int i = 0; i < words.length; i++) { for (int index = 0; index < words.length(); index++) {
words[i] = Varint.readUnsignedVarLong(input); words.set(index, Varint.readUnsignedVarLong(input));
} }
return new Region((short) count, words); return new Region(count, words);
} }
} }
@@ -1,50 +0,0 @@
package art.arcane.iris.core.pregenerator.cache;
record SynchronizedCache(PregenCache cache) implements PregenCache {
@Override
public boolean isThreadSafe() {
return true;
}
@Override
public boolean isChunkCached(int x, int z) {
synchronized (cache) {
return cache.isChunkCached(x, z);
}
}
@Override
public boolean isRegionCached(int x, int z) {
synchronized (cache) {
return cache.isRegionCached(x, z);
}
}
@Override
public void cacheChunk(int x, int z) {
synchronized (cache) {
cache.cacheChunk(x, z);
}
}
@Override
public void cacheRegion(int x, int z) {
synchronized (cache) {
cache.cacheRegion(x, z);
}
}
@Override
public void write() {
synchronized (cache) {
cache.write();
}
}
@Override
public void trim(long unloadDuration) {
synchronized (cache) {
cache.trim(unloadDuration);
}
}
}
@@ -22,6 +22,7 @@ import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.IrisPaperLikeBackendMode; import art.arcane.iris.core.IrisPaperLikeBackendMode;
import art.arcane.iris.core.IrisRuntimeSchedulerMode; import art.arcane.iris.core.IrisRuntimeSchedulerMode;
import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.pregenerator.PregenAdmissionGate;
import art.arcane.iris.core.pregenerator.PregenDiagnostics; import art.arcane.iris.core.pregenerator.PregenDiagnostics;
import art.arcane.iris.core.pregenerator.PregenListener; import art.arcane.iris.core.pregenerator.PregenListener;
import art.arcane.iris.core.pregenerator.PregenMantleBackpressure; import art.arcane.iris.core.pregenerator.PregenMantleBackpressure;
@@ -51,7 +52,6 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@@ -68,6 +68,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private static final int ADAPTIVE_RECOVERY_INTERVAL = 8; private static final int ADAPTIVE_RECOVERY_INTERVAL = 8;
private static final long CLOSE_DRAIN_WARNING_SECONDS = 60L; private static final long CLOSE_DRAIN_WARNING_SECONDS = 60L;
private static final long FLUSH_TIMEOUT_SECONDS = 120L; private static final long FLUSH_TIMEOUT_SECONDS = 120L;
private static final long ADMISSION_WAIT_BOUND_MS = 500L;
private final World world; private final World world;
private final IrisRuntimeSchedulerMode runtimeSchedulerMode; private final IrisRuntimeSchedulerMode runtimeSchedulerMode;
private final IrisPaperLikeBackendMode paperLikeBackendMode; private final IrisPaperLikeBackendMode paperLikeBackendMode;
@@ -82,7 +83,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private final String chunkAccessMode; private final String chunkAccessMode;
private final ChunkRequestExecutor executor; private final ChunkRequestExecutor executor;
private final Executor slowRequestExecutor; private final Executor slowRequestExecutor;
private final Semaphore semaphore; private final PregenAdmissionGate admission;
private final int threads; private final int threads;
private final int slowRequestWarningSeconds; private final int slowRequestWarningSeconds;
private final int slowRequestWarnIntervalMs; private final int slowRequestWarnIntervalMs;
@@ -111,7 +112,6 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private final AtomicLong lastProgressAt = new AtomicLong(M.ms()); private final AtomicLong lastProgressAt = new AtomicLong(M.ms());
private final AtomicBoolean closing = new AtomicBoolean(); private final AtomicBoolean closing = new AtomicBoolean();
private final AtomicBoolean holdsWorkerBoost = new AtomicBoolean(); private final AtomicBoolean holdsWorkerBoost = new AtomicBoolean();
private final Object permitMonitor = new Object();
private volatile Engine metricsEngine; private volatile Engine metricsEngine;
private volatile Mantle cachedMantle; private volatile Mantle cachedMantle;
private final PregenMantleBackpressure backpressure; private final PregenMantleBackpressure backpressure;
@@ -161,7 +161,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
this.runtimeCpuThreads = detectedCpuThreads; this.runtimeCpuThreads = detectedCpuThreads;
this.effectiveWorkerThreads = workerThreadsForCap; this.effectiveWorkerThreads = workerThreadsForCap;
this.recommendedRuntimeConcurrencyCap = configuredThreads; this.recommendedRuntimeConcurrencyCap = configuredThreads;
this.semaphore = new Semaphore(this.threads, true); this.admission = new PregenAdmissionGate(this.threads, ADMISSION_WAIT_BOUND_MS, M::ms);
this.slowRequestWarningSeconds = pregen.getChunkLoadTimeoutSeconds(); this.slowRequestWarningSeconds = pregen.getChunkLoadTimeoutSeconds();
this.slowRequestExecutor = CompletableFuture.delayedExecutor(this.slowRequestWarningSeconds, TimeUnit.SECONDS); this.slowRequestExecutor = CompletableFuture.delayedExecutor(this.slowRequestWarningSeconds, TimeUnit.SECONDS);
this.slowRequestWarnIntervalMs = pregen.getTimeoutWarnIntervalMs(); this.slowRequestWarnIntervalMs = pregen.getTimeoutWarnIntervalMs();
@@ -527,11 +527,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
} catch (Throwable e) { } catch (Throwable e) {
IrisLogging.reportError(e); IrisLogging.reportError(e);
} finally { } finally {
try { markFinished(success);
markFinished(success);
} finally {
semaphore.release();
}
} }
} }
@@ -587,7 +583,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
int next = Math.max(adaptiveMinInFlightLimit, current - 1); int next = Math.max(adaptiveMinInFlightLimit, current - 1);
if (adaptiveInFlightLimit.compareAndSet(current, next)) { if (adaptiveInFlightLimit.compareAndSet(current, next)) {
logAdaptiveLimit("decrease", next); logAdaptiveLimit("decrease", next);
notifyPermitWaiters(); admission.wake();
return; return;
} }
} }
@@ -603,7 +599,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
int next = nextAdaptiveInFlightLimit(current, threads); int next = nextAdaptiveInFlightLimit(current, threads);
if (adaptiveInFlightLimit.compareAndSet(current, next)) { if (adaptiveInFlightLimit.compareAndSet(current, next)) {
logAdaptiveLimit("increase", next); logAdaptiveLimit("increase", next);
notifyPermitWaiters(); admission.wake();
return; return;
} }
} }
@@ -689,7 +685,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private String metricsSnapshot() { private String metricsSnapshot() {
long stalledFor = Math.max(0L, M.ms() - lastProgressAt.get()); long stalledFor = Math.max(0L, M.ms() - lastProgressAt.get());
return "world=" + world.getName() return "world=" + world.getName()
+ " permits=" + semaphore.availablePermits() + "/" + threads + " permits=" + admission.availablePermits() + "/" + threads
+ " adaptiveLimit=" + adaptiveInFlightLimit.get() + " adaptiveLimit=" + adaptiveInFlightLimit.get()
+ " inFlight=" + inFlight.get() + " inFlight=" + inFlight.get()
+ " submitted=" + submitted.get() + " submitted=" + submitted.get()
@@ -716,13 +712,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
if (after < 0) { if (after < 0) {
inFlight.compareAndSet(after, 0); inFlight.compareAndSet(after, 0);
} }
notifyPermitWaiters(); admission.release();
} backpressure.signalProgress();
private void notifyPermitWaiters() {
synchronized (permitMonitor) {
permitMonitor.notifyAll();
}
} }
private void recordAdaptiveWait(long waitedMs) { private void recordAdaptiveWait(long waitedMs) {
@@ -818,14 +809,12 @@ public class AsyncPregenMethod implements PregeneratorMethod {
@Override @Override
public void close() { public void close() {
closing.set(true); closing.set(true);
notifyPermitWaiters(); admission.wake();
// A stop request interrupts the pregen worker; shield the drain and flush so chunks still hit disk. // A stop request interrupts the pregen worker; shield the drain and flush so chunks still hit disk.
boolean interrupted = Thread.interrupted(); boolean interrupted = Thread.interrupted();
try { try {
interrupted |= awaitDrain( interrupted |= admission.awaitDrain(
semaphore,
threads,
CLOSE_DRAIN_WARNING_SECONDS, CLOSE_DRAIN_WARNING_SECONDS,
TimeUnit.SECONDS, TimeUnit.SECONDS,
() -> IrisLogging.warn("Async pregen is still draining outstanding chunks. " + metricsSnapshot()) () -> IrisLogging.warn("Async pregen is still draining outstanding chunks. " + metricsSnapshot())
@@ -843,26 +832,6 @@ public class AsyncPregenMethod implements PregeneratorMethod {
} }
} }
static boolean awaitDrain(
Semaphore semaphore,
int permits,
long warningInterval,
TimeUnit timeUnit,
Runnable onWait
) {
boolean interrupted = false;
while (true) {
try {
if (semaphore.tryAcquire(permits, warningInterval, timeUnit)) {
return interrupted;
}
onWait.run();
} catch (InterruptedException e) {
interrupted = true;
}
}
}
private boolean isCancelled() { private boolean isCancelled() {
return closing.get() || Thread.currentThread().isInterrupted(); return closing.get() || Thread.currentThread().isInterrupted();
} }
@@ -890,37 +859,26 @@ public class AsyncPregenMethod implements PregeneratorMethod {
return; return;
} }
PregenAdmissionGate.Wait waited;
try { try {
long waitStart = M.ms(); waited = admission.admit(() -> inFlight.get() >= adaptiveInFlightLimit.get(), this::isCancelled);
synchronized (permitMonitor) {
while (inFlight.get() >= adaptiveInFlightLimit.get()) {
if (isCancelled()) {
return;
}
permitMonitor.wait(500L);
}
}
long adaptiveWait = Math.max(0L, M.ms() - waitStart);
if (adaptiveWait > 0L) {
recordAdaptiveWait(adaptiveWait);
}
long permitWaitStart = M.ms();
while (!semaphore.tryAcquire(1, TimeUnit.SECONDS)) {
if (isCancelled()) {
return;
}
}
long permitWait = Math.max(0L, M.ms() - permitWaitStart);
if (permitWait > 0L) {
recordPermitWait(permitWait);
}
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
return; return;
} }
if (waited == null) {
return;
}
if (waited.adaptiveMs() > 0L) {
recordAdaptiveWait(waited.adaptiveMs());
}
if (waited.permitMs() > 0L) {
recordPermitWait(waited.permitMs());
}
regionPending.computeIfAbsent(rkey(x >> 5, z >> 5), k -> new AtomicInteger(1)).incrementAndGet(); regionPending.computeIfAbsent(rkey(x >> 5, z >> 5), k -> new AtomicInteger(1)).incrementAndGet();
markSubmitted(); markSubmitted();
executor.generate(x, z, listener); executor.generate(x, z, listener);
@@ -10,6 +10,7 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinWorkerThread; import java.util.concurrent.ForkJoinWorkerThread;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.IntSupplier; import java.util.function.IntSupplier;
public class MultiBurst extends MultiBurstSupport { public class MultiBurst extends MultiBurstSupport {
@@ -21,6 +22,7 @@ public class MultiBurst extends MultiBurstSupport {
* plans never pin the workers that chunk generation's short stage and mantle tasks need. * 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()))); public static final MultiBurst hydrology = new MultiBurst("Iris Hydrology", () -> Math.max(2, IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism())));
private final AtomicInteger parallelismBaseline = new AtomicInteger();
public MultiBurst() { public MultiBurst() {
this("Iris"); this("Iris");
@@ -41,16 +43,38 @@ public class MultiBurst extends MultiBurstSupport {
/** /**
* Raises the pool's parallelism to at least {@code target} workers. Pregeneration fans every * 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, * 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. * so a pregeneration box wants more workers than cores. {@link #restoreParallelism()} puts it back.
*/ */
public boolean raiseParallelism(int target) { public boolean raiseParallelism(int target) {
if (!(service() instanceof ForkJoinPool pool) || target <= pool.getParallelism()) { if (!(service() instanceof ForkJoinPool pool) || target <= pool.getParallelism()) {
return false; return false;
} }
parallelismBaseline.compareAndSet(0, pool.getParallelism());
pool.setParallelism(target); pool.setParallelism(target);
return true; return true;
} }
/**
* Returns the pool to the parallelism it had before the first {@link #raiseParallelism(int)} that took
* effect. A pool that was never raised, or that is not a {@link ForkJoinPool}, is left alone.
*/
public boolean restoreParallelism() {
int baseline = parallelismBaseline.getAndSet(0);
if (baseline <= 0 || !(service() instanceof ForkJoinPool pool) || pool.getParallelism() <= baseline) {
return false;
}
try {
pool.setParallelism(baseline);
} catch (RuntimeException e) {
IrisLogging.reportError("Iris could not return the burst pool to its pre-pregeneration parallelism of "
+ baseline + "; it stays at " + pool.getParallelism() + ".", e);
return false;
}
return true;
}
public int parallelism() { public int parallelism() {
return service() instanceof ForkJoinPool pool ? pool.getParallelism() : -1; return service() instanceof ForkJoinPool pool ? pool.getParallelism() : -1;
} }
@@ -0,0 +1,189 @@
package art.arcane.iris.core.pregenerator;
import org.junit.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class PregenAdmissionGateTest {
private static final long GENEROUS_BOUND_MS = 60_000L;
@Test
public void admissionConsumesOnePermitAndReturnsItOnRelease() throws Exception {
PregenAdmissionGate gate = new PregenAdmissionGate(2, GENEROUS_BOUND_MS, System::currentTimeMillis);
assertEquals(2, gate.availablePermits());
assertNotNull(gate.admit(() -> false, () -> false));
assertEquals(1, gate.availablePermits());
gate.release();
assertEquals(2, gate.availablePermits());
}
@Test
public void releaseNeverPushesThePermitCountAboveTheConfiguredCeiling() throws Exception {
PregenAdmissionGate gate = new PregenAdmissionGate(1, GENEROUS_BOUND_MS, System::currentTimeMillis);
gate.release();
gate.release();
assertEquals(1, gate.availablePermits());
assertNotNull(gate.admit(() -> false, () -> false));
assertEquals(0, gate.availablePermits());
}
@Test
public void aBlockedAdmissionWakesOnTheReleaseSignalRatherThanTheTimeout() throws Exception {
PregenAdmissionGate gate = new PregenAdmissionGate(1, GENEROUS_BOUND_MS, System::currentTimeMillis);
assertNotNull(gate.admit(() -> false, () -> false));
CountDownLatch started = new CountDownLatch(1);
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<PregenAdmissionGate.Wait> blocked = executor.submit(() -> {
started.countDown();
return gate.admit(() -> false, () -> false);
});
assertTrue(started.await(5, TimeUnit.SECONDS));
while (gate.availablePermits() != 0) {
Thread.onSpinWait();
}
gate.release();
assertNotNull(blocked.get(5, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
}
@Test
public void aBlockedAdmissionWakesOnTheAdaptiveLimitSignalWithoutWaitingOutTheBound() throws Exception {
PregenAdmissionGate gate = new PregenAdmissionGate(4, GENEROUS_BOUND_MS, System::currentTimeMillis);
AtomicBoolean overLimit = new AtomicBoolean(true);
CountDownLatch started = new CountDownLatch(1);
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<PregenAdmissionGate.Wait> blocked = executor.submit(() -> {
started.countDown();
return gate.admit(overLimit::get, () -> false);
});
assertTrue(started.await(5, TimeUnit.SECONDS));
Thread.yield();
overLimit.set(false);
gate.wake();
assertNotNull(blocked.get(5, TimeUnit.SECONDS));
assertEquals(3, gate.availablePermits());
} finally {
executor.shutdownNow();
}
}
@Test
public void aCancelledAdmissionReturnsNothingAndConsumesNoPermit() throws Exception {
PregenAdmissionGate gate = new PregenAdmissionGate(1, GENEROUS_BOUND_MS, System::currentTimeMillis);
assertNull(gate.admit(() -> true, () -> true));
assertEquals(1, gate.availablePermits());
}
@Test
public void aCancellationRaisedWhileBlockedEndsTheWait() throws Exception {
PregenAdmissionGate gate = new PregenAdmissionGate(1, GENEROUS_BOUND_MS, System::currentTimeMillis);
assertNotNull(gate.admit(() -> false, () -> false));
AtomicBoolean cancelled = new AtomicBoolean();
CountDownLatch started = new CountDownLatch(1);
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<PregenAdmissionGate.Wait> blocked = executor.submit(() -> {
started.countDown();
return gate.admit(() -> false, cancelled::get);
});
assertTrue(started.await(5, TimeUnit.SECONDS));
Thread.yield();
cancelled.set(true);
gate.wake();
assertNull(blocked.get(5, TimeUnit.SECONDS));
assertEquals(0, gate.availablePermits());
} finally {
executor.shutdownNow();
}
}
@Test
public void theAdaptiveAndPermitWaitsAreAttributedSeparately() throws Exception {
AtomicLong clock = new AtomicLong();
AtomicBoolean overLimit = new AtomicBoolean(true);
PregenAdmissionGate gate = new PregenAdmissionGate(1, 5L, () -> clock.addAndGet(10L));
assertNotNull(gate.admit(() -> false, () -> false));
AtomicInteger passes = new AtomicInteger();
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<PregenAdmissionGate.Wait> blocked = executor.submit(() -> gate.admit(() -> {
if (passes.incrementAndGet() > 2) {
overLimit.set(false);
}
return overLimit.get();
}, () -> false));
while (passes.get() <= 3) {
Thread.onSpinWait();
}
gate.release();
PregenAdmissionGate.Wait wait = blocked.get(5, TimeUnit.SECONDS);
assertNotNull(wait);
assertTrue("adaptive wait was not attributed: " + wait, wait.adaptiveMs() > 0L);
assertTrue("permit wait was not attributed: " + wait, wait.permitMs() > 0L);
} finally {
executor.shutdownNow();
}
}
@Test
public void drainReturnsOnlyAfterEveryPermitIsBack() throws Exception {
PregenAdmissionGate gate = new PregenAdmissionGate(2, GENEROUS_BOUND_MS, System::currentTimeMillis);
assertNotNull(gate.admit(() -> false, () -> false));
assertNotNull(gate.admit(() -> false, () -> false));
AtomicInteger warnings = new AtomicInteger();
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<Boolean> drain = executor.submit(() -> gate.awaitDrain(5L, TimeUnit.MILLISECONDS, warnings::incrementAndGet));
while (warnings.get() < 2) {
Thread.onSpinWait();
}
gate.release();
gate.release();
assertEquals(Boolean.FALSE, drain.get(5, TimeUnit.SECONDS));
assertEquals(2, gate.availablePermits());
} finally {
executor.shutdownNow();
}
}
@Test(expected = IllegalArgumentException.class)
public void aGateWithoutPermitsIsRejected() {
new PregenAdmissionGate(0, GENEROUS_BOUND_MS, System::currentTimeMillis);
}
@Test(expected = IllegalArgumentException.class)
public void aGateWithoutAWaitBoundIsRejected() {
new PregenAdmissionGate(1, 0L, System::currentTimeMillis);
}
}
@@ -107,4 +107,41 @@ public class PregenPerformanceProfileTest {
assertEquals(before + 1, MultiBurst.burst.parallelism()); assertEquals(before + 1, MultiBurst.burst.parallelism());
assertFalse(MultiBurst.burst.raiseParallelism(before)); assertFalse(MultiBurst.burst.raiseParallelism(before));
} }
@Test
public void restoringBurstParallelismReturnsThePoolToItsPrePregenerationSize() {
int before = MultiBurst.ioBurst.parallelism();
assertTrue(MultiBurst.ioBurst.raiseParallelism(before + 3));
assertEquals(before + 3, MultiBurst.ioBurst.parallelism());
assertTrue(MultiBurst.ioBurst.restoreParallelism());
assertEquals(before, MultiBurst.ioBurst.parallelism());
assertFalse(MultiBurst.ioBurst.restoreParallelism());
assertEquals(before, MultiBurst.ioBurst.parallelism());
}
@Test
public void aSecondRaiseKeepsTheOriginalBaselineSoOneRestoreIsEnough() {
int before = MultiBurst.ioBurst.parallelism();
assertTrue(MultiBurst.ioBurst.raiseParallelism(before + 2));
assertTrue(MultiBurst.ioBurst.raiseParallelism(before + 5));
assertTrue(MultiBurst.ioBurst.restoreParallelism());
assertEquals(before, MultiBurst.ioBurst.parallelism());
}
@Test
public void theBurstParallelismHoldIsReleasedByAMatchingRestore() {
while (PregenPerformanceProfile.parallelismHolders() > 0) {
PregenPerformanceProfile.restore();
}
assertFalse(PregenPerformanceProfile.restore());
assertEquals(0, PregenPerformanceProfile.parallelismHolders());
PregenPerformanceProfile.apply();
int holders = PregenPerformanceProfile.parallelismHolders();
PregenPerformanceProfile.restore();
assertEquals(Math.max(0, holders - 1), PregenPerformanceProfile.parallelismHolders());
}
} }
@@ -0,0 +1,111 @@
package art.arcane.iris.core.pregenerator;
import org.junit.Test;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class PregenWaitPolicyContractTest {
@Test
public void noPregenSourceSleepPollsWhileWaitingForCapacity() throws IOException {
List<String> offenders = new ArrayList<>();
Path pregenRoot = sourceRoot().resolve("art/arcane/iris/core/pregenerator");
try (Stream<Path> sources = Files.walk(pregenRoot)) {
for (Path source : sources.filter(path -> path.toString().endsWith(".java")).toList()) {
String text = Files.readString(source);
if (text.contains("Thread.sleep(") || text.contains("J.sleep(")) {
offenders.add(source.getFileName().toString());
}
}
}
assertEquals("sleep polling on the pregen path: " + offenders, List.of(), offenders);
}
@Test
public void mantleBackpressureWaitsOnAProgressSignalWithABoundedTimeout() throws IOException {
String backpressure = read("art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java");
assertTrue(backpressure.contains("progressed.await(waitMs, TimeUnit.MILLISECONDS)"));
assertTrue(method(backpressure, "public void enforceMantleBudget()").contains("awaitProgress()"));
assertTrue(method(backpressure, "public void awaitHeapHeadroom()").contains("awaitProgress()"));
}
@Test
public void chunkCompletionSignalsBothTheAdmissionGateAndTheBackpressureWait() throws IOException {
String finished = method(read("art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java"),
"private void markFinished(boolean success)");
assertTrue(finished, finished.contains("admission.release()"));
assertTrue(finished, finished.contains("backpressure.signalProgress()"));
}
@Test
public void pauseResumeAndStopSignalThePregenGateInsteadOfLettingItPoll() throws IOException {
String pregenerator = read("art/arcane/iris/core/pregenerator/IrisPregenerator.java");
assertTrue(method(pregenerator, "public void pause()").contains("signalState()"));
assertTrue(method(pregenerator, "public void resume()").contains("signalState()"));
assertTrue(method(pregenerator, "public void close()").contains("signalState()"));
assertTrue(method(pregenerator, "private void visitRegion(").contains("awaitStateChange(GATE_WAIT_MS)"));
assertTrue(method(pregenerator, "private void reclaimTectonicPlates(").contains("awaitStateChange(RECLAIM_WAIT_MS)"));
}
@Test
public void aFinishedPregenReleasesTheBurstParallelismItRaised() throws IOException {
String shutdown = method(read("art/arcane/iris/core/pregenerator/IrisPregenerator.java"), "private void shutdown()");
assertTrue(shutdown, shutdown.contains("shutdownStep(\"parallelism\", PregenPerformanceProfile::restore)"));
}
private static Path sourceRoot() throws IOException {
Path current;
try {
current = Path.of(PregenWaitPolicyContractTest.class.getProtectionDomain()
.getCodeSource()
.getLocation()
.toURI());
} catch (URISyntaxException e) {
throw new IOException("Unable to resolve the core source root", e);
}
while (current != null && !Files.isDirectory(current.resolve("src/main/java"))) {
current = current.getParent();
}
if (current == null) {
throw new IOException("Unable to locate the core source root");
}
return current.resolve("src/main/java");
}
private static String read(String relativePath) throws IOException {
return Files.readString(sourceRoot().resolve(relativePath));
}
private static String method(String source, String signature) {
int start = source.indexOf(signature);
assertTrue("Missing source contract signature: " + signature, start >= 0);
int openBrace = source.indexOf('{', start);
assertTrue("Missing source contract method body: " + signature, openBrace >= 0);
int depth = 0;
for (int index = openBrace; index < source.length(); index++) {
char current = source.charAt(index);
if (current == '{') {
depth++;
} else if (current == '}') {
depth--;
if (depth == 0) {
return source.substring(start, index + 1);
}
}
}
throw new IllegalArgumentException("Unclosed source contract method: " + signature);
}
}
@@ -0,0 +1,194 @@
package art.arcane.iris.core.pregenerator.cache;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.io.File;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class PregenCacheConcurrencyTest {
private static final int THREADS = 8;
private File directory;
private ExecutorService pool;
@Before
public void setUp() throws Exception {
directory = Files.createTempDirectory("iris-pregen-cache-concurrency").toFile();
pool = Executors.newFixedThreadPool(THREADS);
}
@After
public void tearDown() throws Exception {
pool.shutdownNow();
pool.awaitTermination(30, TimeUnit.SECONDS);
deleteRecursively(directory);
}
@Test
public void everyConcurrentlyCachedChunkIsReadBackAsCached() throws Exception {
PregenCache cache = PregenCache.create(directory);
int chunksPerThread = 512;
runAll(thread -> () -> {
for (int index = 0; index < chunksPerThread; index++) {
int x = (thread * chunksPerThread) + index;
cache.cacheChunk(x, thread);
}
return null;
});
for (int thread = 0; thread < THREADS; thread++) {
for (int index = 0; index < chunksPerThread; index++) {
int x = (thread * chunksPerThread) + index;
assertTrue("chunk " + x + "," + thread, cache.isChunkCached(x, thread));
}
}
}
@Test
public void concurrentWritersToOneRegionNeverLoseABit() throws Exception {
PregenCache cache = PregenCache.create(directory);
runAll(thread -> () -> {
for (int index = thread; index < 1024; index += THREADS) {
cache.cacheChunk(index & 31, index >> 5);
}
return null;
});
for (int index = 0; index < 1024; index++) {
assertTrue("chunk " + index, cache.isChunkCached(index & 31, index >> 5));
}
assertTrue(cache.isRegionCached(0, 0));
}
@Test
public void aRegionIsOnlyReportedCachedOnceEveryChunkInItLanded() throws Exception {
PregenCache cache = PregenCache.create(directory);
AtomicInteger seenComplete = new AtomicInteger();
runAll(thread -> () -> {
for (int index = thread; index < 1023; index += THREADS) {
cache.cacheChunk(index & 31, index >> 5);
if (cache.isRegionCached(0, 0)) {
seenComplete.incrementAndGet();
}
}
return null;
});
assertEquals(0, seenComplete.get());
assertFalse(cache.isRegionCached(0, 0));
cache.cacheChunk(1023 & 31, 1023 >> 5);
assertTrue(cache.isRegionCached(0, 0));
}
@Test
public void concurrentReadsAndWritesNeverInventACachedChunk() throws Exception {
PregenCache cache = PregenCache.create(directory);
AtomicInteger falsePositives = new AtomicInteger();
runAll(thread -> () -> {
if (thread % 2 == 0) {
for (int index = 0; index < 2048; index++) {
cache.cacheChunk(index, 0);
}
return null;
}
for (int index = 0; index < 2048; index++) {
if (cache.isChunkCached(index, 7000)) {
falsePositives.incrementAndGet();
}
}
return null;
});
assertEquals(0, falsePositives.get());
}
@Test
public void aConcurrentWriteAndFlushRoundTripsThroughDiskWithoutLosingChunks() throws Exception {
PregenCache cache = PregenCache.create(directory);
runAll(thread -> () -> {
for (int index = 0; index < 256; index++) {
cache.cacheChunk((thread * 256) + index, 4);
if (index % 64 == 0) {
cache.write();
}
}
return null;
});
cache.write();
PregenCache reopened = PregenCache.create(directory);
for (int thread = 0; thread < THREADS; thread++) {
for (int index = 0; index < 256; index++) {
int x = (thread * 256) + index;
assertTrue("chunk " + x + " lost across a flush", reopened.isChunkCached(x, 4));
}
}
}
@Test
public void plateEvictionUnderConcurrencyStillPersistsEveryChunk() throws Exception {
PregenCacheImpl cache = new PregenCacheImpl(directory, 1);
int platesPerThread = 6;
runAll(thread -> () -> {
for (int plate = 0; plate < platesPerThread; plate++) {
cache.cacheChunk(plate * 1024, thread);
}
return null;
});
cache.write();
PregenCache reopened = PregenCache.create(directory);
for (int thread = 0; thread < THREADS; thread++) {
for (int plate = 0; plate < platesPerThread; plate++) {
assertTrue("chunk " + (plate * 1024) + "," + thread + " lost to eviction",
reopened.isChunkCached(plate * 1024, thread));
}
}
}
private void runAll(ThreadTask task) throws Exception {
CyclicBarrier barrier = new CyclicBarrier(THREADS);
List<Future<Void>> futures = new ArrayList<>(THREADS);
for (int thread = 0; thread < THREADS; thread++) {
Callable<Void> body = task.forThread(thread);
futures.add(pool.submit(() -> {
barrier.await(30, TimeUnit.SECONDS);
return body.call();
}));
}
for (Future<Void> future : futures) {
future.get(60, TimeUnit.SECONDS);
}
}
private static void deleteRecursively(File file) {
File[] children = file.listFiles();
if (children != null) {
for (File child : children) {
deleteRecursively(child);
}
}
file.delete();
}
private interface ThreadTask {
Callable<Void> forThread(int thread);
}
}
@@ -1,17 +1,18 @@
package art.arcane.iris.core.pregenerator.methods; package art.arcane.iris.core.pregenerator.methods;
import art.arcane.iris.core.pregenerator.PregenAdmissionGate;
import org.junit.Test; import org.junit.Test;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@@ -101,13 +102,13 @@ public class AsyncPregenMethodConcurrencyCapTest {
@Test @Test
public void closeDrainWaitsPastWarningIntervalsUntilEveryPermitReturns() throws Exception { public void closeDrainWaitsPastWarningIntervalsUntilEveryPermitReturns() throws Exception {
Semaphore semaphore = new Semaphore(0); PregenAdmissionGate gate = new PregenAdmissionGate(2, 5L, System::currentTimeMillis);
assertNotNull(gate.admit(() -> false, () -> false));
assertNotNull(gate.admit(() -> false, () -> false));
AtomicInteger warnings = new AtomicInteger(); AtomicInteger warnings = new AtomicInteger();
ExecutorService executor = Executors.newSingleThreadExecutor(); ExecutorService executor = Executors.newSingleThreadExecutor();
try { try {
Future<Boolean> drain = executor.submit(() -> AsyncPregenMethod.awaitDrain( Future<Boolean> drain = executor.submit(() -> gate.awaitDrain(
semaphore,
2,
10L, 10L,
TimeUnit.MILLISECONDS, TimeUnit.MILLISECONDS,
warnings::incrementAndGet warnings::incrementAndGet
@@ -116,11 +117,12 @@ public class AsyncPregenMethodConcurrencyCapTest {
while (warnings.get() == 0) { while (warnings.get() == 0) {
Thread.onSpinWait(); Thread.onSpinWait();
} }
semaphore.release(2); gate.release();
gate.release();
assertFalse(drain.get(1L, TimeUnit.SECONDS)); assertFalse(drain.get(1L, TimeUnit.SECONDS));
assertTrue(warnings.get() > 0); assertTrue(warnings.get() > 0);
assertEquals(0, semaphore.availablePermits()); assertEquals(2, gate.availablePermits());
} finally { } finally {
executor.shutdownNow(); executor.shutdownNow();
} }