Cache per-card trait lists instead of rebuilding them on every access - #11366
Cache per-card trait lists instead of rebuilding them on every access#11366liamiak wants to merge 2 commits into
Conversation
|
i will try to cherry pick some changes tomorrow |
|
i need to check if and if the Stream can be prebuilt, even if the underlying structures change 🤔 |
* 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@>
a848d41 to
73b2bfe
Compare
|
Rebased onto master and cut down to just the caching. @Hanmac @tool4ever - thanks for taking the Three of the five commits are gone:
What is left is only the caching, +81 lines across two files, and it carries the whole speedup:
@Hanmac you asked earlier whether |
73b2bfe to
0356398
Compare
|
Rebased onto master, plus a fix for a hole in the invalidation set.
Rather than add a third invalidation call, the field is now private to 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. |
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>
0356398 to
6840c5e
Compare
| @Override | ||
| protected void onCountersChanged() { | ||
| // Shield and Stun counters contribute replacement effects (see updateReplacementEffects). | ||
| invalidateTraitCaches(); |
There was a problem hiding this comment.
these should not need caching if we have a PR add them differently first: #11631 (review)
There was a problem hiding this comment.
@tool4ever part of this #11409 (comment)
I might cache them with and without stuff like shield counters
CardState.getTriggers,getStaticAbilitiesandgetReplacementEffectsrebuild their list fromthe 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 playthe 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.
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, oneinvalidateTraitCache(), and the getters returning thecached list when it is present. Invalidation happens wherever an input changes: the trait mutators,
copyFrom/addAbilitiesFrom, andupdateTypes, since type drives the Saga, planeswalker andbattle 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 eithersetCounters, so it was the one counter path that never dropped the caches: a card kept its shieldand stun replacement effects after its counters were gone, and
GameAction.changeZonecalls itwhenever 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 newonCountersChanged()afterwards.Cardoverrides that once, in place of its three scattered invalidations, so a counter path added later
cannot forget - it cannot reach the field.
Playershares the field but has nothing derived fromit, 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 acached unmodifiable view - refreshed at the single assignment point rather than allocated per call.
Two callers stored that live reference instead of copying it:
ComputerUtilCardshared one multisetbetween a real card and the LKI copy the AI evaluates pumps against, and
GameStaterestores itafter 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:
FCollectionlazy dedup merged as #11431, the redundant-work skip superseded by #11389, and thetrait hashCodes measure at 0.6% - inside the noise - so they are not worth carrying here.
🤖 Implemented with the assistance of Claude Code (Opus 5).