Skip to content

Cache per-card trait lists instead of rebuilding them on every access - #11366

Open
liamiak wants to merge 2 commits into
Card-Forge:masterfrom
liamiak:perf-trait-cache-lists
Open

Cache per-card trait lists instead of rebuilding them on every access#11366
liamiak wants to merge 2 commits into
Card-Forge:masterfrom
liamiak:perf-trait-cache-lists

Conversation

@liamiak

@liamiak liamiak commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

CardState.getTriggers, getStaticAbilities and getReplacementEffects rebuild their list from
the layer system on every call - tens of millions of times a game, and the result is identical to
the previous one almost every time. This caches each list and drops the cache when an input changes.

Measured

AI-vs-AI, sim -d "Big 240531" "Big 240531" -n 5 -s 12345, same seed both sides so both builds play
the same games. The baseline is master's version of the two changed files restored into the same
tree, so this isolates the change. Both jars are built up front and run alternately, three pairs.

in-game time, 5 games spread
master 215.0s / 229.5s / 223.7s ±3.5%
this PR 93.2s / 97.4s / 93.8s ±2.8%

2.35x on game time - 2.31x, 2.36x, 2.39x across the three pairs. Absolute times drift a few
percent between runs on this machine, so the ratio is the figure to trust; alternating the two
builds is what keeps that drift from landing on one side of the comparison.

The change

Four cached fields on CardState, one invalidateTraitCache(), and the getters returning the
cached list when it is present. Invalidation happens wherever an input changes: the trait mutators,
copyFrom/addAbilitiesFrom, and updateTypes, since type drives the Saga, planeswalker and
battle ETB-counter replacements, the Adventure/Omen rules effects and the basic-land mana abilities.

Mutating a split state's raw lists invalidates the whole card rather than that state, because the
Original state merges LeftSplit/RightSplit's lists into its own. getReplacementEffects(rulesHost)
returns a different list per argument, so the two are cached separately.

Counter mutation, and the gap it left

clearCounters() emptied the counters field directly instead of going through either
setCounters, so it was the one counter path that never dropped the caches: a card kept its shield
and stun replacement effects after its counters were gone, and GameAction.changeZone calls it
whenever a permanent leaves the battlefield.

Rather than add a third invalidation call, the second commit makes the field private to
GameEntity, which owns the writes and calls a new onCountersChanged() afterwards. Card
overrides that once, in place of its three scattered invalidations, so a counter path added later
cannot forget - it cannot reach the field. Player shares the field but has nothing derived from
it, so it takes the default no-op. Making the field private also had the compiler enumerate every
direct access rather than my grepping for them: twelve, all inside those two classes. There is a
regression test for the original bug.

getCounters() was the way left in, since it handed out the live multiset, so it now returns a
cached unmodifiable view - refreshed at the single assignment point rather than allocated per call.
Two callers stored that live reference instead of copying it: ComputerUtilCard shared one multiset
between a real card and the LKI copy the AI evaluates pumps against, and GameState restores it
after a reset. Both copy now.

That also corrects what I said when I dropped the validation mode - that the seeded run covered the
equivalence it was there to prove. It does not. Identical games show the two builds agree with
each other, not that a cached list still agrees with a rebuilt one, and that is the distinction
this bug fell through.

Testing

Full suite, 352 tests, 0 failures, checkstyle clean. This began as five commits and three are gone:
FCollection lazy dedup merged as #11431, the redundant-work skip superseded by #11389, and the
trait hashCodes measure at 0.6% - inside the noise - so they are not worth carrying here.

🤖 Implemented with the assistance of Claude Code (Opus 5).

@tool4ever

Copy link
Copy Markdown
Contributor

Some interesting ideas here and the code doesn't look too messy...
Also probably superior to the similar part from #11314 since it wouldn't be limited to AI 🤔

Maybe @Hanmac wants to think about the caching logic?

I'm more into the FCollection ideas, will see if they cause any problems 👍

@Hanmac

Hanmac commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

i will try to cherry pick some changes tomorrow
(like the collection Logic for FCollection),
and the small changes for LandChanges

@Hanmac

Hanmac commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

i need to check if getChangedCardTraitsList might be better if it would return a Stream<> instead of Iterable

and if the Stream can be prebuilt, even if the underlying structures change 🤔
(That probably doesn't work)

Comment thread forge-game/src/main/java/forge/game/card/CardState.java
Agetian pushed a commit that referenced this pull request Jul 28, 2026
* Make FCollection's dedup uniqueness set lazy

FCollection kept a HashSet beside its list purely to reject duplicate adds
and answer contains() in O(1). Trait rebuilds (getReplacementEffects /
getStaticAbilities / getTriggers, plus every FCollection copy they make)
create tens of millions of these per game, almost all holding one or two
elements and never queried by value - so the set was pure overhead: an extra
allocation per collection and a hash insert per element.

Build the set on demand instead. Until something needs value semantics at
scale (asSet, or growth past a small threshold) uniqueness is enforced by
scanning the list, which is cheaper than the set for the tiny sizes that
dominate. Also copy straight across the backing list when constructing from
another FCollection (the source already guarantees uniqueness) and skip
iterating empty collections in addAll.

Verified against a fixed 5-seed AI-vs-AI match on a 510-card deck: every
game's end state is byte-identical to before, and total match time drops
substantially (the set churn was the single largest allocation source in
the profile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Clean up

* Clean up

---------

Co-authored-by: liamiak <liamiak1@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: tool4EvEr <tool4EvEr@>
@liamiak
liamiak force-pushed the perf-trait-cache-lists branch from a848d41 to 73b2bfe Compare August 1, 2026 04:05
@liamiak

liamiak commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto master and cut down to just the caching. @Hanmac @tool4ever - thanks for taking the
FCollection and LandTraitChanges pieces, that turned out to make this much easier to reason about.

Three of the five commits are gone:

  • FCollection is in via Cherry-pick FCollection from #11366 #11431. The readObject I had was redundant - set is transient, so it
    is already null after deserialization - dropping it was correct.
  • "Skip redundant work in the per-card trait rebuild" is superseded by LandTraitChanges: small improvements #11389. Master already has the
    hasRemoveIntrinsic early-out, the landTraitChanges list-wrapping and the
    getChangedCardTraitsList fast path, so against current master that commit added only comments.
  • The trait/ability hashCode change and the cache validation mode I dropped myself. Measuring them
    separately, the hashCodes are worth 0.6%, which is inside run-to-run noise, and they are a
    different idea from caching - happy to raise them on their own if you think they are worth having.
    The validation mode would have been the first Boolean.getBoolean("forge.…") switch in forge-game
    or forge-core, and adding a configuration mechanism as a side effect of a perf fix seemed like the
    wrong trade.

What is left is only the caching, +81 lines across two files, and it carries the whole speedup:

in-game time wall clock
master 35 877 ms 46.7 s
this PR 16 773 ms 27.3 s

sim -d "Big 240531" "Big 240531" -n 3 -s 12345, so both sides play the same games - identical turn
counts, winners and match scores - which is also the check that the cache is not changing behaviour.
Twice per build, under 1% variance. 336 tests, 0 failures.

@Hanmac you asked earlier whether getChangedCardTraitsList would be better returning a Stream<>,
and whether it could be prebuilt even as the underlying structures change. That method is yours now
after #11389, so I have left it alone here - but the seeded-sim setup above is a reliable way to
measure it if you want a number on it.

@liamiak

liamiak commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto master, plus a fix for a hole in the invalidation set.

clearCounters() emptied the counters field directly rather than going through either setCounters, so it was the one counter path that never dropped the caches — a card kept its shield and stun replacement effects after the counters were gone. GameAction.changeZone hits that path whenever a permanent leaves the battlefield.

Rather than add a third invalidation call, the field is now private to GameEntity, which owns the writes and calls a new onCountersChanged() that Card overrides once. getCounters() handed out the live multiset, which was the remaining way past the hook, so it now returns a cached unmodifiable view — and the two callers that stored that reference now copy instead. One of them was sharing a single multiset between a real card and the LKI copy the AI evaluates pumps against. Regression test included.

That also corrects something I claimed earlier: that the seeded equivalence run covered what a validation mode would prove. It does not. Identical games show the two builds agree with each other, not that a cached list still agrees with a rebuilt one, and that is the distinction this bug fell through.

On the overlap with #10507 — they come at the same three getters from opposite ends. That one early-exits when the list would be empty, so it cannot go stale, but it only helps cards that have nothing. This one caches the built list, so it helps every card, at the price of an invalidation obligation on each path that mutates an input. Both carry a completeness obligation — theirs is that the predicates keep mirroring every source — so it is more a question of which shape is easier to maintain. They also collide textually in the same methods, so probably one or the other rather than both.

Full suite green, checkstyle clean.

@liamiak liamiak mentioned this pull request Aug 16, 2026
liamiak1 and others added 2 commits August 20, 2026 05:36
getReplacementEffects / getStaticAbilities / getTriggers rebuilt their list from
the layer system on every call. Instrumenting one AI-vs-AI game on a 510 card
deck showed 18.0M, 11.2M and 2.7M calls respectively, and in over 99.9% of them
the rebuilt list was identical to the previous one for that card state.

Cache the result per CardState and drop it when an input changes, mirroring the
existing cachedKeywords / updateKeywordsCache pattern. Inputs are: the three
lists' own mutators, bulk copy (copyFrom / addAbilitiesFrom), changed card traits
in layers 3 and 6, the keyword cache, type changes (which drive the Saga,
planeswalker and battle ETB replacements, Adventure/Omen and basic land mana),
counters (Shield and Stun add replacement effects), and setStates.

Two subtleties worth calling out:

- The Original state merges LeftSplit/RightSplit's raw lists into its own, per
  CR 712.3 (a split card has its halves' combined characteristics outside the
  stack), so mutating a split state must invalidate the whole card.
- updateTypes() only refreshes the current state, so updateTypeCache() drops
  every state's cache rather than relying on a non-current state's cache
  happening to agree with its equally stale type. This also covers the clone and
  rollback paths, which reach it through updateChangedText().

rulesHost=true/false yield different lists and cache separately. The LKI/preList
path is safe by construction: ReplacementHandler swaps in the LKI or lastState
card object and queries that, which is a separate CardState with its own cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
clearCounters emptied the counters field directly rather than going
through either setCounters, so it was the one counter path that left the
cached trait lists in place: a card kept its shield and stun replacement
effects after its counters were gone. GameAction.changeZone clears
counters whenever a permanent leaves the battlefield.

Rather than add a third invalidation call, the field is now private to
GameEntity, which owns the writes and calls a new onCountersChanged()
hook afterwards. Card overrides that once, replacing its three scattered
invalidations, so a counter path added later cannot forget - it cannot
reach the field. Player inherits the default no-op.

getCounters() handed out the live multiset, which was the remaining way
past the hook, so it now returns a cached unmodifiable view. Two callers
stored that reference rather than copying it - ComputerUtilCard shared
one multiset between a real card and the LKI copy the AI evaluates pumps
against, and GameState restores it after a reset - and both copy now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@liamiak
liamiak force-pushed the perf-trait-cache-lists branch from 0356398 to 6840c5e Compare August 20, 2026 11:38
@Override
protected void onCountersChanged() {
// Shield and Stun counters contribute replacement effects (see updateReplacementEffects).
invalidateTraitCaches();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

these should not need caching if we have a PR add them differently first: #11631 (review)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@tool4ever part of this #11409 (comment)
I might cache them with and without stuff like shield counters

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants