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

#113 entity version #115

Merged
merged 12 commits into from
Sep 24, 2023
Merged

Conversation

dragbone
Copy link
Contributor

@dragbone dragbone commented Sep 16, 2023

@Quillraven here is my attempt at implementing issue #113. I'm not entirely happy with the solution... I'm open to any suggestion.

Notable changes

  • EntityProvider got a new function fun getCurrentVersion(id: Int): Entity to retrieve the current version of an entity
  • I introduced Entity.NONE as a shorthand to create an invalid entity (replaces Entity(-1))

Open tasks

  • Javadoc for EntityProvider.getCurrentVersion(id: Int)
  • DefaultEntityProvider.minusAssign(entity: Entity) has to update the entity version lookup for snapshots to work correctly, I think that's an ugly workaround. Snapshot loading seems kinda hacky in general, maybe a refactoring is in order now.

Benchmarks

Benchmarking this is a bit different because the benchmark now needs to track the references to the entities. I have run the adjusted benchmark on master as well to get a result that can be compared, you can expect 20%-25% slowdown in the addRemove benchmark, the other two benchmarks seem unaffected:

master with benchmark adjustment

Benchmark Mode Cnt Score Error Units
FleksBenchmark.addRemove thrpt 5 2636.779 ± 37.850 ops/s
FleksBenchmark.complex thrpt 5 1.875 ± 0.038 ops/s
FleksBenchmark.simple thrpt 5 90.531 ± 8.086 ops/s

branch with entity version tracking

Benchmark Mode Cnt Score Error Units
FleksBenchmark.addRemove thrpt 5 2093.105 ± 165.674 ops/s
FleksBenchmark.complex thrpt 5 1.965 ± 0.053 ops/s
FleksBenchmark.simple thrpt 5 88.293 ± 15.189 ops/s

@Quillraven
Copy link
Owner

Thanks for the contribution but I will need some time to check it. I hope that I can give an answer/review by Monday.

If only the first benchmark is affected then I think it is not a big deal because thanks to @tommyettinger that got recently improved by a lot and imo adding a clean solution for entity versioning is more important.


Just an idea that I got from an article. I think an int has 32 bits in Kotlin for all platforms? Or maybe even 64?

Maybe we can again do some bitwise magic stuff to encode the entity version into the ID as well.

My idea is that e.g. we use the first 16 bits for the version and the last 16 bits for the id itself. Of course that reduces the total amount of possible entities but I think it is still a big number that won't be reached by most games using Fleks.

That way we safe an additional lookup for the version and we can keep the value class and therefore still working with an Int instead of a class object.

I am not 100% sure if that works out but it is just an idea that popped up today in my head.

What do you think? Could that work out and maybe simplify the current aporoach and keep performance and memory consumption on the same level?

@dragbone
Copy link
Contributor Author

The problem as I see it is not really with how we encode or store the versions. The storing aspect is mostly a non-issue because for usual use cases the memory consumption is dominated by data in components. Performance wise I don't think we gain much by changing the encoding (might even be a loss if we need to decode and encode too often).
The performance impact comes from the fact that e.g. a family only stores which entities are active (in a BitArray) and needs to lookup the current version to use whenever it is iterated. In addition we need to keep that version lookup array updated which also costs some performance.

@Quillraven
Copy link
Owner

@dragbone: Sorry once more! I will have a look tomorrow morning and give you feedback to the PR.

Coming back to the idea with splitting the 32 bit int into two parts for version and id I still think it might be simpler and does not require most of the logic that we came up with right now. But let me elaborate and please let me know, if my thought process is wrong somewhere.

For simplicity reason, let's say we only have a 4 bit number for entities where 0000 is 0 and 1111 = 15. In total 16 possible values.

If we now want to use half for ids and half for versions, we end up supporting only up to 8 entities.

Now imagine following scenario:

  • We create an entity (id =0, version=0). The Entity value is 0000.
  • In a component we store this value to have a reference to the entity.
  • We now remove the entity and by doing so, we increase its version. 0000 -> 0100 (or 0001; I am not sure yet if it is smarter to use the end bits or the front bits for the version)
  • We create an entity again. Our removed entity gets recycled. It still has id 0 but its version is 1.
  • If we'd check our entity in the component, we'd get the info that it is dead because the number 0000 is not part of our world. We only have 0100.
    -- We can e.g. have an additional EntityBag in our EntityProvider and if the value of the bag at index Entity.id is not equal to the entity value passed to the contains function then the entity got removed and recycled.
    -- In our example it is simply comparing 0000 with 0100 and the index of the bag is 0 (=entity id without version)
  • Also, when a family is iterating or an EntityHook gets called then we pass the entity from that additional bag (=id + version) instead of just the id

The only additional thing that is happening is that a separate bag of entities is used and that a bit shift operation is happening when removing entities, which should be not a big deal imo because direct lookup of an array index should not be that expensive.

