Skip to content

Improve AI play speed#11314

Open
Madwand99 wants to merge 4 commits into
Card-Forge:masterfrom
Madwand99:MakeForgeFaster
Open

Improve AI play speed#11314
Madwand99 wants to merge 4 commits into
Card-Forge:masterfrom
Madwand99:MakeForgeFaster

Conversation

@Madwand99

Copy link
Copy Markdown
Contributor

AI slow-play optimization summary

This patch grew out of reports that AI priority checks and late-game token combat could take seconds—or much longer—at every stop. I added reduced, deterministic reproductions for those paths and used the four-player token Commander profile to identify the repeated work. Changes include:

Change Why it is retained Approximate measured effect
Reuse an unchanged completed priority pass The AI was repeating the full action search at consecutive priority stops even when no tracked state or non-priority game event had changed. The cache stores only a completed pass; it never stores a chosen action, timeout, interrupted evaluation, or simulation result. 23.910 ms -> 0.022 ms median, ~1,070x faster for the exact repeated-pass benchmark. This is deliberately a narrow result, not a claim about every priority decision.
Exact combat-forecast early exits Forecasting used full blocker assignment even when no creature could legally block, or when taking the entire attack unblocked was provably safe. The shortcut is disabled for must-block effects, commander lethal, poison danger, serious danger, and other cases where blocks can change the answer. 37.756 ms -> 24.877 ms median, 1.52x faster (~34% lower latency) in the isolated no-legal-blocker forecast.
Request-local combat evaluation matrix Wide boards repeatedly recomputed the same attacker/blocker legality, kill outcome, damage-to-kill, creature value, and unblocked-damage facts. These are now shared only within one attack/block request. Assignment-dependent entries are cleared after every combat mutation; nothing crosses a game-state boundary. Part of the wide-token benchmark improvement from 9.749 s -> 4.155 s median and 9.771 s -> 4.178 s p95: 2.35x faster / ~57% lower latency overall.
Request-local card trait and combat-trigger views Combat evaluation repeatedly rebuilt the same spell/static/trigger/replacement views and repeatedly collected battlefield/command-zone triggers. The scope is thread-local and limited to a single synchronous attack/block request, so mutable views are not retained across actions. Static-ability scans also visit zones directly and stop on the first match instead of allocating combined collections. Included in the same 2.35x wide-token result above. It was developed and measured together with the matrix, so I do not think a separate percentage would be defensible.
Reduce work before expensive evaluation Action discovery now asks for playable abilities up front and does not manufacture battlefield spell/alternate-cost variants that cannot be cast. Single-candidate removal selection avoids a valuation pass. Fog mana reservation skips speculative combat forecasting while another object is on the stack or when the Fog cannot be paid for. Must-block and poison checks have exact cheap guards. No isolated percentage claimed; these paths are covered by the focused correctness tests and the combined full-game profile. Current focused timings are ~0.14 ms for both busy-stack and unaffordable-Fog checks.
Avoid temporary collections in hot scans Several combat/static paths copied collections solely to iterate or find the first match. Direct visitors and synchronized direct iteration reduce allocation while preserving early termination and thread safety. No isolated percentage claimed; included in the combined wide-board and full-game results.

End-to-end check

The final fixed-seed game used Rhys the Redeemed, Adrix and Nev, Trostani, and Emmara and reproduced a board with roughly 80 attacking tokens plus a large trigger stack. Earlier runs of this scenario did not finish within the five-minute timeout. The final run completed all 39 turns in 102.2 seconds. Because the baseline timed out, the exact improvement cannot be calculated, but this establishes a lower bound of more than 2.9x faster / at least 66% lower wall time. I treat that as end-to-end corroboration rather than adding it to the isolated benchmark results, since whole-game timing is noisy.

The final profile recorded 279 AI evaluations, with 20.3 ms p50, 794.4 ms p95, 1.70 s p99, and 2.59 s maximum. No evaluation reached the 4.5-second timeout. The remaining dominant sampled cost is combat danger forecasting/card-property evaluation, so this patch improves the reported failure mode without claiming that the underlying forecasting algorithm is solved.

All figures above are approximate medians from opt-in wall-clock benchmarks on one machine.

Thanks to Codex for help with code and tests!

Comment thread forge-ai/src/main/java/forge/ai/AiCache.java Outdated
|| (game.getPhaseHandler().getPhase().isBefore(PhaseType.COMBAT_DECLARE_BLOCKERS)))
&& game.getStack().isEmpty()
&& (AiCardMemory.isMemorySetEmpty(ai, AiCardMemory.MemorySet.CHOSEN_FOG_EFFECT))
&& ComputerUtilCost.canPayCost(sa, ai, false)

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.

redundant

@Madwand99 Madwand99 Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The ability is checked again for affordability by canPlayAndPayForFace, but only after FogAi.canPlay returns. This check is an early exit before ComputerUtil.aiLifeInDanger, which can invoke expensive multiplayer combat forecasting. It prevents an unaffordable Fog from paying that forecasting cost merely to be rejected immediately afterward. reserveManaSources also occurs after the forecast and is only a simplified mana-source check. So it is redundant for the eventual play decision, but not for avoiding the expensive work this PR targets.

The "game.getStack().isEmpty()" is similarly critical: it saves A LOT of time (games can slow down a lot if an AI has a fog in hand and this line is not present).

@tool4ever tool4ever Jul 25, 2026

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.

reserveManaSources also occurs after the forecast and is only a simplified mana-source check

wrong

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You’re right that I described reserveManaSources too loosely: it invokes the mana-payment planner and is not merely the final source-count comparison. However, the affordability check here is an early-exit optimization, not a replacement for reservation. Java evaluates it before aiLifeInDanger; reserveManaSources is only called after that forecast. In the wide-board unaffordable-Fog benchmark, keeping the check reduced median evaluation time from 57.933 ms to 0.615 ms while preserving the decision and reservation state. The later general affordability check is also not reached during this speculative pre-combat path. So I believe the line should remain, though the earlier explanation was imprecise.

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.

hmn, I just hate this approach of trying to guard expensive methods with redundant logic
that fact is neither obvious this way and if applied across more places makes everything messier
+ it's not like canPayCost is a very cheap call either...

imo the right solution would be a mix of optimizing the performance inside the cause and trying to keep its AiCache around longer

private final Map<OutcomeKey, Boolean> destroysBlocker = new HashMap<>();
private final Map<PairKey, Integer> blockerDamage = new HashMap<>();
private final Map<DamageToKillKey, Integer> damageToKill = new HashMap<>();
private final Map<CombatCardKey, Integer> creatureValues = new HashMap<>();

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.

I doubt this needs such specific key

also don't want such hardcoded classes but rather more flexible approach like AiCache
or extending the usage of getCachedCreatureComparator so it can be shared between API checks

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Is your concern just CombatCardKey, or a wider architectural change?

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.

tbh wider

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Made several changes that I hope address your concerns, take a look when you can.

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.

thanks, will look for cherry pick potential in a while

Comment thread forge-game/src/main/java/forge/trackable/Tracker.java Outdated
delay = FControlGamePlayback.resolveDelay;
}
if (delay > 0) {
if (delay > 0 && !Boolean.getBoolean("forge.game.noPlaybackDelay")) {

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.

this seems like your local change?

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.

2 participants