From ec07f55fd8b6fa74679eec5862f78143bff488c4 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Mon, 28 Sep 2026 11:12:37 -0400 Subject: [PATCH] Pack and drain object plans - Store source plans as packed per-destination cell runs over a value table - Weigh shared payloads once and give tiles and markers finite weights - Release a plan once every touched destination consumed it, keeping its index - Rebuild only on repeated consumption; debug stats for builds and waits --- .../mantle/MantleObjectComponent.java | 28 +- .../mantle/ObjectDestinationTransaction.java | 6 +- .../generation/mantle/ObjectSourcePlan.java | 384 +++++++++++++----- .../mantle/ObjectSourcePlanCache.java | 154 ++++++- ...jectComponentCollisionDeterminismTest.java | 10 +- ...jectComponentPlacementDeterminismTest.java | 16 +- .../mantle/ObjectContinuationTest.java | 45 +- .../ObjectDestinationTransactionTest.java | 12 +- .../mantle/ObjectSourcePlanCacheTest.java | 239 ++++++----- .../mantle/ObjectSourcePlanTest.java | 154 ++++++- 10 files changed, 802 insertions(+), 246 deletions(-) diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java index c306afb3e..9a597a27b 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java @@ -79,9 +79,11 @@ public class MantleObjectComponent extends IrisMantleComponent { private static final byte LIQUID_FORCED_AIR = 3; private static final Map CAVE_REJECT_LOG_STATE = new ConcurrentHashMap<>(); private static final Set MISSING_LOAD_KEY_WARNED = ConcurrentHashMap.newKeySet(); + private static final long SOURCE_PLAN_STATS_INTERVAL_MS = 30_000L; private final Object collisionRuleLock; private final ObjectSourcePlanCache sourcePlans; + private final AtomicLong lastSourcePlanStatsLog = new AtomicLong(); private volatile int collisionRuleState; public MantleObjectComponent(EngineMantle engineMantle) { @@ -143,13 +145,33 @@ public class MantleObjectComponent extends IrisMantleComponent { z, getRadius(), (sourceX, sourceZ) -> { - ObjectSourcePlan plan = sourcePlans.get(sourceX, sourceZ, + ObjectSourcePlan plan = sourcePlans.acquire(sourceX, sourceZ, x, z, () -> buildSourcePlan(writer, sourceX, sourceZ, context)); - transaction.apply(plan); - plan.persistContinuations(writer, sourceX, sourceZ, x, z); + if (plan != null) { + transaction.apply(plan); + plan.persistContinuations(writer, sourceX, sourceZ, x, z); + } } ); transaction.commit(); + logSourcePlanStats(); + } + + private void logSourcePlanStats() { + long now = System.currentTimeMillis(); + long last = lastSourcePlanStatsLog.get(); + if (now - last < SOURCE_PLAN_STATS_INTERVAL_MS || !lastSourcePlanStatsLog.compareAndSet(last, now)) { + return; + } + ObjectSourcePlanCache.Stats stats = sourcePlans.stats(); + IrisLogging.debug("Object source plans: lookups=" + stats.lookups() + + " builds=" + stats.builds() + + " waits=" + stats.waits() + + " replays=" + stats.replays() + + " drained=" + stats.drained() + + " retained=" + stats.retained() + + " retainedMiB=" + (stats.retainedBytes() >> 20) + + " budgetMiB=" + (stats.budgetBytes() >> 20)); } private ObjectSourcePlan buildSourcePlan( diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java index 31ebc942e..219596fab 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java @@ -54,10 +54,10 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { if (checkpoint < 0 || checkpoint > mutations.size()) { throw new IllegalArgumentException("Mutation checkpoint is outside the transaction"); } - ArrayList> accepted = new ArrayList<>(); + ArrayList accepted = new ArrayList<>(); for (PlacementRange placement : placements) { if (placement.start() >= checkpoint) { - accepted.add(List.copyOf(mutations.subList(placement.start(), placement.end()))); + accepted.add(new PlacementRange(placement.start() - checkpoint, placement.end() - checkpoint)); } } return new ObjectSourcePlan(mutations.subList(checkpoint, mutations.size()), accepted); @@ -432,7 +432,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { } } - private record PlacementRange(int start, int end) { + record PlacementRange(int start, int end) { } record DataKey(int x, int y, int z, Class type) { diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java index 4233fbd42..c90372d47 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java @@ -4,93 +4,242 @@ import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.integration.Identifier; import art.arcane.iris.world.storage.matter.IrisMatterSupport; +import art.arcane.iris.world.storage.matter.TileWrapper; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.IrisMatter; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; -import it.unimi.dsi.fastutil.longs.Long2ObjectMap; -import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import art.arcane.volmlib.util.matter.MatterMarker; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongArrayList; +import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; +import it.unimi.dsi.fastutil.objects.Reference2IntOpenHashMap; import java.util.ArrayList; -import java.util.LinkedHashMap; +import java.util.Arrays; import java.util.List; -import java.util.Map; +import java.util.Objects; final class ObjectSourcePlan { - private static final int PLAN_BYTES = 256; - private static final int DESTINATION_INDEX_BYTES = 96; - private static final int MUTATION_BYTES = 96; - private static final int PLACEMENT_BYTES = 256; - private static final int PLACEMENT_DESTINATION_BYTES = 128; - private static final int REFERENCE_BYTES = 8; - private final Long2ObjectOpenHashMap> destinations; + static final ObjectSourcePlan EMPTY = new ObjectSourcePlan(List.of()); + private static final int PLAN_BYTES = 160; + private static final int DESTINATION_BYTES = 12; + private static final int MUTATION_BYTES = 8; + private static final int VALUE_BYTES = 8; + private static final int PLACEMENT_BYTES = 128; + private static final int PLACEMENT_DESTINATION_BYTES = 40; + private static final int UNKNOWN_PAYLOAD_BYTES = 256; + private static final int CUSTOM_BIT = 1 << 8; + private static final int Y_SHIFT = 9; + private static final int MAXIMUM_Y = (1 << (31 - Y_SHIFT)) - 1; + + private final long[] destinations; + private final int[] offsets; + private final int[] cells; + private final int[] valueIndexes; + private final Object[] values; + private final Placement[] placements; private final int estimatedRetainedBytes; - private final List placements; ObjectSourcePlan(List mutations) { this(mutations, List.of()); } - ObjectSourcePlan(List mutations, List> placements) { + ObjectSourcePlan(List mutations, List accepted) { + int count = mutations.size(); + long[] mutationDestinations = new long[count]; + Long2IntOpenHashMap destinationSlots = new Long2IntOpenHashMap(); + destinationSlots.defaultReturnValue(-1); + LongArrayList firstSeen = new LongArrayList(); + for (int index = 0; index < count; index++) { + ObjectDestinationTransaction.Mutation mutation = Objects.requireNonNull(mutations.get(index), "mutation"); + long destination = destinationKey(mutation.x() >> 4, mutation.z() >> 4); + mutationDestinations[index] = destination; + if (destinationSlots.putIfAbsent(destination, firstSeen.size()) == -1) { + firstSeen.add(destination); + } + } + destinations = firstSeen.toLongArray(); + Arrays.sort(destinations); + for (int slot = 0; slot < destinations.length; slot++) { + destinationSlots.put(destinations[slot], slot); + } + offsets = new int[destinations.length + 1]; + for (int index = 0; index < count; index++) { + offsets[destinationSlots.get(mutationDestinations[index]) + 1]++; + } + for (int slot = 0; slot < destinations.length; slot++) { + offsets[slot + 1] += offsets[slot]; + } + int[] cursor = Arrays.copyOf(offsets, destinations.length); + int[] positions = new int[count]; + cells = new int[count]; + valueIndexes = new int[count]; + ValueTable table = new ValueTable(); + for (int index = 0; index < count; index++) { + ObjectDestinationTransaction.Mutation mutation = mutations.get(index); + int position = cursor[destinationSlots.get(mutationDestinations[index])]++; + positions[index] = position; + ObjectDestinationTransaction.DataKey key = mutation.key(); + if (key.y() < 0 || key.y() > MAXIMUM_Y) { + throw new IllegalArgumentException("Object mutation height is outside the plan range: " + key.y()); + } + Object value; + int custom; + switch (mutation) { + case ObjectDestinationTransaction.SetMutation set -> { + value = Objects.requireNonNull(set.value(), "mutation value"); + if (typeOf(value) != key.type()) { + throw new IllegalArgumentException("Object mutation type " + key.type().getName() + + " does not match its value " + value.getClass().getName()); + } + custom = 0; + } + case ObjectDestinationTransaction.CustomBlockMutation block -> { + value = Objects.requireNonNull(block.state(), "custom block state"); + custom = CUSTOM_BIT; + } + } + cells[position] = (key.y() << Y_SHIFT) | custom | ((key.z() & 15) << 4) | (key.x() & 15); + valueIndexes[position] = table.indexOf(value); + } + values = table.values.toArray(); + ArrayList crossing = new ArrayList<>(); - for (int ordinal = 0; ordinal < placements.size(); ordinal++) { - List placement = placements.get(ordinal); - LinkedHashMap> fragments = group(placement); - if (fragments.size() > 1) { - crossing.add(new Placement(ordinal, bounds(placement), List.copyOf(fragments.keySet()), fragments)); + for (int ordinal = 0; ordinal < accepted.size(); ordinal++) { + ObjectDestinationTransaction.PlacementRange range = accepted.get(ordinal); + Placement placement = crossingPlacement(ordinal, range.start(), range.end(), mutations, mutationDestinations, destinationSlots, positions); + if (placement != null) { + crossing.add(placement); } } - this.placements = List.copyOf(crossing); - Long2ObjectOpenHashMap> grouped = new Long2ObjectOpenHashMap<>(2); - long weight = PLAN_BYTES; - for (ObjectDestinationTransaction.Mutation mutation : mutations) { - long key = destinationKey(mutation.x() >> 4, mutation.z() >> 4); - ArrayList local = grouped.get(key); - if (local == null) { - local = new ArrayList<>(); - grouped.put(key, local); - weight += DESTINATION_INDEX_BYTES; - } - local.add(mutation); - Object payload = switch (mutation) { - case ObjectDestinationTransaction.SetMutation set -> set.value(); - case ObjectDestinationTransaction.CustomBlockMutation custom -> custom.state(); - }; - weight = Math.min(Integer.MAX_VALUE, weight + MUTATION_BYTES + payloadBytes(payload)); - } - destinations = new Long2ObjectOpenHashMap<>(grouped.size()); - for (Long2ObjectMap.Entry> entry : grouped.long2ObjectEntrySet()) { - destinations.put(entry.getLongKey(), List.copyOf(entry.getValue())); - } - for (Placement placement : this.placements) { + placements = crossing.toArray(new Placement[0]); + + long weight = PLAN_BYTES + + (long) destinations.length * DESTINATION_BYTES + + (long) count * MUTATION_BYTES + + table.payloadBytes + + (long) values.length * VALUE_BYTES; + for (Placement placement : placements) { weight += PLACEMENT_BYTES + (long) placement.touched().size() * PLACEMENT_DESTINATION_BYTES; - for (List fragment : placement.fragments().values()) { - long retainedCapacity = Math.max(10L, fragment.size() + (fragment.size() + 1L) / 2L); - weight += retainedCapacity * REFERENCE_BYTES; + } + estimatedRetainedBytes = (int) Math.min(Integer.MAX_VALUE, weight); + } + + private ObjectSourcePlan(long[] destinations) { + this.destinations = destinations; + this.offsets = new int[destinations.length + 1]; + this.cells = new int[0]; + this.valueIndexes = new int[0]; + this.values = new Object[0]; + this.placements = new Placement[0]; + this.estimatedRetainedBytes = PLAN_BYTES + destinations.length * DESTINATION_BYTES; + } + + ObjectSourcePlan destinationIndex() { + return new ObjectSourcePlan(destinations); + } + + private static Placement crossingPlacement( + int ordinal, + int start, + int end, + List mutations, + long[] mutationDestinations, + Long2IntOpenHashMap destinationSlots, + int[] positions + ) { + if (start < 0 || end > mutations.size() || start >= end) { + throw new IllegalArgumentException("Object placement range is outside the plan"); + } + long first = mutationDestinations[start]; + int index = start + 1; + while (index < end && mutationDestinations[index] == first) { + index++; + } + if (index == end) { + return null; + } + LongArrayList touched = new LongArrayList(4); + Long2IntOpenHashMap touchedIndexes = new Long2IntOpenHashMap(4); + touchedIndexes.defaultReturnValue(-1); + ArrayList runs = new ArrayList<>(4); + int minimumX = Integer.MAX_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int maximumZ = Integer.MIN_VALUE; + for (int mutationIndex = start; mutationIndex < end; mutationIndex++) { + ObjectDestinationTransaction.Mutation mutation = mutations.get(mutationIndex); + minimumX = Math.min(minimumX, mutation.x()); + minimumZ = Math.min(minimumZ, mutation.z()); + maximumX = Math.max(maximumX, mutation.x()); + maximumZ = Math.max(maximumZ, mutation.z()); + long destination = mutationDestinations[mutationIndex]; + int touchedIndex = touchedIndexes.get(destination); + if (touchedIndex < 0) { + touchedIndexes.put(destination, touched.size()); + touched.add(destination); + runs.add(new int[]{destinationSlots.get(destination), positions[mutationIndex], 1}); + } else { + runs.get(touchedIndex)[2]++; } } - this.estimatedRetainedBytes = weight >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) weight; + int size = touched.size(); + ArrayList chunks = new ArrayList<>(size); + int[] slots = new int[size]; + int[] runStarts = new int[size]; + int[] runLengths = new int[size]; + for (int touchedIndex = 0; touchedIndex < size; touchedIndex++) { + long destination = touched.getLong(touchedIndex); + chunks.add(new ObjectContinuationBundle.ChunkPosition(destinationX(destination), destinationZ(destination))); + int[] run = runs.get(touchedIndex); + slots[touchedIndex] = run[0]; + runStarts[touchedIndex] = run[1]; + runLengths[touchedIndex] = run[2]; + } + return new Placement(ordinal, new ObjectContinuationBundle.Bounds(minimumX, minimumZ, maximumX, maximumZ), + List.copyOf(chunks), slots, runStarts, runLengths); + } + + int destinationSlot(int chunkX, int chunkZ) { + return Arrays.binarySearch(destinations, destinationKey(chunkX, chunkZ)); + } + + int destinationCount() { + return destinations.length; + } + + boolean isEmpty() { + return destinations.length == 0; } List mutationsFor(int chunkX, int chunkZ) { - List mutations = destinations.get(destinationKey(chunkX, chunkZ)); - return mutations == null ? List.of() : mutations; + int slot = destinationSlot(chunkX, chunkZ); + return slot < 0 ? List.of() : decode(chunkX, chunkZ, offsets[slot], offsets[slot + 1] - offsets[slot]); } void persistContinuations(MantleWriter writer, int sourceX, int sourceZ, int destinationX, int destinationZ) { - ObjectContinuationBundle.ChunkPosition destination = new ObjectContinuationBundle.ChunkPosition(destinationX, destinationZ); + if (placements.length == 0) { + return; + } + int destinationSlot = destinationSlot(destinationX, destinationZ); + if (destinationSlot < 0) { + return; + } for (Placement placement : placements) { - if (!placement.fragments().containsKey(destination)) { + if (!placement.touches(destinationSlot)) { continue; } ObjectContinuationBundle.PlacementKey key = new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, sourceX, sourceZ, placement.ordinal()); - for (Map.Entry> entry : placement.fragments().entrySet()) { - ObjectContinuationBundle.ChunkPosition target = entry.getKey(); - if (target.equals(destination)) { + for (int touchedIndex = 0; touchedIndex < placement.slots().length; touchedIndex++) { + if (placement.slots()[touchedIndex] == destinationSlot) { continue; } + ObjectContinuationBundle.ChunkPosition target = placement.touched().get(touchedIndex); + int runStart = placement.runStarts()[touchedIndex]; + int runLength = placement.runLengths()[touchedIndex]; writer.withChunkFence(target.x(), target.z(), () -> { MantleChunk chunk = writer.acquireChunk(target.x(), target.z()); if (chunk.isFlagged(MantleFlag.REAL)) { @@ -107,13 +256,32 @@ final class ObjectSourcePlan { } } ObjectContinuationBundle.Fragment fragment = new ObjectContinuationBundle.Fragment(key, placement.bounds(), placement.touched(), - encode(entry.getValue(), writer.getMantle().getWorldHeight())); + encode(decode(target.x(), target.z(), runStart, runLength), writer.getMantle().getWorldHeight())); writer.setData(x, 0, z, bundle == null ? new ObjectContinuationBundle(List.of(fragment)) : bundle.with(fragment)); }); } } } + private List decode(int chunkX, int chunkZ, int start, int length) { + ObjectDestinationTransaction.Mutation[] decoded = new ObjectDestinationTransaction.Mutation[length]; + int baseX = chunkX << 4; + int baseZ = chunkZ << 4; + for (int index = 0; index < length; index++) { + int cell = cells[start + index]; + int x = baseX | (cell & 15); + int z = baseZ | ((cell >>> 4) & 15); + int y = cell >>> Y_SHIFT; + Object value = values[valueIndexes[start + index]]; + decoded[index] = (cell & CUSTOM_BIT) != 0 + ? new ObjectDestinationTransaction.CustomBlockMutation( + new ObjectDestinationTransaction.DataKey(x, y, z, NativeBlockState.class), (NativeBlockState) value) + : new ObjectDestinationTransaction.SetMutation( + new ObjectDestinationTransaction.DataKey(x, y, z, typeOf(value)), value); + } + return List.of(decoded); + } + static byte[] encode(List mutations, int height) { IrisMatterSupport.ensureRegistered(); Matter payload = new IrisMatter(16, height, 16); @@ -134,54 +302,41 @@ final class ObjectSourcePlan { return ObjectContinuationBundle.encode(payload); } - private static LinkedHashMap> group( - List mutations - ) { - LinkedHashMap> grouped = new LinkedHashMap<>(); - for (ObjectDestinationTransaction.Mutation mutation : mutations) { - ObjectContinuationBundle.ChunkPosition destination = new ObjectContinuationBundle.ChunkPosition(mutation.x() >> 4, mutation.z() >> 4); - grouped.computeIfAbsent(destination, ignored -> new ArrayList<>()).add(mutation); - } - return grouped; - } - - private static ObjectContinuationBundle.Bounds bounds(List mutations) { - int minimumX = Integer.MAX_VALUE; - int minimumZ = Integer.MAX_VALUE; - int maximumX = Integer.MIN_VALUE; - int maximumZ = Integer.MIN_VALUE; - for (ObjectDestinationTransaction.Mutation mutation : mutations) { - minimumX = Math.min(minimumX, mutation.x()); - minimumZ = Math.min(minimumZ, mutation.z()); - maximumX = Math.max(maximumX, mutation.x()); - maximumZ = Math.max(maximumZ, mutation.z()); - } - return new ObjectContinuationBundle.Bounds(minimumX, minimumZ, maximumX, maximumZ); - } - int estimatedRetainedBytes() { return estimatedRetainedBytes; } + static Class typeOf(Object value) { + return value instanceof NativeBlockState ? NativeBlockState.class : value.getClass(); + } + + static long destinationKey(int chunkX, int chunkZ) { + return ((long) chunkX << 32) | (chunkZ & 0xffffffffL); + } + + private static int destinationX(long destination) { + return (int) (destination >> 32); + } + + private static int destinationZ(long destination) { + return (int) destination; + } + private static long payloadBytes(Object value) { return switch (value) { - case null -> 0L; case String text -> stringBytes(text); - case TreeBlockMaterial material -> 24L + stringBytes(material.materialKey()); - case Identifier identifier -> 32L + stringBytes(identifier.namespace()) + stringBytes(identifier.key()); - case NativeBlockState ignored -> 64L; - case MatterCavern cavern -> 32L + stringBytes(cavern.getCustomBiome()); - case HydrologyCaveCell cell -> 64L + stringBytes(cell.fluidProfileKey()) + stringBytes(cell.floodedBiomeKey()); - case Byte ignored -> 24L; - case Short ignored -> 24L; - case Integer ignored -> 24L; - case Long ignored -> 24L; - case Float ignored -> 24L; - case Double ignored -> 24L; - case Boolean ignored -> 24L; - case Character ignored -> 24L; + case TreeBlockMaterial material -> 16L + stringBytes(material.materialKey()); + case Identifier identifier -> 24L + stringBytes(identifier.namespace()) + stringBytes(identifier.key()); + case NativeBlockState ignored -> 0L; + case MatterCavern cavern -> 24L + stringBytes(cavern.getCustomBiome()); + case HydrologyCaveCell cell -> 48L + stringBytes(cell.fluidProfileKey()) + stringBytes(cell.floodedBiomeKey()); + case MatterMarker marker -> 16L + stringBytes(marker.getTag()); + case TileWrapper ignored -> 4L * UNKNOWN_PAYLOAD_BYTES; case Enum ignored -> 0L; - default -> Integer.MAX_VALUE; + case Number ignored -> 16L; + case Boolean ignored -> 16L; + case Character ignored -> 16L; + default -> UNKNOWN_PAYLOAD_BYTES; }; } @@ -189,12 +344,45 @@ final class ObjectSourcePlan { return value == null ? 0L : 48L + 2L * value.length(); } - private record Placement(int ordinal, ObjectContinuationBundle.Bounds bounds, - List touched, - Map> fragments) { + private static final class ValueTable { + private final ArrayList values = new ArrayList<>(); + private final Object2IntOpenHashMap equalValues = new Object2IntOpenHashMap<>(); + private final Reference2IntOpenHashMap identityValues = new Reference2IntOpenHashMap<>(); + private long payloadBytes; + + private ValueTable() { + equalValues.defaultReturnValue(-1); + identityValues.defaultReturnValue(-1); + } + + private int indexOf(Object value) { + boolean byValue = value instanceof String || value instanceof TreeBlockMaterial; + int index = byValue ? equalValues.getInt(value) : identityValues.getInt(value); + if (index >= 0) { + return index; + } + index = values.size(); + values.add(value); + payloadBytes += payloadBytes(value); + if (byValue) { + equalValues.put(value, index); + } else { + identityValues.put(value, index); + } + return index; + } } - private static long destinationKey(int chunkX, int chunkZ) { - return ((long) chunkX << 32) | (chunkZ & 0xffffffffL); + private record Placement(int ordinal, ObjectContinuationBundle.Bounds bounds, + List touched, + int[] slots, int[] runStarts, int[] runLengths) { + private boolean touches(int slot) { + for (int touchedSlot : slots) { + if (touchedSlot == slot) { + return true; + } + } + return false; + } } } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCache.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCache.java index 4ea851ab9..f10b75541 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCache.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCache.java @@ -8,14 +8,21 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.LongAdder; import java.util.function.Supplier; import static art.arcane.iris.generation.cache.Cache.key; final class ObjectSourcePlanCache { - private static final long MAXIMUM_RETAINED_BYTES = 256L * 1024L * 1024L; + private static final long MAXIMUM_RETAINED_BYTES = 512L * 1024L * 1024L; + private static final int ENTRY_BYTES = 64; private final long maximumRetainedBytes; + private final LongAdder lookups = new LongAdder(); + private final LongAdder builds = new LongAdder(); + private final LongAdder waits = new LongAdder(); + private final LongAdder replays = new LongAdder(); + private final LongAdder drained = new LongAdder(); private volatile State state; ObjectSourcePlanCache() { @@ -31,33 +38,71 @@ final class ObjectSourcePlanCache { } static long retainedByteBudget(long maximumHeapBytes) { - return Math.max(1L, Math.min(MAXIMUM_RETAINED_BYTES, maximumHeapBytes / 64L)); + return Math.max(1L, Math.min(MAXIMUM_RETAINED_BYTES, maximumHeapBytes / 32L)); } - ObjectSourcePlan get(int sourceChunkX, int sourceChunkZ, Supplier builder) { + /** + * Returns the plan whose mutations the destination still has to replay, or null when the source writes nothing + * there. Each destination consumes a retained plan once; a plan whose destinations were all consumed keeps only + * its destination index, so a repeated consumption rebuilds it instead of trusting released state. + */ + ObjectSourcePlan acquire(int sourceChunkX, int sourceChunkZ, int destinationChunkX, int destinationChunkZ, + Supplier builder) { Objects.requireNonNull(builder, "Source plan builder"); + lookups.increment(); State current = state; long source = CacheKey.mix(key(sourceChunkX, sourceChunkZ)); - ObjectSourcePlan cached = current.plans.getIfPresent(source); - if (cached != null) { - return cached; + Entry entry = current.plans.getIfPresent(source); + if (entry == null) { + entry = load(current, source, builder); } + int slot = entry.slot(destinationChunkX, destinationChunkZ); + if (slot < 0) { + return null; + } + Claim claim = entry.claim(slot); + if (claim == null) { + replays.increment(); + return replay(current, source, entry, destinationChunkX, destinationChunkZ, builder); + } + if (claim.last()) { + drained.increment(); + current.plans.asMap().replace(source, entry, entry.released()); + } + return claim.plan(); + } + + private ObjectSourcePlan replay(State current, long source, Entry consumed, int destinationChunkX, int destinationChunkZ, + Supplier builder) { + Entry rebuilt = new Entry(build(builder)); + int slot = rebuilt.slot(destinationChunkX, destinationChunkZ); + Claim claim = slot < 0 ? null : rebuilt.claim(slot); + if (claim == null) { + return null; + } + if (!claim.last() && rebuilt.weight() <= maximumRetainedBytes) { + current.plans.asMap().replace(source, consumed, rebuilt); + } + return claim.plan(); + } + + private Entry load(State current, long source, Supplier builder) { Pending created = new Pending(Thread.currentThread()); Pending pending = current.pending.putIfAbsent(source, created); if (pending != null) { + waits.increment(); return pending.await(); } try { - ObjectSourcePlan plan = current.plans.getIfPresent(source); - if (plan == null) { - plan = builder.get(); - if (plan != null && plan.estimatedRetainedBytes() < Integer.MAX_VALUE - && plan.estimatedRetainedBytes() <= maximumRetainedBytes) { - current.plans.put(source, plan); + Entry entry = current.plans.getIfPresent(source); + if (entry == null) { + entry = new Entry(build(builder)); + if (entry.weight() <= maximumRetainedBytes) { + current.plans.put(source, entry); } } - created.result.complete(plan); - return plan; + created.result.complete(entry); + return entry; } catch (RuntimeException | Error failure) { created.failure = failure; created.result.completeExceptionally(failure); @@ -67,6 +112,11 @@ final class ObjectSourcePlanCache { } } + private ObjectSourcePlan build(Supplier builder) { + builds.increment(); + return Objects.requireNonNull(builder.get(), "Source plan builder returned no plan"); + } + void clear() { state = new State(maximumRetainedBytes); } @@ -77,28 +127,96 @@ final class ObjectSourcePlanCache { return current.plans.estimatedSize(); } + Stats stats() { + State current = state; + current.plans.cleanUp(); + long retainedBytes = current.plans.policy().eviction() + .map(eviction -> eviction.weightedSize().orElse(0L)) + .orElse(0L); + return new Stats(lookups.sum(), builds.sum(), waits.sum(), replays.sum(), drained.sum(), + current.plans.estimatedSize(), retainedBytes, maximumRetainedBytes); + } + + record Stats(long lookups, long builds, long waits, long replays, long drained, long retained, + long retainedBytes, long budgetBytes) { + } + private static final class State { - private final Cache plans; + private final Cache plans; private final ConcurrentHashMap pending = new ConcurrentHashMap<>(); private State(long maximumRetainedBytes) { plans = Caffeine.newBuilder() .maximumWeight(maximumRetainedBytes) - .weigher((Long key, ObjectSourcePlan plan) -> plan.estimatedRetainedBytes()) + .weigher((Long key, Entry entry) -> entry.weight()) .build(); } } + private record Claim(ObjectSourcePlan plan, boolean last) { + } + + private static final class Entry { + private final ObjectSourcePlan index; + private final boolean[] claimed; + private final int weight; + private ObjectSourcePlan plan; + private int remaining; + + private Entry(ObjectSourcePlan plan) { + this.index = plan.isEmpty() ? ObjectSourcePlan.EMPTY : plan; + this.plan = plan.isEmpty() ? null : plan; + this.claimed = new boolean[plan.destinationCount()]; + this.remaining = claimed.length; + this.weight = saturatedWeight(ENTRY_BYTES + (long) claimed.length + index.estimatedRetainedBytes()); + } + + private Entry(ObjectSourcePlan index, boolean[] claimed) { + this.index = index; + this.claimed = claimed; + this.weight = saturatedWeight(ENTRY_BYTES + (long) claimed.length + index.estimatedRetainedBytes()); + } + + private int slot(int destinationChunkX, int destinationChunkZ) { + return index.destinationSlot(destinationChunkX, destinationChunkZ); + } + + private synchronized Claim claim(int slot) { + ObjectSourcePlan retained = plan; + if (retained == null || claimed[slot]) { + return null; + } + claimed[slot] = true; + if (--remaining == 0) { + plan = null; + return new Claim(retained, true); + } + return new Claim(retained, false); + } + + private Entry released() { + return new Entry(index.destinationIndex(), claimed); + } + + private int weight() { + return weight; + } + + private static int saturatedWeight(long weight) { + return (int) Math.min(Integer.MAX_VALUE, weight); + } + } + private static final class Pending { private final Thread owner; - private final CompletableFuture result = new CompletableFuture<>(); + private final CompletableFuture result = new CompletableFuture<>(); private Throwable failure; private Pending(Thread owner) { this.owner = owner; } - private ObjectSourcePlan await() { + private Entry await() { if (owner == Thread.currentThread()) { throw new IllegalStateException("Recursive source plan construction"); } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentCollisionDeterminismTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentCollisionDeterminismTest.java index 7307d8249..b8a126043 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentCollisionDeterminismTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentCollisionDeterminismTest.java @@ -139,9 +139,13 @@ public class MantleObjectComponentCollisionDeterminismTest { Map blocks = new HashMap<>(); MantleWriter writer = writer(blocks); ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, destination, 0); - MantleObjectComponent.replaySourceChunks(destination, 0, RADIUS, (sourceX, sourceZ) -> - transaction.apply(cache.get(sourceX, sourceZ, - () -> buildSourcePlan(writer, sourceX, sourceZ, scenario)))); + MantleObjectComponent.replaySourceChunks(destination, 0, RADIUS, (sourceX, sourceZ) -> { + ObjectSourcePlan plan = cache.acquire(sourceX, sourceZ, destination, 0, + () -> buildSourcePlan(writer, sourceX, sourceZ, scenario)); + if (plan != null) { + transaction.apply(plan); + } + }); transaction.commit(); return blocks; } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentPlacementDeterminismTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentPlacementDeterminismTest.java index 496e934ce..39aebca60 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentPlacementDeterminismTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentPlacementDeterminismTest.java @@ -56,21 +56,29 @@ public class MantleObjectComponentPlacementDeterminismTest { } @Test - public void ordinaryObjectSourcePlansRemainReusableAfterNeighborPublicationAndHotload() { + public void consumedObjectSourcePlansRebuildIdenticallyAfterNeighborPublicationAndHotload() { Fixture fixture = new Fixture(); fixture.generate(List.of(1, 0)); Map expected = fixture.snapshot(); - Map calls = Map.copyOf(fixture.sourceCalls); + assertEquals(12, fixture.sourceCalls.size()); + for (Integer calls : fixture.sourceCalls.values()) { + assertEquals(1, calls.intValue()); + } fixture.generate(List.of(0, 1)); assertEquals(expected, fixture.snapshot()); - assertEquals(calls, fixture.sourceCalls); + assertSourceCalls(fixture, 2, 1); fixture.component.hotload(); fixture.generate(List.of(0, 1)); assertEquals(expected, fixture.snapshot()); + assertSourceCalls(fixture, 3, 2); + } + + private static void assertSourceCalls(Fixture fixture, int writingSources, int emptySources) { for (Map.Entry entry : fixture.sourceCalls.entrySet()) { - assertEquals(2, entry.getValue().intValue()); + boolean writes = entry.getKey().z() == 0 && (entry.getKey().x() == 0 || entry.getKey().x() == 1); + assertEquals(entry.getKey().toString(), writes ? writingSources : emptySources, entry.getValue().intValue()); } } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationTest.java index 60aac432f..b6dd2c259 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationTest.java @@ -86,12 +86,55 @@ public class ObjectContinuationTest { assertNull(decoded.getSlice(String.class).get(1, 5, 1)); } + @Test + public void packedCrossingFragmentsKeepEachPlacementRunInSourceOrder() throws Exception { + Fixture fixture = new Fixture(); + List mutations = List.of( + marker(1, "a"), marker(17, "a"), marker(33, "a"), + marker(5, "b"), + marker(18, "loose"), + marker(40, "c"), marker(20, "c"), marker(2, "c"), marker(41, "d")); + ObjectSourcePlan plan = new ObjectSourcePlan(mutations, List.of( + new ObjectDestinationTransaction.PlacementRange(0, 3), + new ObjectDestinationTransaction.PlacementRange(3, 4), + new ObjectDestinationTransaction.PlacementRange(5, 9))); + + plan.persistContinuations(fixture.writer, 0, 0, 0, 0); + + ObjectContinuationBundle.ChunkPosition zero = new ObjectContinuationBundle.ChunkPosition(0, 0); + ObjectContinuationBundle.ChunkPosition one = new ObjectContinuationBundle.ChunkPosition(1, 0); + ObjectContinuationBundle.ChunkPosition two = new ObjectContinuationBundle.ChunkPosition(2, 0); + assertEquals(2, fixture.bundles.size()); + assertFragment(fixture.bundles.get(1).fragments().get(0), 0, new ObjectContinuationBundle.Bounds(1, 1, 33, 1), + List.of(zero, one, two), List.of(marker(17, "a"))); + assertFragment(fixture.bundles.get(1).fragments().get(1), 2, new ObjectContinuationBundle.Bounds(2, 1, 41, 1), + List.of(two, one, zero), List.of(marker(20, "c"))); + assertFragment(fixture.bundles.get(2).fragments().get(0), 0, new ObjectContinuationBundle.Bounds(1, 1, 33, 1), + List.of(zero, one, two), List.of(marker(33, "a"))); + assertFragment(fixture.bundles.get(2).fragments().get(1), 2, new ObjectContinuationBundle.Bounds(2, 1, 41, 1), + List.of(two, one, zero), List.of(marker(40, "c"), marker(41, "d"))); + } + + private static void assertFragment(ObjectContinuationBundle.Fragment fragment, int ordinal, ObjectContinuationBundle.Bounds bounds, + List touched, + List mutations) throws IOException { + assertEquals(new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, 0, 0, ordinal), fragment.key()); + assertEquals(bounds, fragment.bounds()); + assertEquals(touched, fragment.touchedChunks()); + Matter decoded = fragment.decode(); + assertEquals(mutations.size(), decoded.getSlice(String.class).getEntryCount()); + for (ObjectDestinationTransaction.Mutation mutation : mutations) { + ObjectDestinationTransaction.SetMutation set = (ObjectDestinationTransaction.SetMutation) mutation; + assertEquals(set.value(), decoded.getSlice(String.class).get(set.x() & 15, set.key().y(), set.z() & 15)); + } + } + @Test public void generatedDestinationsDoNotRetainContinuations() { Fixture fixture = new Fixture(); when(fixture.chunk.isFlagged(MantleFlag.REAL)).thenReturn(true); List mutations = List.of(marker(-1, "left"), marker(0, "right")); - ObjectSourcePlan plan = new ObjectSourcePlan(mutations, List.of(mutations)); + ObjectSourcePlan plan = new ObjectSourcePlan(mutations, List.of(new ObjectDestinationTransaction.PlacementRange(0, 2))); plan.persistContinuations(fixture.writer, -1, 0, -1, 0); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java index 8dcdfbab0..b023dd0cd 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java @@ -78,7 +78,7 @@ public class ObjectDestinationTransactionTest { } ObjectSourcePlan plan = source.sourcePlanSince(0); assertEquals(16, source.mutationCheckpoint()); - assertEquals(Integer.MAX_VALUE, plan.estimatedRetainedBytes()); + assertTrue(plan.estimatedRetainedBytes() < Integer.MAX_VALUE); for (int[] position : positions) { int x = position[0]; int z = position[1]; @@ -87,9 +87,7 @@ public class ObjectDestinationTransactionTest { List sourceMutations = plan.mutationsFor(x >> 4, z >> 4); List replayed = destination.sourcePlanSince(0).mutationsFor(x >> 4, z >> 4); assertEquals(sourceMutations.size(), replayed.size()); - for (int index = 0; index < sourceMutations.size(); index++) { - assertSame(sourceMutations.get(index), replayed.get(index)); - } + assertEquals(sourceMutations, replayed); assertEquals(8, destination.mutationCheckpoint()); assertSame(replacement, destination.get(x, 7, z)); assertNull(destination.getDataIfPresent(x, 7, z, Identifier.class)); @@ -143,8 +141,8 @@ public class ObjectDestinationTransactionTest { List replayed = destination.sourcePlanSince(0).mutationsFor(0, 0); assertEquals(2, replayed.size()); - assertSame(plan.mutationsFor(0, 0).get(5), replayed.get(0)); - assertSame(plan.mutationsFor(0, 0).get(6), replayed.get(1)); + assertEquals(plan.mutationsFor(0, 0).get(5), replayed.get(0)); + assertEquals(plan.mutationsFor(0, 0).get(6), replayed.get(1)); assertNull(destination.getDataIfPresent(2, 4, 0, Identifier.class)); assertNull(destination.getDataIfPresent(1, 4, 0, MatterCavern.class)); assertEquals("tree", destination.getDataIfPresent(0, 4, 0, String.class)); @@ -573,7 +571,7 @@ public class ObjectDestinationTransactionTest { ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, 0, 0); destination.apply(plan); assertNull(destination.getDataIfPresent(0, 4, 0, Identifier.class)); - assertSame(plan.mutationsFor(0, 0).getFirst(), destination.sourcePlanSince(0).mutationsFor(0, 0).getFirst()); + assertEquals(plan.mutationsFor(0, 0).getFirst(), destination.sourcePlanSince(0).mutationsFor(0, 0).getFirst()); assertThrows(IllegalStateException.class, destination::commit); verify(writer).clearData(0, 4, 0, Identifier.class); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCacheTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCacheTest.java index a25d56646..5e2201486 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCacheTest.java @@ -4,7 +4,6 @@ import art.arcane.volmlib.util.cache.CacheKey; import org.junit.Test; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -15,71 +14,116 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertSame; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class ObjectSourcePlanCacheTest { @Test - public void budgetRetainsNeighboringSourceWorkingSetWithoutRebuilding() { + public void untouchedDestinationsReuseTheSourceIndexWithoutRebuilding() { ObjectSourcePlanCache cache = new ObjectSourcePlanCache(1024L * 1024L); AtomicInteger builds = new AtomicInteger(); - ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey( - 0, 4, 0, String.class); - ObjectDestinationTransaction.Mutation mutation = new ObjectDestinationTransaction.SetMutation(key, "marker"); - ObjectSourcePlan expected = new ObjectSourcePlan(Collections.nCopies(2, mutation)); - assertTrue(expected.estimatedRetainedBytes() * 1024L < 1_048_576L); - for (int sweep = 0; sweep < 3; sweep++) { - for (int x = -16; x < 16; x++) { - for (int z = -16; z < 16; z++) { - assertSame(expected, cache.get(x, z, () -> { - builds.incrementAndGet(); - return expected; - })); + ObjectSourcePlan plan = planTouching(new int[]{0, 0}, new int[]{1, 0}); + for (int x = -7; x <= 7; x++) { + for (int z = -7; z <= 7; z++) { + if ((x == 0 || x == 1) && z == 0) { + continue; } + assertNull(cache.acquire(0, 0, x, z, () -> { + builds.incrementAndGet(); + return plan; + })); } - assertEquals(1024L, cache.estimatedSize()); - assertEquals(1024, builds.get()); } + assertEquals(1, builds.get()); + assertSame(plan, cache.acquire(0, 0, 1, 0, () -> { + builds.incrementAndGet(); + return plan; + })); + assertEquals(1, builds.get()); + ObjectSourcePlanCache.Stats stats = cache.stats(); + assertEquals(1L, stats.builds()); + assertEquals(224L, stats.lookups()); + assertEquals(0L, stats.drained()); + } + + @Test + public void everyDestinationConsumesARetainedPlanOnceAndRepeatsRebuild() { + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(1024L * 1024L); + AtomicInteger builds = new AtomicInteger(); + ObjectSourcePlan plan = planTouching(new int[]{0, 0}, new int[]{1, 0}); + ObjectSourcePlan rebuilt = planTouching(new int[]{0, 0}, new int[]{1, 0}); + assertSame(plan, cache.acquire(0, 0, 0, 0, () -> { + builds.incrementAndGet(); + return plan; + })); + assertSame(plan, cache.acquire(0, 0, 1, 0, () -> { + builds.incrementAndGet(); + return rebuilt; + })); + assertEquals(1, builds.get()); + assertEquals(1L, cache.stats().drained()); + assertTrue(cache.stats().retainedBytes() < plan.estimatedRetainedBytes()); + + assertNull(cache.acquire(0, 0, 2, 0, () -> { + builds.incrementAndGet(); + return rebuilt; + })); + assertSame(rebuilt, cache.acquire(0, 0, 0, 0, () -> { + builds.incrementAndGet(); + return rebuilt; + })); + assertEquals(2, builds.get()); + assertEquals(1L, cache.stats().replays()); + assertSame(rebuilt, cache.acquire(0, 0, 1, 0, () -> { + builds.incrementAndGet(); + return plan; + })); + assertEquals(2, builds.get()); + assertEquals(2L, cache.stats().drained()); + } + + @Test + public void emptyPlansAreRetainedAsIndexesAndNeverRebuilt() { + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(1024L * 1024L); + AtomicInteger builds = new AtomicInteger(); + for (int sweep = 0; sweep < 3; sweep++) { + for (int chunk = 0; chunk < 1000; chunk++) { + assertNull(cache.acquire(chunk, 0, chunk, 0, () -> { + builds.incrementAndGet(); + return new ObjectSourcePlan(List.of()); + })); + } + } + assertEquals(1000, builds.get()); + assertEquals(1000L, cache.estimatedSize()); cache.clear(); assertEquals(0L, cache.estimatedSize()); - assertSame(expected, cache.get(-16, -16, () -> { - builds.incrementAndGet(); - return expected; - })); - assertEquals(1025, builds.get()); } @Test public void unrelatedCoordinatesBuildWhileAnotherPlanIsPending() throws Exception { ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4096L); - ObjectSourcePlan first = planAt(1); - ObjectSourcePlan second = planAt(2); + ObjectSourcePlan first = planTouching(new int[]{32113, 37}); + ObjectSourcePlan second = planTouching(new int[]{32667, 37}); CountDownLatch firstEntered = new CountDownLatch(1); CountDownLatch releaseFirst = new CountDownLatch(1); ExecutorService executor = Executors.newFixedThreadPool(2); try { assertEquals(Long.hashCode(CacheKey.mix(CacheKey.key(32113, 37))), Long.hashCode(CacheKey.mix(CacheKey.key(32667, 37)))); - Future firstResult = executor.submit(() -> cache.get(32113, 37, () -> { + Future firstResult = executor.submit(() -> cache.acquire(32113, 37, 32113, 37, () -> { firstEntered.countDown(); - try { - assertTrue(releaseFirst.await(10L, TimeUnit.SECONDS)); - } catch (InterruptedException failure) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(failure); - } + await(releaseFirst); return first; })); assertTrue(firstEntered.await(5L, TimeUnit.SECONDS)); - Future secondResult = executor.submit(() -> cache.get(32667, 37, () -> second)); + Future secondResult = executor.submit(() -> cache.acquire(32667, 37, 32667, 37, () -> second)); assertSame(second, secondResult.get(3L, TimeUnit.SECONDS)); releaseFirst.countDown(); assertSame(first, firstResult.get(5L, TimeUnit.SECONDS)); - assertSame(first, cache.get(32113, 37, () -> second)); - assertSame(second, cache.get(32667, 37, () -> first)); } finally { releaseFirst.countDown(); executor.shutdownNow(); @@ -87,9 +131,13 @@ public class ObjectSourcePlanCacheTest { } @Test - public void concurrentRequestsBuildOneSourcePlan() throws Exception { - ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4096L); - ObjectSourcePlan expected = new ObjectSourcePlan(List.of()); + public void concurrentDestinationsShareOneSourceBuild() throws Exception { + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(1024L * 1024L); + int[][] touched = new int[8][]; + for (int index = 0; index < touched.length; index++) { + touched[index] = new int[]{index - 4, -7}; + } + ObjectSourcePlan expected = planTouching(touched); AtomicInteger builds = new AtomicInteger(); CountDownLatch ready = new CountDownLatch(8); CountDownLatch start = new CountDownLatch(1); @@ -97,10 +145,11 @@ public class ObjectSourcePlanCacheTest { List> futures = new ArrayList<>(); try { for (int index = 0; index < 8; index++) { + int destinationX = index - 4; futures.add(executor.submit(() -> { ready.countDown(); assertTrue(start.await(5L, TimeUnit.SECONDS)); - return cache.get(4, -7, () -> { + return cache.acquire(4, -7, destinationX, -7, () -> { builds.incrementAndGet(); return expected; }); @@ -116,16 +165,35 @@ public class ObjectSourcePlanCacheTest { } assertEquals(1, builds.get()); + assertEquals(1L, cache.stats().drained()); + } + + @Test + public void oversizedPlansAreServedWithoutRetention() { + ObjectSourcePlan large = planTouching(new int[]{0, 0}, new int[]{1, 0}); + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(large.estimatedRetainedBytes()); + AtomicInteger builds = new AtomicInteger(); + + assertSame(large, cache.acquire(0, 0, 0, 0, () -> { + builds.incrementAndGet(); + return large; + })); + assertEquals(0L, cache.estimatedSize()); + assertSame(large, cache.acquire(0, 0, 1, 0, () -> { + builds.incrementAndGet(); + return large; + })); + assertEquals(2, builds.get()); } @Test public void cacheEvictsByRetainedBytesAndCanBeCleared() { - ObjectSourcePlan first = planAt(0); - ObjectSourcePlan second = planAt(1); - ObjectSourcePlanCache cache = new ObjectSourcePlanCache(first.estimatedRetainedBytes()); + ObjectSourcePlan first = planTouching(new int[]{0, 0}, new int[]{9, 9}); + ObjectSourcePlan second = planTouching(new int[]{1, 0}, new int[]{9, 9}); + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(first.estimatedRetainedBytes() + 128L); - cache.get(0, 0, () -> first); - cache.get(1, 0, () -> second); + cache.acquire(0, 0, 0, 0, () -> first); + cache.acquire(1, 0, 1, 0, () -> second); assertEquals(1L, cache.estimatedSize()); cache.clear(); @@ -140,12 +208,12 @@ public class ObjectSourcePlanCacheTest { @Test public void clearingPendingWorkKeepsNewGenerationIsolated() throws Exception { ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4096L); - ObjectSourcePlan old = planAt(1); - ObjectSourcePlan replacement = planAt(2); + ObjectSourcePlan old = planTouching(new int[]{7, 8}); + ObjectSourcePlan replacement = planTouching(new int[]{7, 8}); CountDownLatch entered = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); try (ExecutorService executor = Executors.newSingleThreadExecutor()) { - Future first = executor.submit(() -> cache.get(7, 8, () -> { + Future first = executor.submit(() -> cache.acquire(7, 8, 7, 8, () -> { entered.countDown(); await(release); return old; @@ -153,25 +221,25 @@ public class ObjectSourcePlanCacheTest { try { assertTrue(entered.await(5L, TimeUnit.SECONDS)); cache.clear(); - assertSame(replacement, cache.get(7, 8, () -> replacement)); + assertSame(replacement, cache.acquire(7, 8, 7, 8, () -> replacement)); } finally { release.countDown(); } assertSame(old, first.get(5L, TimeUnit.SECONDS)); - assertSame(replacement, cache.get(7, 8, () -> old)); + assertSame(old, cache.acquire(7, 8, 7, 8, () -> old)); } } @Test public void waitingForkJoinWorkerAllowsQueuedDependencyToRun() throws Exception { ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4096L); - ObjectSourcePlan expected = planAt(1); + ObjectSourcePlan expected = planTouching(new int[]{7, 8}, new int[]{8, 8}); CountDownLatch entered = new CountDownLatch(1); CountDownLatch waiting = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); try (ExecutorService owner = Executors.newSingleThreadExecutor(); ForkJoinPool pool = new ForkJoinPool(1)) { - Future first = owner.submit(() -> cache.get(7, 8, () -> { + Future first = owner.submit(() -> cache.acquire(7, 8, 7, 8, () -> { entered.countDown(); await(release); return expected; @@ -180,7 +248,7 @@ public class ObjectSourcePlanCacheTest { assertTrue(entered.await(5L, TimeUnit.SECONDS)); Future second = pool.submit(() -> { waiting.countDown(); - return cache.get(7, 8, () -> { throw new AssertionError("Duplicate build"); }); + return cache.acquire(7, 8, 8, 8, () -> { throw new AssertionError("Duplicate build"); }); }); assertTrue(waiting.await(5L, TimeUnit.SECONDS)); pool.submit(release::countDown).get(5L, TimeUnit.SECONDS); @@ -193,53 +261,23 @@ public class ObjectSourcePlanCacheTest { } @Test - public void failedAndNullBuildsAreRetryableAndRecursionFailsImmediately() { + public void failedAndMissingBuildsAreRetryableAndRecursionFailsImmediately() { ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4096L); IllegalArgumentException failure = new IllegalArgumentException("Build failed"); assertSame(failure, assertThrows(IllegalArgumentException.class, - () -> cache.get(7, 8, () -> { throw failure; }))); - assertNull(cache.get(7, 8, () -> null)); - assertThrows(IllegalStateException.class, () -> cache.get(7, 8, - () -> cache.get(7, 8, () -> planAt(1)))); - ObjectSourcePlan expected = planAt(1); - assertSame(expected, cache.get(7, 8, () -> expected)); + () -> cache.acquire(7, 8, 7, 8, () -> { throw failure; }))); + assertThrows(NullPointerException.class, () -> cache.acquire(7, 8, 7, 8, () -> null)); + assertThrows(IllegalStateException.class, () -> cache.acquire(7, 8, 7, 8, + () -> cache.acquire(7, 8, 7, 8, () -> planTouching(new int[]{7, 8})))); + ObjectSourcePlan expected = planTouching(new int[]{7, 8}); + assertSame(expected, cache.acquire(7, 8, 7, 8, () -> expected)); } @Test public void byteBudgetScalesWithHeapAndHasAnUpperBound() { - assertEquals(64L * 1024L * 1024L, ObjectSourcePlanCache.retainedByteBudget(4L * 1024L * 1024L * 1024L)); - assertEquals(16L * 1024L * 1024L, ObjectSourcePlanCache.retainedByteBudget(1024L * 1024L * 1024L)); - assertEquals(256L * 1024L * 1024L, ObjectSourcePlanCache.retainedByteBudget(Long.MAX_VALUE)); - } - - @Test - public void emptyPlansConsumeTheByteBudget() { - ObjectSourcePlan empty = new ObjectSourcePlan(List.of()); - ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4L * empty.estimatedRetainedBytes()); - for (int chunk = 0; chunk < 1000; chunk++) { - cache.get(chunk, 0, () -> empty); - } - assertTrue(cache.estimatedSize() <= 4L); - } - - @Test - public void oversizedAndUnmeasuredPlansAreReturnedWithoutRetention() { - ObjectSourcePlan large = planAt(1); - ObjectSourcePlan unmeasured = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation( - new ObjectDestinationTransaction.DataKey(0, 4, 0, Object.class), new Object()))); - ObjectSourcePlanCache cache = new ObjectSourcePlanCache(large.estimatedRetainedBytes() - 1L); - assertSame(large, cache.get(1, 0, () -> large)); - assertSame(unmeasured, cache.get(2, 0, () -> unmeasured)); - assertEquals(0L, cache.estimatedSize()); - AtomicInteger builds = new AtomicInteger(); - cache.get(1, 0, () -> { - builds.incrementAndGet(); - return large; - }); - assertEquals(1, builds.get()); - ObjectSourcePlanCache roomy = new ObjectSourcePlanCache(Long.MAX_VALUE); - assertSame(unmeasured, roomy.get(2, 0, () -> unmeasured)); - assertEquals(0L, roomy.estimatedSize()); + assertEquals(128L * 1024L * 1024L, ObjectSourcePlanCache.retainedByteBudget(4L * 1024L * 1024L * 1024L)); + assertEquals(32L * 1024L * 1024L, ObjectSourcePlanCache.retainedByteBudget(1024L * 1024L * 1024L)); + assertEquals(512L * 1024L * 1024L, ObjectSourcePlanCache.retainedByteBudget(Long.MAX_VALUE)); } private static void await(CountDownLatch latch) { @@ -251,17 +289,12 @@ public class ObjectSourcePlanCacheTest { } } - private static ObjectSourcePlan planAt(int x) { - ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey( - x, - 4, - 0, - String.class - ); - ObjectDestinationTransaction.Mutation mutation = new ObjectDestinationTransaction.SetMutation( - key, - "marker" - ); - return new ObjectSourcePlan(List.of(mutation)); + private static ObjectSourcePlan planTouching(int[]... chunks) { + List mutations = new ArrayList<>(); + for (int[] chunk : chunks) { + mutations.add(new ObjectDestinationTransaction.SetMutation( + new ObjectDestinationTransaction.DataKey(chunk[0] << 4, 4, chunk[1] << 4, String.class), "marker")); + } + return new ObjectSourcePlan(mutations); } } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanTest.java index d3b31b29c..c748ef7df 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanTest.java @@ -1,15 +1,23 @@ package art.arcane.iris.generation.mantle; +import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.matter.MatterMarker; import org.junit.Test; import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Random; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class ObjectSourcePlanTest { @Test @@ -29,14 +37,85 @@ public class ObjectSourcePlanTest { List local = plan.mutationsFor(-1, -1); assertEquals(expected, local); - assertSame(local, plan.mutationsFor(-1, -1)); + assertEquals(local, plan.mutationsFor(-1, -1)); assertEquals(3, plan.mutationsFor(Integer.MIN_VALUE >> 4, Integer.MAX_VALUE >> 4).size()); assertEquals(3, plan.mutationsFor(Integer.MAX_VALUE >> 4, Integer.MIN_VALUE >> 4).size()); assertTrue(plan.mutationsFor(1, -1).isEmpty()); assertTrue(plan.mutationsFor(-1, 1).isEmpty()); + assertEquals(5, plan.destinationCount()); assertThrows(UnsupportedOperationException.class, () -> local.clear()); } + @Test + public void packedPlansReplayEveryDestinationInSourceOrder() { + NativeBlockState[] states = new NativeBlockState[4]; + for (int index = 0; index < states.length; index++) { + states[index] = mock(NativeBlockState.class); + } + NativeBlockState custom = mock(NativeBlockState.class); + when(custom.isCustom()).thenReturn(true); + MatterMarker marker = new MatterMarker("loot"); + Random random = new Random(1337L); + for (int trial = 0; trial < 50; trial++) { + int originX = random.nextInt(4096) - 2048; + int originZ = random.nextInt(4096) - 2048; + List mutations = new ArrayList<>(); + List placements = new ArrayList<>(); + int placementCount = 1 + random.nextInt(12); + for (int placement = 0; placement < placementCount; placement++) { + int start = mutations.size(); + String placementMarker = "trees/oak@" + random.nextInt(1000); + int centerX = (originX << 4) + random.nextInt(16); + int centerZ = (originZ << 4) + random.nextInt(16); + int reach = 1 + random.nextInt(40); + int blocks = 1 + random.nextInt(60); + for (int block = 0; block < blocks; block++) { + int x = centerX + random.nextInt(2 * reach + 1) - reach; + int z = centerZ + random.nextInt(2 * reach + 1) - reach; + int y = random.nextInt(768); + mutations.add(switch (random.nextInt(6)) { + case 0 -> new ObjectDestinationTransaction.CustomBlockMutation(key(x, y, z, NativeBlockState.class), custom); + case 1 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, String.class), placementMarker); + case 2 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, TreeBlockMaterial.class), + new TreeBlockMaterial("minecraft:oak_log")); + case 3 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, MatterMarker.class), marker); + default -> { + NativeBlockState state = states[random.nextInt(states.length)]; + yield new ObjectDestinationTransaction.SetMutation(key(x, y, z, NativeBlockState.class), state); + } + }); + } + if (random.nextInt(4) != 0) { + placements.add(new ObjectDestinationTransaction.PlacementRange(start, mutations.size())); + } + } + ObjectSourcePlan plan = new ObjectSourcePlan(mutations, placements); + Map> expected = group(mutations); + assertEquals(expected.size(), plan.destinationCount()); + for (Map.Entry> destination : expected.entrySet()) { + List replayed = plan.mutationsFor(destination.getKey().x(), destination.getKey().z()); + assertEquals(destination.getValue(), replayed); + for (int index = 0; index < replayed.size(); index++) { + Object original = value(destination.getValue().get(index)); + Object decoded = value(replayed.get(index)); + if (!(original instanceof TreeBlockMaterial) && !(original instanceof String)) { + assertSame(original, decoded); + } + } + } + } + } + + @Test + public void heightsOutsideThePackedRangeAreRejected() { + assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlan(List.of( + new ObjectDestinationTransaction.SetMutation(key(0, -1, 0, String.class), "marker")))); + assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlan(List.of( + new ObjectDestinationTransaction.SetMutation(key(0, 1 << 22, 0, String.class), "marker")))); + assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlan(List.of( + new ObjectDestinationTransaction.SetMutation(key(0, 4, 0, Object.class), "marker")))); + } + @Test public void cacheWeightIncludesEachDestinationIndex() { ObjectDestinationTransaction.Mutation first = mutationAt(0, 0); @@ -50,6 +129,24 @@ public class ObjectSourcePlanTest { assertThrows(NullPointerException.class, () -> new ObjectSourcePlan(Arrays.asList(first, null))); } + @Test + public void sharedPayloadsAreWeighedOncePerPlan() { + List one = List.of(mutationAt(0, 0)); + List many = new ArrayList<>(); + for (int index = 0; index < 100; index++) { + many.add(mutationAt(index & 15, 0)); + } + List trees = new ArrayList<>(); + for (int index = 0; index < 100; index++) { + trees.add(new ObjectDestinationTransaction.SetMutation(key(index & 15, 4, 0, TreeBlockMaterial.class), + new TreeBlockMaterial("minecraft:oak_log"))); + } + int single = new ObjectSourcePlan(one).estimatedRetainedBytes(); + + assertEquals(99L * 8L, new ObjectSourcePlan(many).estimatedRetainedBytes() - single); + assertTrue(new ObjectSourcePlan(trees).estimatedRetainedBytes() - single < 100L * 16L); + } + @Test public void retainedBytesIncludeVariableLengthMarkers() { ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey(0, 4, 0, String.class); @@ -58,6 +155,15 @@ public class ObjectSourcePlanTest { assertTrue(longMarker.estimatedRetainedBytes() - shortMarker.estimatedRetainedBytes() >= 8190); } + @Test + public void unknownPayloadsAreWeighedInsteadOfDisablingRetention() { + ObjectSourcePlan unknown = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation( + key(0, 4, 0, Object.class), new Object()))); + + assertTrue(unknown.estimatedRetainedBytes() > new ObjectSourcePlan(List.of(mutationAt(0, 0))).estimatedRetainedBytes()); + assertTrue(unknown.estimatedRetainedBytes() < 4096); + } + @Test public void continuationMetadataAddsRetainedBytesWithoutCountingPayloadsTwice() { List shortMarkers = List.of(mutationAt(0, 0), mutationAt(16, 0)); @@ -66,18 +172,54 @@ public class ObjectSourcePlanTest { new ObjectDestinationTransaction.DataKey(0, 4, 0, String.class), "a".repeat(4096)), new ObjectDestinationTransaction.SetMutation( new ObjectDestinationTransaction.DataKey(16, 4, 0, String.class), "b".repeat(4096))); + ObjectDestinationTransaction.PlacementRange whole = new ObjectDestinationTransaction.PlacementRange(0, 2); ObjectSourcePlan shortPlain = new ObjectSourcePlan(shortMarkers); - ObjectSourcePlan shortCrossing = new ObjectSourcePlan(shortMarkers, List.of(shortMarkers)); + ObjectSourcePlan shortCrossing = new ObjectSourcePlan(shortMarkers, List.of(whole)); ObjectSourcePlan longPlain = new ObjectSourcePlan(longMarkers); - ObjectSourcePlan longCrossing = new ObjectSourcePlan(longMarkers, List.of(longMarkers)); - ObjectSourcePlan repeatedCrossing = new ObjectSourcePlan(shortMarkers, List.of(shortMarkers, shortMarkers)); + ObjectSourcePlan longCrossing = new ObjectSourcePlan(longMarkers, List.of(whole)); + ObjectSourcePlan repeatedCrossing = new ObjectSourcePlan(shortMarkers, List.of(whole, whole)); int metadataBytes = shortCrossing.estimatedRetainedBytes() - shortPlain.estimatedRetainedBytes(); assertTrue(metadataBytes > 0); assertEquals(metadataBytes, longCrossing.estimatedRetainedBytes() - longPlain.estimatedRetainedBytes()); assertTrue(repeatedCrossing.estimatedRetainedBytes() > shortCrossing.estimatedRetainedBytes()); - assertSame(shortMarkers.getFirst(), shortCrossing.mutationsFor(0, 0).getFirst()); - assertSame(shortMarkers.getLast(), shortCrossing.mutationsFor(1, 0).getFirst()); + assertEquals(shortMarkers.getFirst(), shortCrossing.mutationsFor(0, 0).getFirst()); + assertEquals(shortMarkers.getLast(), shortCrossing.mutationsFor(1, 0).getFirst()); + } + + @Test + public void destinationIndexKeepsOnlyTheTouchedChunks() { + ObjectSourcePlan plan = new ObjectSourcePlan(List.of(mutationAt(-1, 3), mutationAt(40, 3), mutationAt(-2, 3))); + ObjectSourcePlan index = plan.destinationIndex(); + + assertEquals(2, index.destinationCount()); + assertEquals(plan.destinationSlot(-1, 0), index.destinationSlot(-1, 0)); + assertEquals(plan.destinationSlot(2, 0), index.destinationSlot(2, 0)); + assertTrue(index.destinationSlot(0, 0) < 0); + assertTrue(index.mutationsFor(-1, 0).isEmpty()); + assertTrue(index.estimatedRetainedBytes() < plan.estimatedRetainedBytes()); + } + + private static Map> group( + List mutations + ) { + Map> grouped = new LinkedHashMap<>(); + for (ObjectDestinationTransaction.Mutation mutation : mutations) { + grouped.computeIfAbsent(new ObjectContinuationBundle.ChunkPosition(mutation.x() >> 4, mutation.z() >> 4), + ignored -> new ArrayList<>()).add(mutation); + } + return grouped; + } + + private static Object value(ObjectDestinationTransaction.Mutation mutation) { + return switch (mutation) { + case ObjectDestinationTransaction.SetMutation set -> set.value(); + case ObjectDestinationTransaction.CustomBlockMutation custom -> custom.state(); + }; + } + + private static ObjectDestinationTransaction.DataKey key(int x, int y, int z, Class type) { + return new ObjectDestinationTransaction.DataKey(x, y, z, type); } private static ObjectDestinationTransaction.Mutation mutationAt(int x, int z) {