My idea would be to have something like this, but not sure if that is possible in Kotlin:

// entity is still a value class like before
@JvmInline
@Serializable
value class Entity(val versionId: Int) {
  val id : Int // add id property to make the code compile. However, this should not be a real property because we are in a value class so it is just some syntax sugar in Kotlin
    get() = versionId and and 0xFFFF

  fun incVersion() : Entity  {
    // Mask out the last 16 bits and increment the first 16 bits
    val last16Bits = versionId and 0xFFFF
    val first16Bits = (versionId ushr 16) + 1
    // Combine the first 16 bits with the last 16 bits
    return Entity((first16Bits shl 16) or last16Bits)
  }
}

With that change the entire codebase should still compile and all tests should pass because id is still available. In addition, we need to increase the version when an entity gets removed and we need to make sure that anywhere we pass the Entity to a user (like in family iteration, EntityHooks, ...) we use the entity value of the new bag.


Maybe that is what you did in your implementation already. Again, I will have a look at it tomorrow.
Let me also share the article that I have found: https://skypjack.github.io/2019-05-06-ecs-baf-part-3/
Maybe it provides you with some awesome idea ;)

@Quillraven
Copy link
Owner

Me again! I also chatted with ChatGPT on my phone now a little bit and it gave me two other ideas:

  1. have UUIDs per entity
  2. use handles for entities

Both strategies don't require the versioning stuff at all.

For solution 1 we'd need some platform specific implementations afaik because there is no build in UUID for kotlin multiplatform.

Solution 2 sounds interesting to me and my idea would be the following:

  • When users need safe references to entities in components, they need to work with a handle instead of the Entity.
  • the handle is a simple container like
    data class EntityHandle(val entity:Entity, var valid: Boolean = true)
  • we could provide an extension function to Entity similar to the existing remove function. E.g. let's call it handle().
  • This function returns either the current existing handle of the entity or a new one, if none exists yet.
  • We could store this e.g. in a bag in the EntityService like we do it for the compMasks
  • When an entity gets removed then we need to set the valid flag of its EntityHandle to false, if it exists and remove it from the bag.

What I like about this approach is that this new functionality is used "lazily" and does not require the lookups of our previous solution (e.g. in family iterations).

The downside is that users need to be aware to work with EntityHandle in such scenarios instead of Entity.

What do you think about that approach?

@dragbone
Copy link
Contributor Author

@Quillraven I moved some stuff around and replaced some data structures to get rid of the lookup. At least on my machine the performance is now pretty much the same as it is on master.

Short summary of what I did:

  • The Entity class now only gets instantiated by EntityProvider (plus one usage in world for snapshot loading - should be removed as well but didn't have time)
  • EntityProvider and Family now store a Bag of the entities that concern them instead of BitArrays
  • Version lookup is no longer needed because the necessary places now have a reference to currently valid entities

I want to write some additional tests to make sure that everything still works, I think there are some gaps in the test coverage that's relevant for this change. I also want to add a benchmark to test if there is a memory/GC issue (I think there isn't, but I'd prefer proof).

@Quillraven Quillraven added the enhancement New feature or request label Sep 20, 2023
@Quillraven Quillraven added this to the 2.5 milestone Sep 20, 2023
@Quillraven
Copy link
Owner

Quillraven commented Sep 20, 2023

@Quillraven I moved some stuff around and replaced some data structures to get rid of the lookup. At least on my machine the performance is now pretty much the same as it is on master.

Short summary of what I did:

  • The Entity class now only gets instantiated by EntityProvider (plus one usage in world for snapshot loading - should be removed as well but didn't have time)
  • EntityProvider and Family now store a Bag of the entities that concern them instead of BitArrays
  • Version lookup is no longer needed because the necessary places now have a reference to currently valid entities

I want to write some additional tests to make sure that everything still works, I think there are some gaps in the test coverage that's relevant for this change. I also want to add a benchmark to test if there is a memory/GC issue (I think there isn't, but I'd prefer proof).

That looks neat! If everything is still working and performance is close (or maybe even better? ;)) to what we have before then let's use your PR instead of the EntityRef PR.

Really appreciate your time and effort for this - thank you!

I added some comments as a review and please also share your info, how you do a memory/GC test and how data class compares to value class. Would be cool if we get this solution up and running.


Btw, the usage of BitArray in the family was also for performance reasons. However, this was in the first versions of Fleks which was mainly focused on JVM, used Java 8 and some reflection stuff. Things have changed quite a bit since then, so hopefully simplifying it to a single bag usage is fine.

@dragbone
Copy link
Contributor Author

I did some memory tests which didn't show much difference between this version and the one on master. I tested the performance under very harsh memory limitation by setting the maximum heap to 8 megabytes (@Fork(value = WARMUPS, jvmArgs = ["-Xmx8m"])) and I did a run with a GC profiler attached by running the benchmark like this:

./gradlew jvmBenchmarksBenchmarkJar
java -jar build/benchmarks/jvmBenchmarks/jars/Fleks-jvmBenchmarks-jmh-2.5-SNAPSHOT-JMH.jar -prof gc FleksBenchmark

Both didn't indicate much difference between the versions. I'll add some more tests and clean up any comments later this week/on the weekend.

@Quillraven
Copy link
Owner

I also run the benchmarks now on my notebook for master and your fork. I adjusted the versions of your fork before to match master because I updated them a few days ago:

[versions]

kotlin = "1.9.10"
kotlinxSerialization = "1.6.0"
kotlinDokka = "1.9.0"
kotlinxBenchmark = "0.4.9"

Result of master:

jvmBenchmarks summary:
Benchmark                  Mode  Cnt     Score     Error  Units
FleksBenchmark.addRemove  thrpt    5  3018,381 ± 127,632  ops/s
FleksBenchmark.complex    thrpt    5     1,664 ±   0,062  ops/s
FleksBenchmark.simple     thrpt    5    92,042 ±   7,373  ops/s

Result of your fork:

jvmBenchmarks summary:
Benchmark                  Mode  Cnt     Score     Error  Units
FleksBenchmark.addRemove  thrpt    5  2127,258 ± 521,508  ops/s
FleksBenchmark.complex    thrpt    5     1,654 ±   0,249  ops/s
FleksBenchmark.simple     thrpt    5    94,820 ±   2,222  ops/s

Simple and complex seem to have no impact. AdddRemove is slowed down quite a bit BUT it is still super fast and in that case I would vote to introduce the feature and ignore the performance impact in that particular use-case because it is a very nice and important feature.

I also modified your fork and used the value class implementation of my PR. This allows us to use MutableEntityBag in the EntityService and Family which simplifies everything down to an IntArray basically.
However, with my entity value class implementation the performance was worse. I guess because the index lookup (=id of the entity) is a getter and the bitwise operation needs to be executed every single time when the id gets accessed.

TL;DR: your implementation looks good and the addRemove performance impact should not be that critical imo. Thank you a lot for your contribution and time investment into this feature!

@dragbone
Copy link
Contributor Author

Remember that the addRemove benchmark is not comparable to master because I had to adjust it to handle entity versions correctly. But I fixed it just now :D https://github.com/Quillraven/Fleks/pull/115/files#diff-e8658b624510082e4daf11283b3a6ac32ee654d77b4eaa8b86fa78ef5034e642
Should now be comparable to master :)

Here are my results from benchmarking with GC profiler:

master

Benchmark Mode Cnt Score Error Units
FleksBenchmark.addRemove thrpt 25 2528.476 ± 36.232 ops/s
FleksBenchmark.addRemove:·gc.alloc.rate thrpt 25 1506.890 ± 21.587 MB/sec
FleksBenchmark.addRemove:·gc.alloc.rate.norm thrpt 25 729170.015 ± 1.211 B/op
FleksBenchmark.addRemove:·gc.churn.G1_Eden_Space thrpt 25 1508.409 ± 50.651 MB/sec
FleksBenchmark.addRemove:·gc.churn.G1_Eden_Space.norm thrpt 25 729766.657 ± 19636.393 B/op
FleksBenchmark.addRemove:·gc.churn.G1_Survivor_Space thrpt 25 0.045 ± 0.022 MB/sec
FleksBenchmark.addRemove:·gc.churn.G1_Survivor_Space.norm thrpt 25 21.518 ± 10.386 B/op
FleksBenchmark.addRemove:·gc.count thrpt 25 223.000 counts
FleksBenchmark.addRemove:·gc.time thrpt 25 134.000 ms
FleksBenchmark.complex thrpt 25 1.801 ± 0.107 ops/s
FleksBenchmark.complex:·gc.alloc.rate thrpt 25 713.654 ± 41.438 MB/sec
FleksBenchmark.complex:·gc.alloc.rate.norm thrpt 25 480314367.381 ± 16654.630 B/op
FleksBenchmark.complex:·gc.churn.G1_Eden_Space thrpt 25 705.871 ± 70.504 MB/sec
FleksBenchmark.complex:·gc.churn.G1_Eden_Space.norm thrpt 25 475086017.877 ± 38615140.377 B/op
FleksBenchmark.complex:·gc.churn.G1_Survivor_Space thrpt 25 0.030 ± 0.016 MB/sec
FleksBenchmark.complex:·gc.churn.G1_Survivor_Space.norm thrpt 25 20318.304 ± 10186.539 B/op
FleksBenchmark.complex:·gc.count thrpt 25 111.000 counts
FleksBenchmark.complex:·gc.time thrpt 25 60.000 ms
FleksBenchmark.simple thrpt 25 93.504 ± 2.781 ops/s
FleksBenchmark.simple:·gc.alloc.rate thrpt 25 0.296 ± 0.001 MB/sec
FleksBenchmark.simple:·gc.alloc.rate.norm thrpt 25 3874.402 ± 121.604 B/op
FleksBenchmark.simple:·gc.count thrpt 25 ≈ 0 counts

