Skip to content

Fix ItemStack hashing and classified ingredient lookups - #238

Open
rubensworks wants to merge 5 commits into
master-26-ltsfrom
fix/itemstack-hash-components
Open

Fix ItemStack hashing and classified ingredient lookups#238
rubensworks wants to merge 5 commits into
master-26-ltsfrom
fix/itemstack-hash-components

Conversation

@rubensworks

@rubensworks rubensworks commented Sep 6, 2026

Copy link
Copy Markdown
Member

Three related changes to how ingredient collections find things. The first is the fix; the other two exist because measuring the first exposed them.

1. The ItemStack hash ignored data components

IItemStackHelpers.getItemStackHashCode hashed only the count and the item, while equality compares components in full. Every stack of the same item therefore hashed alike, so the hash-based ingredient collections collapsed into one bucket per item type and each lookup in such a bucket became a linear scan doing full component comparisons.

The exclusion made sense when stacks carried NBT tags, which were expensive to hash. Component maps are not.

JFR profile of an unmodified 50 000 stack storage terminal open, 3651 server thread samples:

  • 65.3% inside IngredientInstanceWrapper.equals to IIngredientMatcher.matchesExactly to ItemMatch.areItemStacksEqual to DataComparator.compare
  • 61.3% inside treeified HashMap buckets (HashMap$TreeNode.find 57.1%, getTreeNode 43.3%)

Java only treeifies a bucket at eight or more collisions, so a treeified bucket is direct evidence of pathological collisions rather than ordinary hashing cost. IngredientCollectionPrototypeMap.getPrototype normalizes the count to 1, which removed the only other varying term and left the hash a function of the item alone.

2. Classified lookups scanned everything when the classifier was empty

A single-classified collection partitions instances by a category type. When a query's match condition covers that category, every match has to share the query's classifier, so an absent classifier means an empty result. contains and iterator already returned one directly. getAll, keySet, containsKey, countKey and count fell through to the unclassified path, which scans every instance to produce a result already known to be empty.

That made the scan the common case rather than the exception. An IntegratedDynamics storage index keeps one classified map per priority level, so an item lookup visits every level, and every level not holding that item scanned all of its entries. It is why item-only lookups were slow before any of this, and why they got slower still once hashing stopped being nearly free.

This is a pre-existing bug, independent of change 1.

3. Plain stacks were hashing their item's default components

A component map hashes its prototype alongside its patch, and the prototype is the item's defaults, which the item already in the hash stands for. Hashing it again distinguishes nothing. For a stack carrying no patch that walk was the entire cost of the hash, and plain stacks are what most of a storage network consists of. Profiling change 1 showed 54.5% of index modification samples inside DataComponentMap$Builder$SimpleMap.hashCode, which is exactly that.

This stays consistent with equality because PatchedDataComponentMap keeps its patch sanitized: set removes an entry equal to the prototype's default rather than storing it, and applyPatch and fromPatch do the same. Two stacks of one item therefore have equal components exactly when they have equal patches. Vanilla can only report an empty patch by building one, which costs nothing for exactly the stacks this catches, so that is the common implementation and NeoForge overrides it with isComponentsPatchEmpty.

Numbers

IntegratedDynamics index benchmarks, PERFORMANCE_BENCHMARK_ENABLED=true ./gradlew runGameTestServer, ms per operation. Medians of six whole runs, except the hash-only column which is one run and is shown only to separate the three changes. The plain, mixed, single_item, few_items and heavy_components shapes are added in CyclopsMC/IntegratedDynamics#1722.

benchmark before hash only + classified + plain vs before
index_lookup_exact 0.004670 0.001614 0.001738 0.001500 3.1x faster
index_lookup_item 0.268168 0.820625 0.001094 0.001097 245x faster
index_modification 0.000744 0.001789 0.001976 0.001381 ~2x slower
index_lookup_nonempty_first 0.000243 0.000346 0.000252 0.000259 unchanged
index_lookup_nonempty_all 0.012368 0.013368 0.012332 0.012341 unchanged
index_lookup_exact_plain 0.001227 0.002014 0.001903 0.000954 1.3x faster
index_lookup_item_plain 0.231160 0.717556 0.001175 0.001099 210x faster
index_modification_plain 0.000540 0.001276 0.001415 0.000507 1.1x faster
index_lookup_exact_mixed 0.001618 0.001190 1.4x faster
index_lookup_item_mixed 0.220683 0.001053 210x faster
index_modification_mixed 0.000501 0.000667 1.33x slower
index_lookup_exact_single_item 3.796415 0.001340 0.001318 0.001252 3034x faster
index_modification_single_item 3.291132 0.001113 0.001225 0.000688 4784x faster
index_lookup_item_single_item 0.432068 0.558893 0.572138 0.582180 1.35x slower
index_lookup_exact_few_items 0.070593 0.000883 0.000845 0.000858 82x faster
index_modification_few_items 0.156185 0.001145 0.001146 0.000726 215x faster
index_lookup_exact_heavy_components 0.003482 0.003172 0.003255 0.002789 1.2x faster
index_modification_heavy_components 0.000423 0.002611 0.002818 0.002077 4.9x slower

The plain shape is 5000 stacks with no components at all; mixed gives one in ten a component, which is closer to a real modpack storage. Both end up faster or unchanged on lookups, and plain is unchanged on modification.

The regressions, stated plainly

Modification on component-bearing stacks. 1.33x on the realistic mixed shape, about 2x on the spread shape, 4.9x with a large component payload. In absolute terms that is 166 ns, 640 ns and 1.65 microseconds per operation. Run to run spread on these rows is large, so the factors are not well determined; the direction is. On the plain shape, where a modification hashes no components, it is slightly faster than before.

Item-only lookup where one item has thousands of variants, 1.35x. That query has to return every variant, so classification cannot narrow it and each result costs a dearer hash.

Set against them, an item-only lookup on a normal network costs 220 microseconds less, and the collision-heavy shapes are hundreds to thousands of times cheaper. One avoided lookup pays for roughly a thousand modifications.

Effect on a storage terminal

Terminal open on a loopback dedicated server with IntegratedTerminals, medians of seven runs after a discarded warm-up. Times in ms. Scenario A is a first open, B a re-open.

case server before server after client before client after
1 000 A 5.1 4.4 11.5 10.0
10 000 A 287.7 33.1 560.5 84.0
10 000 B 285.4 33.0 611.5 78.0
50 000 A 5535.9 180.6 10482.0 567.0
50 000 B 5524.3 173.5 10356.0 525.0

Scaling stops being superlinear: 50x the stacks costs 31x the server work, against about 1000x before. A 50 000 stack storage where every heavy stack sits on one item used to trip the 60 s single-tick watchdog and now costs 336 ms of server time. Bytes on the wire are unchanged.

Caller audit

Every caller of getItemStackHashCode across CyclopsCore, IntegratedDynamics, IntegratedTerminals and the CommonCapabilities API:

caller effect
ItemStackHelpersCommon the definition changed here
IItemStackHelpers interface declaration only
CraftingHelpersCommon:160, recipe cache key its equals already used ItemStack.isSameItemSameComponents, so the hash was less discriminating than the equality it keyed. Strictly improved.
ValueObjectTypeItemStack:156 (IntegratedDynamics) hashCode for the ItemStack value type, more correct now
IngredientMatcherItemStack.hash (CommonCapabilities) the main consumer, and the path every measurement above goes through

None depend on component-blind hashing for correctness.

Tests

  • TestItemStackHelpersHashCode: equal stacks hash equal, different items and counts hash differently, components affect the hash, 1000 component variants of one item produce over 99% distinct hashes, plain stacks still spread over items and counts, a component set back to its default hashes as plain again, and the hash agrees with ItemStack.isSameItemSameComponents plus count over 100 samples.
  • TestSingleClassifiedAbsentClassifier: getAll, keySet, containsKey, countKey and count return empty for an absent classifier and correct results for a present one, and a counting inner collection asserts that nothing is scanned in the absent case, so the optimization is pinned and not just its answer. A match condition outside the category still scans, which is also asserted.

./gradlew build passes on all three loaders.

Notes for review

  • This changes the behaviour of every IntegratedDynamics storage lookup. It needs a CyclopsCore version bump, and dependent mods will need to require it.
  • Changes 2 and 3 stand on their own and could be split out if you would rather take them separately. Change 2 in particular is a fix for a pre-existing bug and helps regardless of the hash.
  • IngredientMapWrappedAdapter.iterator() calls collection.get(key) per key while iterating the key set, hashing every entry twice. Avoid redundant HashMap lookup in IngredientMapWrappedAdapter.iterator() #232 already fixes that; it applies cleanly on top of this.
  • getItemStackHashCode is byte identical on master-1.21-lts, and IngredientMapSingleClassified has the same fall-through there, so both fixes apply. Given the upmerge direction you may want them there first.

What was not verified

  • No client side profiling informed changes 2 and 3; they were driven by server side profiles and the index benchmarks.
  • The terminal numbers are loopback medians after warm-up, not a statistically rigorous sample.
  • Fabric and Forge keep the common hasComponentPatch implementation, which allocates a patch instance for stacks that carry one. That path was not benchmarked; only NeoForge was.

Copy link
Copy Markdown
Member Author

The companion benchmark PR referenced above is CyclopsMC/IntegratedDynamics#1722. It is independent of this change and can be merged on its own.


Generated by Claude Code

@coveralls

coveralls commented Sep 6, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 31.112% (+0.09%) from 31.026% — fix/itemstack-hash-components into master-26-lts

@rubensworks rubensworks changed the title Include data components in the ItemStack hash Fix ItemStack hashing and classified ingredient lookups Sep 6, 2026

Copy link
Copy Markdown
Member Author

Re-measured the storage terminal against the build carrying all three changes, so the numbers in the description no longer lag the branch. Loopback, one channel, medians of seven runs after a discarded warm-up, times in ms.

case server before server after client before client after
1 000 A 5.1 4.4 11.5 10.0
10 000 A 287.7 33.1 560.5 84.0
10 000 B 285.4 33.0 611.5 78.0
50 000 A 5535.9 180.6 10482.0 567.0
50 000 B 5524.3 173.5 10356.0 525.0

Within the run to run spread this matches the hash-only measurement quoted in the description, which is what I expected: the terminal open path is dominated by exact lookups, and the other two changes target item-only lookups and modifications. Neither made it worse.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

The shapes in the description bracket a real storage network rather than describing one, so I added a mixed shape with one instance in ten carrying a component and the rest plain, which is closer to a modpack: bulk material with a tail of enchanted, damaged or named items. Medians of three runs each, ms per operation.

benchmark before after factor
index_lookup_exact_mixed 0.001618 0.001022 1.6x faster
index_lookup_item_mixed 0.220683 0.000955 231x faster
index_modification_mixed 0.000501 0.000710 1.4x slower

The modification cost tracks the share of instances carrying components, since only those pay for the hash:

component-bearing share modification, after vs before
0% (plain) 1.05x
10% (mixed) 1.42x
74% (spread) 1.70x
100% with a large payload (heavy_components) 6.53x

In absolute terms a modification on the mixed shape costs 209 ns more; an item-only lookup on the same shape costs 220 microseconds less.

Correcting something I wrote earlier

I listed caching the hash inside IngredientInstanceWrapper as the biggest remaining win. It is not one. IngredientMapWrappedAdapter builds a fresh wrapper inside each of get, put and remove, so each wrapper is hashed exactly once, and HashMap stores the hash in its nodes so a resize never recomputes it. Caching there would only help where a stored wrapper is hashed again, which is IngredientMapWrappedAdapter.iterator() calling collection.get(key) on keys it just took from keySet().

The real redundancy for modifications is different: the get-then-put pairs in IngredientCollectionPrototypeMap.add and IngredientPositionsIndex.addPosition each build their own wrapper, so the same prototype is hashed twice, and PositionedAddonsNetworkIngredients.applyChangesToChannel then runs the whole thing again for the wildcard channel. A compute or merge style method on IIngredientMapMutable, overridden in the wrapped and classified maps, would collapse each pair into one hash. Hashing is 63.6% of index modification samples, so that is worth roughly a third of what remains of the regression.

I have not implemented it; it is a wider API change than this PR should carry, and it is orthogonal to the correctness fix here. Happy to open it separately if you want it.


Generated by Claude Code

getItemStackHashCode hashed only count and item. Equality compares data components,
so the hash was strictly less discriminating than equality: every stack of one item
landed in the same bucket of any hash-based ingredient collection.

Collapsed collections normalise the count to 1 before using a stack as a key, so the
count term is constant there and the hash degenerated to a function of the item alone.
With enough component variants per item the buckets treeify and every lookup turns into
a tree walk doing full data component comparisons, which is what made large Integrated
Dynamics storage networks scale quadratically.

Profiling a 50k stack storage terminal open showed 61% of server thread samples inside
treeified HashMap buckets and 65% inside IngredientInstanceWrapper.equals. Including
components takes that open from 4686 ms to 168 ms of server thread time.

The exclusion comment dated from NBT tags, which were expensive to hash. Component maps
are not, and vanilla hashes them the same way in ItemStack.hashItemAndComponents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mxjin31W1Lmq5XK1CCe84v
ItemStackHelpersCommon is abstract on more than getItemStackHashCode, so an
anonymous subclass does not compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mxjin31W1Lmq5XK1CCe84v
A single-classified collection partitions its instances by a category type. When
a query's match condition covers that category, every match has to share the
query's classifier, so an absent classifier means an empty result. contains and
iterator already returned one directly, but getAll, keySet, containsKey,
countKey and count fell through to the unclassified path, which scans every
instance to produce a result that was already known to be empty.

That fallback made the scan the common case rather than the exception. An
IntegratedDynamics storage index keeps one classified map per priority level, so
looking up an item touches every level, and every level that does not happen to
hold that item scanned all of its entries.

Measured on the IntegratedDynamics index benchmarks, item-only lookups over
5000 instances spread across 200 positions and 4 priority levels:

  index_lookup_item  0.240831 ms/op  before
                     0.001157 ms/op  after

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mxjin31W1Lmq5XK1CCe84v
Stacks carrying no component patch, and stacks whose only component was set
back to its default, are the cases most of a storage network consists of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mxjin31W1Lmq5XK1CCe84v
A component map hashes its prototype alongside its patch, and the prototype is
the item's default components. The item is already in the hash, so hashing its
defaults again distinguishes nothing while walking the whole default map. For a
plain stack, which carries no patch at all, that walk was the entire cost of the
hash, and plain stacks are what most of a storage network consists of.

This stays consistent with equality because PatchedDataComponentMap keeps its
patch sanitized: setting a component to its default removes it from the patch
rather than storing it. Two stacks of one item therefore have equal components
exactly when they have equal patches.

Vanilla can only report an empty patch by building one, which is free for
exactly the stacks this catches, so the common implementation does that and
NeoForge overrides it with the direct check.

Measured on the IntegratedDynamics index benchmarks, over 5000 plain stacks
distinct by item and count:

  index_lookup_exact  0.002014 -> 0.000988 ms/op
  index_modification  0.001276 -> 0.000582 ms/op

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mxjin31W1Lmq5XK1CCe84v
@rubensworks
rubensworks force-pushed the fix/itemstack-hash-components branch from 8130dbc to c676887 Compare September 6, 2026 18:15
@sonarqubecloud

sonarqubecloud Bot commented Sep 6, 2026

Copy link
Copy Markdown

Copy link
Copy Markdown
Member Author

A master-1.21-lts version of this now exists as #240, since the same bug is present there and the plan is to upmerge rather than maintain both.

It is the same three changes. The five ingredient collection files are byte identical between the branches, so only ItemStackHelpersCommon and the tests needed adjusting, the latter because this repo uses JUnit 4 on 1.21 and bootstraps items through intrusive holders.

I re-measured on 1.21.1 rather than assuming, and the result is not the same. On 1.21 the modification benchmarks improve where they regressed here:

benchmark 26.1 1.21
index_modification ~2x slower 5.9x faster
index_modification_heavy_components 4.9x slower 2.2x faster
index_modification_mixed 1.33x slower 1.5x faster
index_lookup_item 245x faster 5241x faster

The reason is that the 1.21 baseline is far worse to begin with: index_modification starts at 0.0099 there against 0.00074 here, and index_lookup_item at 9.51 against 0.27. The added hashing cost is real on both branches; on 1.21 it is swamped by how much worse the collisions were. I have not isolated why 1.21 collides harder. The likeliest explanation is that 1.21.1 registers fewer items, lengthening every collision chain, but that is a hypothesis I did not test.

Also confirmed on 1.21.1 directly rather than assumed: PatchedDataComponentMap sanitizes its patch in both set and applyPatch, hashCode includes the prototype, and ItemStack.isComponentsPatchEmpty() exists in NeoForge 21.1.2. So all three changes are sound there.

This PR stays open. Merge whichever branch suits your upmerge direction; they are not meant to both land independently.


Generated by Claude Code

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.

3 participants