From eecf9561188890c11c1520509aa4ef8c2865f503 Mon Sep 17 00:00:00 2001 From: rasmus123d <59487370+RasmusKD@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:43:31 +0200 Subject: [PATCH 1/2] Fast path for self-triggered IC thinks Every self-triggered IC paid for a full sign snapshot (getState), component serialization of all four lines and an IC id regex match on every think tick, even though the IC instance itself is cached. At 2000 STs that measured +3.4ms per tick with spikes to 100ms. The think handler now reuses the cached IC and family directly and only reruns the full setupIC verification once a second per IC; entries are only honoured while the IC remains in ICManager's cache, so break/unload invalidation is unchanged, and a sign edit is picked up within a second. Chunk-loaded lookups in the ST sweep are also memoised per pass, since clustered STs ask about the same few chunks thousands of times and a chunk cannot load or unload mid-sweep. Re-measured: 2000 active STs are indistinguishable from an idle server (50.5ms avg tick vs 50.3 baseline). --- .../core/st/SelfTriggeringManager.java | 42 ++++++++++--------- .../craftbook/mechanics/ic/ICMechanic.java | 33 +++++++++++++++ 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/sk89q/craftbook/core/st/SelfTriggeringManager.java b/src/main/java/com/sk89q/craftbook/core/st/SelfTriggeringManager.java index c207f30ec..1b06449a3 100644 --- a/src/main/java/com/sk89q/craftbook/core/st/SelfTriggeringManager.java +++ b/src/main/java/com/sk89q/craftbook/core/st/SelfTriggeringManager.java @@ -21,7 +21,9 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.logging.Level; public class SelfTriggeringManager implements Listener { @@ -91,31 +93,30 @@ public Collection getSelfTriggeringMechanics() { private Location[] registeredLocations; private boolean hasChanged = false; - private boolean areAdjacentChunksLoaded(Location loc) { + private static boolean areAdjacentChunksLoaded(Location loc, Map> cache) { World world = loc.getWorld(); final int CX = loc.getBlockX() >> 4; final int CZ = loc.getBlockZ() >> 4; - for (int x = -1; x <= 1; ++x) { - for (int z = -1; z <= 1; ++z) { - // Check only cardinal directions - if (x != 0 && z != 0) { - continue; - } - - // Don't test the current chunk - if (x == 0 && z == 0) { - continue; - } + return isLoadedCached(world, CX - 1, CZ, cache) + && isLoadedCached(world, CX + 1, CZ, cache) + && isLoadedCached(world, CX, CZ - 1, cache) + && isLoadedCached(world, CX, CZ + 1, cache); + } - if (!world.isChunkLoaded(CX + x, CZ + z)) { - return false; - } - } + // Chunk-loaded lookups are memoised per think pass: clustered STs ask about the + // same few chunks thousands of times in one sweep, and a chunk cannot load or + // unload in the middle of the (single-threaded) sweep. + private static boolean isLoadedCached(World world, int cx, int cz, Map> cache) { + Map perWorld = cache.computeIfAbsent(world, w -> new HashMap<>()); + long key = ((long) cx << 32) | (cz & 0xFFFFFFFFL); + Boolean loaded = perWorld.get(key); + if (loaded == null) { + loaded = world.isChunkLoaded(cx, cz); + perWorld.put(key, loaded); } - - return true; + return loaded; } /** @@ -129,15 +130,16 @@ public void think() { registeredLocations = thinkingMechanics.toArray(new Location[thinkingMechanics.size()]); } + Map> loadedCache = new HashMap<>(); for (Location location : registeredLocations) { - if(!location.getWorld().isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) { + if(!isLoadedCached(location.getWorld(), location.getBlockX() >> 4, location.getBlockZ() >> 4, loadedCache)) { unregisterSelfTrigger(location, UnregisterReason.UNLOAD); continue; } // If some of the adjacent chunks aren't loaded, don't self trigger the IC yet; effectively "pause" it. // This prevents some occasionally serious chunk thrashing. - if (!areAdjacentChunksLoaded(location)) { + if (!areAdjacentChunksLoaded(location, loadedCache)) { continue; } diff --git a/src/main/java/com/sk89q/craftbook/mechanics/ic/ICMechanic.java b/src/main/java/com/sk89q/craftbook/mechanics/ic/ICMechanic.java index f930aaa7f..515a6f286 100644 --- a/src/main/java/com/sk89q/craftbook/mechanics/ic/ICMechanic.java +++ b/src/main/java/com/sk89q/craftbook/mechanics/ic/ICMechanic.java @@ -37,6 +37,7 @@ import com.sk89q.worldedit.bukkit.BukkitAdapter; import org.apache.commons.lang.StringUtils; import org.bukkit.Bukkit; +import org.bukkit.Location; import org.bukkit.block.Block; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -45,8 +46,10 @@ import org.bukkit.event.block.SignChangeEvent; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.regex.Matcher; /** @@ -303,10 +306,35 @@ public void onThink(SelfTriggerThinkEvent event) { if(!EventUtil.passesFilter(event)) return; + // Fast path: a cached self-triggered IC thinks without re-snapshotting and + // re-parsing its sign (getState + component serialization + regex, per IC + // per tick - the dominant framework cost with many STs). The full setupIC + // verification still reruns once a second per IC, and the entry is only + // honoured while the IC remains in ICManager's cache, so break/unload + // invalidation is unchanged. + Location loc = event.getBlock().getLocation(); + long now = System.currentTimeMillis(); + Object[] fast = thinkFastCache.get(loc); + if (fast != null && now < (Long) fast[1] && ICManager.isCachedIC(loc)) { + IC cachedIC = ICManager.getCachedIC(loc); + if (cachedIC instanceof SelfTriggeredIC selfTriggeredIC) { + event.setHandled(true); + ChipState chipState = ((ICFamily) fast[0]).detectSelfTriggered(BukkitAdapter.adapt(loc), cachedIC.getSign()); + selfTriggeredIC.think(chipState); + try { + cachedIC.getSign().update(false); + } catch (Throwable ignored) {} + return; + } + } + final Object[] icData = setupIC(event.getBlock(), true); if(icData != null && icData[2] instanceof SelfTriggeredIC ic) { event.setHandled(true); + if (thinkFastCache.size() > MAX_THINK_FAST_ENTRIES) + thinkFastCache.clear(); + thinkFastCache.put(loc, new Object[] { icData[1], now + THINK_REVERIFY_MILLIS }); ChipState chipState = ((ICFamily) icData[1]).detectSelfTriggered(BukkitAdapter.adapt(event.getBlock().getLocation()), ((IC) icData[2]).getSign()); ic.think(chipState); try { @@ -315,6 +343,11 @@ public void onThink(SelfTriggerThinkEvent event) { } } + /** Location -> {ICFamily, reverify-deadline millis} for the think fast path. */ + private final Map thinkFastCache = new HashMap<>(); + private static final long THINK_REVERIFY_MILLIS = 1000L; + private static final int MAX_THINK_FAST_ENTRIES = 4096; + @EventHandler(priority = EventPriority.HIGH) public void onBlockBreak(BlockBreakEvent event) { From 27e9b1bff5e488c4841546d31f8cb4a890343b37 Mon Sep 17 00:00:00 2001 From: rasmus123d <59487370+RasmusKD@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:33:02 +0200 Subject: [PATCH 2/2] Drop the chunk-loaded memoisation isChunkLoaded is already a cheap map lookup on modern servers, so the cache traded one lookup for two plus boxing. The measured win came from skipping the sign snapshot and regex, which stays. --- .../core/st/SelfTriggeringManager.java | 31 +++++-------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/sk89q/craftbook/core/st/SelfTriggeringManager.java b/src/main/java/com/sk89q/craftbook/core/st/SelfTriggeringManager.java index 1b06449a3..893329584 100644 --- a/src/main/java/com/sk89q/craftbook/core/st/SelfTriggeringManager.java +++ b/src/main/java/com/sk89q/craftbook/core/st/SelfTriggeringManager.java @@ -21,9 +21,7 @@ import java.util.ArrayList; import java.util.Collection; -import java.util.HashMap; import java.util.HashSet; -import java.util.Map; import java.util.logging.Level; public class SelfTriggeringManager implements Listener { @@ -93,30 +91,16 @@ public Collection getSelfTriggeringMechanics() { private Location[] registeredLocations; private boolean hasChanged = false; - private static boolean areAdjacentChunksLoaded(Location loc, Map> cache) { + private static boolean areAdjacentChunksLoaded(Location loc) { World world = loc.getWorld(); final int CX = loc.getBlockX() >> 4; final int CZ = loc.getBlockZ() >> 4; - return isLoadedCached(world, CX - 1, CZ, cache) - && isLoadedCached(world, CX + 1, CZ, cache) - && isLoadedCached(world, CX, CZ - 1, cache) - && isLoadedCached(world, CX, CZ + 1, cache); - } - - // Chunk-loaded lookups are memoised per think pass: clustered STs ask about the - // same few chunks thousands of times in one sweep, and a chunk cannot load or - // unload in the middle of the (single-threaded) sweep. - private static boolean isLoadedCached(World world, int cx, int cz, Map> cache) { - Map perWorld = cache.computeIfAbsent(world, w -> new HashMap<>()); - long key = ((long) cx << 32) | (cz & 0xFFFFFFFFL); - Boolean loaded = perWorld.get(key); - if (loaded == null) { - loaded = world.isChunkLoaded(cx, cz); - perWorld.put(key, loaded); - } - return loaded; + return world.isChunkLoaded(CX - 1, CZ) + && world.isChunkLoaded(CX + 1, CZ) + && world.isChunkLoaded(CX, CZ - 1) + && world.isChunkLoaded(CX, CZ + 1); } /** @@ -130,16 +114,15 @@ public void think() { registeredLocations = thinkingMechanics.toArray(new Location[thinkingMechanics.size()]); } - Map> loadedCache = new HashMap<>(); for (Location location : registeredLocations) { - if(!isLoadedCached(location.getWorld(), location.getBlockX() >> 4, location.getBlockZ() >> 4, loadedCache)) { + if(!location.getWorld().isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) { unregisterSelfTrigger(location, UnregisterReason.UNLOAD); continue; } // If some of the adjacent chunks aren't loaded, don't self trigger the IC yet; effectively "pause" it. // This prevents some occasionally serious chunk thrashing. - if (!areAdjacentChunksLoaded(location, loadedCache)) { + if (!areAdjacentChunksLoaded(location)) { continue; }