branch with version

Benchmark Mode Cnt Score Error Units
FleksBenchmark.addRemove thrpt 25 2548.235 ± 48.443 ops/s
FleksBenchmark.addRemove:·gc.alloc.rate thrpt 25 1352.019 ± 25.684 MB/sec
FleksBenchmark.addRemove:·gc.alloc.rate.norm thrpt 25 649171.789 ± 2.297 B/op
FleksBenchmark.addRemove:·gc.churn.G1_Eden_Space thrpt 25 1359.566 ± 44.474 MB/sec
FleksBenchmark.addRemove:·gc.churn.G1_Eden_Space.norm thrpt 25 653034.494 ± 22035.481 B/op
FleksBenchmark.addRemove:·gc.churn.G1_Survivor_Space thrpt 25 0.117 ± 0.026 MB/sec
FleksBenchmark.addRemove:·gc.churn.G1_Survivor_Space.norm thrpt 25 56.370 ± 12.668 B/op
FleksBenchmark.addRemove:·gc.count thrpt 25 201.000 counts
FleksBenchmark.addRemove:·gc.time thrpt 25 131.000 ms
FleksBenchmark.complex thrpt 25 1.855 ± 0.017 ops/s
FleksBenchmark.complex:·gc.alloc.rate thrpt 25 490.499 ± 3.819 MB/sec
FleksBenchmark.complex:·gc.alloc.rate.norm thrpt 25 320261164.053 ± 1041.862 B/op
FleksBenchmark.complex:·gc.churn.G1_Eden_Space thrpt 25 488.030 ± 32.974 MB/sec
FleksBenchmark.complex:·gc.churn.G1_Eden_Space.norm thrpt 25 318655255.893 ± 21458615.694 B/op
FleksBenchmark.complex:·gc.churn.G1_Survivor_Space thrpt 25 0.010 ± 0.006 MB/sec
FleksBenchmark.complex:·gc.churn.G1_Survivor_Space.norm thrpt 25 6476.000 ± 3956.521 B/op
FleksBenchmark.complex:·gc.count thrpt 25 77.000 counts
FleksBenchmark.complex:·gc.time thrpt 25 55.000 ms
FleksBenchmark.simple thrpt 25 89.084 ± 1.233 ops/s
FleksBenchmark.simple:·gc.alloc.rate thrpt 25 0.294 ± 0.001 MB/sec
FleksBenchmark.simple:·gc.alloc.rate.norm thrpt 25 4042.661 ± 56.375 B/op
FleksBenchmark.simple:·gc.count thrpt 25 ≈ 0 counts

If you compare the two it seems that this branch actually requires less garbage collection (gc.count is lower), probably because we no longer need to create Entity classes for iterations.

@Quillraven
Copy link
Owner

Very cool stuff! There was also another issue and PR today (#117) that further reduces GC. I will have a final look over the weekend but once the documentation/comments are ready we can merge it and I will try it out in my three example games that I always take as a reference.

If everything looks good I will release version 2.5 in the near future.

Once more - thank you a lot for your time and help <3

@dragbone dragbone changed the base branch from #113_entity-version to master September 23, 2023 08:35
@dragbone
Copy link
Contributor Author

@Quillraven I think I'm done! I added some tests, adjusted comments and answered the last two open questions.
Thank you for your time and being open to the idea of this feature <3

@Quillraven
Copy link
Owner

I thank you for taking your time and implement this feature. It is a great improvement because before, as mentioned in your issue, I always used workarounds for the use case and now we have an elegant solution imo.

Let's change the version to an Int and then I will verify it in my three example games and merge it.

Btw.: Yesterday I tried a few things on your fork. One of them was to remove the compasks of the entity service and put it directly into the entity. I thought this will improve the performance further because the compmask of the entity doesn't need to be looked up anymore. However at least for me it had a negative Impact. I found that interesting and don't understand it to be honest. Maybe you have an explanation for it?

Also, since an entity is now a class we could potentially add a world reference to it and remove the EntityContext concept.

But that is something for the future. And maybe that will also have a negative impact for whatever reason 😅

Anyway, once more, great job and thanks!

@Quillraven Quillraven merged commit a6ad28b into Quillraven:master Sep 24, 2023
4 checks passed
@Quillraven
Copy link
Owner

Verified it in my example games and everything looks good -> merged

@Quillraven Quillraven mentioned this pull request Oct 17, 2023
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants