-
-
Notifications
You must be signed in to change notification settings - Fork 37
Full rewrite and redesign #69
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
Conversation
|
Finally. Documentation. |
eventbus-jmh/src/main/java/net/minecraftforge/eventbus/benchmarks/BenchmarkUtils.java
Show resolved
Hide resolved
eventbus-jmh/src/main/java/net/minecraftforge/eventbus/benchmarks/BenchmarkModLauncher.java
Show resolved
Hide resolved
| The core functionality of EventBus is to provide a simple and efficient way to handle events in a decoupled manner. | ||
|
|
||
| ### Thanks | ||
| [](https://www.yourkit.com/) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep
eventbus-jmh/src/main/java/net/minecraftforge/eventbus/benchmarks/BenchmarkNoLoader.java
Outdated
Show resolved
Hide resolved
eventbus-jmh/src/main/java/net/minecraftforge/eventbus/benchmarks/MockTransformerService.java
Show resolved
Hide resolved
...-test-jar/src/main/java/net/minecraftforge/eventbus/testjar/benchmarks/BenchmarkManager.java
Show resolved
Hide resolved
...-jar/src/main/java/net/minecraftforge/eventbus/testjar/benchmarks/ModLauncherBenchmarks.java
Show resolved
Hide resolved
...est-jar/src/main/java/net/minecraftforge/eventbus/testjar/benchmarks/NoLoaderBenchmarks.java
Show resolved
Hide resolved
...-jar/src/main/java/net/minecraftforge/eventbus/testjar/benchmarks/RegistrationBenchmark.java
Show resolved
Hide resolved
LexManos
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks sane code wise, tons of cleanup needed.
Haven't run the code yet or verified the benchmarks since our last conversation.
But the core of it looks good.
eventbus-test/src/test/java/net/minecraftforge/eventbus/test/MockTransformerService.java
Show resolved
Hide resolved
eventbus-test/src/test/java/net/minecraftforge/eventbus/test/TestModLauncher.java
Show resolved
Hide resolved
eventbus-test/src/test/java/net/minecraftforge/eventbus/test/TestModLauncherBase.java
Show resolved
Hide resolved
eventbus-test/src/test/java/net/minecraftforge/eventbus/test/TestNoLoader.java
Outdated
Show resolved
Hide resolved
src/main/java/net/minecraftforge/eventbus/internal/EventListenerFactory.java
Show resolved
Hide resolved
| * <p><u>Notes:</u></p> | ||
| * <ul> | ||
| * <li>Setting to 0 will disable this optimisation.</li> | ||
| * <li>Setting too high can counter-intuitively slow down the event bus.</li> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we've discussed this before. The reason for this being counter intuitive is that this optimization takes advantage of the JRE's inlining ability which has a set depth/size budget. It may be worth making note of this because my immediate though was "Why not just unroll into multiple layers if the size matters", but then I remember our conversation and why it wouldn't help.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea, I'll leave a note. In case I forget, the reason why layers (wrapping in batches of 4 manually unrolled methods and chaining those) wouldn't work is because of method inlining.
| if ((eventCharacteristics & Constants.CHARACTERISTIC_MONITOR_AWARE) != 0) { | ||
| if (!MutableEvent.class.isAssignableFrom(eventType)) | ||
| throw new UnsupportedOperationException("This version of EventBus only supports " + | ||
| "EventCharacteristics.MonitorAware on MutableEvent"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should there be a feature that auto-translates monitor to lowest priority if a event looses its MutableEvent as to not cause breaking changes with compiled code? Probably not important but a passing thought.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, all monitoring listeners have Priority.MONITOR. This is handled in net.minecraftforge.eventbus.internal.EventListenerImpl.MonitoringListener.
All events support monitoring. The MonitorAware characteristic only works with MutableEvent at the moment and is for allowing the event to check if it's in the monitoring phase.
There was the related point brought up on Discord last night about a breaking change for cancellation-aware monitoring listeners (ObjBooleanBiConsumer) going from a CancellableEventBus to a non-cancellable one. This follows the same logic as cancelling listeners - it's assumed that if you're asking about the cancellation status, you care about it and want it to break if it is no longer cancellable. Normal Consumer monitoring listeners would continue working. We need to decide if this is the behaviour we want or if we want monitoring listeners that ask about cancellation status to always get false instead of failing when an event is no longer cancellable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So the issue becomes that we will have a crash on the listener's end if an event looses its Cancelability.
Namely the Event.BUS.register() call will crash with either a NoSuchFieldError (If the JVM checks the type of the field) or a ClassCastException/NoSuchMethodError. We should verify what this case is, and see what we can do about making it more obvious to modders what happened.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it's designed to fail as early as possible now rather than throwing during posting. Best case it won't compile and will tell mod devs the exact line where it's wrong, worst case it'll crash at listener registration time. I think it'll be quite obvious... they'd open up their IDE and see that addListener method call highlighted as not found, so they'd jump to the field definition and see it says EventBus<Foo> instead of CancellableEventBus<Foo>, or they'd look at the available addListener methods and pick a Consumer taking method instead.
Better than compiling and registering fine and only failing on event post imo (v6's behaviour).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am fine with the compiler errors.
The runtime issue is the concern, we're targeting mod users at that point. Getting them to the correct place is important.
So at the very least we need a unit test to verify what actually happens in this case. So that we know what the player will see and if its good enough to blame the correct person (mod dev, not us)
src/main/java/net/minecraftforge/eventbus/internal/InvokerFactory.java
Outdated
Show resolved
Hide resolved
src/main/java/net/minecraftforge/eventbus/internal/InvokerFactoryUtils.java
Outdated
Show resolved
Hide resolved
src/main/java/net/minecraftforge/eventbus/internal/EventListenerImpl.java
Show resolved
Hide resolved
|
After spending a not-insignificant amount of time notating the JavaDocs for the API, I feel the need to further express just how impressed I am by all of this. Not only is the API incredibly concise, but even the internal implementations are concise enough -- even though it's kind of hard to read (again, unavoidable considering we're squeezing the JVM for money at this point), it's still clear what each piece of the implementation is meant to do. And this is even more-so for the API. I think in terms of the API, the only comments I have are to potentially re-assess |
Jonathing
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are a couple of Gradle-related issues with your tests that were reported in the problems file. We need to make sure to account for them.
eventbus-test:test
- The automatic loading of test framework implementation dependencies has been deprecated.
- This is scheduled to be removed in Gradle 9.0.
- Solutions
- Declare the desired test framework directly on the test suite or explicitly declare the test framework implementation dependencies on the test's runtime classpath.
- No test executed. This behavior has been deprecated.
- This will fail with an error in Gradle 9.0.
- Solutions
- There are test sources present but no test was executed. Please check your test configuration.
Thank you, I put a lot of thought into the API design and minimising complexity, I'm glad it's paid off. :)
I've explained it here, I think there was a misunderstanding of its purpose that should be solved with better Javadocs in future: #69 (comment)
These were already broken before the PR, but I've gone ahead and fixed them anyway. |
Yes! And I hope our recent conversations in voice chat have helped to solidify this. A lot of effort has been put on all three of our parts into making a better understanding of our projects while also focusing scope. I'm very grateful for that. While it's probably biased based off of my personal experiences with other projects and teams, I think part of the growing pain with this review has been to see how far we can get within scope of EventBus's usage. It's good to, at the bare minimum, try to figure out what we can do within the new EventBus to see if it'd be a reasonable thing to expect of consumers in the future. And when it's not worth it, detailing the reason for that should be given as much care as detailing why a new feature should be added, something I've meticulously done within the PRs I've triaged for Forge itself.
Yes, I agree. I think the JavaDocs will also need to be expanded upon over time as we get more comfortable with the redesign of EB7. Aside from the fact that we (in general, not specifically) are historically bad at naming things, keeping things well documented with use cases, usages, and abilities is going to keep the maintenance of the API in a good state. |
eventbus-test/src/test/java/net/minecraftforge/eventbus/test/IndividualEventListenerTests.java
Outdated
Show resolved
Hide resolved
LexManos
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would like to see more explicit tests. for example, tests verifying that the strict registration throws errors in the cases we want. and that we thimk users will run into. however functionality wise its looking good.
eventbus-test/src/test/java/net/minecraftforge/eventbus/test/IndividualEventListenerTests.java
Outdated
Show resolved
Hide resolved
Main concepts and changes: - ListenerList is merged into EventBus. Groups of them are called a BusGroup now. - Posting, registration, etc is all done directly on the events you care about - Events can be records or mutable classes and can opt-into various characteristics - Events can support multi-inheritance with an opt-in, and selectively opt-out certain subclasses in the inheritance chain using `@MarkerEvent` - Only monitoring events can receiveCancelled now - Cancellation and monitoring state are decoupled from the event objects themselves, allowing for records and other immutable data structures (such as the upcoming Valhalla value classes) - Strong encapsulation with module system enforcement and sealed types, good separation between API spec and implementation to allow freedom for future internal enhancements
Highlights/main concepts and changes: - Major performance improvements, no transforms needed - ListenerList is merged into EventBus. Groups of them are called a BusGroup now - Posting, registration, etc is all done directly on the events you care about - Events can be records or mutable classes and can opt-into various characteristics - Events can support multi-inheritance with an opt-in - Only monitoring events can receiveCancelled now - Cancellation and monitoring state are decoupled from the event objects themselves, allowing for records and other immutable data structures (such as the upcoming Valhalla value classes) - Strong encapsulation with module system enforcement and sealed types, good separation between API spec and implementation to allow freedom for future internal enhancements See the linked PR for more details.
|
Looks good :D |
This PR is a full rewrite of Forge's EventBus, a conclusion to the NovaBus experiments worked on-and-off for the past
year (first started in late February 2024), incorporating some of the requested API designs discussed on the Discord.
Table of contents
What's NovaBus?
The codename for my experiments with EventBus. Over the past year, I've been playing around with many different ideas
and designs with others, learning a lot about the JVM and Java along the way. A small subset have already been
backported into Forge's EventBus, such as #58, #63 and #65. But there's a lot that can't be done without breaking
changes, which is what this PR is for.
To be clear, the codename is mainly to avoid confusion and verbosity when comparing v6 and v7. When I say NovaBus here,
I mean the new EventBus v7 which this PR represents. The name of the EventBus project remains unchanged.
Why a full rewrite?
A good portion of the experiments were focused on maintaining partial backwards-compatibility with the current EventBus
and not making too many breaking changes. By the time we were finished, there was huge implementation complexity and
only a handful of desired features and minor performance improvements to show for it...
It became apparent that more meaningful improvements (in every metric - performance, API, impl...) would only be
possible when freeing ourselves from the constraints of the current design. After further discussion on Discord, the
decision was made to drop various unwanted features and to go ahead with a full rewrite.
List of changes
Consumer,Predicate, orObjBooleanBiConsumer, the latter two for cancellable eventsoptimisations
CancellableEventBus extends EventBusto avoid breaking changes for non-cancellable listeners relying on events thatchange from cancellable to non-cancellable
ListenerListandEventBushave been merged. EachEventhas its ownEventBus. AnEventcan have multipleEventBusinstances, but eachEventBuscan only have oneEvent. A group ofEventBuses is called aBusGroup.Here's a rough mapping from old to new:
ListenerList+EventBus->EventBusEventBus->BusGroupBusBuilder->EventCharacteristicinterfacesand allow flexibility for future internal changes without the risk of breaking the API. Strong separation of API spec
and implementation details.
apipackages are exposed, the rest is internaleventfor creating eventslistenerfor creating listenersbusfor registering listeners, posting events and managing busesEventis now a sealed interface to support records and multiple inheritanceannotation restrictions/limitations)
bothopt-inand opt-outInheritableEventinterfaceOpt-out is done by annotating an event that extendsInheritableEventwith@MarkerEventMonitorAwareinterfaceEventCharacteristic.SelfDestructing) to automatically dispose ofthemselves after being posted to save memory
EventBusdetermines its functionality based on theEventit's associated with and its registered listenersConsumers? Can also skip checking if the event was cancelledbyteinstead of anenum, larger numbers run first, with 0 being the default. Decided on abyteinstead of
intbecause:z-index: 999999momentJVM's preloaded autoboxing cache than an Integer
receiveCanceledhas been removed for normal listeners, monitoring listeners have it implicitlyLambdaMetaFactoryinstead of manual class generation and transformsregistration of the wrong class
registering instance vs static methods
final(such as for implementations ofRecordEvent), the child list will be a shared empty listsealed, the child list will be exactly sized based on the number of permitted subclassesnew MyEvent().post())BusGroupsupports trimming the backing lists of all loadedEventBuses in the group to save memoryRemoved features
receiveCanceledfor normal listeners (use monitoring listeners instead)Class<?>field/record component to your event or use event inheritance)Booleanresult field/record component instead, or make your own enum type)WIP/Todo
This is stuff I'd like some help with and/or plan to do in the near future:
BusGroupCompatibility
This is a full rewrite that deserves a major version bump. The underlying design is different enough that most existing
code wouldn't work without changes even if the packages and classes had the same names. I decided that it's better to
do one big breaking change now than to annoy developers with a lot of spread-out, smaller breaking changes.
Due to the scale of change, it might be good to merge this as EventBus 7.0.0-beta.1 or similar, to allow a short
breaking change window just in case we find anything important that needs fixing while integrating into Forge.
We should make a separate branch for EventBus 6.x so that we can continue maintaining it.
Performance techniques
Event dispatch is specialised based on each event's capabilities, its listeners' capabilities, the number of listeners
and available JVM features. Invokers are generated lazily and cached, with careful consideration for JVM performance
pitfalls. The code for posting hotpaths has been iterated on a lot overtime, driven by benchmark results.
Posting
In the case of zero listeners, EventBus v7 is able to dynamically instruct the JVM that the event dispatch is no-op and
is optimised away entirely by the C2 JIT. The performance is the same as if the event was never instantiated or posted,
effectively resulting in zero overhead for unused events without requiring special handling from library users.
Events with a single listener are resolved to a direct method call, skipping any redundant cancellation checks and loops.
Depending on the size of the compiled method bytecode for lambdas, a small number of listeners can be preloaded into
trusted finals with a manually unrolled loop. This allows for additional optimisations by the JVM that typically
wouldn't be possible with a normal array of listeners, without eating up too much of the inlining budget.
Cancellation checks are only performed if the event is cancellable and at least one listener possibly cancels the event.
If all listeners are known to never cancel the event, we can treat it as if the event isn't cancellable until a
possibly cancelling listener is registered later.
If a listener always cancels, we skip remaining listeners at invoker build time, reducing memory accesses and allowing
for some of the size-specific optimisations mentioned above to still apply even when the number of listeners would
otherwise exceed it.
There are additional tricks involved, but I think you get the idea... ;)
Registration
A lot less indirection is involved in registration now and the new design of registering directly to events removes the
need for TypeTools. Adding individual listeners is basically wrapping the lambda in an
EventListenerinstance andadding it to a synchronized collection... that's pretty much it.
Bulk registration is no longer an entirely separate system - it's now a frontend for the same system used for individual
registration. It takes a class, does some reflection to list its methods, performs some sanity checks, converts them to
direct lambdas with
LambdaMetaFactoryand then registers those as if they were individual listeners. The classgeneration is all done for us by the JDK and is much more efficient. Still slower than individual registration because
it shifts a bunch of work that would otherwise be done at compile-time to runtime, but that might be something we can
improve with compile-time transformers in the future.
Inheritance support is handled differently to significantly reduce the amount of work involved in building the sorted
list for the invoker. The child's backing list is preloaded with the parent's listeners if the event is inheritable.
After the EventBus is built, the parent EventBus is notified of the new child and future listeners added to/removed from
parents propagate to their children. Sorting is done directly on the backing list, so resorting is relatively cheap.
Benchmarks
EventBus v7 offers significant performance improvements across the board in like-for-like benchmarks, no transforms
needed. I encourage reviewers to also run the benchmarks themselves to provide additional data points.
Posting
Dozen
Compared to EventBus v6 in ModLauncher mode, NovaBus is about 35% faster for dynamic listeners, 20% faster for lambda and
25% faster for static listeners. The improvements are of course even larger when comparing both in NoLoader mode.
Hundred
The numbers are less consistent when there's so many listeners, but NovaBus still manages to be within the same ballpark
of improvements as with a dozen listeners:
The relative performance difference between dynamic and static listeners has also reduced enough that I no longer
consider it worth thinking about from a performance perspective, especially combined with the gains over the previous
design.
Single
For single listeners, we can see NovaBus has allowed the JVM to optimise away the event dispatch entirely:
This is intentional. The new design ensures a fully inlinable execution chain for upto a handful of listeners, which
allows the JVM to see that although a listener is registered, the listener method is no-op. The performance is the
same as an empty JMH benchmark method - it's as if the event was never instantiated and posted.
To ensure a like-for-like comparison, I did not change the existing benchmarks. There are real-world cases where people
accidentally register empty event listeners, so it's good to show that the new architecture can accommodate this.
I did look into solutions which avoid inlining, such as using JMH blackholes in all the events and consuming the event
instance from each listener, however this isn't straightforward to do in a way that reflects real-world usage with all
listeners not being no-ops. For instance, the blackhole approach prevented inlining of event instantiation for events
with constant values. Another approach was to use a
@CompilerControlannotation to prevent inlining, but this onlyworked for the initial event instantiation and not for the listener method calls.
We can reconsider the benchmarks in a future PR if needed. For reference, I got around 5ns/op for the single dynamic
listener case with the Blackhole approach and around 1ns/op with the CompilerControl approach, but I'm not confident
in the accuracy of these inlining mitigation numbers at this time and didn't apply those approaches to the other
benchmarks (and thus are not directly comparable).
Scaling
The biggest wins come from when you have only a handful of listeners at most. The zero overhead for posting unused events
is especially nice.
Sidenote
This is to do with the handling of duplicate listeners. In the December preview, listeners were not deduplicated by
NovaBus, and I confirmed the number of listeners was as expected, therefore, I didn't use the ClassFactory as I thought
it was unnecessary. This was further confirmed by the SubscriberLambda posting benchmarks performing similarly without
the ClassFactory for EventBus v6 and the remark/comment I remember seeing somewhere about how lambda listeners are not
deduplicated.
After further investigation, I found that while the number of listeners was indeed correct and not deduplicated, the
JVM was smart enough to tell that they all referred to the same static method and performed additional optimisations.
Note that these optimisations were not on NovaBus' end and explicitly deduplicating still provided much better results.
So technically, both the December preview and the current version have accurate benchmark numbers, but the December
preview was less representative of real-world performance. The December preview benchmarks acted as if each mod was
doing
addListener(LibraryMod::onEvent)instead of actually unique methods specific to each mod(like
addListener(ModA::onEvent),addListener(ModB::onEvent), etc...). The current version uses unique methods foreach listener.
The numbers are fair in both versions because I did like-for-like comparisons with Forge EventBus without any explicit
deduplication. In other words, the December preview benchmarks also skipped the ClassFactory for Forge EventBus and
both used the SubscriberLambda suite, so the numbers were still comparable. I opted to use the ClassFactory again for
this PR's numbers for more realistic results.
Posting data
(Click to show/hide)
Registration
Registration performance hasn't been neglected either...
Due to the way registration is now handled, we can turn off deduplication without needing a memory-heavy huge Deque of
generated unique listeners. This makes the margin of error in these benchmarks much more useful than before:
Registration data
(click to show/hide)
Future work
I have further ideas which I've put on hold for now, such as:
EventCharacteristic.SelfPosting(?)and runtimeRecordEventLooking far ahead
To demonstrate the forward-thinking design of NovaBus, here's a look of what might be possible in the far future,
without breaking changes...
This example uses various WIP Java features:
The event would look something like this:
And listeners could optionally do something like this:
The listener return type is explicitly nullable, indicating it might cancel the event. Returning a null-restricted
PlayerXpEvent.XpChange!would allow NovaBus to treat this as a never-cancelling listener instead. Returningnullcancels the event.
The
event.with {}returns a new instance of the event with the changes applied, all other record components leftunchanged. Thanks to Valhalla, this wouldn't necessarily create new instances of the record on the heap on each mutation.
Performance would be as fast as primitives for reading data, with the syntactic sugar of mutable fields for writing data.
Instead of setter methods, validation can be done in the record's canonical constructor.
And that's just the API side of things. Implementation-wise, we could offer the JVM guarantees that the contents of the
built listeners array are never mutated and that they're never null, allowing for much more aggressive optimisations by
the JVM, such as flattening the array's memory layout from pointers of objects with headers into a contiguous block of
direct values.