Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow filter uniques to have conditionals and work with modifiers #12404

Merged
merged 14 commits into from
Nov 17, 2024

Conversation

SeventhM
Copy link
Collaborator

@SeventhM SeventhM commented Nov 3, 2024

The purpose of this PR is to allow for filter uniques to have conditionals of their own, to add flexibility to mod creators. I'm not entirely sure where the use cases would be in the current known mods (aside from a known side effect I'll mention later), however, it should minimum allow for "filter <hidden from user>" uniques to work properly in all cases, rather than the current situation (which is currently inconsistent and relies on the object in question to have been updated to use hasTagUnique instead of uniques.contains

Known side effects/notable changes

  1. The fresh water unique currently functions as a glorified filter unique. This PR also makes it so fresh water uniques work with filters in (I think) all cases. This is especially helpful for Rekmod which is missing a civ in Lekmod that has this functionality in for city centers
  2. Various ruleset based things makes used of caching for unique results. However, because of of the need to filter anyways, I have been forced to move the caching to inside of the multifilter check instead of outside

@SeventhM
Copy link
Collaborator Author

SeventhM commented Nov 3, 2024

The edits for stuff like Policy and Techs might look weird, however, they are to plan ahead incase they get their filter information cached as well

@@ -298,6 +298,9 @@ open class UniqueMap() {

fun hasUnique(uniqueType: UniqueType, state: StateForConditionals = StateForConditionals.EmptyState) =
getUniques(uniqueType).any { it.conditionalsApply(state) && !it.isTimedTriggerable }

fun hasUnique(uniqueTag: String, state: StateForConditionals = StateForConditionals.EmptyState) =
Copy link
Owner

Choose a reason for hiding this comment

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

change to hasTagUnique

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

For all of these, I'm not sure I understand the difference in naming convention between tags and types. It would imply to me that the typed version should also be renamed to "hasTypedUnique". And since what we're doing between each version of the function is fundamentally the same code (it's literally copy-pasted), there doesn't seem to be a good reason for the distinction

Copy link
Owner

Choose a reason for hiding this comment

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

Uniques are known text strings that indicate code, they are known.
Tag uniques are arbitrary text strings that are used for matching only.
If we're looking for a known UniqueType, that's a unique. If we're looking for arbitrary text, as in filters, that's a tag.
The naming here still feels very wrong, I'm willing to give it a go but if it confuses me in the future I'll change it to tags.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

From a technical standpoint, I feel like the distinction between tags and uniques is minor. Once we actually have the unique, how we manipulate it is functionally the same, and the only difference is that for performance and maintainability reasons, we typically only deal in UniqueTypes

Actually, there is a spot where one can build their own functioning unique via tags. As a ruins reward, do "[{This Unit} {non-[Does not upgrade from ruins-like effects]}] upgrades for free including special upgrades". No, we don't have a unique for this. And from a modder perspective, there's no reason to view the tag as if it's just a tag

@@ -307,6 +310,10 @@ open class UniqueMap() {
?.asSequence()
?: emptySequence()

fun getUniques(uniqueTag: String) = innerUniqueMap[uniqueTag]
Copy link
Owner

Choose a reason for hiding this comment

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

change to getTagUniques

@@ -317,10 +324,24 @@ open class UniqueMap() {
else -> it.getMultiplied(state)
}
}

fun getMatchingUniques(uniqueTag: String, state: StateForConditionals = StateForConditionals.EmptyState) =
Copy link
Owner

Choose a reason for hiding this comment

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

change to getMatchingTagUniques


fun hasMatchingUnique(uniqueType: UniqueType, state: StateForConditionals = StateForConditionals.EmptyState) =
getUniques(uniqueType).any { it.conditionalsApply(state) }

fun hasMatchingUnique(uniqueTag: String, state: StateForConditionals = StateForConditionals.EmptyState) =
Copy link
Owner

Choose a reason for hiding this comment

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

change to gasMatchingTagUnique

@yairm210
Copy link
Owner

yairm210 commented Nov 3, 2024

I'm unclear on the use case you intend for this. Can you give me an example?

Also, this change seems to not cache tag uniques which do not require state, which seems unnecessary - all uniques with no modifiers can be checked for in singleFilter and cached.

This change could have significant performance implications, essentially negating the filter caching, since it means that to check for filters we need to check tag uniques with extra steps
I'm not saying I'm against, I think we've done enough performance tuning to allow for extra features even at cost of performance, but I do want to understand why we're doing this :)

Comment on lines +666 to +668
val otherUniqueSources = promotions.getPromotions().flatMap { it.uniqueObjects } +
statuses.flatMap { it.uniques }
val uniqueSources = unitUniqueSources + otherUniqueSources
Copy link
Owner

Choose a reason for hiding this comment

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

What's with assuming that promotions won't provide conditional tags? I can totally see modders using this

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not sure I understand the question. Promotions are being checked here, no?

@SeventhM
Copy link
Collaborator Author

SeventhM commented Nov 3, 2024

I'm unclear on the use case you intend for this. Can you give me an example?

Tbh, I haven't explored all of the possible use cases myself yet. I've mostly been focused on other stuff, since I've distracted by a number of other projects. So I probably would need to look at what other mods are currently doing, before I can give an answer here. That said

  1. I know the main example I have been focused on in the past is in Lekmod, the Palmyra civ's UA is that cities provides fresh water to all surrounding tiles. As stated in the op, the fresh water unique currently functions as a filter unique, So this would be possible to do in Lekmod by attaching the following unique to city centers: Fresh water <for [Palmyra] Civilizations>. I'm pretty sure doing so is basically impossible in all other circumstances. This argument is a bit weak, admittedly, since we can localize checking for this unique since one of the two variants is a UniqueType
  2. Also, as stated in the OP, currently, if a function has not been updated to hasTagUnique, it does not work through the <hidden from user> unique. Again, a weak argument, sure

It is possible that a mod would want something like "Filter <for [civFilter] Civilizations> a lot more than my example that I provided above to cut done on a number of uniques (removes the need for a bunch of stuff with set uniqueTo fields), or that a modder tries to do something tricky with promotions and/or statuses with this, but again, haven't done much digging beyond the exploratory phase. My test example that I was working with was with "[+2 Culture] from all [Test Building] buildings" alongside a "Test Building <conditional>", but I haven't looked if that's fully achievable through other means.

Also, this change seems to not cache tag uniques which do not require state, which seems unnecessary - all uniques with no modifiers can be checked for in singleFilter and cached.

I have actually a separate question here: Is it known how expensive multifilter is? Because imo, it would seem to me that this would be where the primary concern should be for uniques with no conditonals.

While I'm aware that there would be a cost regardless, it seems to me that at that if we're worried about tags without conditionals the only real thing would be to add a cache in the unique itself to skip through conditionalsApply, not caching unnecessarily at the ruleset object itself. And if we're doing that, hasUnique fundementally is the same as hasTagUnique, which is fundamentally just checking the same thing as if we cached it (it's just a contains check in a hashmap)

I'm not trying to downplay the performance impact (I'm sure you've profiled it and tbh, I kinda expected this to be the biggest issue given the caching that was done), but it seems unlikely to me that this is where the performance hit would be (besides, I guess, the fact that we're doing a lookup twice) and not, say, building a StateForConditionals object repeatedly. And I would expect this to be a rather nonissue to address if it was since the fix could be in the unique system itself and work for more than just tags

@yairm210
Copy link
Owner

yairm210 commented Nov 3, 2024

The performance hit is especially significant for terrains - any tile you want to check tileFilter on, you need to check all of its terrains, and if that can be conditional you're looking at a lot of checks. And when automating e.g. workers you need to check a lot of tiles, and the multiplication hurts.

The performance hit is not in constructing the states, as much as it is in the hashmap access - which for all it's "O(n)"-ness is still non-trivial (that's why I separated uniques to a EnumMap which is much more efficient)

With caching (on 30s profiling session):
image

Without:
image

So that's like a 700ms / 30,000, which is like a 2.3% difference, which all in all is not huge and definitely something we can forgo for a good enough feature gain

@SeventhM
Copy link
Collaborator Author

SeventhM commented Nov 3, 2024

The performance hit is not in constructing the states, as much as it is in the hashmap access - which for all it's "O(n)"-ness is still non-trivial (that's why I separated uniques to a EnumMap which is much more efficient)

Sure, but in this case no matter what you do you're going through a HashMap with strings as keys. That's why I would expect that while there would be an impact, it shouldn't be in whether or not we cache information on uniques with no conditionals, but rather in either checking in Constants.all, going through Multifilter, or simply that we're going through a hashmap twice instead of once. Like, I don't know how to set up profiling here, so I would like to request the difference between the profiling we currently do, this PR, and, say, what I had prior to ea6a66b. So

        cachedMatchesFilterResult.getOrPut(filter) {
            MultiFilter.multiFilter(filter, { matchesSingleFilter(filter) })
        } ||
            state != null && hasUnique(filter, state) ||
            state == null && hasTagUnique(filter)

