Skip to content

Fast path for self-triggered IC thinks - #1379

Merged
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path
Aug 27, 2026
Merged

Fast path for self-triggered IC thinks#1379
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path

Conversation

@RasmusKD

Copy link
Copy Markdown
Contributor

Every self-triggered IC pays 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 already 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 the full setupIC verification still reruns once a second per IC. The fast entry is 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. The think itself runs exactly the code it always did (same IC instance, same sign object), so variables etc are untouched.

Chunk-loaded lookups in the ST sweep are also memoised per pass: clustered STs ask about the same few chunks thousands of times in one sweep, and a chunk cannot load or unload mid-sweep.

Re-measured after the change: 2000 active STs are indistinguishable from an idle server (50.5ms avg tick vs 50.3 baseline).

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).
Map<World, Map<Long, Boolean>> 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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the actual cost of checking if a chunk is loaded now? Last I checked this was functionally equivalent to a complex map lookup, which I would assume this would basically mirror.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The measured win came from skipping the sign snapshot and the id regex, the memoisation was added on top and never measured on its own, and with isChunkLoaded being a cheap map lookup it just traded one lookup for two plus boxing. Dropped it, the checks call isChunkLoaded directly again.

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.

if(icData != null && icData[2] instanceof SelfTriggeredIC ic) {
event.setHandled(true);
if (thinkFastCache.size() > MAX_THINK_FAST_ENTRIES)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This here feels weird to me, it fully clears the entire cache if it hits the limit? That feels like it'd create a tonne of churn?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CraftBook's HistoryHashMap might end up working better here rather than manually performing this validation, it has a maxEntries option

Otherwise one of the Guava cache classes, as that'd also allow you to drop the time revalidation measure too

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair - it dumps all 4096 to make room for one, and anything sitting above the limit would keep landing back on the slow path in waves. Which is the cost this is meant to avoid. Fixed.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with Guava. expireAfterWrite replaces the deadline I was storing, so the value is just the ICFamily now instead of an Object[] with a timestamp to unbox. Same shape as ItemSyntax and ParsingUtil already use.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment feels a bit bulky – It should probably just say it's a cached fast path, the rest doesn't really add much

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to one line.

Review feedback: clearing the whole map at the limit churns every entry to make
room for one, and on a server above the limit that puts every IC back on the
slow path in waves - the cost the fast path exists to avoid.
CacheBuilder with maximumSize evicts one entry instead, and expireAfterWrite
replaces the hand-rolled deadline, so the value is just the ICFamily rather
than an Object[] with a timestamp to unbox and compare. Guava caches are
already used this way in ItemSyntax and ParsingUtil.
Comment trimmed to what it is.
@me4502
me4502 merged commit 9424f86 into EngineHub:masterAug 27, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@RasmusKD@me4502
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Fast path for self-triggered IC thinks by RasmusKD · Pull Request #1379 · EngineHub/CraftBook · GitHub
Skip to content

Fast path for self-triggered IC thinks - #1379

Merged
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path
Aug 27, 2026
Merged

Fast path for self-triggered IC thinks#1379
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path

Conversation

@RasmusKD

Copy link
Copy Markdown
Contributor

Every self-triggered IC pays 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 already 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 the full setupIC verification still reruns once a second per IC. The fast entry is 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. The think itself runs exactly the code it always did (same IC instance, same sign object), so variables etc are untouched.

Chunk-loaded lookups in the ST sweep are also memoised per pass: clustered STs ask about the same few chunks thousands of times in one sweep, and a chunk cannot load or unload mid-sweep.

Re-measured after the change: 2000 active STs are indistinguishable from an idle server (50.5ms avg tick vs 50.3 baseline).

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).
Map<World, Map<Long, Boolean>> 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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the actual cost of checking if a chunk is loaded now? Last I checked this was functionally equivalent to a complex map lookup, which I would assume this would basically mirror.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The measured win came from skipping the sign snapshot and the id regex, the memoisation was added on top and never measured on its own, and with isChunkLoaded being a cheap map lookup it just traded one lookup for two plus boxing. Dropped it, the checks call isChunkLoaded directly again.

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.

if(icData != null && icData[2] instanceof SelfTriggeredIC ic) {
event.setHandled(true);
if (thinkFastCache.size() > MAX_THINK_FAST_ENTRIES)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This here feels weird to me, it fully clears the entire cache if it hits the limit? That feels like it'd create a tonne of churn?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CraftBook's HistoryHashMap might end up working better here rather than manually performing this validation, it has a maxEntries option

Otherwise one of the Guava cache classes, as that'd also allow you to drop the time revalidation measure too

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair - it dumps all 4096 to make room for one, and anything sitting above the limit would keep landing back on the slow path in waves. Which is the cost this is meant to avoid. Fixed.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with Guava. expireAfterWrite replaces the deadline I was storing, so the value is just the ICFamily now instead of an Object[] with a timestamp to unbox. Same shape as ItemSyntax and ParsingUtil already use.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment feels a bit bulky – It should probably just say it's a cached fast path, the rest doesn't really add much

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to one line.

Review feedback: clearing the whole map at the limit churns every entry to make
room for one, and on a server above the limit that puts every IC back on the
slow path in waves - the cost the fast path exists to avoid.
CacheBuilder with maximumSize evicts one entry instead, and expireAfterWrite
replaces the hand-rolled deadline, so the value is just the ICFamily rather
than an Object[] with a timestamp to unbox and compare. Guava caches are
already used this way in ItemSyntax and ParsingUtil.
Comment trimmed to what it is.
@me4502
me4502 merged commit 9424f86 into EngineHub:masterAug 27, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@RasmusKD@me4502
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fast path for self-triggered IC thinks by RasmusKD · Pull Request #1379 · EngineHub/CraftBook · GitHub
Skip to content

Fast path for self-triggered IC thinks - #1379

Merged
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path
Aug 27, 2026
Merged

Fast path for self-triggered IC thinks#1379
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path

Conversation

@RasmusKD

Copy link
Copy Markdown
Contributor

Every self-triggered IC pays 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 already 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 the full setupIC verification still reruns once a second per IC. The fast entry is 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. The think itself runs exactly the code it always did (same IC instance, same sign object), so variables etc are untouched.

Chunk-loaded lookups in the ST sweep are also memoised per pass: clustered STs ask about the same few chunks thousands of times in one sweep, and a chunk cannot load or unload mid-sweep.

Re-measured after the change: 2000 active STs are indistinguishable from an idle server (50.5ms avg tick vs 50.3 baseline).

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).
Map<World, Map<Long, Boolean>> 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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the actual cost of checking if a chunk is loaded now? Last I checked this was functionally equivalent to a complex map lookup, which I would assume this would basically mirror.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The measured win came from skipping the sign snapshot and the id regex, the memoisation was added on top and never measured on its own, and with isChunkLoaded being a cheap map lookup it just traded one lookup for two plus boxing. Dropped it, the checks call isChunkLoaded directly again.

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.

if(icData != null && icData[2] instanceof SelfTriggeredIC ic) {
event.setHandled(true);
if (thinkFastCache.size() > MAX_THINK_FAST_ENTRIES)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This here feels weird to me, it fully clears the entire cache if it hits the limit? That feels like it'd create a tonne of churn?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CraftBook's HistoryHashMap might end up working better here rather than manually performing this validation, it has a maxEntries option

Otherwise one of the Guava cache classes, as that'd also allow you to drop the time revalidation measure too

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair - it dumps all 4096 to make room for one, and anything sitting above the limit would keep landing back on the slow path in waves. Which is the cost this is meant to avoid. Fixed.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with Guava. expireAfterWrite replaces the deadline I was storing, so the value is just the ICFamily now instead of an Object[] with a timestamp to unbox. Same shape as ItemSyntax and ParsingUtil already use.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment feels a bit bulky – It should probably just say it's a cached fast path, the rest doesn't really add much

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to one line.

Review feedback: clearing the whole map at the limit churns every entry to make
room for one, and on a server above the limit that puts every IC back on the
slow path in waves - the cost the fast path exists to avoid.
CacheBuilder with maximumSize evicts one entry instead, and expireAfterWrite
replaces the hand-rolled deadline, so the value is just the ICFamily rather
than an Object[] with a timestamp to unbox and compare. Guava caches are
already used this way in ItemSyntax and ParsingUtil.
Comment trimmed to what it is.
@me4502
me4502 merged commit 9424f86 into EngineHub:masterAug 27, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@RasmusKD@me4502
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fast path for self-triggered IC thinks by RasmusKD · Pull Request #1379 · EngineHub/CraftBook · GitHub
Skip to content

Fast path for self-triggered IC thinks - #1379

Merged
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path
Aug 27, 2026
Merged

Fast path for self-triggered IC thinks#1379
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path

Conversation

@RasmusKD

Copy link
Copy Markdown
Contributor

Every self-triggered IC pays 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 already 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 the full setupIC verification still reruns once a second per IC. The fast entry is 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. The think itself runs exactly the code it always did (same IC instance, same sign object), so variables etc are untouched.

Chunk-loaded lookups in the ST sweep are also memoised per pass: clustered STs ask about the same few chunks thousands of times in one sweep, and a chunk cannot load or unload mid-sweep.

Re-measured after the change: 2000 active STs are indistinguishable from an idle server (50.5ms avg tick vs 50.3 baseline).

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).
Map<World, Map<Long, Boolean>> 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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the actual cost of checking if a chunk is loaded now? Last I checked this was functionally equivalent to a complex map lookup, which I would assume this would basically mirror.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The measured win came from skipping the sign snapshot and the id regex, the memoisation was added on top and never measured on its own, and with isChunkLoaded being a cheap map lookup it just traded one lookup for two plus boxing. Dropped it, the checks call isChunkLoaded directly again.

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.

if(icData != null && icData[2] instanceof SelfTriggeredIC ic) {
event.setHandled(true);
if (thinkFastCache.size() > MAX_THINK_FAST_ENTRIES)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This here feels weird to me, it fully clears the entire cache if it hits the limit? That feels like it'd create a tonne of churn?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CraftBook's HistoryHashMap might end up working better here rather than manually performing this validation, it has a maxEntries option

Otherwise one of the Guava cache classes, as that'd also allow you to drop the time revalidation measure too

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair - it dumps all 4096 to make room for one, and anything sitting above the limit would keep landing back on the slow path in waves. Which is the cost this is meant to avoid. Fixed.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with Guava. expireAfterWrite replaces the deadline I was storing, so the value is just the ICFamily now instead of an Object[] with a timestamp to unbox. Same shape as ItemSyntax and ParsingUtil already use.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment feels a bit bulky – It should probably just say it's a cached fast path, the rest doesn't really add much

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to one line.

Review feedback: clearing the whole map at the limit churns every entry to make
room for one, and on a server above the limit that puts every IC back on the
slow path in waves - the cost the fast path exists to avoid.
CacheBuilder with maximumSize evicts one entry instead, and expireAfterWrite
replaces the hand-rolled deadline, so the value is just the ICFamily rather
than an Object[] with a timestamp to unbox and compare. Guava caches are
already used this way in ItemSyntax and ParsingUtil.
Comment trimmed to what it is.
@me4502
me4502 merged commit 9424f86 into EngineHub:masterAug 27, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@RasmusKD@me4502
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Fast path for self-triggered IC thinks by RasmusKD · Pull Request #1379 · EngineHub/CraftBook · GitHub
Skip to content

Fast path for self-triggered IC thinks - #1379

Merged
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path
Aug 27, 2026
Merged

Fast path for self-triggered IC thinks#1379
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path

Conversation

@RasmusKD

Copy link
Copy Markdown
Contributor

Every self-triggered IC pays 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 already 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 the full setupIC verification still reruns once a second per IC. The fast entry is 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. The think itself runs exactly the code it always did (same IC instance, same sign object), so variables etc are untouched.

Chunk-loaded lookups in the ST sweep are also memoised per pass: clustered STs ask about the same few chunks thousands of times in one sweep, and a chunk cannot load or unload mid-sweep.

Re-measured after the change: 2000 active STs are indistinguishable from an idle server (50.5ms avg tick vs 50.3 baseline).

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).
Map<World, Map<Long, Boolean>> 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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the actual cost of checking if a chunk is loaded now? Last I checked this was functionally equivalent to a complex map lookup, which I would assume this would basically mirror.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The measured win came from skipping the sign snapshot and the id regex, the memoisation was added on top and never measured on its own, and with isChunkLoaded being a cheap map lookup it just traded one lookup for two plus boxing. Dropped it, the checks call isChunkLoaded directly again.

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.

if(icData != null && icData[2] instanceof SelfTriggeredIC ic) {
event.setHandled(true);
if (thinkFastCache.size() > MAX_THINK_FAST_ENTRIES)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This here feels weird to me, it fully clears the entire cache if it hits the limit? That feels like it'd create a tonne of churn?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CraftBook's HistoryHashMap might end up working better here rather than manually performing this validation, it has a maxEntries option

Otherwise one of the Guava cache classes, as that'd also allow you to drop the time revalidation measure too

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair - it dumps all 4096 to make room for one, and anything sitting above the limit would keep landing back on the slow path in waves. Which is the cost this is meant to avoid. Fixed.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with Guava. expireAfterWrite replaces the deadline I was storing, so the value is just the ICFamily now instead of an Object[] with a timestamp to unbox. Same shape as ItemSyntax and ParsingUtil already use.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment feels a bit bulky – It should probably just say it's a cached fast path, the rest doesn't really add much

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to one line.

Review feedback: clearing the whole map at the limit churns every entry to make
room for one, and on a server above the limit that puts every IC back on the
slow path in waves - the cost the fast path exists to avoid.
CacheBuilder with maximumSize evicts one entry instead, and expireAfterWrite
replaces the hand-rolled deadline, so the value is just the ICFamily rather
than an Object[] with a timestamp to unbox and compare. Guava caches are
already used this way in ItemSyntax and ParsingUtil.
Comment trimmed to what it is.
@me4502
me4502 merged commit 9424f86 into EngineHub:masterAug 27, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@RasmusKD@me4502
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fast path for self-triggered IC thinks by RasmusKD · Pull Request #1379 · EngineHub/CraftBook · GitHub
Skip to content

Fast path for self-triggered IC thinks - #1379

Merged
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path
Aug 27, 2026
Merged

Fast path for self-triggered IC thinks#1379
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path

Conversation

@RasmusKD

Copy link
Copy Markdown
Contributor

Every self-triggered IC pays 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 already 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 the full setupIC verification still reruns once a second per IC. The fast entry is 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. The think itself runs exactly the code it always did (same IC instance, same sign object), so variables etc are untouched.

Chunk-loaded lookups in the ST sweep are also memoised per pass: clustered STs ask about the same few chunks thousands of times in one sweep, and a chunk cannot load or unload mid-sweep.

Re-measured after the change: 2000 active STs are indistinguishable from an idle server (50.5ms avg tick vs 50.3 baseline).

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).
Map<World, Map<Long, Boolean>> 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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the actual cost of checking if a chunk is loaded now? Last I checked this was functionally equivalent to a complex map lookup, which I would assume this would basically mirror.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The measured win came from skipping the sign snapshot and the id regex, the memoisation was added on top and never measured on its own, and with isChunkLoaded being a cheap map lookup it just traded one lookup for two plus boxing. Dropped it, the checks call isChunkLoaded directly again.

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.

if(icData != null && icData[2] instanceof SelfTriggeredIC ic) {
event.setHandled(true);
if (thinkFastCache.size() > MAX_THINK_FAST_ENTRIES)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This here feels weird to me, it fully clears the entire cache if it hits the limit? That feels like it'd create a tonne of churn?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CraftBook's HistoryHashMap might end up working better here rather than manually performing this validation, it has a maxEntries option

Otherwise one of the Guava cache classes, as that'd also allow you to drop the time revalidation measure too

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair - it dumps all 4096 to make room for one, and anything sitting above the limit would keep landing back on the slow path in waves. Which is the cost this is meant to avoid. Fixed.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with Guava. expireAfterWrite replaces the deadline I was storing, so the value is just the ICFamily now instead of an Object[] with a timestamp to unbox. Same shape as ItemSyntax and ParsingUtil already use.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment feels a bit bulky – It should probably just say it's a cached fast path, the rest doesn't really add much

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to one line.

Review feedback: clearing the whole map at the limit churns every entry to make
room for one, and on a server above the limit that puts every IC back on the
slow path in waves - the cost the fast path exists to avoid.
CacheBuilder with maximumSize evicts one entry instead, and expireAfterWrite
replaces the hand-rolled deadline, so the value is just the ICFamily rather
than an Object[] with a timestamp to unbox and compare. Guava caches are
already used this way in ItemSyntax and ParsingUtil.
Comment trimmed to what it is.
@me4502
me4502 merged commit 9424f86 into EngineHub:masterAug 27, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@RasmusKD@me4502
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fast path for self-triggered IC thinks by RasmusKD · Pull Request #1379 · EngineHub/CraftBook · GitHub
Skip to content

Fast path for self-triggered IC thinks - #1379

Merged
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path
Aug 27, 2026
Merged

Fast path for self-triggered IC thinks#1379
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path

Conversation

@RasmusKD

Copy link
Copy Markdown
Contributor

Every self-triggered IC pays 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 already 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 the full setupIC verification still reruns once a second per IC. The fast entry is 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. The think itself runs exactly the code it always did (same IC instance, same sign object), so variables etc are untouched.

Chunk-loaded lookups in the ST sweep are also memoised per pass: clustered STs ask about the same few chunks thousands of times in one sweep, and a chunk cannot load or unload mid-sweep.

Re-measured after the change: 2000 active STs are indistinguishable from an idle server (50.5ms avg tick vs 50.3 baseline).

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).
Map<World, Map<Long, Boolean>> 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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the actual cost of checking if a chunk is loaded now? Last I checked this was functionally equivalent to a complex map lookup, which I would assume this would basically mirror.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The measured win came from skipping the sign snapshot and the id regex, the memoisation was added on top and never measured on its own, and with isChunkLoaded being a cheap map lookup it just traded one lookup for two plus boxing. Dropped it, the checks call isChunkLoaded directly again.

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.

if(icData != null && icData[2] instanceof SelfTriggeredIC ic) {
event.setHandled(true);
if (thinkFastCache.size() > MAX_THINK_FAST_ENTRIES)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This here feels weird to me, it fully clears the entire cache if it hits the limit? That feels like it'd create a tonne of churn?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CraftBook's HistoryHashMap might end up working better here rather than manually performing this validation, it has a maxEntries option

Otherwise one of the Guava cache classes, as that'd also allow you to drop the time revalidation measure too

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair - it dumps all 4096 to make room for one, and anything sitting above the limit would keep landing back on the slow path in waves. Which is the cost this is meant to avoid. Fixed.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with Guava. expireAfterWrite replaces the deadline I was storing, so the value is just the ICFamily now instead of an Object[] with a timestamp to unbox. Same shape as ItemSyntax and ParsingUtil already use.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment feels a bit bulky – It should probably just say it's a cached fast path, the rest doesn't really add much

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to one line.

Review feedback: clearing the whole map at the limit churns every entry to make
room for one, and on a server above the limit that puts every IC back on the
slow path in waves - the cost the fast path exists to avoid.
CacheBuilder with maximumSize evicts one entry instead, and expireAfterWrite
replaces the hand-rolled deadline, so the value is just the ICFamily rather
than an Object[] with a timestamp to unbox and compare. Guava caches are
already used this way in ItemSyntax and ParsingUtil.
Comment trimmed to what it is.
@me4502
me4502 merged commit 9424f86 into EngineHub:masterAug 27, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@RasmusKD@me4502
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Fast path for self-triggered IC thinks by RasmusKD · Pull Request #1379 · EngineHub/CraftBook · GitHub
Skip to content

Fast path for self-triggered IC thinks - #1379

Merged
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path
Aug 27, 2026
Merged

Fast path for self-triggered IC thinks#1379
me4502 merged 3 commits into
EngineHub:masterfrom
RasmusKD:st-think-fast-path

Conversation

@RasmusKD

Copy link
Copy Markdown
Contributor

Every self-triggered IC pays 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 already 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 the full setupIC verification still reruns once a second per IC. The fast entry is 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. The think itself runs exactly the code it always did (same IC instance, same sign object), so variables etc are untouched.

Chunk-loaded lookups in the ST sweep are also memoised per pass: clustered STs ask about the same few chunks thousands of times in one sweep, and a chunk cannot load or unload mid-sweep.

Re-measured after the change: 2000 active STs are indistinguishable from an idle server (50.5ms avg tick vs 50.3 baseline).

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).
Map<World, Map<Long, Boolean>> 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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the actual cost of checking if a chunk is loaded now? Last I checked this was functionally equivalent to a complex map lookup, which I would assume this would basically mirror.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The measured win came from skipping the sign snapshot and the id regex, the memoisation was added on top and never measured on its own, and with isChunkLoaded being a cheap map lookup it just traded one lookup for two plus boxing. Dropped it, the checks call isChunkLoaded directly again.

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.

if(icData != null && icData[2] instanceof SelfTriggeredIC ic) {
event.setHandled(true);
if (thinkFastCache.size() > MAX_THINK_FAST_ENTRIES)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This here feels weird to me, it fully clears the entire cache if it hits the limit? That feels like it'd create a tonne of churn?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CraftBook's HistoryHashMap might end up working better here rather than manually performing this validation, it has a maxEntries option

Otherwise one of the Guava cache classes, as that'd also allow you to drop the time revalidation measure too

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair - it dumps all 4096 to make room for one, and anything sitting above the limit would keep landing back on the slow path in waves. Which is the cost this is meant to avoid. Fixed.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with Guava. expireAfterWrite replaces the deadline I was storing, so the value is just the ICFamily now instead of an Object[] with a timestamp to unbox. Same shape as ItemSyntax and ParsingUtil already use.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment feels a bit bulky – It should probably just say it's a cached fast path, the rest doesn't really add much

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to one line.

Review feedback: clearing the whole map at the limit churns every entry to make
room for one, and on a server above the limit that puts every IC back on the
slow path in waves - the cost the fast path exists to avoid.
CacheBuilder with maximumSize evicts one entry instead, and expireAfterWrite
replaces the hand-rolled deadline, so the value is just the ICFamily rather
than an Object[] with a timestamp to unbox and compare. Guava caches are
already used this way in ItemSyntax and ParsingUtil.
Comment trimmed to what it is.
@me4502
me4502 merged commit 9424f86 into EngineHub:masterAug 27, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@RasmusKD@me4502