Conversation
8a6c751 to
2e73a12
Compare
|
This looks promising. Besides some details we can discuss later, I have a few questions and remarks though:
|
|
Hi @SirYwell thanks, good questions
You're right, I forgot about TreeSet. I will delete the claim. TreeSet does satisfy the Set contract and is O(log n)
The reason was mostly because I wanted to have the
Hmm yes I see what you mean, worst case would be
Yes, this is orthogonal and I think it is best addressed in a separate PR. From what I see: Disks themselves are pruned indirectly Reproduction, as a test in
|
2e73a12 to
4bc5c03
Compare
|
Re-ran the benchmark on the 80 disk world with CPU (ms, Server thread)
Allocation (MiB, Server thread)
This result is not suprising as the |
|
I'm glad always using |
perf(storage): resolve tracked resources for the whole network in one pass
Replaces #1405. Also replaces the now closed #1403.
Sorry for the size of this one. The idea is small and most of the diff is repetitive, so let me walk you through it. The detail is in collapsed sections to keep this readable.
Both #1403 and #1405 went after the symptom: the stream allocations inside
findTrackedResourceByActorType. That made each lookup cheaper, but there were still just as many lookups. The allocations were the result of calling it millions of times, not the reason it was slow.The change
Opening a Grid asked every storage about every resource. That is resources x storages, so 37,000 resources across 80 disks means 2,960,000 lookups.
Now every storage hands over what it knows once, into a single map.
Three new
defaultmethods.TrackedStorageandTrackedStorageRepositoryboth getcollectTrackedResourcesByActorType, which hands every tracked resource a storage knows about to a consumer.TrackedStoragealso getsgetTrackedResourcesByActorType, which collects those into one map.InMemoryTrackedStorageRepositoryis the only one that actually walks its map, everything else just forwards to its delegate.Nothing breaks for addons. Both interfaces are
@API(status = STABLE). Nothing existing changed, I only added default methods, so addons keep compiling and loading as they are. And because the default body is the old per-resource loop, an addon that does not override it still gets the right answer, it just does not get faster.Results
37,000 resources, 80 disks:
The last row is the one that matters, the rest follows from it. It is also the only row not from spark, because a sampling profiler cannot count calls. That number comes from the JVM benchmark further down, which counts them directly. The other four rows are spark averages over three runs each.
Full profiles, scaling data and the microbenchmark
Scaling confirms the cause
Three setups, each captured three times on the spark execution profiler and three times on the allocation profiler. All 18 captures are linked below.
openis the whole Grid open,getResourcesis the part that resolves the resources,findisfindTrackedResourceByActorType.Spark samples every 4 ms, so the execution numbers only come in steps of 4.
Execution, ms:
Allocation, MiB:
Going from 16 to 80 disks on develop is 5x the storages, with the same number of resources.
findgets 4.35x slower and allocates 4.11x more. Cost grows with the number of storages, which is the whole problem.This PR at 80 disks is still better than develop at 16: resolution is 10.5x faster and allocates 37.4x less.
The biggest allocator on develop is
StreamSupport.streamat 980.5 MiB, which is the stream #1405 tried to remove. After this change the top allocators are netty buffers, so just sending the packet.Grid open is still 92 ms, and only 14.7 ms of that is resource resolution. The other 77 ms is menu construction and packet serialization, which this PR does not touch.
Microbenchmark
A plain JVM benchmark that does the same work outside the game, 5 warmup and 10 measured runs, counting lookups directly. It is not part of this PR, I removed it before submitting since it is not a test. The numbers are lower than in-game because there is no menu and no networking. Allocation lines up with spark (141x here, 150.8x in-game). Time looks better here (108x versus 45x) because spark's
getResourcesalso covers work this benchmark does not do.The
stale-*rows say "enumerated" because that is the method being called, but those are exactly the cases where the guard kicks in and does direct lookups instead.On trusting these numbers
Flame graphs are easy to read wrong. A composite calling a composite calling a disk shows up at every level, so the same work can look like several times the time it actually took. I only counted the outermost occurrence of each frame, ran every setup three times, and checked the result against the lookup count, which sampling cannot get wrong. Every capture is linked above, so please do check rather than take my tables at face value. I can share the test world if that helps.
Why default methods, and the one subtlety
Since the default body is the old per-resource loop, there is nothing to opt into, no marker interface to check, and no way for an implementation that has not been updated to give a wrong answer. It is per storage too: 79 updated disks and one addon storage means 79 fast and one slow, not the whole network falling back.
Tracked resources are never cleaned up. A disk that has seen a million different items still holds a million entries, even if it currently holds ten. Walking that map blindly means doing work based on everything the disk has ever seen instead of what is on it now. Hence the guard in
InMemoryTrackedStorageRepository:I found this the hard way. Without the guard, the stale-brutal case (200 items stored, 1,000,000 tracked) took 63.56 ms and 46.53 MB, worse than develop's 1.00 ms. With it, 0.10 ms and 0.02 MB.
The new method may return more than you asked for. This is the contract of
getTrackedResourcesByActorTypeitself, not a change to anything:findTrackedResourceByActorTypestill behaves exactly as it did, so no existing code sees a difference.When a storage holds fewer tracked entries than the number of resources being asked about, it hands over all of them instead of checking each one, and some of those may have since been removed from the network. The javadoc says so. Our callers loop over what is actually stored and look each one up in the result, so the extra entries are never read. An addon that wants an exact result can pass a
Setinstead of aListand the repository will filter, which is free becausecontainson aSetis already O(1). Doing it for everyone would mean building aHashSeton each call, which is why it is not the default. Happy to change that if you would rather have it.Ties resolve the same as before. The old code used
Stream.max(comparingLong(getTime)), which keeps the first one on a tie. The new code usesHashMap.mergewithexisting.getTime() >= other.getTime() ? existing : other, which also keeps the first one. Both end up with the first storage holding the newest timestamp, nested composites included. Two tests cover this.A pre-existing issue I did not touch
This is already on develop and has nothing to do with this PR, I just ran into it while measuring.
ExternalStorageTrackedStorageRepositorysaves its entire tracking map, so external storages keep collecting entries forever. Disks do not have this problem becausePlatformStorage.toContents()only saves what is actually stored, which clears the rest on save and load. The guard above stops that growing map from being a performance problem here, but the growth itself is a separate thing. Happy to open an issue for it.Full transparency
I was assisted in this PR by AI (Claude fable 5.1 and Gemini 3.8). This is in no way intended as a slop PR, I'm a full time software engineer and reviewed every line of code. Tested it myself and calculated the results myself, I believe this is sound.
Happy to answer any questions