From e2430500cfad03ac9c6861f874da7a7454268410 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 21 Oct 2026 18:47:25 -0400 Subject: [PATCH] Added probe parity oracle - Dump every genProbe chunk as palette-indexed blocks and biomes with -PprobeDumpFile - Add genDiff task comparing two dumps by coordinate with block, surface, biome and category parity - Generate measured chunks on N threads in spiral order with -PprobeThreads and report wall-clock cps - Mirror pregeneration with -PprobePregenProfile (pregen performance profile and hydrology prefetch scope) - Print the measured window for JFR attribution and accept -PprobeJfrSettings - Keep chunk hashes and the run signature byte-identical to the sequential probe --- probe/build.gradle | 39 +- .../java/art/arcane/iris/probe/ChunkDump.java | 310 ++++++++++++ .../art/arcane/iris/probe/GenerationDiff.java | 424 ++++++++++++++++ .../arcane/iris/probe/GenerationProbe.java | 464 +++++++++++++++--- .../arcane/iris/probe/GenerationDiffTest.java | 146 ++++++ .../iris/probe/GenerationProbeTest.java | 55 +++ 6 files changed, 1356 insertions(+), 82 deletions(-) create mode 100644 probe/src/main/java/art/arcane/iris/probe/ChunkDump.java create mode 100644 probe/src/main/java/art/arcane/iris/probe/GenerationDiff.java create mode 100644 probe/src/test/java/art/arcane/iris/probe/GenerationDiffTest.java diff --git a/probe/build.gradle b/probe/build.gradle index bc874dddb..bf17231f1 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -173,11 +173,28 @@ tasks.register('genProbe', JavaExec) { if (providers.gradleProperty('probeNativeLibrary').isPresent()) { systemProperty('volmlib.noise.nativeBillowLibrary', providers.gradleProperty('probeNativeLibrary').get()) } + systemProperty('iris.probe.threads', providers.gradleProperty('probeThreads').orElse('1').get()) + systemProperty('iris.probe.pregenProfile', providers.gradleProperty('probePregenProfile').orElse('false').get()) dependsOn(':core:compileJava') doFirst { if (!probePack.isPresent() || probePack.get().isBlank()) { throw new GradleException('genProbe requires -PprobePack=/absolute/path/to/pack') } + Provider probeDumpFile = providers.gradleProperty('probeDumpFile') + if (probeDumpFile.isPresent()) { + File dump = new File(probeDumpFile.get()) + if (!dump.isAbsolute() || dump.isDirectory()) { + throw new GradleException('probeDumpFile must be an absolute file path') + } + java.nio.file.Path normalizedDump = dump.canonicalFile.toPath() + if (normalizedDump.startsWith(rootProject.projectDir.canonicalFile.toPath())) { + throw new GradleException('probeDumpFile must point outside the repository') + } + if (normalizedDump.parent == null || !normalizedDump.parent.toFile().isDirectory()) { + throw new GradleException('probeDumpFile parent directory must already exist') + } + systemProperty('iris.probe.dumpFile', normalizedDump.toString()) + } if (!probeDimension.isPresent() || probeDimension.get().isBlank()) { throw new GradleException('genProbe requires -PprobeDimension=') } @@ -194,6 +211,22 @@ tasks.register('genProbe', JavaExec) { } } +tasks.register('genDiff', JavaExec) { + group = 'verification' + description = 'Compares two genProbe chunk dumps and reports block, surface, biome and per-category parity.' + mainClass = 'art.arcane.iris.probe.GenerationDiff' + classpath = sourceSets.main.runtimeClasspath + maxHeapSize = '4g' + doFirst { + Provider baseline = providers.gradleProperty('diffBaseline') + Provider candidate = providers.gradleProperty('diffCandidate') + if (!baseline.isPresent() || !candidate.isPresent()) { + throw new GradleException('genDiff requires -PdiffBaseline= and -PdiffCandidate=') + } + args(baseline.get(), candidate.get(), providers.gradleProperty('diffWorstChunks').orElse('10').get()) + } +} + tasks.register('hydrologyPackProbe', JavaExec) { group = 'verification' description = 'Scans explicitly bounded real-pack hydrology tiles across multiple engine seeds and enforces accepted feature/profile coverage.' @@ -353,9 +386,13 @@ tasks.register('transitionProbe', JavaExec) { if (parent == null || !parent.isDirectory()) { throw new GradleException('probeJfr parent directory must already exist') } + String settings = providers.gradleProperty('probeJfrSettings').orElse('profile').get() + if (settings.isBlank() || settings.contains(',')) { + throw new GradleException('probeJfrSettings must name a JFR configuration or an absolute .jfc path') + } task.jvmArgs( '-XX:FlightRecorderOptions=stackdepth=256', - "-XX:StartFlightRecording=filename=${normalized},settings=profile,dumponexit=true" + "-XX:StartFlightRecording=filename=${normalized},settings=${settings},dumponexit=true" ) } } diff --git a/probe/src/main/java/art/arcane/iris/probe/ChunkDump.java b/probe/src/main/java/art/arcane/iris/probe/ChunkDump.java new file mode 100644 index 000000000..5c3b1e85e --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/ChunkDump.java @@ -0,0 +1,310 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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 . + */ + +package art.arcane.iris.probe; + +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +public final class ChunkDump { + static final long MAGIC = 0x4952495344554D50L; + static final int VERSION = 1; + static final String NULL_BLOCK_KEY = "minecraft:air"; + static final String NULL_BIOME_KEY = "null"; + private static final int MAXIMUM_PALETTE = 65_535; + private static final int STREAM_BUFFER = 1 << 20; + + private ChunkDump() { + } + + record Header(String dimensionKey, long seed, int minY, int height) { + } + + record ChunkSnapshot(int chunkX, int chunkZ, int height, + String[] blockPalette, char[] blocks, + String[] biomePalette, char[] biomes) { + ChunkSnapshot { + int expected = 256 * height; + if (blocks.length != expected || biomes.length != expected) { + throw new IllegalArgumentException("Chunk " + chunkX + "," + chunkZ + " expected " + expected + + " entries but held " + blocks.length + " blocks and " + biomes.length + " biomes."); + } + } + + static int index(int localX, int localZ, int y, int height) { + return ((localX << 4) | localZ) * height + y; + } + + long coordinateKey() { + return key(chunkX, chunkZ); + } + + static long key(int chunkX, int chunkZ) { + return ((long) chunkX << 32) | (chunkZ & 0xFFFFFFFFL); + } + } + + static ChunkSnapshot capture(int chunkX, int chunkZ, Hunk blocks, + Hunk biomes, int height) { + int size = 256 * height; + char[] blockIndices = new char[size]; + char[] biomeIndices = new char[size]; + PaletteBuilder blockPalette = new PaletteBuilder(); + PaletteBuilder biomePalette = new PaletteBuilder(); + int index = 0; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < height; y++) { + NativeBlockState state = blocks.get(x, y, z); + NativeBiome biome = biomes.get(x, y, z); + blockIndices[index] = blockPalette.indexOf(state, state == null ? NULL_BLOCK_KEY : null); + biomeIndices[index] = biomePalette.indexOf(biome, biome == null ? NULL_BIOME_KEY : null); + index++; + } + } + } + return new ChunkSnapshot(chunkX, chunkZ, height, blockPalette.keys(), blockIndices, + biomePalette.keys(), biomeIndices); + } + + static final class Writer implements AutoCloseable { + private final DataOutputStream output; + private boolean closed; + + Writer(Path file, Header header) throws IOException { + this.output = new DataOutputStream(new BufferedOutputStream(new GZIPOutputStream( + Files.newOutputStream(file, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE), STREAM_BUFFER), STREAM_BUFFER)); + output.writeLong(MAGIC); + output.writeInt(VERSION); + output.writeUTF(header.dimensionKey()); + output.writeLong(header.seed()); + output.writeInt(header.minY()); + output.writeInt(header.height()); + } + + void write(ChunkSnapshot snapshot) throws IOException { + output.writeByte(1); + output.writeInt(snapshot.chunkX()); + output.writeInt(snapshot.chunkZ()); + writePalette(snapshot.blockPalette(), snapshot.blocks()); + writePalette(snapshot.biomePalette(), snapshot.biomes()); + } + + private void writePalette(String[] palette, char[] indices) throws IOException { + output.writeInt(palette.length); + for (String key : palette) { + output.writeUTF(key); + } + if (palette.length <= 256) { + byte[] encoded = new byte[indices.length]; + for (int index = 0; index < indices.length; index++) { + encoded[index] = (byte) indices[index]; + } + output.write(encoded); + return; + } + byte[] encoded = new byte[indices.length * 2]; + for (int index = 0; index < indices.length; index++) { + char value = indices[index]; + encoded[index * 2] = (byte) (value >>> 8); + encoded[index * 2 + 1] = (byte) value; + } + output.write(encoded); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + try { + output.writeByte(0); + } finally { + output.close(); + } + } + } + + static final class Reader implements AutoCloseable { + private final DataInputStream input; + private final Header header; + private boolean finished; + + Reader(Path file) throws IOException { + this.input = new DataInputStream(new BufferedInputStream( + new GZIPInputStream(Files.newInputStream(file), STREAM_BUFFER), STREAM_BUFFER)); + long magic = input.readLong(); + if (magic != MAGIC) { + input.close(); + throw new IOException(file + " is not an Iris chunk dump."); + } + int version = input.readInt(); + if (version != VERSION) { + input.close(); + throw new IOException(file + " uses dump version " + version + ", expected " + VERSION + "."); + } + this.header = new Header(input.readUTF(), input.readLong(), input.readInt(), input.readInt()); + if (header.height() < 1) { + input.close(); + throw new IOException(file + " declares a non-positive height " + header.height() + "."); + } + } + + Header header() { + return header; + } + + ChunkSnapshot next() throws IOException { + if (finished) { + return null; + } + int marker; + try { + marker = input.readUnsignedByte(); + } catch (EOFException truncated) { + throw new IOException("Chunk dump ended without its terminator.", truncated); + } + if (marker == 0) { + finished = true; + return null; + } + if (marker != 1) { + throw new IOException("Chunk dump holds an unknown record marker " + marker + "."); + } + int chunkX = input.readInt(); + int chunkZ = input.readInt(); + int size = 256 * header.height(); + String[] blockPalette = readPalette(); + char[] blocks = readIndices(blockPalette.length, size); + String[] biomePalette = readPalette(); + char[] biomes = readIndices(biomePalette.length, size); + return new ChunkSnapshot(chunkX, chunkZ, header.height(), blockPalette, blocks, biomePalette, biomes); + } + + private String[] readPalette() throws IOException { + int length = input.readInt(); + if (length < 1 || length > MAXIMUM_PALETTE) { + throw new IOException("Chunk dump palette length " + length + " is out of range."); + } + String[] palette = new String[length]; + for (int index = 0; index < length; index++) { + palette[index] = input.readUTF(); + } + return palette; + } + + private char[] readIndices(int paletteLength, int size) throws IOException { + char[] indices = new char[size]; + if (paletteLength <= 256) { + byte[] encoded = new byte[size]; + input.readFully(encoded); + for (int index = 0; index < size; index++) { + indices[index] = (char) (encoded[index] & 0xFF); + } + } else { + byte[] encoded = new byte[size * 2]; + input.readFully(encoded); + for (int index = 0; index < size; index++) { + indices[index] = (char) (((encoded[index * 2] & 0xFF) << 8) | (encoded[index * 2 + 1] & 0xFF)); + } + } + for (char value : indices) { + if (value >= paletteLength) { + throw new IOException("Chunk dump index " + (int) value + " exceeds palette length " + paletteLength + "."); + } + } + return indices; + } + + @Override + public void close() throws IOException { + input.close(); + } + } + + private static final class PaletteBuilder { + private final IdentityHashMap byIdentity = new IdentityHashMap<>(); + private final Map byKey = new HashMap<>(); + private final List keys = new ArrayList<>(); + private Object lastValue; + private char lastIndex; + private boolean hasLast; + + char indexOf(Object value, String nullKey) { + if (hasLast && value == lastValue) { + return lastIndex; + } + Character cached = value == null ? null : byIdentity.get(value); + char resolved; + if (cached != null) { + resolved = cached; + } else { + String key = value == null ? nullKey : keyOf(value); + Character existing = byKey.get(key); + if (existing != null) { + resolved = existing; + } else { + if (keys.size() >= MAXIMUM_PALETTE) { + throw new IllegalStateException("Chunk palette exceeds " + MAXIMUM_PALETTE + " entries."); + } + resolved = (char) keys.size(); + keys.add(key); + byKey.put(key, resolved); + } + if (value != null) { + byIdentity.put(value, resolved); + } + } + lastValue = value; + lastIndex = resolved; + hasLast = true; + return resolved; + } + + String[] keys() { + return keys.toArray(new String[0]); + } + + private static String keyOf(Object value) { + if (value instanceof NativeBlockState state) { + return state.key(); + } + return ((NativeBiome) value).key(); + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/GenerationDiff.java b/probe/src/main/java/art/arcane/iris/probe/GenerationDiff.java new file mode 100644 index 000000000..65607d3fb --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/GenerationDiff.java @@ -0,0 +1,424 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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 . + */ + +package art.arcane.iris.probe; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class GenerationDiff { + private static final String LOG_PREFIX = "[gendiff]"; + private static final int DEFAULT_WORST_CHUNKS = 10; + private static final Set AIR = Set.of("air", "cave_air", "void_air"); + private static final Set FLUID = Set.of("water", "lava", "bubble_column"); + private static final Set ORE_BLOCKS = Set.of("ancient_debris", "raw_iron_block", "raw_copper_block", + "raw_gold_block"); + private static final Set DECOR_EXACT = Set.of("snow", "fire", "grass", "short_grass", "tall_grass", + "farmland", "wheat", "turtle_egg", "lily_pad", "hay_block"); + private static final List DECOR_FRAGMENTS = List.of("log", "wood", "leaves", "hyphae", "stem", + "sapling", "propagule", "flower", "tulip", "orchid", "allium", "poppy", "dandelion", "daisy", + "cornflower", "lily", "bluet", "rose", "peony", "lilac", "petals", "eyeblossom", "leaf_litter", + "fern", "bush", "shrub", "vine", "mushroom", "kelp", "seagrass", "coral", "sea_pickle", "cactus", + "sugar_cane", "bamboo", "carpet", "dripleaf", "azalea", "roots", "sprouts", "fungus", "wart_block", + "shroomlight", "lichen", "spore_blossom", "berry", "pumpkin", "melon", "pointed_dripstone", "cobweb", + "torch", "lantern", "planks", "stairs", "slab", "fence", "wall", "door", "glass", "brick", "button", + "chest", "barrel", "bookshelf", "chain", "_bed", "banner", "sign", "campfire", "candle", "head", + "skull", "sculk_sensor", "sculk_shrieker", "sculk_catalyst", "pillar", "sea_lantern", "glowstone"); + + private GenerationDiff() { + } + + enum Category { + AIR, + FLUID, + TERRAIN, + ORE, + DECOR + } + + record ChunkScore(int chunkX, int chunkZ, long blockMismatches, long nonAirPositions, long nonAirMismatches, + long surfaceDeltaSum, int surfaceMaxDelta, long biomeMismatches) { + double nonAirIdentity() { + return nonAirPositions == 0L ? 100D : 100D * (nonAirPositions - nonAirMismatches) / nonAirPositions; + } + } + + static final class Totals { + long chunksBaseline; + long chunksCandidate; + long duplicateBaseline; + long duplicateCandidate; + long commonChunks; + long onlyBaseline; + long onlyCandidate; + long positions; + long identical; + long nonAirPositions; + long nonAirIdentical; + long columns; + long surfaceIdentical; + long surfaceDeltaSum; + int surfaceMaxDelta; + long terrainSurfaceIdentical; + long terrainSurfaceDeltaSum; + int terrainSurfaceMaxDelta; + long biomePositions; + long biomeIdentical; + final long[][] matrix = new long[Category.values().length][Category.values().length]; + final long[] baselineCategoryCounts = new long[Category.values().length]; + final long[] candidateCategoryCounts = new long[Category.values().length]; + final List scores = new ArrayList<>(); + final Map canonicalIds = new HashMap<>(); + } + + public static void main(String[] arguments) { + if (arguments.length < 2 || arguments.length > 3) { + System.out.println(LOG_PREFIX + " FAIL: expected [worstChunks]"); + System.exit(2); + return; + } + try { + int worst = arguments.length == 3 ? Integer.parseInt(arguments[2]) : DEFAULT_WORST_CHUNKS; + Totals totals = compare(Path.of(arguments[0]), Path.of(arguments[1])); + print(totals, Math.max(0, worst)); + System.exit(totals.commonChunks > 0 ? 0 : 1); + } catch (Throwable failure) { + System.out.println(LOG_PREFIX + " FAIL: " + failure.getMessage()); + failure.printStackTrace(System.out); + System.exit(1); + } + } + + static Totals compare(Path baselineFile, Path candidateFile) throws IOException { + Totals totals = new Totals(); + Map baseline = new HashMap<>(); + ChunkDump.Header baselineHeader; + try (ChunkDump.Reader reader = new ChunkDump.Reader(baselineFile)) { + baselineHeader = reader.header(); + ChunkDump.ChunkSnapshot snapshot; + while ((snapshot = reader.next()) != null) { + totals.chunksBaseline++; + if (baseline.putIfAbsent(snapshot.coordinateKey(), snapshot) != null) { + totals.duplicateBaseline++; + } + } + } + Map seenCandidate = new HashMap<>(); + try (ChunkDump.Reader reader = new ChunkDump.Reader(candidateFile)) { + ChunkDump.Header candidateHeader = reader.header(); + printHeaders(baselineFile, baselineHeader, candidateFile, candidateHeader); + ChunkDump.ChunkSnapshot snapshot; + while ((snapshot = reader.next()) != null) { + totals.chunksCandidate++; + long key = snapshot.coordinateKey(); + if (seenCandidate.putIfAbsent(key, Boolean.TRUE) != null) { + totals.duplicateCandidate++; + continue; + } + ChunkDump.ChunkSnapshot expected = baseline.remove(key); + if (expected == null) { + totals.onlyCandidate++; + continue; + } + totals.commonChunks++; + compareChunk(totals, expected, baselineHeader.minY(), snapshot, candidateHeader.minY()); + } + } + totals.onlyBaseline = baseline.size(); + return totals; + } + + static Category classify(String key) { + int properties = key.indexOf('['); + String material = properties >= 0 ? key.substring(0, properties) : key; + int namespace = material.indexOf(':'); + String path = namespace >= 0 ? material.substring(namespace + 1) : material; + if (AIR.contains(path)) { + return Category.AIR; + } + if (FLUID.contains(path)) { + return Category.FLUID; + } + if (path.endsWith("_ore") || ORE_BLOCKS.contains(path)) { + return Category.ORE; + } + if (DECOR_EXACT.contains(path)) { + return Category.DECOR; + } + for (String fragment : DECOR_FRAGMENTS) { + if (path.contains(fragment)) { + return Category.DECOR; + } + } + return Category.TERRAIN; + } + + private static void compareChunk(Totals totals, ChunkDump.ChunkSnapshot baseline, int baselineMinY, + ChunkDump.ChunkSnapshot candidate, int candidateMinY) { + String[] baselinePalette = baseline.blockPalette(); + String[] candidatePalette = candidate.blockPalette(); + int[] baselineBlockIds = canonicalIds(totals, baselinePalette); + int[] candidateBlockIds = canonicalIds(totals, candidatePalette); + int[] baselineBiomeIds = canonicalIds(totals, baseline.biomePalette()); + int[] candidateBiomeIds = canonicalIds(totals, candidate.biomePalette()); + byte[] baselineCategories = categories(baselinePalette); + byte[] candidateCategories = categories(candidatePalette); + int air = Category.AIR.ordinal(); + int fluid = Category.FLUID.ordinal(); + int terrain = Category.TERRAIN.ordinal(); + int ore = Category.ORE.ordinal(); + int baselineHeight = baseline.height(); + int candidateHeight = candidate.height(); + int lowY = Math.min(baselineMinY, candidateMinY); + int highY = Math.max(baselineMinY + baselineHeight, candidateMinY + candidateHeight) - 1; + int biomeLowY = Math.max(baselineMinY, candidateMinY); + int biomeHighY = Math.min(baselineMinY + baselineHeight, candidateMinY + candidateHeight) - 1; + char[] baselineBlocks = baseline.blocks(); + char[] candidateBlocks = candidate.blocks(); + char[] baselineBiomes = baseline.biomes(); + char[] candidateBiomes = candidate.biomes(); + long blockMismatches = 0L; + long nonAirPositions = 0L; + long nonAirMismatches = 0L; + long surfaceDeltaSum = 0L; + int surfaceMaxDelta = 0; + long biomeMismatches = 0L; + for (int column = 0; column < 256; column++) { + int baselineBase = column * baselineHeight; + int candidateBase = column * candidateHeight; + int baselineSurface = lowY - 1; + int candidateSurface = lowY - 1; + int baselineTerrainSurface = lowY - 1; + int candidateTerrainSurface = lowY - 1; + for (int y = lowY; y <= highY; y++) { + int baselineY = y - baselineMinY; + int candidateY = y - candidateMinY; + boolean baselineInside = baselineY >= 0 && baselineY < baselineHeight; + boolean candidateInside = candidateY >= 0 && candidateY < candidateHeight; + int baselineIndex = baselineInside ? baselineBlocks[baselineBase + baselineY] : -1; + int candidateIndex = candidateInside ? candidateBlocks[candidateBase + candidateY] : -1; + int baselineCategory = baselineIndex < 0 ? air : baselineCategories[baselineIndex]; + int candidateCategory = candidateIndex < 0 ? air : candidateCategories[candidateIndex]; + boolean equal; + if (baselineIndex < 0 || candidateIndex < 0) { + equal = baselineCategory == air && candidateCategory == air; + } else { + equal = baselineBlockIds[baselineIndex] == candidateBlockIds[candidateIndex]; + } + totals.positions++; + totals.baselineCategoryCounts[baselineCategory]++; + totals.candidateCategoryCounts[candidateCategory]++; + if (equal) { + totals.identical++; + } else { + blockMismatches++; + totals.matrix[baselineCategory][candidateCategory]++; + } + if (baselineCategory != air || candidateCategory != air) { + nonAirPositions++; + if (!equal) { + nonAirMismatches++; + } + } + if (baselineCategory != air && baselineCategory != fluid) { + baselineSurface = y; + } + if (candidateCategory != air && candidateCategory != fluid) { + candidateSurface = y; + } + if (baselineCategory == terrain || baselineCategory == ore) { + baselineTerrainSurface = y; + } + if (candidateCategory == terrain || candidateCategory == ore) { + candidateTerrainSurface = y; + } + } + int delta = Math.abs(baselineSurface - candidateSurface); + int terrainDelta = Math.abs(baselineTerrainSurface - candidateTerrainSurface); + totals.columns++; + if (delta == 0) { + totals.surfaceIdentical++; + } + if (terrainDelta == 0) { + totals.terrainSurfaceIdentical++; + } + totals.surfaceDeltaSum += delta; + totals.terrainSurfaceDeltaSum += terrainDelta; + totals.surfaceMaxDelta = Math.max(totals.surfaceMaxDelta, delta); + totals.terrainSurfaceMaxDelta = Math.max(totals.terrainSurfaceMaxDelta, terrainDelta); + surfaceDeltaSum += delta; + surfaceMaxDelta = Math.max(surfaceMaxDelta, delta); + for (int y = biomeLowY; y <= biomeHighY; y++) { + int baselineBiome = baselineBiomes[baselineBase + y - baselineMinY]; + int candidateBiome = candidateBiomes[candidateBase + y - candidateMinY]; + totals.biomePositions++; + if (baselineBiomeIds[baselineBiome] == candidateBiomeIds[candidateBiome]) { + totals.biomeIdentical++; + } else { + biomeMismatches++; + } + } + } + totals.nonAirPositions += nonAirPositions; + totals.nonAirIdentical += nonAirPositions - nonAirMismatches; + totals.scores.add(new ChunkScore(baseline.chunkX(), baseline.chunkZ(), blockMismatches, nonAirPositions, + nonAirMismatches, surfaceDeltaSum, surfaceMaxDelta, biomeMismatches)); + } + + private static int[] canonicalIds(Totals totals, String[] palette) { + int[] ids = new int[palette.length]; + for (int index = 0; index < palette.length; index++) { + ids[index] = totals.canonicalIds.computeIfAbsent(canonicalKey(palette[index]), + key -> totals.canonicalIds.size()); + } + return ids; + } + + static String canonicalKey(String key) { + int properties = key.indexOf('['); + int namespace = key.indexOf(':'); + if (namespace >= 0 && (properties < 0 || namespace < properties)) { + return key; + } + return "minecraft:" + key; + } + + private static byte[] categories(String[] palette) { + byte[] categories = new byte[palette.length]; + for (int index = 0; index < palette.length; index++) { + categories[index] = (byte) classify(palette[index]).ordinal(); + } + return categories; + } + + private static void printHeaders(Path baselineFile, ChunkDump.Header baseline, Path candidateFile, + ChunkDump.Header candidate) { + System.out.println(LOG_PREFIX + " baseline: " + baselineFile + " dimension=" + baseline.dimensionKey() + + " seed=" + baseline.seed() + " minY=" + baseline.minY() + " height=" + baseline.height()); + System.out.println(LOG_PREFIX + " candidate: " + candidateFile + " dimension=" + candidate.dimensionKey() + + " seed=" + candidate.seed() + " minY=" + candidate.minY() + " height=" + candidate.height()); + if (!baseline.dimensionKey().equals(candidate.dimensionKey()) || baseline.seed() != candidate.seed()) { + System.out.println(LOG_PREFIX + " WARNING: dumps differ in dimension or seed"); + } + if (baseline.minY() != candidate.minY() || baseline.height() != candidate.height()) { + System.out.println(LOG_PREFIX + " WARNING: dumps differ in vertical range; blocks compare over the union" + + " (outside a range counts as air) and biomes over the intersection"); + } + } + + private static void print(Totals totals, int worst) { + System.out.println(LOG_PREFIX + " chunks: baseline=" + totals.chunksBaseline + " candidate=" + + totals.chunksCandidate + " common=" + totals.commonChunks + " only_baseline=" + totals.onlyBaseline + + " only_candidate=" + totals.onlyCandidate + " duplicate_baseline=" + totals.duplicateBaseline + + " duplicate_candidate=" + totals.duplicateCandidate); + System.out.printf(Locale.ROOT, "%s blocks identical: %.4f%% (%d/%d)%n", LOG_PREFIX, + percent(totals.identical, totals.positions), totals.identical, totals.positions); + System.out.printf(Locale.ROOT, "%s blocks identical where either side is non-air: %.4f%% (%d/%d)%n", + LOG_PREFIX, percent(totals.nonAirIdentical, totals.nonAirPositions), totals.nonAirIdentical, + totals.nonAirPositions); + System.out.printf(Locale.ROOT, "%s surface (highest non-air non-fluid) identical columns: %.4f%% mean_abs_delta=%.4f max_abs_delta=%d%n", + LOG_PREFIX, percent(totals.surfaceIdentical, totals.columns), + mean(totals.surfaceDeltaSum, totals.columns), totals.surfaceMaxDelta); + System.out.printf(Locale.ROOT, "%s terrain surface (highest terrain or ore) identical columns: %.4f%% mean_abs_delta=%.4f max_abs_delta=%d%n", + LOG_PREFIX, percent(totals.terrainSurfaceIdentical, totals.columns), + mean(totals.terrainSurfaceDeltaSum, totals.columns), totals.terrainSurfaceMaxDelta); + System.out.printf(Locale.ROOT, "%s biomes identical: %.4f%% (%d/%d)%n", LOG_PREFIX, + percent(totals.biomeIdentical, totals.biomePositions), totals.biomeIdentical, totals.biomePositions); + + Category[] categories = Category.values(); + System.out.println(LOG_PREFIX + " category volumes (baseline -> candidate):"); + for (Category category : categories) { + long before = totals.baselineCategoryCounts[category.ordinal()]; + long after = totals.candidateCategoryCounts[category.ordinal()]; + System.out.printf(Locale.ROOT, " %-8s %12d -> %12d (%+.3f%%)%n", category, before, after, + before == 0L ? 0D : 100D * (after - before) / before); + } + System.out.println(LOG_PREFIX + " mismatches by category (either side in category):"); + for (Category category : categories) { + System.out.printf(Locale.ROOT, " %-8s %12d%n", category, categoryMismatches(totals, category)); + } + System.out.println(LOG_PREFIX + " mismatch matrix (rows baseline category, columns candidate category):"); + StringBuilder headerLine = new StringBuilder(String.format(Locale.ROOT, " %-8s", "")); + for (Category category : categories) { + headerLine.append(String.format(Locale.ROOT, " %12s", category)); + } + System.out.println(headerLine); + for (Category row : categories) { + StringBuilder line = new StringBuilder(String.format(Locale.ROOT, " %-8s", row)); + for (Category column : categories) { + line.append(String.format(Locale.ROOT, " %12d", totals.matrix[row.ordinal()][column.ordinal()])); + } + System.out.println(line); + } + + List ranked = new ArrayList<>(totals.scores); + ranked.sort(Comparator.comparingLong(ChunkScore::nonAirMismatches) + .thenComparingLong(ChunkScore::surfaceDeltaSum) + .reversed()); + int shown = Math.min(worst, ranked.size()); + System.out.println(LOG_PREFIX + " worst " + shown + " chunks:"); + for (int index = 0; index < shown; index++) { + ChunkScore score = ranked.get(index); + System.out.printf(Locale.ROOT, + " chunk=%d,%d nonair_mismatch=%d nonair_identity=%.3f%% block_mismatch=%d surface_mean_abs_delta=%.3f surface_max_abs_delta=%d biome_mismatch=%d%n", + score.chunkX(), score.chunkZ(), score.nonAirMismatches(), score.nonAirIdentity(), + score.blockMismatches(), score.surfaceDeltaSum() / 256D, score.surfaceMaxDelta(), + score.biomeMismatches()); + } + + System.out.printf(Locale.ROOT, + "IRIS_GENDIFF_RESULT version=1 common_chunks=%d only_baseline=%d only_candidate=%d block_identity_pct=%.4f nonair_identity_pct=%.4f surface_identity_pct=%.4f surface_mean_abs_delta=%.4f surface_max_abs_delta=%d terrain_surface_identity_pct=%.4f terrain_surface_mean_abs_delta=%.4f biome_identity_pct=%.4f mismatch_air=%d mismatch_fluid=%d mismatch_terrain=%d mismatch_ore=%d mismatch_decor=%d%n", + totals.commonChunks, totals.onlyBaseline, totals.onlyCandidate, + percent(totals.identical, totals.positions), + percent(totals.nonAirIdentical, totals.nonAirPositions), + percent(totals.surfaceIdentical, totals.columns), + mean(totals.surfaceDeltaSum, totals.columns), totals.surfaceMaxDelta, + percent(totals.terrainSurfaceIdentical, totals.columns), + mean(totals.terrainSurfaceDeltaSum, totals.columns), + percent(totals.biomeIdentical, totals.biomePositions), + categoryMismatches(totals, Category.AIR), categoryMismatches(totals, Category.FLUID), + categoryMismatches(totals, Category.TERRAIN), categoryMismatches(totals, Category.ORE), + categoryMismatches(totals, Category.DECOR)); + } + + private static long categoryMismatches(Totals totals, Category category) { + long count = 0L; + for (Category other : Category.values()) { + count += totals.matrix[category.ordinal()][other.ordinal()]; + if (other != category) { + count += totals.matrix[other.ordinal()][category.ordinal()]; + } + } + return count; + } + + private static double percent(long part, long whole) { + return whole == 0L ? 100D : 100D * part / whole; + } + + private static double mean(long sum, long count) { + return count == 0L ? 0D : sum / (double) count; + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java index 38c63808c..671b2b408 100644 --- a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java @@ -32,8 +32,13 @@ import art.arcane.iris.generation.runtime.EngineWorldManager; import art.arcane.iris.generation.runtime.EngineWorldManagerProvider; import art.arcane.iris.generation.runtime.MeteredCache; import art.arcane.iris.generation.runtime.PreservationRegistry; +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.concurrent.MultiBurst; +import art.arcane.iris.generation.hydrology.HydrologyTileCache; +import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.pregen.PregenPerformanceProfile; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; import art.arcane.volmlib.nativelib.terrain.NativeBiome; @@ -42,6 +47,7 @@ import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.volmlib.util.noise.FractalBillowSimplexNoise; import java.io.File; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -55,7 +61,11 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; public final class GenerationProbe { private static final List REPORTED = Collections.synchronizedList(new ArrayList<>()); @@ -158,9 +168,62 @@ public final class GenerationProbe { } } + record ProbeOptions(int threads, Path dumpFile, boolean pregenProfile) { + ProbeOptions { + if (threads < 1) { + throw new IllegalArgumentException("Thread count must be at least 1."); + } + if (dumpFile != null && !dumpFile.isAbsolute()) { + throw new IllegalArgumentException("Dump file must be an absolute path."); + } + } + + static ProbeOptions fromSystemProperties() { + String dump = System.getProperty("iris.probe.dumpFile", "").trim(); + return new ProbeOptions( + Integer.parseInt(System.getProperty("iris.probe.threads", "1").trim()), + dump.isEmpty() ? null : Path.of(dump), + Boolean.parseBoolean(System.getProperty("iris.probe.pregenProfile", "false").trim())); + } + + boolean parallel() { + return threads > 1; + } + } + record ChunkCoordinate(int x, int z) { } + record ChunkOutcome(int index, ChunkCoordinate coordinate, long elapsedNanos, + Hunk blocks, Hunk biomes, List failures) { + } + + record ThroughputSummary(int threads, boolean multicore, boolean pregenProfile, int burstParallelism, + int noiseCacheSize, int measuredChunks, long warmupWallNanos, long measuredWallNanos, + long latencySumNanos, TimingSummary latencies, int unattributedReports) { + String machineLine() { + double wallSeconds = measuredWallNanos / 1_000_000_000D; + double chunksPerSecond = wallSeconds == 0D ? 0D : measuredChunks / wallSeconds; + return String.format(Locale.ROOT, + "IRIS_GENPROBE_THROUGHPUT version=1 threads=%d multicore=%s pregen_profile=%s burst_parallelism=%d noise_cache_size=%d measured_chunks=%d warmup_wall_ms=%.3f measured_wall_ms=%.3f wall_cps=%.3f latency_sum_ms=%.3f latency_median_ms=%.3f latency_p95_ms=%.3f latency_max_ms=%.3f effective_concurrency=%.2f unattributed_reports=%d", + threads, + multicore, + pregenProfile, + burstParallelism, + noiseCacheSize, + measuredChunks, + nanosToMillis(warmupWallNanos), + nanosToMillis(measuredWallNanos), + chunksPerSecond, + nanosToMillis(latencySumNanos), + nanosToMillis(latencies.medianNanos()), + nanosToMillis(latencies.p95Nanos()), + nanosToMillis(latencies.maxNanos()), + measuredWallNanos == 0L ? 0D : latencySumNanos / (double) measuredWallNanos, + unattributedReports); + } + } + record TimingSummary(long medianNanos, long p95Nanos, long maxNanos, long totalNanos) { static TimingSummary from(List samples) { if (samples.isEmpty()) { @@ -184,6 +247,10 @@ public final class GenerationProbe { } return new TimingSummary(median, sorted.get(p95Index), sorted.get(size - 1), total); } + + TimingSummary withTotal(long totalNanos) { + return new TimingSummary(medianNanos, p95Nanos, maxNanos, totalNanos); + } } record GenerationResult(int successfulChunks, int failedChunks, long firstChunkNanos, @@ -220,8 +287,10 @@ public final class GenerationProbe { public static void main(String[] args) { ProbeConfiguration configuration; + ProbeOptions options; try { configuration = ProbeConfiguration.parse(args); + options = ProbeOptions.fromSystemProperties(); } catch (Throwable e) { System.out.println("[genprobe] FAIL: " + e.getMessage()); e.printStackTrace(System.out); @@ -231,7 +300,7 @@ public final class GenerationProbe { int exitCode; try { - ProbeResult result = run(configuration); + ProbeResult result = run(configuration, options); System.out.println(result.machineLine()); exitCode = result.failedChunks() == 0 ? 0 : 1; } catch (Throwable e) { @@ -272,7 +341,7 @@ public final class GenerationProbe { return coordinates; } - private static ProbeResult run(ProbeConfiguration configuration) throws Throwable { + private static ProbeResult run(ProbeConfiguration configuration, ProbeOptions options) throws Throwable { if (!configuration.packSource().isDirectory()) { throw new IllegalArgumentException("Pack folder not found: " + configuration.packSource().getAbsolutePath()); } @@ -296,6 +365,9 @@ public final class GenerationProbe { System.out.println("[genprobe] center chunk: " + configuration.centerChunkX() + "," + configuration.centerChunkZ()); System.out.println("[genprobe] multicore: " + configuration.multicore()); System.out.println("[genprobe] studio: " + configuration.studio()); + System.out.println("[genprobe] threads: " + options.threads()); + System.out.println("[genprobe] pregen profile: " + options.pregenProfile()); + System.out.println("[genprobe] dump file: " + (options.dumpFile() == null ? "none" : options.dumpFile())); long engineStart = System.nanoTime(); data = IrisData.get(pack); @@ -327,7 +399,7 @@ public final class GenerationProbe { + " minY=" + engine.getMinHeight() + " maxY=" + engine.getMaxHeight() + " timeMs=" + String.format(Locale.ROOT, "%.3f", nanosToMillis(engineReadyNanos))); long nativeSamplesBefore = FractalBillowSimplexNoise.nativeSampleCount(); - GenerationResult generation = generate(engine, configuration); + GenerationResult generation = generateWithProfile(engine, configuration, options); verifyBackend(nativeSamplesBefore); String status = generation.failedChunks() == 0 ? "PASS" : "FAIL"; printGenerationFailures(generation); @@ -401,77 +473,213 @@ public final class GenerationProbe { System.out.println("[genprobe] offline pack validation: PASS"); } - private static GenerationResult generate(Engine engine, ProbeConfiguration configuration) { + private static GenerationResult generateWithProfile(Engine engine, ProbeConfiguration configuration, + ProbeOptions options) throws Exception { + if (!options.pregenProfile()) { + return generate(engine, configuration, options); + } + PregenPerformanceProfile.apply(engine); + IrisHydrologyRuntime hydrology = engine.getComplex().getHydrologyRuntime(); + try (HydrologyTileCache.PregenerationScope ignored = hydrology == null ? null + : hydrology.preparePregeneration(pregenerationArea(configuration))) { + return generate(engine, configuration, options); + } finally { + PregenPerformanceProfile.restore(); + } + } + + static HydrologyTileCache.PregenerationArea pregenerationArea(ProbeConfiguration configuration) { + List coordinates = scheduleCoordinates( + configuration.warmupChunks() + configuration.measuredChunks(), + configuration.centerChunkX(), configuration.centerChunkZ()); + int minimumX = Integer.MAX_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int maximumZ = Integer.MIN_VALUE; + for (ChunkCoordinate coordinate : coordinates) { + minimumX = Math.min(minimumX, coordinate.x()); + minimumZ = Math.min(minimumZ, coordinate.z()); + maximumX = Math.max(maximumX, coordinate.x()); + maximumZ = Math.max(maximumZ, coordinate.z()); + } + int centerBlockX = Math.toIntExact(Math.clamp(((long) configuration.centerChunkX() << 4) + 8L, + (long) minimumX << 4, (((long) maximumX + 1L) << 4) - 1L)); + int centerBlockZ = Math.toIntExact(Math.clamp(((long) configuration.centerChunkZ() << 4) + 8L, + (long) minimumZ << 4, (((long) maximumZ + 1L) << 4) - 1L)); + return new HydrologyTileCache.PregenerationArea(centerBlockX, centerBlockZ, + (long) minimumX << 4, (long) minimumZ << 4, + (((long) maximumX + 1L) << 4) - 1L, (((long) maximumZ + 1L) << 4) - 1L); + } + + static List spiralOrder(List coordinates, int fromIndex, int centerChunkX, + int centerChunkZ) { + List order = new ArrayList<>(coordinates.size() - fromIndex); + for (int index = fromIndex; index < coordinates.size(); index++) { + order.add(index); + } + order.sort(Comparator + .comparingInt((Integer index) -> ringOf(coordinates.get(index), centerChunkX, centerChunkZ)) + .thenComparingDouble(index -> angleOf(coordinates.get(index), centerChunkX, centerChunkZ))); + return order; + } + + private static int ringOf(ChunkCoordinate coordinate, int centerChunkX, int centerChunkZ) { + return Math.max(Math.abs(coordinate.x() - centerChunkX), Math.abs(coordinate.z() - centerChunkZ)); + } + + private static double angleOf(ChunkCoordinate coordinate, int centerChunkX, int centerChunkZ) { + return Math.atan2(coordinate.z() - centerChunkZ, coordinate.x() - centerChunkX); + } + + private static GenerationResult generate(Engine engine, ProbeConfiguration configuration, + ProbeOptions options) throws Exception { int totalChunks = configuration.warmupChunks() + configuration.measuredChunks(); List coordinates = scheduleCoordinates( totalChunks, configuration.centerChunkX(), configuration.centerChunkZ()); - List measuredTimings = new ArrayList<>(configuration.measuredChunks()); - MessageDigest signature = sha256(); - int successfulChunks = 0; - int failedChunks = 0; - long firstChunkNanos = 0L; - Map distinctFailures = new LinkedHashMap<>(); - Map firstFailureChunk = new LinkedHashMap<>(); int height = engine.getTarget().getHeight(); - - for (int index = 0; index < totalChunks; index++) { - ChunkCoordinate coordinate = coordinates.get(index); - drainReported(); - List failures = new ArrayList<>(); - Hunk blocks = Hunk.newArrayHunk(16, height, 16); - Hunk biomes = Hunk.newArrayHunk(16, height, 16); - long started = System.nanoTime(); - try { - engine.generate( - coordinate.x() << 4, - coordinate.z() << 4, - blocks, - biomes, - configuration.multicore()); - } catch (Throwable e) { - failures.add(e); - } - long elapsed = System.nanoTime() - started; - failures.addAll(drainReported()); - - if (index == 0) { - firstChunkNanos = elapsed; - } - if (index >= configuration.warmupChunks()) { - measuredTimings.add(elapsed); - } - - if (failures.isEmpty()) { - successfulChunks++; - updateSignature(signature, coordinate, blocks, biomes, height); + ChunkDump.Header header = new ChunkDump.Header(configuration.dimensionKey(), configuration.seed(), + engine.getMinHeight(), height); + try (OutcomeSink sink = new OutcomeSink(options.dumpFile(), header, configuration.measuredChunks())) { + if (options.parallel()) { + generateParallel(engine, configuration, options, coordinates, sink); } else { - failedChunks++; - recordFailures(coordinate, failures, distinctFailures, firstFailureChunk); + generateSequential(engine, configuration, coordinates, sink); } + return sink.result(configuration); + } + } - int completed = index + 1; - if (completed == configuration.warmupChunks()) { - System.out.println("[genprobe] warmup complete: " + configuration.warmupChunks() + " chunks"); - } else if (completed > configuration.warmupChunks() - && (completed == totalChunks || (completed - configuration.warmupChunks()) % 128 == 0)) { - System.out.println("[genprobe] measured progress: " - + (completed - configuration.warmupChunks()) + "/" + configuration.measuredChunks()); + private static void generateSequential(Engine engine, ProbeConfiguration configuration, + List coordinates, OutcomeSink sink) throws IOException { + int totalChunks = coordinates.size(); + long measuredStartEpochMillis = 0L; + for (int index = 0; index < totalChunks; index++) { + if (index == configuration.warmupChunks()) { + measuredStartEpochMillis = System.currentTimeMillis(); + } + drainReported(); + ChunkOutcome outcome = generateChunk(engine, index, coordinates.get(index), configuration.multicore()); + outcome.failures().addAll(drainReported()); + sink.accept(outcome, index >= configuration.warmupChunks()); + printProgress(configuration, index + 1, totalChunks); + } + printWindow(measuredStartEpochMillis, System.currentTimeMillis()); + } + + private static void printWindow(long startEpochMillis, long endEpochMillis) { + System.out.println("IRIS_GENPROBE_WINDOW phase=measured start_epoch_ms=" + startEpochMillis + + " end_epoch_ms=" + endEpochMillis); + } + + private static void generateParallel(Engine engine, ProbeConfiguration configuration, ProbeOptions options, + List coordinates, OutcomeSink sink) throws Exception { + ChunkOutcome[] outcomes = new ChunkOutcome[coordinates.size()]; + ExecutorService executor = Executors.newFixedThreadPool(options.threads()); + long warmupWallNanos; + long measuredWallNanos; + List unattributed = new ArrayList<>(); + try { + drainReported(); + List warmup = new ArrayList<>(configuration.warmupChunks()); + for (int index = 0; index < configuration.warmupChunks(); index++) { + warmup.add(index); + } + long warmupStarted = System.nanoTime(); + runPhase(executor, engine, configuration, coordinates, warmup, outcomes); + warmupWallNanos = System.nanoTime() - warmupStarted; + unattributed.addAll(drainReported()); + System.out.println("[genprobe] warmup complete: " + configuration.warmupChunks() + " chunks on " + + options.threads() + " threads"); + + List measured = spiralOrder(coordinates, configuration.warmupChunks(), + configuration.centerChunkX(), configuration.centerChunkZ()); + long measuredStartEpochMillis = System.currentTimeMillis(); + long measuredStarted = System.nanoTime(); + runPhase(executor, engine, configuration, coordinates, measured, outcomes); + measuredWallNanos = System.nanoTime() - measuredStarted; + printWindow(measuredStartEpochMillis, System.currentTimeMillis()); + unattributed.addAll(drainReported()); + System.out.println("[genprobe] measured progress: " + configuration.measuredChunks() + "/" + + configuration.measuredChunks()); + } finally { + executor.shutdown(); + if (!executor.awaitTermination(30L, TimeUnit.SECONDS)) { + executor.shutdownNow(); } } - if (!distinctFailures.isEmpty()) { - System.out.println("[genprobe] DISTINCT ROOT CAUSES (" + distinctFailures.size() + "):"); - for (Map.Entry entry : distinctFailures.entrySet()) { - System.out.println(" x" + entry.getValue() + " (first at chunk " - + firstFailureChunk.get(entry.getKey()) + ") " + entry.getKey()); + for (int index = 0; index < outcomes.length; index++) { + ChunkOutcome outcome = outcomes[index]; + outcomes[index] = null; + sink.accept(outcome, index >= configuration.warmupChunks()); + } + sink.reportUnattributed(unattributed); + TimingSummary latencies = TimingSummary.from(sink.measuredTimings()); + ThroughputSummary throughput = new ThroughputSummary( + options.threads(), + configuration.multicore(), + options.pregenProfile(), + MultiBurst.burst.parallelism(), + IrisSettings.get().getPerformance().getNoiseCacheSize(), + configuration.measuredChunks(), + warmupWallNanos, + measuredWallNanos, + latencies.totalNanos(), + latencies, + unattributed.size()); + sink.measuredWallNanos(measuredWallNanos); + System.out.println(throughput.machineLine()); + } + + private static void runPhase(ExecutorService executor, Engine engine, ProbeConfiguration configuration, + List coordinates, List order, + ChunkOutcome[] outcomes) throws Exception { + List> futures = new ArrayList<>(order.size()); + for (int index : order) { + ChunkCoordinate coordinate = coordinates.get(index); + futures.add(executor.submit(() -> generateChunk(engine, index, coordinate, configuration.multicore()))); + } + for (Future future : futures) { + ChunkOutcome outcome; + try { + outcome = future.get(); + } catch (ExecutionException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof Error error) { + throw error; + } + if (cause instanceof Exception exception) { + throw exception; + } + throw new IllegalStateException(cause); } + outcomes[outcome.index()] = outcome; + } + } + + private static ChunkOutcome generateChunk(Engine engine, int index, ChunkCoordinate coordinate, + boolean multicore) { + int height = engine.getTarget().getHeight(); + List failures = new ArrayList<>(); + Hunk blocks = Hunk.newArrayHunk(16, height, 16); + Hunk biomes = Hunk.newArrayHunk(16, height, 16); + long started = System.nanoTime(); + try { + engine.generate(coordinate.x() << 4, coordinate.z() << 4, blocks, biomes, multicore); + } catch (Throwable e) { + failures.add(e); + } + return new ChunkOutcome(index, coordinate, System.nanoTime() - started, blocks, biomes, failures); + } + + private static void printProgress(ProbeConfiguration configuration, int completed, int totalChunks) { + if (completed == configuration.warmupChunks()) { + System.out.println("[genprobe] warmup complete: " + configuration.warmupChunks() + " chunks"); + } else if (completed > configuration.warmupChunks() + && (completed == totalChunks || (completed - configuration.warmupChunks()) % 128 == 0)) { + System.out.println("[genprobe] measured progress: " + + (completed - configuration.warmupChunks()) + "/" + configuration.measuredChunks()); } - return new GenerationResult( - successfulChunks, - failedChunks, - firstChunkNanos, - TimingSummary.from(measuredTimings), - HexFormat.of().formatHex(signature.digest()).substring(0, 16)); } private static void recordFailures(ChunkCoordinate coordinate, List failures, @@ -499,32 +707,42 @@ public final class GenerationProbe { nanosToMillis(generation.measuredTimings().maxNanos()))); } - private static void updateSignature(MessageDigest digest, ChunkCoordinate coordinate, - Hunk blocks, Hunk biomes, int height) { + private static void updateSignature(MessageDigest digest, ChunkDump.ChunkSnapshot snapshot, int height) { MessageDigest blockDigest = sha256(); MessageDigest biomeDigest = sha256(); - updateDigest(digest, coordinate.x() + "," + coordinate.z()); - updateDigest(blockDigest, coordinate.x() + "," + coordinate.z()); - updateDigest(biomeDigest, coordinate.x() + "," + coordinate.z()); - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - for (int y = 0; y < height; y++) { - NativeBlockState state = blocks.get(x, y, z); - NativeBiome biome = biomes.get(x, y, z); - String stateKey = state == null ? "minecraft:air" : state.key(); - String biomeKey = biome == null ? "null" : biome.key(); - updateDigest(digest, stateKey); - updateDigest(digest, biomeKey); - updateDigest(blockDigest, stateKey); - updateDigest(biomeDigest, biomeKey); - } - } + String coordinateLabel = snapshot.chunkX() + "," + snapshot.chunkZ(); + updateDigest(digest, coordinateLabel); + updateDigest(blockDigest, coordinateLabel); + updateDigest(biomeDigest, coordinateLabel); + byte[][] blockKeys = terminatedKeys(snapshot.blockPalette()); + byte[][] biomeKeys = terminatedKeys(snapshot.biomePalette()); + char[] blocks = snapshot.blocks(); + char[] biomes = snapshot.biomes(); + int size = 256 * height; + for (int index = 0; index < size; index++) { + byte[] stateKey = blockKeys[blocks[index]]; + byte[] biomeKey = biomeKeys[biomes[index]]; + digest.update(stateKey); + digest.update(biomeKey); + blockDigest.update(stateKey); + biomeDigest.update(biomeKey); } - System.out.println("GENPROBE_CHUNK_HASH chunk=" + coordinate.x() + "," + coordinate.z() + System.out.println("GENPROBE_CHUNK_HASH chunk=" + coordinateLabel + " blocks=" + HexFormat.of().formatHex(blockDigest.digest()).substring(0, 16) + " biomes=" + HexFormat.of().formatHex(biomeDigest.digest()).substring(0, 16)); } + private static byte[][] terminatedKeys(String[] palette) { + byte[][] encoded = new byte[palette.length][]; + for (int index = 0; index < palette.length; index++) { + byte[] key = palette[index].getBytes(StandardCharsets.UTF_8); + byte[] terminated = new byte[key.length + 1]; + System.arraycopy(key, 0, terminated, 0, key.length); + encoded[index] = terminated; + } + return encoded; + } + private static void updateDigest(MessageDigest digest, String value) { digest.update(value.getBytes(StandardCharsets.UTF_8)); digest.update((byte) 0); @@ -659,4 +877,88 @@ public final class GenerationProbe { private static double nanosToMillis(long nanos) { return nanos / 1_000_000D; } + + private static final class OutcomeSink implements AutoCloseable { + private final MessageDigest signature = sha256(); + private final List measuredTimings; + private final Map distinctFailures = new LinkedHashMap<>(); + private final Map firstFailureChunk = new LinkedHashMap<>(); + private final ChunkDump.Writer dump; + private final int height; + private int successfulChunks; + private int failedChunks; + private int unattributedReports; + private long firstChunkNanos; + private long measuredWallNanos = -1L; + + private OutcomeSink(Path dumpFile, ChunkDump.Header header, int measuredChunks) throws IOException { + this.measuredTimings = new ArrayList<>(measuredChunks); + this.height = header.height(); + this.dump = dumpFile == null ? null : new ChunkDump.Writer(dumpFile, header); + } + + void accept(ChunkOutcome outcome, boolean measured) throws IOException { + if (outcome.index() == 0) { + firstChunkNanos = outcome.elapsedNanos(); + } + if (measured) { + measuredTimings.add(outcome.elapsedNanos()); + } + if (!outcome.failures().isEmpty()) { + failedChunks++; + recordFailures(outcome.coordinate(), outcome.failures(), distinctFailures, firstFailureChunk); + return; + } + successfulChunks++; + ChunkDump.ChunkSnapshot snapshot = ChunkDump.capture(outcome.coordinate().x(), outcome.coordinate().z(), + outcome.blocks(), outcome.biomes(), height); + updateSignature(signature, snapshot, height); + if (dump != null) { + dump.write(snapshot); + } + } + + void reportUnattributed(List reports) { + unattributedReports += reports.size(); + if (!reports.isEmpty()) { + printDistinctCauses("parallel generation reported errors without a chunk owner", reports); + } + } + + void measuredWallNanos(long nanos) { + measuredWallNanos = nanos; + } + + List measuredTimings() { + return measuredTimings; + } + + GenerationResult result(ProbeConfiguration configuration) { + if (!distinctFailures.isEmpty()) { + System.out.println("[genprobe] DISTINCT ROOT CAUSES (" + distinctFailures.size() + "):"); + for (Map.Entry entry : distinctFailures.entrySet()) { + System.out.println(" x" + entry.getValue() + " (first at chunk " + + firstFailureChunk.get(entry.getKey()) + ") " + entry.getKey()); + } + } + TimingSummary timings = TimingSummary.from(measuredTimings); + if (measuredWallNanos >= 0L) { + timings = timings.withTotal(measuredWallNanos); + } + int failures = failedChunks + (unattributedReports > 0 && failedChunks == 0 ? 1 : 0); + return new GenerationResult( + successfulChunks, + failures, + firstChunkNanos, + timings, + HexFormat.of().formatHex(signature.digest()).substring(0, 16)); + } + + @Override + public void close() throws IOException { + if (dump != null) { + dump.close(); + } + } + } } diff --git a/probe/src/test/java/art/arcane/iris/probe/GenerationDiffTest.java b/probe/src/test/java/art/arcane/iris/probe/GenerationDiffTest.java new file mode 100644 index 000000000..049109da7 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/GenerationDiffTest.java @@ -0,0 +1,146 @@ +package art.arcane.iris.probe; + +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public final class GenerationDiffTest { + private static final int HEIGHT = 8; + private static final int MIN_Y = -4; + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + @Test + public void roundTripsWidePalettesAndChunkCoordinates() throws IOException { + String[] palette = new String[300]; + for (int index = 0; index < palette.length; index++) { + palette[index] = "minecraft:block_" + index; + } + char[] blocks = new char[256 * HEIGHT]; + for (int index = 0; index < blocks.length; index++) { + blocks[index] = (char) (index % palette.length); + } + ChunkDump.ChunkSnapshot wide = new ChunkDump.ChunkSnapshot(-3, 7, HEIGHT, palette, blocks, + new String[]{"minecraft:plains"}, new char[256 * HEIGHT]); + Path file = write("wide.bin", List.of(wide)); + + try (ChunkDump.Reader reader = new ChunkDump.Reader(file)) { + assertEquals(new ChunkDump.Header("overworld", 1337L, MIN_Y, HEIGHT), reader.header()); + ChunkDump.ChunkSnapshot read = reader.next(); + assertEquals(-3, read.chunkX()); + assertEquals(7, read.chunkZ()); + assertArrayEquals(palette, read.blockPalette()); + assertArrayEquals(blocks, read.blocks()); + assertArrayEquals(new String[]{"minecraft:plains"}, read.biomePalette()); + assertNull(reader.next()); + } + } + + @Test + public void capturesUnsetHunkEntriesAsAirAndNullBiome() { + Hunk blocks = Hunk.newArrayHunk(16, HEIGHT, 16); + Hunk biomes = Hunk.newArrayHunk(16, HEIGHT, 16); + + ChunkDump.ChunkSnapshot snapshot = ChunkDump.capture(1, 2, blocks, biomes, HEIGHT); + + assertArrayEquals(new String[]{ChunkDump.NULL_BLOCK_KEY}, snapshot.blockPalette()); + assertArrayEquals(new String[]{ChunkDump.NULL_BIOME_KEY}, snapshot.biomePalette()); + assertEquals(256 * HEIGHT, snapshot.blocks().length); + } + + @Test + public void reportsFullIdentityIndependentOfChunkOrderAndNamespaceSpelling() throws IOException { + Path baseline = write("a.bin", List.of(terrain(0, 0, 3, "minecraft:air"), terrain(1, 0, 5, "minecraft:air"))); + Path candidate = write("b.bin", List.of(terrain(1, 0, 5, "air"), terrain(0, 0, 3, "minecraft:air"))); + + GenerationDiff.Totals totals = GenerationDiff.compare(baseline, candidate); + + assertEquals(2L, totals.commonChunks); + assertEquals(0L, totals.onlyBaseline); + assertEquals(0L, totals.onlyCandidate); + assertEquals(totals.positions, totals.identical); + assertEquals(totals.nonAirPositions, totals.nonAirIdentical); + assertEquals(totals.columns, totals.surfaceIdentical); + assertEquals(totals.biomePositions, totals.biomeIdentical); + } + + @Test + public void treatsBothAirSpellingsInOnePaletteAsTheSameBlock() throws IOException { + char[] blocks = new char[256 * HEIGHT]; + Arrays.fill(blocks, 0, HEIGHT, (char) 1); + ChunkDump.ChunkSnapshot mixed = new ChunkDump.ChunkSnapshot(0, 0, HEIGHT, + new String[]{"minecraft:air", "air"}, blocks, new String[]{"minecraft:plains"}, new char[256 * HEIGHT]); + ChunkDump.ChunkSnapshot reordered = new ChunkDump.ChunkSnapshot(0, 0, HEIGHT, + new String[]{"air", "minecraft:air"}, blocks.clone(), new String[]{"plains"}, new char[256 * HEIGHT]); + + GenerationDiff.Totals totals = GenerationDiff.compare(write("a.bin", List.of(mixed)), + write("b.bin", List.of(reordered))); + + assertEquals(totals.positions, totals.identical); + assertEquals(totals.biomePositions, totals.biomeIdentical); + } + + @Test + public void attributesSurfaceAndCategoryMismatchesAndUnmatchedChunks() throws IOException { + Path baseline = write("a.bin", List.of(terrain(0, 0, 5, "minecraft:air"), terrain(4, 4, 5, "minecraft:air"))); + Path candidate = write("b.bin", List.of(terrain(0, 0, 3, "minecraft:air"), terrain(9, 9, 5, "minecraft:air"))); + + GenerationDiff.Totals totals = GenerationDiff.compare(baseline, candidate); + + assertEquals(1L, totals.commonChunks); + assertEquals(1L, totals.onlyBaseline); + assertEquals(1L, totals.onlyCandidate); + assertEquals(256L * 2L, totals.matrix[GenerationDiff.Category.TERRAIN.ordinal()][GenerationDiff.Category.AIR.ordinal()]); + assertEquals(0L, totals.surfaceIdentical); + assertEquals(256L * 2L, totals.surfaceDeltaSum); + assertEquals(2, totals.surfaceMaxDelta); + assertEquals(256L * 5L, totals.nonAirPositions); + assertEquals(256L * 3L, totals.nonAirIdentical); + } + + @Test + public void classifiesBlockFamilies() { + assertEquals(GenerationDiff.Category.AIR, GenerationDiff.classify("cave_air")); + assertEquals(GenerationDiff.Category.AIR, GenerationDiff.classify("minecraft:void_air")); + assertEquals(GenerationDiff.Category.FLUID, GenerationDiff.classify("minecraft:water[level=0]")); + assertEquals(GenerationDiff.Category.ORE, GenerationDiff.classify("minecraft:deepslate_diamond_ore")); + assertEquals(GenerationDiff.Category.DECOR, GenerationDiff.classify("minecraft:oak_leaves[distance=1,persistent=false]")); + assertEquals(GenerationDiff.Category.DECOR, GenerationDiff.classify("minecraft:snow[layers=1]")); + assertEquals(GenerationDiff.Category.TERRAIN, GenerationDiff.classify("minecraft:snow_block")); + assertEquals(GenerationDiff.Category.TERRAIN, GenerationDiff.classify("minecraft:grass_block[snowy=false]")); + assertEquals("minecraft:air", GenerationDiff.canonicalKey("air")); + assertEquals("custom:thing[a=b:c]", GenerationDiff.canonicalKey("custom:thing[a=b:c]")); + } + + private ChunkDump.ChunkSnapshot terrain(int chunkX, int chunkZ, int solidHeight, String airKey) { + char[] blocks = new char[256 * HEIGHT]; + for (int column = 0; column < 256; column++) { + Arrays.fill(blocks, column * HEIGHT, column * HEIGHT + solidHeight, (char) 1); + } + return new ChunkDump.ChunkSnapshot(chunkX, chunkZ, HEIGHT, new String[]{airKey, "minecraft:stone"}, blocks, + new String[]{"minecraft:plains"}, new char[256 * HEIGHT]); + } + + private Path write(String name, List snapshots) throws IOException { + Path file = folder.getRoot().toPath().resolve(name); + try (ChunkDump.Writer writer = new ChunkDump.Writer(file, new ChunkDump.Header("overworld", 1337L, MIN_Y, HEIGHT))) { + for (ChunkDump.ChunkSnapshot snapshot : snapshots) { + writer.write(snapshot); + } + } + return file; + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/GenerationProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/GenerationProbeTest.java index 6334befe5..716aca6b8 100644 --- a/probe/src/test/java/art/arcane/iris/probe/GenerationProbeTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/GenerationProbeTest.java @@ -1,8 +1,10 @@ package art.arcane.iris.probe; +import art.arcane.iris.generation.hydrology.HydrologyTileCache; import org.junit.Test; import java.io.File; +import java.nio.file.Path; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -76,6 +78,59 @@ public final class GenerationProbeTest { assertTrue(coordinates.stream().allMatch(coordinate -> Math.abs(coordinate.z() + 2048) <= 18)); } + @Test + public void ordersMeasuredChunksOutwardFromTheCenterWithoutTouchingWarmup() { + List coordinates = GenerationProbe.scheduleCoordinates(320, 2048, 2048); + List order = GenerationProbe.spiralOrder(coordinates, 64, 2048, 2048); + + assertEquals(256, order.size()); + assertEquals(256, new HashSet<>(order).size()); + assertTrue(order.stream().allMatch(index -> index >= 64)); + int previousRing = -1; + for (int index : order) { + GenerationProbe.ChunkCoordinate coordinate = coordinates.get(index); + int ring = Math.max(Math.abs(coordinate.x() - 2048), Math.abs(coordinate.z() - 2048)); + assertTrue(ring >= previousRing); + previousRing = ring; + } + } + + @Test + public void pregenerationAreaCoversEveryScheduledChunk() { + GenerationProbe.ProbeConfiguration configuration = new GenerationProbe.ProbeConfiguration( + new File("/tmp/pack"), "overworld", 64, 256, 2048, 2048, true, false, 1337L); + HydrologyTileCache.PregenerationArea area = GenerationProbe.pregenerationArea(configuration); + + for (GenerationProbe.ChunkCoordinate coordinate : GenerationProbe.scheduleCoordinates(320, 2048, 2048)) { + assertTrue(((long) coordinate.x() << 4) >= area.minimumBlockX()); + assertTrue((((long) coordinate.x() << 4) + 15L) <= area.maximumBlockX()); + assertTrue(((long) coordinate.z() << 4) >= area.minimumBlockZ()); + assertTrue((((long) coordinate.z() << 4) + 15L) <= area.maximumBlockZ()); + } + } + + @Test + public void readsThroughputAndDumpOptionsFromSystemProperties() { + System.setProperty("iris.probe.threads", "16"); + System.setProperty("iris.probe.dumpFile", "/tmp/iris-dump.bin"); + System.setProperty("iris.probe.pregenProfile", "true"); + try { + GenerationProbe.ProbeOptions options = GenerationProbe.ProbeOptions.fromSystemProperties(); + assertEquals(16, options.threads()); + assertEquals(Path.of("/tmp/iris-dump.bin"), options.dumpFile()); + assertTrue(options.pregenProfile()); + assertTrue(options.parallel()); + } finally { + System.clearProperty("iris.probe.threads"); + System.clearProperty("iris.probe.dumpFile"); + System.clearProperty("iris.probe.pregenProfile"); + } + GenerationProbe.ProbeOptions defaults = GenerationProbe.ProbeOptions.fromSystemProperties(); + assertEquals(1, defaults.threads()); + assertNull(defaults.dumpFile()); + assertThrows(IllegalArgumentException.class, () -> new GenerationProbe.ProbeOptions(0, null, false)); + } + @Test public void reportsMedianNearestRankP95MaximumAndTotal() { GenerationProbe.TimingSummary odd = GenerationProbe.TimingSummary.from(List.of(50L, 10L, 30L, 20L, 40L));