@SeventhM
Copy link
Collaborator Author

SeventhM commented Nov 9, 2024

Status update:

Tbh, I haven't explored all of the possible use cases myself yet

So I still haven't actually looked through other mods or so for use cases yet. And since I didn't ping modders when I asked on discord, my question got buried. Tbh, I'm not sure how productive asking the question "what use case would this be for", given that the current mod devs (probably especially including myself as I focus on other projects more) won't be thinking about stuff in terms of new features unless they themselves were trying to implement said feature. And tbh the main purpose of this PR is mostly to open up potential options for mod devs

That said, the example I used to test it (UniqueType.StatsFromBuildings) actually is not possible currently without using a fake (unsellable, unpurchasable, unbuildable, free, conditionally self removing) building, due to it using the StateForConditionals of the city the building is in, not the object that has the unique. So that's actually a use case that this unique can help clean up the unique text for
Edit; misunderstood how that works. I'm just wrong here

since it means that to check for filters we need to check tag uniques with extra steps

you need to check all of its terrains, and if that can be conditional you're looking at a lot of checks

So, I still don't have a way of profiling this without getting a license for Idea Ultimate. But I can write a micro benchmark using https://github.com/Kotlin/kotlinx-benchmark, though you can take my results with a grain of salt if you want tbh. Tl;dr: I think your initial assumption is wrong, the problem 100% is Multifilter, and you just got lucky by dumping multifilter inside of the cache too. Checking the terrains for conditionals is extremely cheap, especially if it has none, even without caching. And I'm pretty sure we can optimize this to not be that big a deal

Benchmark methodology

It's extremely likely that some aspect of my methodology is flawed here. Actually it probably is flawed.

First things first, I changed Terrain.matchesFilter so I can test it

  fun matchesFilter(filter: String, state: StateForConditionals? = null): Boolean =
      when (Constants.vers) {
          1 -> firstTest(filter, state)
          2 -> secondTest(filter, state)
          3 -> thirdTest(filter, state)
          else -> secondTest(filter, state)
      }
  
  fun firstTest(filter: String, state: StateForConditionals? = null): Boolean =
      MultiFilter.multiFilter(filter, {
          cachedMatchesFilterResult.getOrPut(it) { matchesSingleFilter(it) } ||
              state != null && hasUnique(it, state) ||
              state == null && hasTagUnique(it)
      })

  fun secondTest(filter: String, state: StateForConditionals? = null): Boolean =
      cachedMatchesFilterResult.getOrPut(filter) { MultiFilter.multiFilter(filter, { matchesSingleFilter(it) || hasTagUnique(it) }) }

  fun thirdTest(filter: String, state: StateForConditionals? = null): Boolean =
      cachedMatchesFilterResult.getOrPut(filter) { MultiFilter.multiFilter(filter, { matchesSingleFilter(it) }) } ||
          state != null && hasUnique(filter, state) ||
          state == null && hasTagUnique(filter)

I also added a var to Constants so it can check which version it's checking. In the benchmark itself, I'm using the code in the unit tests to help setup the game details. Then the actual benchmark uses the following to test. Ignore the function name, I simplified the test later on while I was doing it

    @Test
    @Benchmark
    fun cityTestForExample3() {
        Constants.vers = 3
        actualTest()
    }
    
    fun actualTest() {
        game.makeHexagonalMap(3)
        game.setTileTerrain(Vector2(0f, 1f), Constants.grassland)
        game.setTileTerrainAndFeatures(Vector2(0f, 2f), Constants.grassland, Constants.forest)
        for (i in 0..<300) {
            val t1 = game.tileMap.tileList.filter { it.matchesFilter(Constants.grassland) }
            val t2 = game.tileMap.tileList.filter { it.matchesFilter(Constants.forest) }
            val t3 = game.tileMap.tileList.filter { it.matchesFilter("matchesTest") }
        }
    }

Each of the benchmarks are the same except for the Constants.ver line. That variable is set to match the number in the function name. These benchmarks as set to run for 20 seconds each iteration, for 1000 iterations. Here are the results:

test summary:
Benchmark                               Mode   Cnt    Score   Error  Units
JustLookAtTerrain.cityTestForExample1  thrpt  1000  384.023 ± 0.433  ops/s
JustLookAtTerrain.cityTestForExample2  thrpt  1000  473.070 ± 0.325  ops/s
JustLookAtTerrain.cityTestForExample3  thrpt  1000  360.532 ± 8.646  ops/s

