Skip to content

Speed up crafting job scheduling and execution - #218

Merged
rubensworks merged 5 commits into
master-1.21-ltsfrom
claude/performance-optimizations-44692n
Aug 29, 2026
Merged

Speed up crafting job scheduling and execution#218
rubensworks merged 5 commits into
master-1.21-ltsfrom
claude/performance-optimizations-44692n

Conversation

@rubensworks

Copy link
Copy Markdown
Member

Uses the performance benchmarks added in #217 to find and remove the largest avoidable costs in auto-crafting. A JFR profile of the whole benchmark suite showed that the mod's own time was concentrated in a few spots that do work proportional to something other than the amount of crafting.

Changes

Look crafting table recipes up through the cache (CraftingProcessOverrideCraftingTable).
CraftingHelpers.findServerRecipe goes straight to RecipeManager.getRecipeFor, which is a linear scan over every crafting recipe in the game, calling matches() on each one. Every craft does at least two of those lookups: one to simulate the craft, and one to actually perform it. findRecipeCached is CyclopsCore's cached equivalent, already used by RecipeHandlerRecipeType (and therefore by the crafting interface's own recipe validation). This was 9% of the mod's samples in the profile, and it scales with the number of installed recipes rather than with the amount of crafting, so the win grows in a modpack.

Stop asking the network channel for its total capacity on every recipe input evaluation (CraftingHelpers.getIngredientRecipeInputs).
The "quickly return if the storage is empty" shortcut led with storage.getMaxQuantity() == 0. For a network channel that is not a cheap call: IngredientChannelAdapter.getMaxQuantity() aggregates the capacity of every storage position in the channel, costing a capability lookup and a slot enumeration per position, and it was paid on every call for every input component of every candidate recipe. An indexed channel that holds at least one instance is guaranteed to have capacity, so its index now answers this without touching any position. The condition is otherwise unchanged: when the index is empty, getMaxQuantity() is still consulted. Skipping the call also skips the observation it incidentally scheduled, which is harmless because the extraction that follows schedules one itself.

Classify the crafting job index (CraftingJobIndexDefault).
Crafting writers ask every tick whether an instance is already being crafted, using a quantity-less match condition. A plain IngredientHashMap cannot hash that, so it answered by filtering over every crafting job in the network. Using IngredientMapSingleClassified — exactly what RecipeIndexDefault already does — narrows the lookup to the jobs producing that item first.

Two smaller ones. CraftingJobDependencyGraph.getDependencies/getDependents no longer allocate a throwaway IntArrayList for the empty case and no longer route int ids through a boxing stream; getDependents runs for every completed craft entry. And CraftingJobHandler.update no longer looks the crafting network up when the handler has nothing pending, which is what every idle crafting interface does on every tick, and it no longer repeats that lookup per finished job.

Measurements

Average network tick time in ms, two runs per revision, lower is better:

Preset Before After Change
craft_simple 1.72, 1.80 0.93, 1.18 -40%
craft_recipe_index 1.62, 1.76 1.02, 1.13 -36%
craft_nested 3.35, 5.06 2.88, 2.69 -34%

The idle and churn presets stay within run-to-run noise, which is expected: they schedule no jobs and execute no crafts. Server tick time is dominated by chunk ticking on the test machine and is too noisy at this scale to read anything into.

Notes

The recipe cache holds entries for a minute and is not invalidated by a datapack reload, so a /reload can take up to a minute to be reflected in crafting table process overrides. The crafting interface's recipe validation already goes through the same cache, so this is not a new property of the mod.

Testing

All 49 game tests pass, as do the unit tests and spotlessCheck.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MxquU3sDjDpKbeHrVyrV1t


Generated by Claude Code

claude added 5 commits August 26, 2026 19:36
CraftingHelpers.findServerRecipe goes straight to RecipeManager.getRecipeFor,
which is a linear scan over every crafting recipe in the game that calls
matches() on each one. Every craft does at least two of those lookups: one to
simulate the craft, and one to actually perform it.

findRecipeCached is CyclopsCore's cached equivalent, and is already what the
crafting interface's own recipe validation ends up using through
RecipeHandlerRecipeType. Its input is copied into the cache key, so the item
stacks of the grid can not corrupt an entry afterwards.

This was 9% of the mod's samples in a profile of the benchmark suite. It is
also the part of a craft that scales with the number of installed recipes
rather than with the amount of crafting, so it grows in a modpack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MxquU3sDjDpKbeHrVyrV1t
The "quickly return if the storage is empty" shortcut in
getIngredientRecipeInputs led with storage.getMaxQuantity() == 0. For a network
channel that is not a cheap call: IngredientChannelAdapter.getMaxQuantity()
aggregates the capacity of every storage position in the channel, costing a
capability lookup and a slot enumeration per position. It was paid on every
call, for every input component of every candidate recipe, while the shortcut
it guards can only trigger for a network that has no storage at all.

A channel that is indexed and holds at least one instance is guaranteed to have
capacity, so its index now answers this without touching any position. The
condition is otherwise unchanged: an empty index still consults
getMaxQuantity(). Skipping the call also skips the observation it incidentally
scheduled, which is harmless, as the extraction that follows schedules one
itself.

This was 6% of the mod's samples in a profile of the benchmark suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MxquU3sDjDpKbeHrVyrV1t
Crafting writers ask the index on every tick whether an instance is already
being crafted, using a quantity-less match condition. A plain IngredientHashMap
can not hash that condition, so it answered by filtering over every crafting job
in the network.

Use IngredientMapSingleClassified, exactly as RecipeIndexDefault already does,
so that such a lookup narrows to the jobs producing that item first.

This was 5% of the mod's samples in a profile of the benchmark suite, and it
scales with the number of crafting jobs a network is running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MxquU3sDjDpKbeHrVyrV1t
getDependencies and getDependents allocated a throwaway IntArrayList for the
common case of a job without edges, and routed the int ids they do have through
a stream that boxes every one of them.

getDependents runs for every completed crafting job entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MxquU3sDjDpKbeHrVyrV1t
CraftingJobHandler.update looked the crafting network capability up to find a
job to start, even when the handler has no pending jobs at all, which is what
every idle crafting interface does on every tick. It also repeated that same
lookup for every finished job instead of once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MxquU3sDjDpKbeHrVyrV1t
@rubensworks
rubensworks force-pushed the claude/performance-optimizations-44692n branch from 1885c91 to 1b65458 Compare August 26, 2026 19:40
@rubensworks
rubensworks merged commit 8fcb621 into master-1.21-lts Aug 29, 2026
7 checks passed
Sign up for free to 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