Skip to content

Small performance optimization#14820

Open
benprew wants to merge 1 commit into
magefree:masterfrom
benprew:optimize-continuous-effects
Open

Small performance optimization#14820
benprew wants to merge 1 commit into
magefree:masterfrom
benprew:optimize-continuous-effects

Conversation

@benprew
Copy link
Copy Markdown

@benprew benprew commented Apr 30, 2026

Summary

Performance improvements to ContinuousEffects layer processing:

  • Use HashSet for O(1) effect ID lookups in updateTimestamps
  • Replace stream with loop in filterLayeredEffects
  • Cache ability lookups across PT sublayer passes
  • Return static empty set from getAbility instead of allocating

Includes regression tests.

Profiled with JDK Mission Control on MacOS Tahoe 26 (M1 Air)

Before (lots of time in ApplyEffect):
Screenshot 2026-04-24 at 3 20 05 PM

After (less time spent in ApplyEffect):
Screenshot 2026-04-24 at 3 21 01 PM

Performance improvements to ContinuousEffects layer processing:
- Use HashSet for O(1) effect ID lookups in updateTimestamps
- Replace stream with loop in filterLayeredEffects
- Cache ability lookups across PT sublayer passes
- Return static empty set from getAbility instead of allocating
@benprew
Copy link
Copy Markdown
Author

benprew commented May 8, 2026

Build failed with this error, doesn't reproduce when I run locally:

[INFO] Stack trace
[INFO] java.lang.AssertionError: Found set errors: 3
at org.junit.Assert.fail(Assert.java:89)
at mage.verify.VerifyCardDataTest.test_checkMissingSetData(VerifyCardDataTest.java:1187)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.junit.vintage.engine.execution.RunnerExecutor.execute(RunnerExecutor.java:42)
at org.junit.vintage.engine.VintageTestEngine.executeAllChildren(VintageTestEngine.java:80)
at org.junit.vintage.engine.VintageTestEngine.execute(VintageTestEngine.java:72)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:107)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:88)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:54)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:67)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:52)
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114)
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:86)
at org.junit.platform.launcher.core.DefaultLauncherSession$DelegatingLauncher.execute(DefaultLauncherSession.java:86)
at org.apache.maven.surefire.junitplatform.LazyLauncher.execute(LazyLauncher.java:56)
at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.execute(JUnitPlatformProvider.java:184)
at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:148)
at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:122)
at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385)
at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162)
at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507)
at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495)

[INFO]
[INFO] Standard out
Error: wrong booster settings (set must have boosters, but it does not) - 2018 - SS1 - Signature Spellbook: Jace - boosters: [default]
Error: wrong booster settings (set must have boosters, but it does not) - 2019 - SS2 - Signature Spellbook: Gideon - boosters: [default]
Error: wrong booster settings (set must have boosters, but it does not) - 2020 - SS3 - Signature Spellbook: Chandra - boosters: [default]
[INFO]
[INFO] Standard error
[INFO]
[INFO] Results:
[INFO]
Error: Failures:
Error: VerifyCardDataTest.test_checkMissingSetData:1187 Found set errors: 3
[INFO]
Error: Tests run: 27, Failures: 1, Errors: 0, Skipped: 8
[INFO]

@JayDi85
Copy link
Copy Markdown
Member

JayDi85 commented May 8, 2026

Last screenshot is little wrong - it’s show database warm up on first run (card scan), so ApplyEffect’s different is hard to read/analyse.

You can run one of the performance unit test multiple time and collect total time to execute (or just run whole project tests) - ApplyEffects optimization must affect any of it.

Copy link
Copy Markdown
Member

@JayDi85 JayDi85 left a comment

Choose a reason for hiding this comment

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

Some ideas. Need farther research.

// build a HashSet of previous effect IDs for O(1) lookups instead of O(n) linear scans
Set<UUID> prevIds = new HashSet<>(prevs.size());
for (ContinuousEffect prev : prevs) {
prevIds.add(prev.getId());
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Must add additional check here on false result and raise error on it (to catch bad use cases with same effect id and research it).

.filter(effect -> effect.hasLayer(layer))
.collect(Collectors.toList());
List<ContinuousEffect> filtered = new ArrayList<>();
for (ContinuousEffect effect : effects) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

“Stream to for cycle” replacement is useless for performance optimization


layer = filterLayeredEffects(activeLayerEffects, Layer.PTChangingEffects_7);
// pre-compute ability lookups for PT layer since we iterate 5 sublayers over the same effects
Map<UUID, Set<Ability>> ptAbilitiesCache = new HashMap<>(layer.size());
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don’t like HashMap usage - it’s will generate random list in some use cases. If something depends on the effects orders then can fail. It’s better to use LinkedHashMap instead.

for (ContinuousEffect effect : layer) {
Set<Ability> abilities = layeredEffects.getAbility(effect.getId());
for (Ability ability : abilities) {
ptAbilitiesCache.put(effect.getId(), layeredEffects.getAbility(effect.getId()));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Single effects list calculation instead recalculate can lead to buggy use cases - if some of the effect added to the game after apply call then it will be missed from the list. I’m not sure - is that actual or not, need research.

@benprew
Copy link
Copy Markdown
Author

benprew commented May 11, 2026

Ok I reran the tests and generated profile SVGs

Test Setup

  • 2 decks from rogue_dck/: ape_lord.dck and azaar_lichlord.dck (old school/Shanalar decks)
  • 5 AI-vs-AI games (skill=3, maxTurns=30), seed=42, fixed pairings
  • BEFORE = parent of 98b960d (a61c5f7); only ContinuousEffects.java + ContinuousEffectsList.java reverted, then Mage module rebuilt into .m2-local
  • JFR settings=profile,dumponexit=true for each run

You can see in the AFTER section more time is spent in JUNIT testing (SVGs are clickable to dive into details)

Biggest improvement is from the abilities cache, but they all contribute.

2.6x reduction in wallclock time:

  |        | Total   | Avg/game | Per-game (ms)                  |
  |--------+---------+----------+--------------------------------|
  | BEFORE | 124.5 s | 24.9 s   | 65664, 38768, 8168, 10448, 1428|
  | AFTER  | 47.3 s  | 9.5 s    | 4304, 16189, 6994, 10496, 9285 |

BEFORE

profile_before ## AFTER profile_after

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