Cache the per-card trigger and static ability lists - #11366
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. |
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
There was a problem hiding this comment.
That's fair, and going through it the objection lands on one of the four caches rather than all of them.
Only updateReplacementEffects reads counters, and only in its rulesHost branch — shield/stun/finality. updateTriggers and updateStaticAbilities read getChangedCardTraitsList and getUnhiddenKeywords, nothing else. Statics already solved this the way you're describing: the counter-derived ones live in counterTypeKeywordStatic, served by getHiddenStaticAbilities() and added on demand at GameAction:1109, so they never enter the cached list.
So I've dropped the replacement cache and everything that existed to serve it. The counter-routing commit goes entirely and Card.setCounters is untouched again. What's left is triggers and statics, +62/-0 in two files.
@Hanmac — on the Plain/AsRulesHost pair, I did build that, and it's exactly the part that drags counters in. If the rules-host extras move to getReplacementList() on demand as tool4ever suggests, it collapses to one list needing no counter invalidation at all, so I'd rather that land in #11631 than cache around it here.
Verification: every cache hit rebuilds the list and compares it against the cached one — 4M+ verified hits across the suite, zero mismatches, with a deliberately injected disagreement first to prove the check actually fires, plus a targeted check on the LeftSplit/RightSplit merge path since that is what the suite covers thinnest.
Timing, corrected since I first posted this: 1.242x on an ordinary board, 1.094x on a dense one, A/B in one JVM behind a static flag. The figures I originally gave (1.155x / 1.042x) used a disabled arm that still ran the new invalidation calls, so it was not master. The flag now gates those too, so the disabled arm matches master. Both boards are AITest setups over six turns rather than full games. Suite 357/0/6.
Two things worth flagging rather than leaving for you to find:
copyFromandaddAbilitiesFromdropped the cache before copying the traits in, and those copies read back through the host card — so a read mid-copy could refill from a half-copied state. Nothing in the suite hits it, but the invalidation now sits after the mutations.- The cache fields are unsynchronised and
CardViewreadsgetTriggers()from view code. That is the same exposure as the existingcachedKeywordsfield onCardState, so not a new risk class, but I have not tested it and would not claim it is safe.
Also correcting my last point above: I said I had found a counters aliasing bug in getPumpedCreature and would send it separately. Having looked properly that is weaker than I made it sound. The GameState half is deliberate — the comment there says the reference is kept on purpose — and the ComputerUtilCard half is real but latent: all four callers only read the copy, and I found no path that mutates its counters. So I am not filing it.
getTriggers() and getStaticAbilities() rebuild their result from the layer system on every call, and the rebuilt list is identical to the previous one over 99% of the time. Cache each list on the CardState and drop it whenever an input changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6840c5e to
c1735a9
Compare
|
Pushed a much smaller version of this. The replacement-effect cache is gone, along with the counter plumbing that only existed to serve it — it is now triggers and statics only, +62/-0 in two files. The reasoning is in this thread, which GitHub has collapsed as outdated since the line it was anchored to no longer exists. In short: |
|
@tool4ever — late answer to your note about the overlap with #10507 (the thread is collapsed as outdated, so putting this here). I ported MostCromulent's fast path onto this branch behind a second flag, so both could be measured in one JVM with both flags off equal to master. They aren't alternatives. The fast path fires only when a card has no triggers/statics of its own, no split states, no trait-granting keyword and no overlays — i.e. when the list would be empty. This PR only pays off in the opposite case, when there is something to build. The one place they meet is that empty population, where MostCromulent's returns a shared empty and mine returns a cached one; the fast path is cheaper there, since it skips the cache write and the invalidation bookkeeping entirely. By call count the empty population dominates — 2,272,336 fast-path hits over ten turns across two boards. Correctness: I ran the fast path against a full rebuild on every skip. 2,272,336 checks, zero cases where it returned empty and a rebuild would not have. The Timing, ordinary board (30 interleaved reps, medians): master 538ms · this PR 439ms (1.227x) · #10507 495ms (1.158x) · both 450ms (1.195x). Of the four arms, this PR alone was the fastest. On their own it was the larger of the two by 1.227x against 1.158x — an ordering that also held across the first nine reps before the box started drifting, though at ~6% apart I would not lean on the margin. Running both measured no better than this PR on its own (450ms against 439ms), a gap inside the run's noise, so read that as no gain rather than a regression. It is what the mechanism predicts on a light board: the fast path takes the empty-list calls the cache would have served cheaply, while the cache still pays its invalidation bookkeeping. Heavy board: no usable number. Within-arm spread was 5231-8195ms against differences of a few percent, so that 2x2 is inconclusive and I'm not going to claim additivity I can't show. That is the board where I would expect them to diverge, since it is where the fast path stops firing — but expecting is not measuring. |
CardState.getTriggers()andgetStaticAbilities()rebuild their result from the layer system on every call — the changed-traits list, then the unhidden keywords — and the rebuilt list is identical to the previous one well over 99% of the time. This caches each list on the state and drops it whenever an input changes.Scope. This previously cached replacement effects too. That is gone, along with the counter plumbing that existed only to serve it: the shield/stun/finality block in
updateReplacementEffectsis the only place counters reach a trait list, and per #11631 those extras are better moved out ofgetReplacementEffectsthan cached around. Triggers and statics have no such dependency —getHiddenStaticAbilities()already keeps the counter-derived statics out of the cached list.Invalidation points: changed card traits (both tables), keyword cache updates, type changes (via
hasRemoveIntrinsic), trigger/static mutation, state replacement, and card copies.Measured.
Known limit. The cache fields are unsynchronised and
CardViewreadsgetTriggers()from view code. That is the same exposure as the existingcachedKeywordsfield onCardState, so not a new risk class, but untested.Written with Claude Code; the measurements and the differential check are mine to defend.