-
-
Notifications
You must be signed in to change notification settings - Fork 21.1k
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
DRAFT: C#-Performance: Use a custom static lookup to handle InvokeGodotClassMethod and HasGodotClassMethod #89826
base: master
Are you sure you want to change the base?
Conversation
8ccbde2
to
b3b098a
Compare
I expect this is a bom issue, double-check the file encoding between utf8 and utf8-bom. |
b3b098a
to
b39c70e
Compare
Thanks, I was too lazy to setup the pre-commit hooks it seems (my apologies) - I fixed the BOM issue and will move on to the other broken pipeline steps. EDIT: Scratch that, the GH action is running for my forked repo on its own! |
0ae33cc
to
07a6bdb
Compare
I gave this one more round of polish, names are more in line with standard Godot wording and I refactored the If someone with some spare time and an actual Godot C# project stumbles upon this PR, I would love to hear if this PR breaks or improves stuff for you! I am adding this comment as I am force pushing at the moment, so the history is kind of lost |
…InvokeGodotClassMethod and HasGodotClassMethod
07a6bdb
to
f0cf4fc
Compare
It seems like I miscalculated how much the release template performance would differ from "Editor -> Run Project". Furthermore I had to redo my test project as the benchmark was most likely affected by dead code elimnation (empty Sorry for the back and forth, still need to get the hang of how to do this properly. |
Preface
This is in an early draft state and by no means complete. I am presenting my working proof of concept to discuss if and how to progress with the main idea of this PR. There are lots of open TODOs, questions and polishing to be done. Please focus on the idea instead of the rough sketch shown here (there is also some debug output which will obviously be removed la<zter on).
Be warned: this is a long one to give readers enough context! If you want a TL;DR scroll to the bottom for the performance numbers.
Introduction
Everytime Godot wants to call a C# script method from C++, the auto-generated
InvokeGodotClassMethod
is invoked.The current C# bridge implementation of
InvokeGodotClassMethod
approximately works like this for a C# script which overrides_Ready
and_Process
:While this seems pretty o.k. there is a small detail which makes
InvokeGodotClassMethod
more complicated than it seems: the engine is passing the string"_process"
as the method name while the user scriptInvokeGodotClassMethod
checks for"_Process"
. This case is handled by the C# glue of the builtin Godot classes (e.g.Node2D
orNode3D
) which all user scripts are inherting from (either directly or indirectly via other user scripts):And then there is
HasGodotClassMethod
here - what does it do? It checks if a method is actually implemented as e.g.Node
has to supply an empty stub implementation for_Process(double delta)
to make this code compile. But why not just call the stub implementation? Because the C++ -> C# function parameter marshalling done viaVariantUtils
could be costly, especially for more complex types like arrays - so why marshall if an empty stub is called? This also has to be implemented this way because what happens if a user actually implements both_Process(double)
and_process(double)
in their script? We can't just auto-proxy"_process"
to"_Process"
because of this.At this point we traversed the class hierarchy for a bit and jump back up to the original C# class to call
HasGodotClassMethod
.This really isn't cheap and there is another part to this: the engine calls our C# script with
"_process"
,"_enter_tree"
etc. no matter if we implement it or not and callingHasGodotClassMethod
with an "unimplemented" method name traverses the whole class hierarchy and depending on what class the current C# script is based on, this can be quite expensive - especially for_PhysicsProcess
for Nodes which don't override it in C#.Additionally, every* public C# script method gets added into the
if
-chain ofInvokeClassMethod
andHasGodotClassMethod
, which indirectly makes all Godot base class (Node
etc) method calls slower because of how the snake_case to PascalCase handling is implemented (see above) - this hurts, especially for_Process
and_PhysicsProcess
.This makes the current implementation:
An alternative implementation
I will try to keep this section high level:
Instead of a bunch of
if
s we will use aDictionary<(ParameterCount, MethodName), Delegate>
to resolve functions. Adding all the C# bridge details to this yields a wrapper class (calledScriptMethodRegistry
for now) and the following snippet for a user script calledMainScene
which implements_Ready
and_Process
in C#:This still misses the
"_process"
->"_Process"
mapping for the engine calls which is done via aliases, here is an excerpt fromNode.cs
:These aliases are then imported which gives us:
This concept fixes all* evil issues:
.Register
definitions_process(double)
and_Process(double)
, the correct method is invoked when the engine passes"_process"
as the method nameThe
.Compile()
step is needed because we need to apply the alias logic somewhere.And thats about it, invoking a function is easy and fast now, a negative response is just as fast which is a big boon (see introduction):
HasGodotClassMethod
I went ahead and changed the implementation for
HasGodotClassMethod
as well because it kind of uses the same pattern and theScriptMethodRegistry
could fulfill its requirements with minor adjustments. We will have to think about other places likeInvokeGodotClassStaticMethod
, should we apply this pattern there too?Downsides and caveats
Here is an unordered list of downsides / caveats I can think of:
static
context as it would negatively impact users (stutters, hanging because of load time initialization); this is especially annoying for Godot users because its hard to manually control static initialization (I thinkRuntimeHelpers.RunClassConstructor
etc. should be avoided in Godot user code). Speaking from personal experience, we would have to do significantly more work for this to become noticableKnown issues
Node
which provides_Process(int)
will erroneously be called as a substitute for_Process(double)
because the current aliasing implementation does no type checks. This will be difficult to fix / address. Themaster
version ofInvokeGodotClassMethod
would only "erroneously" call the stub_Process(double)
inNode
(becauseHasGodotClassMethod
does no type checks and thus returnstrue
). How would this work in GDScript, what is the expected or acceptable behaviour?master
if a user defines_Process(int)
before defining_Process(double)
in their C# script. Overloads are deduplicated inScriptMethodGenerator
by name and argument count and only one of them is emitted intoInvokeGodotClassMerthod
-_Process(int)
in this case. The correct method would still be called when the engine passes"_process"
toInvokeGodotMethod
thoughmaster
handles overloading for methods with the same parameter count by using the first occurence, this PR uses the last occurencePersonal opinion on downsides, caveats, issues and how to deal with them
This is just my personal opinion on how to deal with certain issues, feel free to skip it.
I think we need a clear definition of what the C# integration supports and what is outright illegal, leading to compilation issues (the same as missing the
partial
identifier in user scripts).I propose the following new constraints:
_[a-z]
(in words: underscore with a lowercase character) as this heavily conflicts with the snake_case to PascalCase resolution which even breaksmaster
at the moment (_process(int)
would be called by the engine"_process"
call)Users affected by 1. are already somewhat affected, as
master
only registers the first overload for signal bindings etc.Users affected by 2. are on the wrong path anyways, using
_
as a prefix to non-engine supplied functions is suspicious on its own, going for lowercase characters afterwards is even more weird.I think very few users will be affected by these constraints. Right now its hard to gauge what input (user scripts) the C# integration has to accomodate, which is also shown by the lack of extensive tests or documentation on what edge-cases are legal or illegal (the C# integration even silently discards all but one same parameter method overload).
Godots C# integration has good error reporting in place, we should use it more.
Method overloading was also requested in GDScript, but declined for now because of performance concerns.
Performance measurements
I created a test project which spawn 100.000 Nodes with an attached C# script. The attached C# script overrides
_Ready
and_Process
and increments a global variable which is printed on the screen to avoid dead code elimination, which pretty much nullified my previous benchmark results. Additionally the minimum and average process time is printed, which is used in the table below. If you do your own testing with this project, please do multiple runs as the run to run variance (especially in release builds) is quite high, 5 - 10 runs should suffice. As the project is a CPU intensive synthetic scenario you should aim to reduce the noise on your machine (e.g. IDEs indexing stuff, streaming 8k videos of Godot showcases).HighNodeCountNoDeadCode.zip
On my machine (Win11, NET.Core 7.0.2, CPU: 5800x3D, 120hz display - might matter because of vsync), I get the following results:
master
@ Godot 4.3 Dev 5master
@ Godot 4.3 Dev 5(Remember: I took the best run for all variants out of ~4-6 runs after letting the project run ~ 10-15 seconds as the run to run variance is high enough to make a difference here)
The gains for in editor running are significant (>5x).
The gains for release builds are smaller by comparison, around 14-20%.
Having the editor be this much closer to the release template is a goodie for the developer experience, especially for large projects.
I attached a .NET profiler to the release version of the test project and there is still some room to improve the lookup by using a custom hash code, that shaved off at most 1-2ms of process time locally. I will get to these micro-optimizations when we commit to this PR.
TODOs
FunctionRegistry
toScriptMethodRegistry
and its accompanying classes to*Method*
to be in line with other Godot wordingsgodot_string_name._data
visible withinternal
as everything else breaks apart if this change is declinedmaster
overload handling (discarding all but one overload, by definition order) would be complicated in this PRHasGodotClassMethod
is used inCSharpInstanceBridge._Get
without parameter count checks, a getter with parameters makes no sense to meTODOs if we proceed with this PRs idea
InvokeGodotClassStaticMethod
?ScriptMethodRegistry
as the whole "inheritance" and aliasing part of it is pretty easily testableThank you to @paulloz and @raulsntos for helping me in Rocket.Chat / Discord on how to go on with my idea!