Merge pull request #15 from ludovic111/culling-empty-whitelists
Reduce empty culling allocations and release interrupted DH reads
This commit is contained in:
@@ -63,6 +63,10 @@ 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: Compile the private terrain read shutdown fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-read', 'dev.horizonlab.dispatch.TerrainReadIntegration')"
|
||||
- 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 +76,12 @@ 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: Verify interrupted DH reads and preserved shared futures
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_terrain_read.py
|
||||
- name: Upload original mod and pack manifests only
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -85,3 +95,5 @@ jobs:
|
||||
dist/object-header-benchmark.txt
|
||||
dist/dispatch-integration.json
|
||||
dist/tick-dedup-integration.json
|
||||
dist/culling-empty-integration.json
|
||||
dist/terrain-read-integration.json
|
||||
|
||||
@@ -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,13 @@ 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. DH profiles also request
|
||||
a scoped [interruptible blending-read wait](docs/terrain-read-wait.md) to let
|
||||
background workers leave a pending metadata read during shutdown.
|
||||
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"platform": "Linux-6.17.0-1022-azure-x86_64-with-glibc2.39",
|
||||
"architecture": "x86_64",
|
||||
"minecraft": "26.3",
|
||||
"distant_horizons": "3.3.4",
|
||||
"original_mod_sha256": "c506982631880604513de8ef85e3b4461fbb898702e60b4a6f74ca3b3d4a687f",
|
||||
"results": [
|
||||
{
|
||||
"enabled": false,
|
||||
"applied_io_worker_checked": true,
|
||||
"ordinary_join_checked": true,
|
||||
"shared_future_preserved": true,
|
||||
"exception_identity_checked": true,
|
||||
"completed_interrupt_checked": true,
|
||||
"cancelled_completion_checked": true,
|
||||
"preinterrupted_pending_checked": true
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"applied_io_worker_checked": true,
|
||||
"ordinary_join_checked": true,
|
||||
"shared_future_preserved": true,
|
||||
"exception_identity_checked": true,
|
||||
"completed_interrupt_checked": true,
|
||||
"cancelled_completion_checked": true,
|
||||
"preinterrupted_pending_checked": true
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"Actual game IOWorker under Fabric, with controlled cached blending-read futures.",
|
||||
"DH worker interruption releases only its waiter; the shared future remains consumable.",
|
||||
"Ordinary callers retain join behavior; completed values, cancellation and exception identity are checked.",
|
||||
"This is a shutdown correctness fixture, not an FPS or world-generation throughput benchmark."
|
||||
],
|
||||
"provenance": {
|
||||
"source_commit": "9a1b22aa56f58a3ee39a3564be859a662d2beedc",
|
||||
"workflow_run": "https://github.com/ludovic111/minecraft-optimization-lab/actions/runs/37166150119",
|
||||
"job": "portable-mod (ubuntu-latest)",
|
||||
"artifact": "horizon-ubuntu-latest",
|
||||
"command": "python tools/test_terrain_read.py"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"platform": "Linux-7.0.0-31-generic-x86_64-with-glibc2.43",
|
||||
"architecture": "x86_64",
|
||||
"minecraft": "26.3",
|
||||
"distant_horizons": "3.3.4",
|
||||
"original_mod_sha256": "c506982631880604513de8ef85e3b4461fbb898702e60b4a6f74ca3b3d4a687f",
|
||||
"results": [
|
||||
{
|
||||
"enabled": false,
|
||||
"applied_io_worker_checked": true,
|
||||
"ordinary_join_checked": true,
|
||||
"shared_future_preserved": true,
|
||||
"exception_identity_checked": true,
|
||||
"completed_interrupt_checked": true,
|
||||
"cancelled_completion_checked": true,
|
||||
"preinterrupted_pending_checked": true
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"applied_io_worker_checked": true,
|
||||
"ordinary_join_checked": true,
|
||||
"shared_future_preserved": true,
|
||||
"exception_identity_checked": true,
|
||||
"completed_interrupt_checked": true,
|
||||
"cancelled_completion_checked": true,
|
||||
"preinterrupted_pending_checked": true
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"Actual game IOWorker under Fabric, with controlled cached blending-read futures.",
|
||||
"DH worker interruption releases only its waiter; the shared future remains consumable.",
|
||||
"Ordinary callers retain join behavior; completed values, cancellation and exception identity are checked.",
|
||||
"This is a shutdown correctness fixture, not an FPS or world-generation throughput benchmark."
|
||||
],
|
||||
"provenance": {
|
||||
"source_commit": "9a1b22aa56f58a3ee39a3564be859a662d2beedc",
|
||||
"environment": "local Linux x64 software graphics; actual Fabric fixture",
|
||||
"command": "python tools/test_terrain_read.py"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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. Repeated matching workloads and real hardware tests remain needed. A fixed-order
|
||||
Linux software-rendered pair observed about 97,445 versus 35,128 allocated bytes
|
||||
per second on `CullThread`, over separate 64-second counter spans. The captured
|
||||
graphics matched and both recordings excluded zero frames, but this one pair is
|
||||
not a repeatable FPS or retained-RAM result. The candidate also encountered an
|
||||
intermittent shutdown wait after its world had saved; the control exited normally.
|
||||
See the [terrain read shutdown check](terrain-read-wait.md).
|
||||
|
||||
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.
|
||||
|
||||
The final candidate JAR (`c506982631880604513de8ef85e3b4461fbb898702e60b4a6f74ca3b3d4a687f`)
|
||||
then recorded 579 frame intervals and 2,408 integrated-server tick durations over
|
||||
120 seconds, with zero excluded frames and matching CSV counts/means. Its actual
|
||||
frame report retained custom graphics, render/simulation distances 12/12, DH radius
|
||||
128 at MEDIUM/MEDIUM quality, two workers at runtime ratio 0.5, compact headers,
|
||||
and both the culling shortcut and scoped terrain-read fix enabled. No captured
|
||||
visual setting changed. This verifies integration; it is not a repeated hardware
|
||||
performance comparison.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Release a blocked terrain read during shutdown
|
||||
|
||||
A local world test saved its chunks and closed the DH databases, then hit the
|
||||
client shutdown watchdog. Its remaining non-daemon DH world-generation worker
|
||||
was waiting in `IOWorker.isOldChunkAround` on a cached blending-metadata future.
|
||||
The unchanged control exited normally, so the failure is intermittent; this
|
||||
observation does not attribute it to the culling optimization.
|
||||
|
||||
The scoped redirect uses an interruptible wait only for pending blending reads
|
||||
on threads named `DH-World Gen Thread[...]`. The pinned DH 3.3.4 pool uses this
|
||||
name and interrupts remaining workers during shutdown. Ordinary Minecraft
|
||||
callers keep `CompletableFuture.join` behavior. Completed results use the original
|
||||
join path. Failed reads retain join's exception identity and wrapping behavior.
|
||||
|
||||
An interrupted DH waiter restores its interrupt flag and throws a completion
|
||||
exception whose interrupted cause is recognized by DH's existing shutdown
|
||||
handling. It never cancels or completes the cached future, which belongs to other
|
||||
callers too. Chunk stores, saves, storage closure and world difficulty are untouched.
|
||||
This changes cancellation of an interrupted background task, rather than terrain
|
||||
results from an uninterrupted task.
|
||||
|
||||
Profiles with distant terrain request the fix; baseline and low-memory profiles
|
||||
without DH do not. It is restricted to Minecraft 26.3 and DH 3.3.4. To opt out,
|
||||
set `enabled=false` in `config/horizon-terrain-read-wait.properties` and restart.
|
||||
Frame reports record `interruptible_terrain_read`, and comparison tools disclose
|
||||
changes in that state.
|
||||
|
||||
`python tools/test_terrain_read.py` checks the actual transformed game IOWorker
|
||||
inside Fabric, with controlled cached metadata futures. It forces an interrupted
|
||||
pending DH read, verifies the shared future remains available to a later caller,
|
||||
and checks that ordinary callers remain blocked until completion. It also
|
||||
checks an interrupt already set before a pending read, successful completed
|
||||
reads with an interrupt already set, cancelled
|
||||
futures, exceptional completion identity, and recognition by DH's shutdown
|
||||
exception utility. The private fixture does not enter release packs. This is a
|
||||
correctness check, not an FPS, throughput or Mac shutdown benchmark.
|
||||
|
||||
The [sanitized Linux CI result](../benchmarks/terrain-read-2026-10-04/linux-applied-hooks.json)
|
||||
includes source/workflow provenance and the original mod checksum. The CI JAR
|
||||
matches the local candidate byte-for-byte. Windows and hosted Apple Silicon
|
||||
macOS 26 compile the fixture and pass the portable build/metrics checks; applied
|
||||
read interruption is exercised on Linux, not a live macOS game.
|
||||
|
||||
The exact candidate also completed a 120-second local world recording, saved,
|
||||
and reloaded that world in the same client. After the first save, thread inspection
|
||||
found no remaining DH generation workers and no background workers blocked on a
|
||||
future. After reload, two generation workers existed and neither was waiting in
|
||||
blending metadata; no background metadata-scan wait was present at that snapshot.
|
||||
This is one software-rendered lifecycle observation, not proof that every
|
||||
shutdown race, world or platform is covered.
|
||||
@@ -0,0 +1,27 @@
|
||||
package dev.horizonlab.budget;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
/** Allow the pinned DH generation pool's shutdown interrupt to release a read-only wait. */
|
||||
public final class TerrainReadWait {
|
||||
private TerrainReadWait() {}
|
||||
|
||||
public static <T> T join(CompletableFuture<T> future) {
|
||||
// Ordinary game callers retain join's interrupt behavior. Completed reads use the original path.
|
||||
if (future.isDone() || !Thread.currentThread().getName().startsWith("DH-World Gen Thread["))
|
||||
return future.join();
|
||||
try {
|
||||
return future.get();
|
||||
} catch (InterruptedException shutdown) {
|
||||
Thread.currentThread().interrupt();
|
||||
// DH recognizes this cause as shutdown. The cached future belongs to other callers too.
|
||||
throw new CompletionException(shutdown);
|
||||
} catch (ExecutionException | CancellationException failure) {
|
||||
// Now completed: retain join's original exception identity and wrapping semantics.
|
||||
return future.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,8 @@ 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("interruptible_terrain_read", dev.horizonlab.mixin.HorizonMixinPlugin.terrainReadWaitEnabled());
|
||||
context.put("adaptive_target_fps", targetFps);
|
||||
context.putAll(visualSettings(client));
|
||||
var device = RenderSystem.getDevice().getDeviceInfo();
|
||||
@@ -115,6 +117,7 @@ public final class HorizonClient implements ClientModInitializer {
|
||||
|
||||
private static Map<String, Object> visualSettings(Minecraft client) {
|
||||
Map<String, Object> 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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package dev.horizonlab.mixin;
|
||||
|
||||
import dev.horizonlab.budget.TerrainReadWait;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import net.minecraft.world.level.chunk.storage.IOWorker;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||
|
||||
/** Only blending metadata reads on pinned DH workers become interruptible; saves stay upstream. */
|
||||
@Mixin(IOWorker.class)
|
||||
public abstract class DhTerrainReadWaitMixin {
|
||||
@Redirect(method = "isOldChunkAround", at = @At(value = "INVOKE",
|
||||
target = "Ljava/util/concurrent/CompletableFuture;join()Ljava/lang/Object;"))
|
||||
private Object horizon$waitForBlendingMetadata(CompletableFuture<?> future) {
|
||||
return TerrainReadWait.join(future);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ public final class HorizonMixinPlugin implements IMixinConfigPlugin {
|
||||
private static boolean debounceWait;
|
||||
private static boolean dispatchRetry;
|
||||
private static boolean tickDedup;
|
||||
private static boolean cullingEmpty;
|
||||
private static boolean terrainReadWait;
|
||||
|
||||
@Override public void onLoad(String mixinPackage) {
|
||||
var loader = FabricLoader.getInstance();
|
||||
@@ -79,17 +81,54 @@ 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);
|
||||
}
|
||||
try {
|
||||
Properties properties = new Properties();
|
||||
var readFile = loader.getConfigDir().resolve("horizon-terrain-read-wait.properties");
|
||||
if (Files.exists(readFile)) {
|
||||
try (var reader = Files.newBufferedReader(readFile)) { properties.load(reader); }
|
||||
}
|
||||
boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false"));
|
||||
boolean supported = loader.getModContainer("distanthorizons")
|
||||
.map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("3.3.4")).orElse(false)
|
||||
&& loader.getModContainer("minecraft")
|
||||
.map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("26.3")).orElse(false);
|
||||
terrainReadWait = requested && supported;
|
||||
if (requested) LoggerFactory.getLogger("horizonlab").info(
|
||||
"Interruptible DH blending metadata reads: {} (requires Minecraft 26.3 and DH 3.3.4)",
|
||||
terrainReadWait ? "enabled" : "unavailable");
|
||||
} catch (Exception error) {
|
||||
LoggerFactory.getLogger("horizonlab").warn("Interruptible terrain read 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; }
|
||||
public static boolean terrainReadWaitEnabled() { return terrainReadWait; }
|
||||
@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;
|
||||
if (mixinClassName.endsWith("DhTerrainReadWaitMixin")) return terrainReadWait;
|
||||
return true;
|
||||
}
|
||||
@Override public String getRefMapperConfig() { return null; }
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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", "DhTerrainReadWaitMixin"],
|
||||
"injectors": {"defaultRequire": 1}
|
||||
}
|
||||
|
||||
@@ -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<E> extends HashSet<E> {
|
||||
int iterators;
|
||||
@Override public Iterator<E> 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<Function<net.minecraft.world.entity.Entity, Boolean>>();
|
||||
var blocks = new CountingSet<Function<BlockEntity, Boolean>>();
|
||||
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<Integer>();
|
||||
Function<BlockEntity, Boolean> first = value -> { called.add(1); return false; };
|
||||
Function<BlockEntity, Boolean> 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<Integer>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package dev.horizonlab.dispatch;
|
||||
|
||||
import com.seibel.distanthorizons.core.util.ExceptionUtil;
|
||||
import dev.horizonlab.mixin.HorizonMixinPlugin;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.BitSet;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.chunk.storage.IOWorker;
|
||||
import net.minecraft.world.level.chunk.storage.RegionStorageInfo;
|
||||
|
||||
/** Applies the redirect to a real game IOWorker, using controlled cached read futures. */
|
||||
public final class TerrainReadIntegration implements ClientModInitializer {
|
||||
private static final class Worker extends IOWorker {
|
||||
Worker(Path path) { super(new RegionStorageInfo("fixture", Level.OVERWORLD, "horizon-read-fixture"), path, false); }
|
||||
}
|
||||
private record Outcome(Boolean result, Throwable error, boolean interrupted) {}
|
||||
private record Call(Thread thread, CountDownLatch finished, AtomicReference<Outcome> outcome) {}
|
||||
|
||||
@Override public void onInitializeClient() {
|
||||
ClientLifecycleEvents.CLIENT_STARTED.register(client -> {
|
||||
try { exercise(); }
|
||||
catch (Exception failure) { throw new AssertionError("Terrain read integration failed", failure); }
|
||||
if (Boolean.getBoolean("horizon.fixture.exit")) System.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void cache(Worker worker, CompletableFuture<BitSet> future) throws Exception {
|
||||
var field = IOWorker.class.getDeclaredField("regionCacheForBlender");
|
||||
field.setAccessible(true);
|
||||
var map = (Long2ObjectLinkedOpenHashMap<CompletableFuture<BitSet>>) field.get(worker);
|
||||
synchronized (map) { map.clear(); map.put(ChunkPos.pack(0, 0), future); }
|
||||
}
|
||||
|
||||
private static Call start(Worker worker, String name, boolean initiallyInterrupted) {
|
||||
var result = new AtomicReference<Outcome>();
|
||||
var finished = new CountDownLatch(1);
|
||||
Thread thread = new Thread(() -> {
|
||||
if (initiallyInterrupted) Thread.currentThread().interrupt();
|
||||
Boolean value = null; Throwable error = null;
|
||||
try { value = worker.isOldChunkAround(new ChunkPos(0, 0), 0); }
|
||||
catch (Throwable failure) { error = failure; }
|
||||
result.set(new Outcome(value, error, Thread.currentThread().isInterrupted()));
|
||||
finished.countDown();
|
||||
}, name);
|
||||
thread.setDaemon(true); // Fixture failures cannot strand the test JVM.
|
||||
thread.start();
|
||||
return new Call(thread, finished, result);
|
||||
}
|
||||
|
||||
private static Outcome finish(Call call) throws Exception {
|
||||
if (!call.finished.await(4, TimeUnit.SECONDS)) throw new AssertionError("Worker did not finish");
|
||||
call.thread.join(1000);
|
||||
return call.outcome.get();
|
||||
}
|
||||
|
||||
private static void awaitPending(Call call) throws Exception {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(4);
|
||||
while (call.thread.getState() != Thread.State.WAITING) {
|
||||
if (!call.thread.isAlive() || System.nanoTime() > deadline) throw new AssertionError("Read never waited");
|
||||
Thread.sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void pending(Worker worker, String name, boolean cancellable) throws Exception {
|
||||
var future = new CompletableFuture<BitSet>();
|
||||
cache(worker, future);
|
||||
var call = start(worker, name, false);
|
||||
try {
|
||||
awaitPending(call);
|
||||
call.thread.interrupt();
|
||||
if (cancellable) {
|
||||
var outcome = finish(call);
|
||||
if (!(outcome.error instanceof CompletionException) || !ExceptionUtil.isShutdownException(outcome.error)
|
||||
|| !outcome.interrupted || future.isDone()) throw new AssertionError("Shutdown changed shared future or interrupt");
|
||||
} else {
|
||||
if (call.finished.await(100, TimeUnit.MILLISECONDS)) throw new AssertionError("Ordinary join became interruptible");
|
||||
}
|
||||
future.complete(new BitSet());
|
||||
var outcome = finish(call);
|
||||
if (!cancellable && (!Boolean.FALSE.equals(outcome.result) || outcome.error != null || !outcome.interrupted))
|
||||
throw new AssertionError("Original join result or interrupt changed");
|
||||
// A later caller can still consume the shared read after cancellation of only one waiter.
|
||||
if (worker.isOldChunkAround(new ChunkPos(0, 0), 0)) throw new AssertionError("Shared read was damaged");
|
||||
} finally { future.complete(new BitSet()); call.thread.join(4000); }
|
||||
}
|
||||
|
||||
private static void exercise() throws Exception {
|
||||
boolean enabled = HorizonMixinPlugin.terrainReadWaitEnabled();
|
||||
Path path = Files.createTempDirectory("horizon-read-fixture-");
|
||||
try (var worker = new Worker(path)) {
|
||||
pending(worker, "DH-World Gen Thread[fixture]", enabled);
|
||||
pending(worker, "Server thread fixture", false);
|
||||
pending(worker, "DH-LOD Builder Thread[fixture]", false);
|
||||
var waiting = new CompletableFuture<BitSet>();
|
||||
cache(worker, waiting);
|
||||
var initialCall = start(worker, "DH-World Gen Thread[fixture]", true);
|
||||
try {
|
||||
if (enabled) {
|
||||
var preInterrupted = finish(initialCall);
|
||||
if (!(preInterrupted.error instanceof CompletionException) || !preInterrupted.interrupted
|
||||
|| waiting.isDone() || !ExceptionUtil.isShutdownException(preInterrupted.error))
|
||||
throw new AssertionError("Pending read ignored an existing shutdown interrupt");
|
||||
} else {
|
||||
awaitPending(initialCall);
|
||||
if (initialCall.finished.await(100, TimeUnit.MILLISECONDS))
|
||||
throw new AssertionError("Original pending join honored an existing interrupt");
|
||||
waiting.complete(new BitSet());
|
||||
var preInterrupted = finish(initialCall);
|
||||
if (!Boolean.FALSE.equals(preInterrupted.result) || preInterrupted.error != null || !preInterrupted.interrupted)
|
||||
throw new AssertionError("Original pre-interrupted join changed");
|
||||
}
|
||||
} finally { waiting.complete(new BitSet()); initialCall.thread.join(4000); }
|
||||
BitSet bits = new BitSet(); bits.set(0);
|
||||
cache(worker, CompletableFuture.completedFuture(bits));
|
||||
var complete = finish(start(worker, "DH-World Gen Thread[fixture]", true));
|
||||
if (!Boolean.TRUE.equals(complete.result) || complete.error != null || !complete.interrupted)
|
||||
throw new AssertionError("Completed read changed result or interrupt");
|
||||
var failed = new CompletableFuture<BitSet>();
|
||||
var original = new CompletionException(new IllegalStateException("fixture failure"));
|
||||
cache(worker, failed);
|
||||
var call = start(worker, "DH-World Gen Thread[fixture]", false);
|
||||
awaitPending(call); failed.completeExceptionally(original);
|
||||
if (finish(call).error != original) throw new AssertionError("Exceptional completion identity changed");
|
||||
var pendingCancelled = new CompletableFuture<BitSet>();
|
||||
cache(worker, pendingCancelled);
|
||||
var cancelCall = start(worker, "DH-World Gen Thread[fixture]", false);
|
||||
awaitPending(cancelCall); pendingCancelled.cancel(false);
|
||||
Throwable expectedCancellation = null;
|
||||
try { pendingCancelled.join(); } catch (java.util.concurrent.CancellationException expected) { expectedCancellation = expected; }
|
||||
var actualCancellation = finish(cancelCall).error;
|
||||
if (!(actualCancellation instanceof java.util.concurrent.CancellationException)
|
||||
|| !java.util.Objects.equals(actualCancellation.getMessage(), expectedCancellation.getMessage())
|
||||
|| actualCancellation.getCause() != expectedCancellation.getCause())
|
||||
throw new AssertionError("Pending cancellation changed join's message or cause");
|
||||
var cancelled = new CompletableFuture<BitSet>(); cancelled.cancel(false);
|
||||
cache(worker, cancelled);
|
||||
if (!(finish(start(worker, "DH-World Gen Thread[fixture]", false)).error instanceof java.util.concurrent.CancellationException))
|
||||
throw new AssertionError("Cancelled completion changed");
|
||||
System.out.println("HORIZON_TERRAIN_READ_RESULT {\"enabled\":" + enabled
|
||||
+ ",\"applied_io_worker_checked\":true,\"ordinary_join_checked\":true,\"shared_future_preserved\":true"
|
||||
+ ",\"exception_identity_checked\":true,\"completed_interrupt_checked\":true,\"cancelled_completion_checked\":true,\"preinterrupted_pending_checked\":true}");
|
||||
} finally { Files.deleteIfExists(path); }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -154,3 +158,9 @@ and field of view at capture boundaries. If visual settings changed, the viewer
|
||||
flags the run. Older reports without these fields cannot establish quality
|
||||
equivalence. Snapshots cannot detect a change that was restored before the end,
|
||||
and do not prove that all requested distant terrain finished rendering.
|
||||
|
||||
Since 0.14, profiles with Distant Horizons request an interruptible blending-read
|
||||
wait for its generation workers during shutdown. Ordinary Minecraft callers and
|
||||
chunk saves keep their original behavior. To disable, set enabled=false in
|
||||
config/horizon-terrain-read-wait.properties and restart. This is a scoped shutdown
|
||||
correctness fix, not a claim of greater FPS or faster terrain generation.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+21
-2
@@ -87,6 +87,24 @@ 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_terrain_read_shutdown_fix_only_requested_with_distant_terrain(self):
|
||||
for name, profile in build_pack.PROFILES.items():
|
||||
files = build_pack.overrides(name, b"fixture")
|
||||
self.assertEqual(files["config/horizon-terrain-read-wait.properties"],
|
||||
b"enabled=true\n" if profile["lod"] else b"enabled=false\n")
|
||||
|
||||
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())
|
||||
@@ -173,13 +191,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()
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</main>
|
||||
<script>
|
||||
'use strict';
|
||||
const SETTINGS=['minecraft','kind','os','os_version','architecture','gpu','graphics_backend','graphics_driver','java_version','heap_max_bytes','framebuffer_width','framebuffer_height','render_distance','simulation_distance','vsync','fps_limit','graphics_preset','ambient_occlusion','improved_transparency','mipmap_levels','entity_distance_scaling','entity_shadows','particles','clouds','cloud_range','fov','dh_capture_status','dh_rendering_enabled','dh_renderer_mode','dh_lod_radius_chunks','dh_horizontal_quality','dh_vertical_quality','dh_max_horizontal_resolution','dh_transparency','dh_lod_shading','dh_lod_only_mode','dh_camera_quality_dropoff','dh_ambient_occlusion','iris_capture_status','iris_shaders_enabled','iris_shader_pack'];
|
||||
const SETTINGS=['minecraft','kind','os','os_version','architecture','gpu','graphics_backend','graphics_driver','java_version','heap_max_bytes','framebuffer_width','framebuffer_height','render_distance','simulation_distance','vsync','fps_limit','graphics_preset','ambient_occlusion','improved_transparency','mipmap_levels','entity_distance_scaling','entity_shadows','particles','clouds','cloud_range','fov','world_difficulty','dh_capture_status','dh_rendering_enabled','dh_renderer_mode','dh_lod_radius_chunks','dh_horizontal_quality','dh_vertical_quality','dh_max_horizontal_resolution','dh_transparency','dh_lod_shading','dh_lod_only_mode','dh_camera_quality_dropoff','dh_ambient_occlusion','iris_capture_status','iris_shaders_enabled','iris_shader_pack'];
|
||||
const state={baseline:null,candidate:null},generation={baseline:0,candidate:0};
|
||||
let comparison=null,chartData=null;
|
||||
const $=id=>document.getElementById(id);
|
||||
@@ -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','interruptible_terrain_read','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]]){
|
||||
@@ -119,12 +119,15 @@ function renderDetails(side){
|
||||
if(number(r.dh_thread_count))fields.push(['Terrain workers',r.dh_thread_count+' · runtime ratio at start '+format(r.dh_runtime_ratio)+(number(r.visual_settings_end?.dh_runtime_ratio)?' / end '+format(r.visual_settings_end.dh_runtime_ratio):'')]);
|
||||
fields.push(['Iris shaders',r.iris_capture_status==='not_installed'?'Not installed':r.iris_capture_status==='available'?r.iris_shaders_enabled?'Enabled · '+(r.iris_shader_pack||'pack name unavailable'):'Disabled':'Not captured']);
|
||||
if(number(r.fov))fields.push(['Field of view',format(r.fov)]);
|
||||
if(r.world_difficulty)fields.push(['World difficulty',r.world_difficulty]);
|
||||
fields.push(['Frame limit',r.fps_limit_unlimited===true||(r.minecraft==='26.3'&&number(r.fps_limit)&&r.fps_limit>=260)?'Uncapped':number(r.fps_limit)?r.fps_limit+' FPS':'not captured']);
|
||||
if(r.adaptive_budget_enabled&&number(r.adaptive_target_fps))fields.push(['Adaptive frame goal',r.adaptive_target_fps+' FPS (not a cap)']);
|
||||
}
|
||||
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']);
|
||||
if(r.kind==='frames')fields.push(['Interruptible terrain read',r.interruptible_terrain_read===true?'on':r.interruptible_terrain_read===false?'off':'not captured']);
|
||||
for(const [label,value]of fields)target.append(text('dt',label),text('dd',value));
|
||||
}
|
||||
async function loadFiles(side,files){
|
||||
|
||||
+11
-1
@@ -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)
|
||||
|
||||
+6
-1
@@ -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,10 @@ 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()
|
||||
files["config/horizon-terrain-read-wait.properties"] = (
|
||||
f"enabled={str(bool(profile['lod'])).lower()}\n").encode()
|
||||
if profile["lod"]:
|
||||
if not config.exists():
|
||||
raise FileNotFoundError(f"Missing verified DH profile config: {config}")
|
||||
|
||||
@@ -9,7 +9,7 @@ MATCH_FIELDS = ("minecraft", "kind", "os", "os_version", "architecture", "gpu",
|
||||
"java_version", "heap_max_bytes", "framebuffer_width", "framebuffer_height", "render_distance",
|
||||
"simulation_distance", "vsync", "fps_limit", "graphics_preset", "ambient_occlusion",
|
||||
"improved_transparency", "mipmap_levels", "entity_distance_scaling", "entity_shadows", "particles",
|
||||
"clouds", "cloud_range", "fov", "dh_capture_status", "dh_rendering_enabled", "dh_renderer_mode",
|
||||
"clouds", "cloud_range", "fov", "world_difficulty", "dh_capture_status", "dh_rendering_enabled", "dh_renderer_mode",
|
||||
"dh_lod_radius_chunks", "dh_horizontal_quality", "dh_vertical_quality", "dh_max_horizontal_resolution",
|
||||
"dh_transparency", "dh_lod_shading", "dh_lod_only_mode", "dh_camera_quality_dropoff",
|
||||
"dh_ambient_occlusion", "iris_capture_status", "iris_shaders_enabled", "iris_shader_pack")
|
||||
@@ -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", "interruptible_terrain_read", "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)]:
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise interruptible blending reads on an actual transformed Minecraft IOWorker."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
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_integration_fixture("terrain-read", "dev.horizonlab.dispatch.TerrainReadIntegration")
|
||||
mod = root / "dist" / f"horizon-lab-{build_pack.read_lock()['pack_version']}.jar"
|
||||
original_sha256 = hashlib.sha256(mod.read_bytes()).hexdigest()
|
||||
results = []
|
||||
for enabled in [False, True]:
|
||||
instance = root / ".local/runtime" / ("terrain-read-fixture-patched" if enabled else "terrain-read-fixture-upstream")
|
||||
build_pack.prepare_instance("m2-balanced", instance, mod)
|
||||
if hashlib.sha256((instance / "mods" / mod.name).read_bytes()).hexdigest() != original_sha256:
|
||||
raise AssertionError("Runtime mod does not match the intended build")
|
||||
shutil.copy2(fixture, instance / "mods/terrain-read-fixture.jar")
|
||||
(instance / "config/horizon-terrain-read-wait.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_TERRAIN_READ_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"Terrain read fixture failed; inspect {instance / 'fixture.log'}")
|
||||
report = reports[0]
|
||||
if report["enabled"] != enabled or not all(report.get(key) for key in [
|
||||
"applied_io_worker_checked", "ordinary_join_checked", "shared_future_preserved",
|
||||
"exception_identity_checked", "completed_interrupt_checked", "cancelled_completion_checked", "preinterrupted_pending_checked"]):
|
||||
raise AssertionError("Applied terrain read hook did not preserve the required outcomes")
|
||||
results.append(report)
|
||||
print(json.dumps(report), flush=True)
|
||||
output = root / "dist/terrain-read-integration.json"
|
||||
output.write_text(json.dumps({"schema": 1, "platform": platform.platform(), "architecture": platform.machine(),
|
||||
"minecraft": "26.3", "distant_horizons": "3.3.4", "original_mod_sha256": original_sha256, "results": results,
|
||||
"notes": ["Actual game IOWorker under Fabric, with controlled cached blending-read futures.",
|
||||
"DH worker interruption releases only its waiter; the shared future remains consumable.",
|
||||
"Ordinary callers retain join behavior; completed values, cancellation and exception identity are checked.",
|
||||
"This is a shutdown correctness fixture, not an FPS or world-generation throughput benchmark."]}, indent=2) + "\n")
|
||||
print(f"Applied terrain read checks passed: {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user