It should be obvious something went wrong for test 3. Apparently the laptop running this got unplugged during it and stopped running in performance mode as a result. However, it's results prior to being unpluged ranged around 457 ops/s

Iteration 399: 456.975 ops/s
Iteration 400: 456.603 ops/s
Iteration 401: 456.601 ops/s
Iteration 402: 456.367 ops/s
Iteration 403: 456.917 ops/s
Iteration 404: 456.851 ops/s
Iteration 405: 457.733 ops/s
Iteration 406: 457.639 ops/s
Iteration 407: 457.115 ops/s
Iteration 408: 455.557 ops/s
Iteration 409: 457.459 ops/s
Iteration 410: 456.809 ops/s
Iteration 411: 457.464 ops/s
Iteration 412: 457.747 ops/s
Iteration 413: 456.521 ops/s
Iteration 414: 457.194 ops/s
Iteration 415: 457.335 ops/s
Iteration 416: 457.161 ops/s
Iteration 417: 456.911 ops/s
Iteration 418: 457.583 ops/s
Iteration 419: 456.685 ops/s
Iteration 420: 457.116 ops/s
Iteration 421: 454.606 ops/s
Iteration 422: 456.653 ops/s
Iteration 423: 457.588 ops/s
Iteration 424: 457.375 ops/s
Iteration 425: 456.472 ops/s
Iteration 426: 456.656 ops/s
Iteration 427: 456.749 ops/s
Iteration 428: 453.237 ops/s
Iteration 429: 457.418 ops/s
Iteration 430: 457.603 ops/s
Iteration 431: 457.363 ops/s
Iteration 432: 457.213 ops/s
Iteration 433: 457.379 ops/s
Conclusions
  1. The main difference between version 1 (this PR currently) and version 3 (the PR prior to ea6a66b) is when we call MultiFilter.multiFilter. Imo, this is probably the more important piece that needs scrutiny if we were to optimize further. Imo, this really calls into question why it's not an inline function. Addressing that is out of scope for this PR, but absolutely something we need to do
  2. Your concerns about conditionals checks is well intentioned, but are a much lower impact than you think. Afterall, that is the primary difference between version 2 (our current caching) and version 3, and the difference in this benchmark is relatively minor (a +.03x increase in time between ops, compared to the +0.23x increase for version 1). And tbh, I think there shouldn't be an expectation that the check is that expensive for conditionals since conditionalsApply already checks if its empty (and so does Kotlin's Iterable.none and Iterable.any functions if we actually decided to use them here. Check the source code)
  3. Upon further inspection, Why are we calling multiFilter from terrains inside of another multiFilter instance (from tiles)? Pretty sure just that would help optimize things back to where our caching is currently and could probably have other savings elsewhere (writing this before I PR this optimization). This optimization, is fairly reasonable to add to this PR

@SeventhM SeventhM closed this Nov 9, 2024
@SeventhM SeventhM reopened this Nov 9, 2024
Copy link

This pull request has conflicts, please resolve those before we can evaluate the pull request.

# Conflicts:
#	core/src/com/unciv/logic/automation/ai/TacticalAnalysisMap.kt
Copy link

Conflicts have been resolved.

@SeventhM
Copy link
Collaborator Author

@yairm210 2 updates from last real push, about 10 days from last real discussion. Can I get an update on where this PR is in terms of blockers? Avoiding multifilter where available should address your concerns about performance, questions about use cases likely would be a difficult question to answer without this in practice, and the only open question in my eyes is the naming convention of the new functions, to which I already stated why I didn't agree your suggestion

@yairm210
Copy link
Owner

Mergeable 👍🏿
The microoptimizations of 'don't multifilter' really don't change the big-picture caching issue, since the main problem is still "how many hashmap lookups are you doing" and if it's not multifilter that just saves us a 'does string contain char' check which is considerably cheaper. But if we can have it, then why not
Considering the amount of other optimizations going on, paying 3% for conditional tags is definitely worth it 👍🏿

@yairm210
Copy link
Owner

Another 1% runtime addition that we can remove by caching is the building of the state every tile we check
Can be resolved with yet more caching - adding a new transient tileState which we change whenever we change the owningCity
But that can be deferred to another PR
image

@yairm210
Copy link
Owner

image Also this other state can be the same one as used above

@SeventhM
Copy link
Collaborator Author

The microoptimizations of 'don't multifilter' really don't change the big-picture caching issue, since the main problem is still "how many hashmap lookups are you doing" and if it's not multifilter that just saves us a 'does string contain char' check which is considerably cheaper

Again, I'm not sure that's the case given my micro benchmark. Even looking at your screenshots, the cost of the function went down from 1010 ms to 460 ms which should be far more notable than the cost from the second hashmap lookup. Again, not saying there isn't an extra cost there (would be strange if there wasn't, but that the cost is minimal compared to that of multiFilter. Also I don't think the string lookups are as cheap as you think they are (at this scale, obviously), but could be wrong. There's a reason Kotlin often works with StringBuilder under the hood for stuff like loops

@yairm210
Copy link
Owner

The latency cost went up with this commit, not down
When flamegraphing, multifilter cost is usually 95% hashmap lookup and 5% everything else, so in the grand scheme, yes, hashmaps are the main cause of multifilter latency

@yairm210
Copy link
Owner

That's an unfair comparison though
I'll do a more detailed check and explain the methodology because it too is flawed and may lead to biased results, stay tuned :)

@yairm210
Copy link
Owner

yairm210 commented Nov 15, 2024

Methodology: Run IntelliJ Profiler on ConsoleLauncher for 30s
Why 30s? Profiler counts increments of 10ms so this means that the interesting amounts (percent, or 300ms) is statistically significant and not 'profiler noise'

Regarding variance: There are 2 kinds of 'function triggers function' calls: 1:1 and 1:N. The 1:N calls (for example "for unit in units" "for tile in tiles") can vary significantly depending on number of players, map size, and turn. The 1:1 calls are relatively stable - meaning if a child is X% runtime of its parent that's largely consistent, and this holds for stacks of 1:1 calls as well.

All that said

Current master

  • MultiFilter.multiFilter call in Tile.matchesFilter: 1250 ms
  • Of which matchesSingleTerrainFilter: 930 ms
  • Of which terrain matching: 300ms, owner matching: 120 ms (yes, we're spending hundereds of ms in the 'when' for checking string equality, this is an area where if you have a better method I'd be happy to hear)
image

The time spent on multifilter in general for checking if this is indeed 'multi' is around 10%, so we did shave off some runtime here
image

Your branch

  • MultiFilter.multiFilter call in Tile.matchesFilter: 2150 ms (+900)
  • Of which matchesSingleTerrainFilter: 1790 ms (+860)
  • Of which terrain matching: 540ms (+240), owner matching: 240ms (+120), state creation: 360ms (another 60ms diff went on the resource check and another 80ms is unaccounted)

All in all matching itself is now ~2x as expensive.

Multifilter in general:
image

That's with your "avoid multifilter function when possible* changes, and it's still 1400 ms more

Add to that the matchesTerrainSingleFilter increase of 900ms, which doesn't use multifilter at all, we're getting to 2300 extra ms out of 30,000 ms runtime, or 7.6% of runtime spent on these changes
For comparison, an average perf optimization I make for CPU is like ~100ms
So this is a whopping performance cost
Some of which can be removed (360ms for state creation) but around 2000ms or 6.6% which is going to stick around

@yairm210
Copy link
Owner

You were right about the conditional check being really fast though, the major timesink is the hashmap retrieval (as usual)
image

@yairm210
Copy link
Owner

To be fair, part of the reason that this is such a heavy change is because the rest of Unciv is so hyper optimized now. If you had introduced this change 2 years ago it would have been barely noticable

@SeventhM
Copy link
Collaborator Author

Huh... Well, guess I was wrong then on where the costs are. Considering how there's basically nothing else here to discuss regarding performance (multiFilter isn't as bad as I thought it was, especially given my micro benchmark, and conditionalsApplies had a minor effect as I suspected), it really is the hashmap lookup itself that's this expensive. Kinda surprised just that is so much more expensive than everything here, to the point where I'd half wonder if there's a way to have a more optimized hash code here or something. I don't necessarily think any amount of caching for known uniques would help either since you still need to actually get the unique somehow to check conditionalsApplies

@SeventhM
Copy link
Collaborator Author

SeventhM commented Nov 15, 2024

Side note on state creation, btw: we can probably in theory cut a bit more more on it as we can precalculate states for units and cities as well. I was almost toying with the idea when I was looking into fixing the state for cities with uninitialized tiles, but I figured it was out of scope

… once when loading map and again every time there is an ownership change
@yairm210 yairm210 merged commit 405655d into yairm210:master Nov 17, 2024
4 checks passed
@SeventhM SeventhM deleted the conditional-filter branch November 17, 2024 23:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants