From 35dd69e9a6279fa89d762a0d24e49b0dbdb11b51 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:07:55 +0200 Subject: [PATCH 1/4] Avoid empty culling whitelist iterator allocation while keeping live registration --- .github/workflows/ci.yml | 6 ++ README.md | 8 +- docs/culling-empty.md | 33 ++++++ .../dev/horizonlab/client/HorizonClient.java | 1 + .../mixin/CullingEmptyWhitelistMixin.java | 19 ++++ .../horizonlab/mixin/HorizonMixinPlugin.java | 18 ++++ .../src/main/resources/fabric.mod.json | 2 +- .../src/main/resources/horizonlab.mixins.json | 2 +- .../dispatch/CullingEmptyIntegration.java | 100 ++++++++++++++++++ packs/START-HERE.txt | 4 + packs/mods.lock.json | 2 +- tests/test_packs.py | 12 +++ tools/Compare-Benchmarks.html | 3 +- tools/build_mod.py | 12 ++- tools/build_pack.py | 5 +- tools/compare_benchmarks.py | 2 +- tools/test_culling_empty.py | 64 +++++++++++ 17 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 docs/culling-empty.md create mode 100644 mods/horizon-lab/src/main/java/dev/horizonlab/mixin/CullingEmptyWhitelistMixin.java create mode 100644 mods/horizon-lab/src/test/java/dev/horizonlab/dispatch/CullingEmptyIntegration.java create mode 100644 tools/test_culling_empty.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a70ab7cbc..ebc6d6e87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,8 @@ jobs: run: python -c "from tools.build_mod import build_dispatch_fixture; build_dispatch_fixture()" - name: Compile the private terrain tick membership fixture run: python -c "from tools.build_mod import build_tick_dedup_fixture; build_tick_dedup_fixture()" + - name: Compile the private culling whitelist fixture + run: python -c "from tools.build_mod import build_culling_empty_fixture; build_culling_empty_fixture()" - name: Install headless graphics libraries for applied-hook checks if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libegl1 libvulkan1 mesa-vulkan-drivers libasound2t64 libxkbcommon0 @@ -72,6 +74,9 @@ jobs: - name: Verify applied tick membership transitions and allocation if: runner.os == 'Linux' run: python tools/test_tick_dedup.py + - name: Verify live culling whitelist registration and empty iteration + if: runner.os == 'Linux' + run: python tools/test_culling_empty.py - name: Upload original mod and pack manifests only uses: actions/upload-artifact@v4 with: @@ -85,3 +90,4 @@ jobs: dist/object-header-benchmark.txt dist/dispatch-integration.json dist/tick-dedup-integration.json + dist/culling-empty-integration.json diff --git a/README.md b/README.md index d2ff5de59..9066179db 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Client-first performance research for Minecraft Java **26.3**, preserving vanill ## Play on your Mac -Download **horizon-mac-starter-0.13.0.zip** from [Releases](https://github.com/ludovic111/minecraft-optimization-lab/releases), extract it and open `Mac-Start.command`. It verifies and installs official universal Prism Launcher in `~/Applications` if needed, then opens the balanced pack import. Sign into Microsoft **inside Prism yourself**. This project needs no credentials. +Download **horizon-mac-starter-0.14.0.zip** from [Releases](https://github.com/ludovic111/minecraft-optimization-lab/releases), extract it and open `Mac-Start.command`. It verifies and installs official universal Prism Launcher in `~/Applications` if needed, then opens the balanced pack import. Sign into Microsoft **inside Prism yourself**. This project needs no credentials. Use native **Java 25 ARM64**, maximum heap **6144 MiB**, minimum **512 MiB** on your 16 GB M2 Pro. Prism can download Java automatically. Start at 1280×800 or 1920×1200; full Retina resolution is a larger GPU workload. [START-HERE.txt](packs/START-HERE.txt) has details. The same `.mrpack` files import into Prism on Windows and Linux. @@ -21,6 +21,7 @@ Use native **Java 25 ARM64**, maximum heap **6144 MiB**, minimum **512 MiB** on | adaptive-queue (experimental) | 12 | 128 | 6144 MiB | | dispatch-retry (experimental) | 12 | 128 | 6144 MiB | | tick-dedup (experimental) | 12 | 128 | 6144 MiB | +| culling-empty (experimental) | 12 | 128 | 6144 MiB | | foreground-first (combined experiment) | 12 | 128 | 6144 MiB | | foreground-vulkan (combined experiment) | 12 | 128 | 6144 MiB | | low-memory | 8 | none | 3072 MiB | @@ -34,6 +35,11 @@ Distant LOD is simplified terrain. Nearby blocks retain full detail. Initial gen The optional `queue-wait` pack yields during pending distant-terrain debounce retries; `adaptive-queue` combines it with the existing controllers. [Queue experiment and measurement limits](docs/debounce-queue.md). +Version 0.14 adds an isolated [empty culling whitelist experiment](docs/culling-empty.md) +that avoids allocating iterators while there are no dynamic handlers. Live +registration and nonempty callbacks retain upstream behavior. Balanced keeps it +off; whole-game gains still need hardware comparisons. + Version 0.13 adds an isolated [tick membership experiment](docs/tick-membership.md) that reuses compact storage for DH's private per-tick membership checks. The combined foreground profiles include it too; balanced keeps it off. The applied diff --git a/docs/culling-empty.md b/docs/culling-empty.md new file mode 100644 index 000000000..679802511 --- /dev/null +++ b/docs/culling-empty.md @@ -0,0 +1,33 @@ +# Avoid allocating iterators for empty culling whitelists + +A local game JFR found `HashMap.KeySet` iterator allocation in Entity Culling's +dynamic entity/block-entity whitelist checks on `CullThread`. The pinned 1.11.2 +methods create a set iterator before discovering that there are no handlers. + +The optional hook returns the JDK's shared empty iterator while the live set is +empty. It checks that set every call; it does not cache a whitelist, remember an +empty state, skip a registered handler, or alter culling decisions. Nonempty +sets use their original iterator, callback order, type guard and short circuit. +No per-call callback object is introduced by the hook. + +Use the `culling-empty` profile for an isolated comparison with `m2-balanced`. +Both keep the same graphics, LOD radius, worker budget and other controller +settings. The hook is opt-in, restricted to Entity Culling 1.11.2, and can be +disabled with `enabled=false` in `config/horizon-culling-empty.properties` and a +restart. Frame reports record `empty_culling_whitelist`; the CLI and offline +viewer disclose a difference as an experiment variable. + +`python tools/test_culling_empty.py` runs the actual publisher methods inside +Fabric. It checks empty iterator dispatch, registration after initialization, +duplicate registration, real block-entity callback order/short circuit, null and +wrong-type inputs, and returning to an empty state. The menu-only fixture does +not exercise callbacks using a valid world entity; nonempty entity iteration +uses the original implementation. Private instrumentation and fixture JARs +never enter release packs. Full game logs remain private. + +A local warmed loop of four million empty checks allocated 128,001,248 bytes +upstream versus 1,248 with the hook. CPU was 41 ms versus 33 ms in that single +observation. These counts isolate this allocation path; JIT escape analysis can +produce different results in other calling contexts. They are not game FPS, +whole-game RAM, statistical speed estimates or Mac GPU results. Live culling +thread profiles, matching workloads and repeated hardware tests remain needed. diff --git a/mods/horizon-lab/src/main/java/dev/horizonlab/client/HorizonClient.java b/mods/horizon-lab/src/main/java/dev/horizonlab/client/HorizonClient.java index a3f80c882..4fd4cb7bd 100644 --- a/mods/horizon-lab/src/main/java/dev/horizonlab/client/HorizonClient.java +++ b/mods/horizon-lab/src/main/java/dev/horizonlab/client/HorizonClient.java @@ -97,6 +97,7 @@ public final class HorizonClient implements ClientModInitializer { context.put("wakeable_idle_wait", dev.horizonlab.mixin.HorizonMixinPlugin.debounceWaitEnabled()); context.put("dispatch_retry_backoff", dev.horizonlab.mixin.HorizonMixinPlugin.dispatchRetryEnabled()); context.put("tick_membership_dedup", dev.horizonlab.mixin.HorizonMixinPlugin.tickDedupEnabled()); + context.put("empty_culling_whitelist", dev.horizonlab.mixin.HorizonMixinPlugin.cullingEmptyEnabled()); context.put("adaptive_target_fps", targetFps); context.putAll(visualSettings(client)); var device = RenderSystem.getDevice().getDeviceInfo(); diff --git a/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/CullingEmptyWhitelistMixin.java b/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/CullingEmptyWhitelistMixin.java new file mode 100644 index 000000000..8b16c0449 --- /dev/null +++ b/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/CullingEmptyWhitelistMixin.java @@ -0,0 +1,19 @@ +package dev.horizonlab.mixin; + +import java.util.Collections; +import java.util.Iterator; +import java.util.Set; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +/** Empty dynamic whitelists have no handlers to visit; nonempty sets retain their own iterator. */ +@Mixin(targets = "dev.tr7zw.entityculling.EntityCullingModBase", remap = false) +public abstract class CullingEmptyWhitelistMixin { + @Redirect(method = {"isEntityDynamicWhitelisted", "isBlockEntityDynamicWhitelisted"}, + at = @At(value = "INVOKE", target = "Ljava/util/Set;iterator()Ljava/util/Iterator;")) + private Iterator horizon$iterateDynamicHandlers(Set handlers) { + // Check the live set on every call. Registration, order, callbacks and nonempty iteration stay upstream. + return handlers.isEmpty() ? Collections.emptyIterator() : handlers.iterator(); + } +} diff --git a/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/HorizonMixinPlugin.java b/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/HorizonMixinPlugin.java index e52e8f253..5e52fdd81 100644 --- a/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/HorizonMixinPlugin.java +++ b/mods/horizon-lab/src/main/java/dev/horizonlab/mixin/HorizonMixinPlugin.java @@ -16,6 +16,7 @@ public final class HorizonMixinPlugin implements IMixinConfigPlugin { private static boolean debounceWait; private static boolean dispatchRetry; private static boolean tickDedup; + private static boolean cullingEmpty; @Override public void onLoad(String mixinPackage) { var loader = FabricLoader.getInstance(); @@ -79,17 +80,34 @@ public final class HorizonMixinPlugin implements IMixinConfigPlugin { } catch (Exception error) { LoggerFactory.getLogger("horizonlab").warn("Tick membership deduplication disabled: invalid configuration", error); } + try { + Properties properties = new Properties(); + var cullingFile = loader.getConfigDir().resolve("horizon-culling-empty.properties"); + if (Files.exists(cullingFile)) { + try (var reader = Files.newBufferedReader(cullingFile)) { properties.load(reader); } + } + boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false")); + boolean supported = loader.getModContainer("entityculling") + .map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("1.11.2")).orElse(false); + cullingEmpty = requested && supported; + if (requested) LoggerFactory.getLogger("horizonlab").info( + "Empty culling whitelist iteration: {} (requires pinned Entity Culling 1.11.2)", cullingEmpty ? "enabled" : "unavailable"); + } catch (Exception error) { + LoggerFactory.getLogger("horizonlab").warn("Empty culling whitelist iteration disabled: invalid configuration", error); + } } public static boolean workerPacingEnabled() { return workerPacing; } public static boolean debounceWaitEnabled() { return debounceWait; } public static boolean dispatchRetryEnabled() { return dispatchRetry; } public static boolean tickDedupEnabled() { return tickDedup; } + public static boolean cullingEmptyEnabled() { return cullingEmpty; } @Override public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { if (mixinClassName.endsWith("DhWorkerPacingMixin")) return workerPacing; if (mixinClassName.endsWith("DhDebounceWaitMixin")) return debounceWait; if (mixinClassName.endsWith("DhPriorityDispatchMixin")) return dispatchRetry; if (mixinClassName.endsWith("DhTickDedupMixin")) return tickDedup; + if (mixinClassName.endsWith("CullingEmptyWhitelistMixin")) return cullingEmpty; return true; } @Override public String getRefMapperConfig() { return null; } diff --git a/mods/horizon-lab/src/main/resources/fabric.mod.json b/mods/horizon-lab/src/main/resources/fabric.mod.json index 21271d5ed..175a5526c 100644 --- a/mods/horizon-lab/src/main/resources/fabric.mod.json +++ b/mods/horizon-lab/src/main/resources/fabric.mod.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "horizonlab", - "version": "0.13.0", + "version": "0.14.0", "name": "Horizon Lab", "description": "Client frame pacing and server tick measurements for reproducible optimization experiments.", "authors": [ diff --git a/mods/horizon-lab/src/main/resources/horizonlab.mixins.json b/mods/horizon-lab/src/main/resources/horizonlab.mixins.json index 308a9aee7..d4fd16b41 100644 --- a/mods/horizon-lab/src/main/resources/horizonlab.mixins.json +++ b/mods/horizon-lab/src/main/resources/horizonlab.mixins.json @@ -4,6 +4,6 @@ "plugin": "dev.horizonlab.mixin.HorizonMixinPlugin", "compatibilityLevel": "JAVA_25", "mixins": ["ServerTickMixin"], - "client": ["FrameMixin", "DhWorkerPacingMixin", "DhDebounceWaitMixin", "DhPriorityDispatchMixin", "DhTickDedupMixin"], + "client": ["FrameMixin", "DhWorkerPacingMixin", "DhDebounceWaitMixin", "DhPriorityDispatchMixin", "DhTickDedupMixin", "CullingEmptyWhitelistMixin"], "injectors": {"defaultRequire": 1} } diff --git a/mods/horizon-lab/src/test/java/dev/horizonlab/dispatch/CullingEmptyIntegration.java b/mods/horizon-lab/src/test/java/dev/horizonlab/dispatch/CullingEmptyIntegration.java new file mode 100644 index 000000000..fa4c0e53f --- /dev/null +++ b/mods/horizon-lab/src/test/java/dev/horizonlab/dispatch/CullingEmptyIntegration.java @@ -0,0 +1,100 @@ +package dev.horizonlab.dispatch; + +import dev.horizonlab.mixin.HorizonMixinPlugin; +import dev.tr7zw.entityculling.EntityCullingModBase; +import dev.tr7zw.entityculling.access.Cullable; +import java.lang.management.ManagementFactory; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.function.Function; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.ChestBlockEntity; + +/** Private fixture validates applied redirects and live handler registration on the publisher's instance. */ +public final class CullingEmptyIntegration implements ClientModInitializer { + private static volatile boolean result; + private static final class CountingSet extends HashSet { + int iterators; + @Override public Iterator iterator() { iterators++; return super.iterator(); } + } + @Override public void onInitializeClient() { + ClientLifecycleEvents.CLIENT_STARTED.register(client -> { + try { exercise(); } + catch (ReflectiveOperationException failure) { throw new AssertionError("Culling integration failed", failure); } + if (Boolean.getBoolean("horizon.fixture.exit")) System.exit(0); + }); + } + + private static void exercise() throws ReflectiveOperationException { + var owner = EntityCullingModBase.instance; + if (owner == null) throw new AssertionError("Publisher culling mod was not initialized"); + Field entity = EntityCullingModBase.class.getDeclaredField("dynamicEntityWhitelist"); + Field block = EntityCullingModBase.class.getDeclaredField("dynamicBlockEntityWhitelist"); + entity.setAccessible(true); block.setAccessible(true); + Object oldEntity = entity.get(owner), oldBlock = block.get(owner); + boolean enabled = HorizonMixinPlugin.cullingEmptyEnabled(); + var entities = new CountingSet>(); + var blocks = new CountingSet>(); + var chest = new ChestBlockEntity(BlockPos.ZERO, Blocks.CHEST.defaultBlockState()); + var subject = (Cullable) (Object) chest; + try { + entity.set(owner, entities); block.set(owner, blocks); + if (owner.isEntityDynamicWhitelisted(null) || owner.isBlockEntityDynamicWhitelisted(subject)) + throw new AssertionError("Empty whitelist unexpectedly matched"); + int expectedEmpty = enabled ? 0 : 1; + if (entities.iterators != expectedEmpty || blocks.iterators != expectedEmpty) + throw new AssertionError("Empty whitelist iterator hook did not apply"); + var called = new ArrayList(); + Function first = value -> { called.add(1); return false; }; + Function second = value -> { called.add(2); return value == chest; }; + owner.addDynamicBlockEntityWhitelist(first); + owner.addDynamicBlockEntityWhitelist(second); + owner.addDynamicBlockEntityWhitelist(first); // Duplicate registration remains a set operation. + var expected = new ArrayList(); + for (var handler : blocks) { expected.add(handler == first ? 1 : 2); if (handler == second) break; } + int before = blocks.iterators; + if (!owner.isBlockEntityDynamicWhitelisted(subject) || !called.equals(expected) || blocks.iterators != before + 1) + throw new AssertionError("Live registration, callback order or short circuit changed"); + int callbackCount = called.size(); + if (owner.isBlockEntityDynamicWhitelisted(null) || called.size() != callbackCount) + throw new AssertionError("Null unexpectedly matched or invoked a callback"); + owner.addDynamicEntityWhitelist(value -> { throw new AssertionError("Wrong entity type invoked handler"); }); + before = entities.iterators; + if (owner.isEntityDynamicWhitelisted(subject) || entities.iterators != before + 1) + throw new AssertionError("Nonempty entity iterator or type guard changed"); + blocks.clear(); entities.clear(); before = blocks.iterators; + if (owner.isBlockEntityDynamicWhitelisted(subject) || blocks.iterators != before + expectedEmpty) + throw new AssertionError("Empty state after clear was cached incorrectly"); + // Normal HashSets for the allocation observation; instrumentation is not in this loop. + entity.set(owner, new HashSet<>()); block.set(owner, new HashSet<>()); + loop(owner, 200000); + var bean = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); + bean.setThreadAllocatedMemoryEnabled(true); bean.setThreadCpuTimeEnabled(true); + long id = Thread.currentThread().threadId(), bytes = bean.getThreadAllocatedBytes(id); + long cpu = bean.getCurrentThreadCpuTime(), wall = System.nanoTime(); + loop(owner, 2000000); + long wallDelta = System.nanoTime() - wall, cpuDelta = bean.getCurrentThreadCpuTime() - cpu; + long byteDelta = bean.getThreadAllocatedBytes(id) - bytes; + System.out.println("HORIZON_CULLING_EMPTY_RESULT {\"enabled\":" + enabled + + ",\"empty_iterator_calls\":" + expectedEmpty + ",\"live_registration_checked\":true" + + ",\"block_callback_order_checked\":true,\"type_guard_checked\":true" + + ",\"allocated_bytes\":" + byteDelta + ",\"cpu_ns\":" + cpuDelta + ",\"wall_ns\":" + wallDelta + + ",\"measured_calls\":4000000,\"result\":" + result + "}"); + } finally { entity.set(owner, oldEntity); block.set(owner, oldBlock); } + } + + private static void loop(EntityCullingModBase owner, int iterations) { + boolean found = false; + for (int index = 0; index < iterations; index++) { + found |= owner.isEntityDynamicWhitelisted(null); + found |= owner.isBlockEntityDynamicWhitelisted(null); + } + result = found; + } +} diff --git a/packs/START-HERE.txt b/packs/START-HERE.txt index afe0ce763..edbd2946c 100644 --- a/packs/START-HERE.txt +++ b/packs/START-HERE.txt @@ -62,6 +62,10 @@ PROFILES terrain tick membership checks. Action order and visual settings stay the same. Set enabled=false in config/horizon-tick-dedup.properties and restart to disable. Compare against m2-balanced for an isolated test; no Mac FPS gain is measured yet. +- culling-empty: same balanced settings, with a shared empty iterator for empty + dynamic entity/block-entity whitelists. Registered callbacks stay upstream. + Disable in config/horizon-culling-empty.properties (enabled=false), then restart. + Compare against m2-balanced; no whole-game or Mac FPS gain is measured yet. - foreground-first: combines adaptive terrain, precise pacing, wakeable queue waits, bounded rejected dispatch retries and (since 0.13) tick membership reuse. Uses OpenGL and a 120 FPS diff --git a/packs/mods.lock.json b/packs/mods.lock.json index c89996a73..5b78d3184 100644 --- a/packs/mods.lock.json +++ b/packs/mods.lock.json @@ -3,7 +3,7 @@ "minecraft": "26.3", "java_major": 25, "fabric_loader": "0.19.5", - "pack_version": "0.13.0", + "pack_version": "0.14.0", "options_data_version": 5023, "projects": { "sodium": { diff --git a/tests/test_packs.py b/tests/test_packs.py index 7aaff9586..b10e6e1d9 100644 --- a/tests/test_packs.py +++ b/tests/test_packs.py @@ -87,6 +87,18 @@ class PackTests(unittest.TestCase): "config/horizon-dispatch-retry.properties"]: self.assertEqual(original[filename], candidate[filename]) + def test_empty_culling_experiment_preserves_balanced_settings(self): + original = build_pack.overrides("m2-balanced", b"fixture") + for name in build_pack.PROFILES: + files = build_pack.overrides(name, b"fixture") + self.assertEqual(files["config/horizon-culling-empty.properties"], + b"enabled=true\n" if name == "culling-empty" else b"enabled=false\n") + candidate = build_pack.overrides("culling-empty", b"fixture") + for filename in ["options.txt", "config/DistantHorizons.toml", "config/horizon-adaptive.properties", + "config/horizon-worker-pacing.properties", "config/horizon-queue-wait.properties", + "config/horizon-dispatch-retry.properties", "config/horizon-tick-dedup.properties"]: + self.assertEqual(original[filename], candidate[filename]) + def test_wrong_game_or_download_path_is_rejected(self): for field, value in [("game_versions", ["1.21"]), ("filename", "../bad.jar"), ("url", "http://unsafe.invalid")]: lock = copy.deepcopy(build_pack.read_lock()) diff --git a/tools/Compare-Benchmarks.html b/tools/Compare-Benchmarks.html index acf36697e..0df12a094 100644 --- a/tools/Compare-Benchmarks.html +++ b/tools/Compare-Benchmarks.html @@ -66,7 +66,7 @@ function compareReports(a,b){ else if(JSON.stringify(a[key])!==JSON.stringify(b[key]))warnings.push('JVM '+key+' differs. Treat it as an explicit experiment variable.'); } if(a.kind==='frames'){ - for(const key of ['adaptive_budget_enabled','adaptive_target_fps','precise_worker_pacing','debounce_queue_wait','wakeable_idle_wait','dispatch_retry_backoff','tick_membership_dedup','dh_thread_count','dh_runtime_ratio']){ + for(const key of ['adaptive_budget_enabled','adaptive_target_fps','precise_worker_pacing','debounce_queue_wait','wakeable_idle_wait','dispatch_retry_backoff','tick_membership_dedup','empty_culling_whitelist','dh_thread_count','dh_runtime_ratio']){ if(key in a&&key in b&&JSON.stringify(a[key])!==JSON.stringify(b[key]))warnings.push('Experiment '+key+' differs.'); } for(const [label,r] of [['Baseline',a],['Candidate',b]]){ @@ -125,6 +125,7 @@ function renderDetails(side){ fields.push(['Rejected dispatch retry', r.dispatch_retry_backoff===true?'on':r.dispatch_retry_backoff===false?'off':'not captured']); fields.push(['Wakeable queue idle', r.wakeable_idle_wait===true?'on':r.wakeable_idle_wait===false?'off':'not captured']); if(r.kind==='frames')fields.push(['Tick membership deduplication',r.tick_membership_dedup===true?'on':r.tick_membership_dedup===false?'off':'not captured']); + if(r.kind==='frames')fields.push(['Empty culling whitelist',r.empty_culling_whitelist===true?'on':r.empty_culling_whitelist===false?'off':'not captured']); for(const [label,value]of fields)target.append(text('dt',label),text('dd',value)); } async function loadFiles(side,files){ diff --git a/tools/build_mod.py b/tools/build_mod.py index 2b1c3617e..d100325cd 100644 --- a/tools/build_mod.py +++ b/tools/build_mod.py @@ -126,10 +126,20 @@ def build_tick_dedup_fixture(): return build_integration_fixture("tick-dedup", "dev.horizonlab.dispatch.TickDedupIntegration") -def build_integration_fixture(name, entrypoint): +def build_culling_empty_fixture(): + return build_integration_fixture("culling-empty", "dev.horizonlab.dispatch.CullingEmptyIntegration", ["entityculling"]) + + +def build_integration_fixture(name, entrypoint, extra_projects=()): """Create a private integration-test mod; never include it in release packs.""" arguments = [json.loads(line) for line in (BUILD / "javac.args").read_text().splitlines()] classpath = arguments[arguments.index("-classpath") + 1] + os.pathsep + str(BUILD / "classes") + lock = json.loads((ROOT / "packs/mods.lock.json").read_text()) + for name_project in extra_projects: + project = lock["projects"][name_project] + jar = runtime.fetch(project["url"], ROOT / ".local/pack-cache" / project["filename"], + project["hashes"]["sha512"], "sha512") + classpath += os.pathsep + str(jar) classes = BUILD / f"{name}-fixture" if classes.exists(): shutil.rmtree(classes) diff --git a/tools/build_pack.py b/tools/build_pack.py index c131e878d..d2cd7133f 100644 --- a/tools/build_pack.py +++ b/tools/build_pack.py @@ -25,6 +25,7 @@ PROFILES = { "adaptive-queue": {"title": "Adaptive pacing and queue wait experiment", "mods": CORE + ["distanthorizons"], "render": 12, "lod": 128, "heap": 6144, "adaptive": True, "pacing": True, "queue_wait": True}, "dispatch-retry": {"title": "Rejected dispatch retry experiment", "mods": CORE + ["distanthorizons"], "render": 12, "lod": 128, "heap": 6144, "dispatch_retry": True}, "tick-dedup": {"title": "Terrain tick membership experiment", "mods": CORE + ["distanthorizons"], "render": 12, "lod": 128, "heap": 6144, "tick_dedup": True}, + "culling-empty": {"title": "Empty culling whitelist experiment", "mods": CORE + ["distanthorizons"], "render": 12, "lod": 128, "heap": 6144, "culling_empty": True}, "foreground-first": {"title": "Foreground-first combined experiment", "mods": CORE + ["distanthorizons"], "render": 12, "lod": 128, "heap": 6144, "adaptive": True, "pacing": True, "queue_wait": True, "dispatch_retry": True, "tick_dedup": True, "target_fps": 120}, "foreground-vulkan": {"title": "Foreground-first Vulkan experiment", "mods": CORE + ["distanthorizons"], "render": 12, "lod": 128, "heap": 6144, "adaptive": True, "pacing": True, "queue_wait": True, "dispatch_retry": True, "tick_dedup": True, "target_fps": 120, "backend": "vulkan"}, "low-memory": {"title": "Low memory", "mods": CORE, "render": 8, "lod": 0, "heap": 3072}, @@ -80,7 +81,7 @@ def overrides(profile_name, mod_bytes): files = {f"mods/horizon-lab-{read_lock()['pack_version']}.jar": mod_bytes, "config/horizon-profile.txt": (profile_name + "\n").encode(), "options.txt": options(profile).encode()} - experiment = any(profile.get(key, False) for key in ["adaptive", "pacing", "queue_wait", "dispatch_retry", "tick_dedup"]) + experiment = any(profile.get(key, False) for key in ["adaptive", "pacing", "queue_wait", "dispatch_retry", "tick_dedup", "culling_empty"]) config = ROOT / "packs/config" / f"{'m2-balanced' if experiment else profile_name}.toml" files["config/horizon-adaptive.properties"] = ( f"enabled={str(profile.get('adaptive', False)).lower()}\ntargetFps={profile.get('target_fps', 60)}\n").encode() @@ -92,6 +93,8 @@ def overrides(profile_name, mod_bytes): f"enabled={str(profile.get('dispatch_retry', False)).lower()}\n").encode() files["config/horizon-tick-dedup.properties"] = ( f"enabled={str(profile.get('tick_dedup', False)).lower()}\n").encode() + files["config/horizon-culling-empty.properties"] = ( + f"enabled={str(profile.get('culling_empty', False)).lower()}\n").encode() if profile["lod"]: if not config.exists(): raise FileNotFoundError(f"Missing verified DH profile config: {config}") diff --git a/tools/compare_benchmarks.py b/tools/compare_benchmarks.py index 3c2e93b9b..eac936913 100644 --- a/tools/compare_benchmarks.py +++ b/tools/compare_benchmarks.py @@ -28,7 +28,7 @@ def compare(baseline, candidate): warnings = ["World, route, warm-up, shader state and LOD cache must also match; these are not verified by this tool."] if baseline["kind"] == "frames": for field in ["adaptive_budget_enabled", "adaptive_target_fps", "precise_worker_pacing", "debounce_queue_wait", - "wakeable_idle_wait", "dispatch_retry_backoff", "tick_membership_dedup", "dh_thread_count", "dh_runtime_ratio"]: + "wakeable_idle_wait", "dispatch_retry_backoff", "tick_membership_dedup", "empty_culling_whitelist", "dh_thread_count", "dh_runtime_ratio"]: if field in baseline and field in candidate and baseline[field] != candidate[field]: warnings.append(f"Experiment {field} differs: {baseline[field]!r} vs {candidate[field]!r}.") for name, report in [("baseline", baseline), ("candidate", candidate)]: diff --git a/tools/test_culling_empty.py b/tools/test_culling_empty.py new file mode 100644 index 000000000..fd278733b --- /dev/null +++ b/tools/test_culling_empty.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Check live culling whitelist registration and the optional empty-iterator shortcut.""" + +import argparse +import json +import os +import platform +import shutil +import subprocess +import sys + +try: + from . import build_mod, build_pack +except ImportError: + import build_mod + import build_pack + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--native-display", action="store_true") + args = parser.parse_args() + root = build_pack.ROOT + fixture = build_mod.build_culling_empty_fixture() + mod = root / "dist" / f"horizon-lab-{build_pack.read_lock()['pack_version']}.jar" + results = [] + for enabled in [False, True]: + instance = root / ".local/runtime" / ("culling-empty-fixture-patched" if enabled else "culling-empty-fixture-upstream") + build_pack.prepare_instance("m2-balanced", instance, mod) + shutil.copy2(fixture, instance / "mods/culling-empty-fixture.jar") + (instance / "config/horizon-culling-empty.properties").write_text(f"enabled={str(enabled).lower()}\n") + command = [sys.executable, str(root / "tools/runtime.py"), "demo", "--instance", str(instance), "--heap", "3G", "--compact-headers"] + if platform.system() == "Linux" and not args.native_display: + command = ["xvfb-run", "-a", *command] + environment = os.environ.copy() + environment["_JAVA_OPTIONS"] = "-Dhorizon.fixture.exit=true" + run = subprocess.run(command, cwd=root, env=environment, text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=240) + (instance / "fixture.log").write_text(run.stdout) + marker = "HORIZON_CULLING_EMPTY_RESULT " + reports = [json.loads(line.split(marker, 1)[1]) for line in run.stdout.splitlines() if marker in line] + if run.returncode or len(reports) != 1: + raise RuntimeError(f"Culling empty fixture failed; inspect {instance / 'fixture.log'}") + report = reports[0] + if report["enabled"] != enabled or not report["live_registration_checked"] or not report["block_callback_order_checked"] or not report["type_guard_checked"] or report["result"]: + raise AssertionError("Applied hook or semantic outcome did not match") + results.append(report) + print(json.dumps(report), flush=True) + if [r["empty_iterator_calls"] for r in results] != [1, 0]: + raise AssertionError("Empty shortcut did not apply to actual methods") + output = root / "dist/culling-empty-integration.json" + output.write_text(json.dumps({"schema": 1, "platform": platform.platform(), "architecture": platform.machine(), + "minecraft": "26.3", "entity_culling": "1.11.2", "results": results, + "notes": ["Actual publisher whitelist methods inside Fabric; live registration and block-entity callbacks checked.", + "Nonempty iteration, callback order, short circuit, null/wrong-type inputs and clearing retain upstream behavior.", + "Empty-state iterator counts use a private instrumented set; allocation loop uses normal empty HashSets.", + "Entity callbacks with a valid game entity are not exercised by this menu-only fixture.", + "JIT escape analysis can remove allocations in an isolated loop; real culling-thread profiles are needed.", + "Not a game FPS, whole-game RAM, visual equivalence or Mac GPU benchmark."]}, indent=2) + "\n") + print(f"Applied empty whitelist checks passed: {output}") + + +if __name__ == "__main__": + main() From 1f97a8e48795be99c7239b21156730ce49931f99 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:17:00 +0200 Subject: [PATCH 2/4] Record actual world difficulty and preserve sanitized culling fixture evidence --- .../linux-applied-hooks.json | 47 +++++++++++++++++++ docs/culling-empty.md | 11 +++++ .../dev/horizonlab/client/HorizonClient.java | 1 + tests/test_packs.py | 5 +- tools/Compare-Benchmarks.html | 3 +- tools/compare_benchmarks.py | 2 +- 6 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 benchmarks/culling-empty-2026-10-04/linux-applied-hooks.json diff --git a/benchmarks/culling-empty-2026-10-04/linux-applied-hooks.json b/benchmarks/culling-empty-2026-10-04/linux-applied-hooks.json new file mode 100644 index 000000000..5a3a31a0d --- /dev/null +++ b/benchmarks/culling-empty-2026-10-04/linux-applied-hooks.json @@ -0,0 +1,47 @@ +{ + "schema": 1, + "platform": "Linux-6.17.0-1022-azure-x86_64-with-glibc2.39", + "architecture": "x86_64", + "minecraft": "26.3", + "entity_culling": "1.11.2", + "results": [ + { + "enabled": false, + "empty_iterator_calls": 1, + "live_registration_checked": true, + "block_callback_order_checked": true, + "type_guard_checked": true, + "allocated_bytes": 128001272, + "cpu_ns": 29632156, + "wall_ns": 49712014, + "measured_calls": 4000000, + "result": false + }, + { + "enabled": true, + "empty_iterator_calls": 0, + "live_registration_checked": true, + "block_callback_order_checked": true, + "type_guard_checked": true, + "allocated_bytes": 208, + "cpu_ns": 809881, + "wall_ns": 801517, + "measured_calls": 4000000, + "result": false + } + ], + "notes": [ + "Actual publisher whitelist methods inside Fabric; live registration and block-entity callbacks checked.", + "Nonempty iteration, callback order, short circuit, null/wrong-type inputs and clearing retain upstream behavior.", + "Empty-state iterator counts use a private instrumented set; allocation loop uses normal empty HashSets.", + "Entity callbacks with a valid game entity are not exercised by this menu-only fixture.", + "JIT escape analysis can remove allocations in an isolated loop; real culling-thread profiles are needed.", + "Not a game FPS, whole-game RAM, visual equivalence or Mac GPU benchmark.", + "Much of an empty warmed loop may be folded by the JIT; CPU timings are not per-check costs or game speed estimates." + ], + "provenance": { + "source_commit": "35dd69e9a6279fa89d762a0d24e49b0dbdb11b51", + "ci_run": "https://github.com/ludovic111/minecraft-optimization-lab/actions/runs/37164002916", + "artifact": "horizon-ubuntu-latest" + } +} diff --git a/docs/culling-empty.md b/docs/culling-empty.md index 679802511..7c0e1bfdf 100644 --- a/docs/culling-empty.md +++ b/docs/culling-empty.md @@ -31,3 +31,14 @@ observation. These counts isolate this allocation path; JIT escape analysis can produce different results in other calling contexts. They are not game FPS, whole-game RAM, statistical speed estimates or Mac GPU results. Live culling thread profiles, matching workloads and repeated hardware tests remain needed. + +For local world smoke checks, the baseline and candidate use separate copies of +the same peaceful demo-world snapshot. An earlier capture was interrupted by a +zombie killing the player and is excluded from performance comparisons. Release +packs do not change world difficulty. Frame reports now capture actual world +difficulty at their start/end boundaries so comparison tools can flag a mismatch. + +The [sanitized Linux CI result](../benchmarks/culling-empty-2026-10-04/linux-applied-hooks.json) +includes its source/workflow provenance and independently checks live registration +and the applied shortcut. CPU timings can include JIT folding of this empty loop; +they are not per-check costs or estimates of game speed. diff --git a/mods/horizon-lab/src/main/java/dev/horizonlab/client/HorizonClient.java b/mods/horizon-lab/src/main/java/dev/horizonlab/client/HorizonClient.java index 4fd4cb7bd..2a7442ea9 100644 --- a/mods/horizon-lab/src/main/java/dev/horizonlab/client/HorizonClient.java +++ b/mods/horizon-lab/src/main/java/dev/horizonlab/client/HorizonClient.java @@ -116,6 +116,7 @@ public final class HorizonClient implements ClientModInitializer { private static Map visualSettings(Minecraft client) { Map context = new LinkedHashMap<>(); + if (client.level != null) context.put("world_difficulty", client.level.getDifficulty().name()); context.put("render_distance", client.options.renderDistance().get()); context.put("simulation_distance", client.options.simulationDistance().get()); context.put("vsync", client.options.enableVsync().get()); diff --git a/tests/test_packs.py b/tests/test_packs.py index b10e6e1d9..ffe754910 100644 --- a/tests/test_packs.py +++ b/tests/test_packs.py @@ -185,13 +185,14 @@ class ComparisonTests(unittest.TestCase): def test_terrain_quality_and_shader_changes_cannot_look_like_equal_workloads(self): baseline = self.report() baseline.update(dh_capture_status="available", dh_lod_radius_chunks=128, dh_horizontal_quality="MEDIUM", - iris_capture_status="available", iris_shaders_enabled=False, dh_thread_count=2) + iris_capture_status="available", iris_shaders_enabled=False, dh_thread_count=2, world_difficulty="NORMAL") candidate = copy.deepcopy(baseline) candidate.update(dh_lod_radius_chunks=256, iris_shaders_enabled=True, iris_shader_pack="Example pack", - dh_thread_count=1, visual_settings_changed=["render_distance"]) + dh_thread_count=1, world_difficulty="PEACEFUL", visual_settings_changed=["render_distance"]) result = compare_benchmarks.compare(baseline, candidate) self.assertTrue(any("dh_lod_radius_chunks" in field for field in result["mismatches"])) self.assertTrue(any("iris_shaders_enabled" in field for field in result["mismatches"])) + self.assertTrue(any("world_difficulty" in field for field in result["mismatches"])) self.assertTrue(any("dh_thread_count differs" in warning for warning in result["warnings"])) self.assertTrue(any("changed between capture boundaries" in warning for warning in result["warnings"])) legacy = self.report() diff --git a/tools/Compare-Benchmarks.html b/tools/Compare-Benchmarks.html index 0df12a094..1df766bce 100644 --- a/tools/Compare-Benchmarks.html +++ b/tools/Compare-Benchmarks.html @@ -30,7 +30,7 @@