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

Fix generic arrays and dictionaries in .NET not calling set_typed #98545

Merged

Conversation

juanjp600
Copy link
Contributor

@juanjp600 juanjp600 commented Oct 26, 2024

See #97948 (review), the changes made here are based on this feedback.

I wasn't able to find a way to completely avoid using reflection, but since ScriptManagerBridge does rely on it for a few things, I figured that I could generalize what it does use and get something that's still going to work with trimming.

Here are some highlights in this PR:

  • CSharpScript::get_instance_base_type now returns a cached StringName that is passed in by Godot.Bridge.ScriptManagerBridge.UpdateScriptClassInfo. This reduces interop overhead and the use of reflection, which is important to speed up the godotsharp_*_set_typed functions.
  • GodotObject.InternalGetClassNativeBase is no longer recursive. Also, it now performs a direct comparison with a reference to the GodotSharp assembly, and only performs a name comparison in the editor because getting an assembly's name has some overhead.
  • Now there's a GodotObject.InternalGetClassNativeBaseName method. This method caches the StringNames it returns, to avoid always getting the static fields' values through reflection.

I benchmarked this PR using the following project: TypedCollectionBenchmarkDotnet.zip. I've made sure that it works on NativeAOT.

Here are my results:

JIT

BenchmarkDotNet v0.14.0, Pop!_OS 22.04 LTS
AMD Ryzen 5 3600, 1 CPU, 12 logical and 6 physical cores
.NET SDK 8.0.110
  [Host] : .NET 8.0.10 (8.0.1024.46610), X64 RyuJIT AVX2
Method MeanErrorStdDevMedian
UntypedDictionary859.7 ns17.19 ns43.75 ns854.3 ns
TypedDictionaryWithNativeElements1,938.9 ns23.91 ns26.57 ns1,930.0 ns
TypedDictionaryWithScriptElements2,177.0 ns43.56 ns102.67 ns2,135.7 ns
UntypedArray854.8 ns25.56 ns75.37 ns829.7 ns
TypedArrayWithNativeElements1,574.6 ns31.54 ns72.47 ns1,555.6 ns
TypedArrayWithScriptElements1,734.3 ns34.38 ns75.47 ns1,707.4 ns

NativeAOT

BenchmarkDotNet v0.14.0, Pop!_OS 22.04 LTS
AMD Ryzen 5 3600, 1 CPU, 12 logical and 6 physical cores
  [Host] : .NET 8.0.10, X64 NativeAOT AVX2
Method MeanErrorStdDev
UntypedDictionary1,053.0 ns15.93 ns14.12 ns
TypedDictionaryWithNativeElements3,200.2 ns63.78 ns156.46 ns
TypedDictionaryWithScriptElements3,419.6 ns68.35 ns191.66 ns
UntypedArray725.2 ns13.28 ns11.77 ns
TypedArrayWithNativeElements1,928.4 ns37.92 ns56.76 ns
TypedArrayWithScriptElements2,102.2 ns30.91 ns28.92 ns

Copy link
Contributor

@a-johnston a-johnston left a comment

Choose a reason for hiding this comment

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

Overall I think these are good changes but it's a bummer the calls are so much slower. I'm assuming that untyped vs typed performance difference is similar when using gdscript? If _p->typed.validate is that expensive, it might be worth (in a different PR) adding internal unchecked_set methods and using those when calling from a fully typed gdscript or generic c# context.

Comment on lines 1118 to +1119
_underlyingArray = new Array();
SetTypedForUnderlyingArray();
Copy link
Contributor

Choose a reason for hiding this comment

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

now that there is more common logic, should these array and dictionary constructors generally use : this() { .. } or is it still worth checking/throwing prior?

@@ -1137,6 +1156,16 @@ public Array(Array array)
throw new ArgumentNullException(nameof(array));

_underlyingArray = array;
Copy link
Contributor

Choose a reason for hiding this comment

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

it's existing code but imo it's a little weird that these end up with mutable externally provided underlying arrays, and as a result if array is not typed and not empty, we can't set typing on the native array. It's probably not worth shallow copying the input array to get around this, but it's a bummer that it won't be able to fix cases where we have a existing saved untyped array as in #98309. At least in that case the data just has to be regenerated. Maybe it would be worth changing set_typed to allow checking of the existing elements, although in this case it may also run against the refcount <= 1 constraint.

Copy link
Contributor Author

@juanjp600 juanjp600 Oct 30, 2024

Choose a reason for hiding this comment

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

I personally think that the constructor should shallow copy, because right now you can have the odd situation where you have two collections that are not the same according to object.ReferenceEquals, but nonetheless refer to the same engine-side array and as such will observe changes made by each other.

However, changing that would be a breaking change with user-observable effects (IIRC this is relevant when there are multiple instances of the same scene) and at the very least I think it's not within scope for this PR.

Also worth noting is that were this constructor changed to shallow copy, the implementation of the Duplicate method should also change to remain as efficient as possible.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah I agree it should be the behavior but given the current use it would be both a breaking change and make existing code paths of native -> c# array slower; maybe godot 5 lol? I think the current constructors with that behavior could be made internal and explicitly for cases like CreateTakingOwnershipOfDisposableValue, and then the public constructor making a shallow copy still benefits over the c# collection constructors since it knows it can make a single api call to duplicate rather than 2N calls to read/insert.

Copy link
Member

@raulsntos raulsntos left a comment

Choose a reason for hiding this comment

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

Thank you so much for this amazing work, and sorry it took me so long to review.

I appreciate how much work you put into this, trying to reduce the overhead as much as possible, benchmarking the changes, and ensuring NativeAOT works.

My only concern is with setting the type for the underlying array/dictionary in the constructors that "borrow" a reference to an untyped array/dictionary. I share the opinion that these constructors should shallow copy, but unfortunately that'd be a breaking change.

StringName? nativeBase = GodotObject.InternalGetClassNativeBaseName(scriptType);

godot_string_name nativeBaseName = nativeBase != null
? NativeFuncs.godotsharp_string_name_new_copy((godot_string_name)nativeBase.NativeValue)
Copy link
Member

Choose a reason for hiding this comment

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

Do we need to copy the StringName?

Copy link
Contributor Author

@juanjp600 juanjp600 Dec 8, 2024

Choose a reason for hiding this comment

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

I'm fairly certain that this is necessary. At the very least, it's consistent with how ScriptManagerBridge.GetScriptNativeName handles passing a managed StringName to unmanaged code.

If I'm understanding this correctly, it's effectively the same as an implicit call to StringName's copy constructor within C++. This is important to maintain a correct refcount; I tested what happens when a copy isn't made, and I get the following errors when I close the editor:

ERROR: BUG: Unreferenced static string to 0: EditorExportPlugin
   at: unref (core/string/string_name.cpp:144)
ERROR: BUG: Unreferenced static string to 0: HBoxContainer
   at: unref (core/string/string_name.cpp:144)
ERROR: BUG: Unreferenced static string to 0: EditorPlugin
   at: unref (core/string/string_name.cpp:144)
ERROR: BUG: Unreferenced static string to 0: MarginContainer
   at: unref (core/string/string_name.cpp:144)
ERROR: BUG: Unreferenced static string to 0: EditorInspectorPlugin
   at: unref (core/string/string_name.cpp:144)
ERROR: BUG: Unreferenced static string to 0: RefCounted
   at: unref (core/string/string_name.cpp:144)
ERROR: BUG: Unreferenced static string to 0: Node2D
   at: unref (core/string/string_name.cpp:144)

I suppose given the static lifetime of the StringNames returned by GodotObject.InternalGetClassNativeBaseName, it might be safe to pass a pointer to the underlying native form of that instead of constructing a new one, but I am not 100% sure about that so I went with this since I'm more confident that it's safe.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, I think you are 100% correct. I was also thinking about passing a pointer to the underlying native form, but I agree it's safer to do the copy.

{
unsafe
{
Godot.Bridge.ScriptManagerBridge.GetOrLoadOrCreateScriptForType(typeof(T), &scriptRef);
Copy link
Member

Choose a reason for hiding this comment

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

I'm guessing we don't expect this to ever create a script here, since if T is not a native class it must be a C# script so a script reference should already exist.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is possible for the construction of a collection to trigger ScriptManagerBridge.CreateScriptBridgeForType. Here's an example project illustrating this: RecursiveReferenceThruArrays.zip

The stacktrace for such a call looks like this:

   at System.Environment.get_StackTrace()
   at Godot.Bridge.ScriptManagerBridge.CreateScriptBridgeForType(Type scriptType, godot_ref* outScript) in /***/godot/modules/mono/glue/GodotSharp/GodotSharp/Core/Bridge/ScriptManagerBridge.cs:line 565
   at Godot.Bridge.ScriptManagerBridge.GetOrCreateScriptBridgeForType(Type scriptType, godot_ref* outScript) in /***/godot/modules/mono/glue/GodotSharp/GodotSharp/Core/Bridge/ScriptManagerBridge.cs:line 469
   at Godot.Bridge.ScriptManagerBridge.GetOrCreateScriptBridgeForPath(godot_string* scriptPath, godot_string* c_backtrace, godot_ref* outScript) in /***/godot/modules/mono/glue/GodotSharp/GodotSharp/Core/Bridge/ScriptManagerBridge.cs:line 452
   at Godot.Bridge.ScriptManagerBridge.GetOrLoadOrCreateScriptForType(Type scriptType, godot_ref* outScript) in /***/godot/modules/mono/glue/GodotSharp/GodotSharp/Core/Bridge/ScriptManagerBridge.cs:line 540
   at Godot.NativeInterop.Marshaling.GetTypedCollectionParameterInfo[T](Type& variantType, godot_string_name& className, godot_ref& script) in /***/godot/modules/mono/glue/GodotSharp/GodotSharp/Core/NativeInterop/Marshaling.cs:line 222
   at Godot.Collections.Array`1.SetTypedForUnderlyingArray() in /***/godot/modules/mono/glue/GodotSharp/GodotSharp/Core/Array.cs:line 1065
   at Godot.Collections.Array`1..ctor() in /***/godot/modules/mono/glue/GodotSharp/GodotSharp/Core/Array.cs:line 1102
   at ScriptNodeA.GetGodotPropertyDefaultValues() in /home/juan/GodotMrps/RecursiveReferenceThruArrays/Godot.SourceGenerators/Godot.SourceGenerators.ScriptPropertyDefValGenerator/ScriptNodeA_ScriptPropertyDefVal.generated.cs:line 15
   at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
   at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
   at Godot.Bridge.ScriptManagerBridge.GetPropertyDefaultValuesForType(Type type, IntPtr scriptPtr,  addDefValFunc) in /***/godot/modules/mono/glue/GodotSharp/GodotSharp/Core/Bridge/ScriptManagerBridge.cs:line 1125
   at Godot.Bridge.ScriptManagerBridge.GetPropertyDefaultValues(IntPtr scriptPtr,  addDefValFunc) in /***/godot/modules/mono/glue/GodotSharp/GodotSharp/Core/Bridge/ScriptManagerBridge.cs:line 1100
   at Godot.Bridge.ScriptManagerBridge.CreateScriptBridgeForType(Type scriptType, godot_ref* outScript) in /***/godot/modules/mono/glue/GodotSharp/GodotSharp/Core/Bridge/ScriptManagerBridge.cs:line 572
   at Godot.Bridge.ScriptManagerBridge.GetOrCreateScriptBridgeForType(Type scriptType, godot_ref* outScript) in /***/godot/modules/mono/glue/GodotSharp/GodotSharp/Core/Bridge/ScriptManagerBridge.cs:line 469
   at Godot.Bridge.ScriptManagerBridge.GetOrCreateScriptBridgeForPath(godot_string* scriptPath, godot_string* c_backtrace, godot_ref* outScript) in /***/godot/modules/mono/glue/GodotSharp/GodotSharp/Core/Bridge/ScriptManagerBridge.cs:line 452

I'm not aware of any issues that might arise in this scenario.

Copy link
Member

Choose a reason for hiding this comment

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

I see, thank you. I don't think there should be any issues with this.

modules/mono/glue/GodotSharp/GodotSharp/Core/Array.cs Outdated Show resolved Hide resolved
modules/mono/glue/GodotSharp/GodotSharp/Core/Dictionary.cs Outdated Show resolved Hide resolved
Copy link
Member

@raulsntos raulsntos left a comment

Choose a reason for hiding this comment

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

Looks good to me, thanks!

@Repiteo Repiteo merged commit b4bd384 into godotengine:master Dec 9, 2024
20 checks passed
@Repiteo
Copy link
Contributor

Repiteo commented Dec 9, 2024

Thanks!

@juanjp600 juanjp600 deleted the dotnet-generic-collections-set-typed branch December 10, 2024 02:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
5 participants