I have a prototype where I intentionally invoke GC after every frame. My reasoning is that some amount of GC will eventually need to occur, so why not do it as frequently as possible and at the exact moment where we can compensate with other measures, such as the frame scheduler & time step abstractions.
Even with heavy allocation (i.e. deserializing the scene graph from a wire protocol before every frame), this approach seems to keep GC pauses bounded to ~1-5 milliseconds in my implementations.
If I did not explicitly call GC when I thought best, the runtime would decide to run a 100-1000 ms collection at its convenience and the entire experience would become worthless to human perception.
Missing a VSync in mobile means that the refresh rate will switch to 30hz for a brief moment, there is no in-between (ie; 54 FPS or something like that) which is pretty noticeable.
The only "great strategy" is to have zero alloc (or close) while gameplay is running and if you want to go the extra miles, disable GC completely and call it only in some appropriate moment (level loading, bringing up the menu, etc.).
This is the path I am taking.
Ultimately, I would try as hard as I can to not allocate, but having a good plan for dealing with some per-frame blips is going to be essential. It gives me a reasonable starting point to work with on the journey to zero alloc.
But it's not very complicated to write a simple IL analyzer, using Mono.Cecil to go over the raw IL, instead of using Roslyn. The idea is to look for the newobj or box IL opcodes, but also to recurse to called methods (when meeting the callX opcodes), by finding using Cecil where the call leads us, and loading that method.
This could be used after compilation instead of during compilation, but it can be used without actually running the code, so it's a sort of static analysis.
I threw together a simple demo, that has some bugs but is generic enough and should work. It's not very efficient though.
See here :
https://dotnetfiddle.net/EyuYSh
(It can also be run from there since dotnetfiddle supports nugets).
And also in godbolt:
https://godbolt.org/z/P3rK1voKP
(it doesn't run there since Godbolt apparently doesn't support nugets)
It's self contained and will run as a console application by just copying and pasting the code into an IDE. You need to add Mono.Cecil as a nuget package though.
As a Stride user you have to follow similar guide lines in your scripts to avoid pressure on the garbage collector. But that's not so difficult as soon as you get the hang of it.
But it's a very modular engine, you can change parts of it to better accommodate your needs, I'm currently finishing implementing my "no-allocation" ECS library and will 100% try to integrate it into my fork.
.NET platform is server focused, it does a lot at runtime, including recompiling bunch of functions
It was a huge problem before Unity had the IL2CPP target, you had to deal with the slow cold start and you also had to manually call your hot functions to avoid getting them recompiled during gameplay, wich would cause ton of micro-stutters
Hence people prefer languages like LUA for their scripting needs, even for Unity [1], great perf for interpreted and you can ship it on consoles/mobile platform that forbid JIT
Now that .net has a dedicated AOT compiler, things could improve, but i doubt this engine supports NativeAOT
Things were different for Unity games using the Boehm collector though, since it's a conservative non-generational collector that as a result is very slow to collect and collects more frequently. That's why you saw a lot of frequent pauses in Unity games (my understanding is they no longer use Boehm).
The game I'm currently working on GCs maybe 1-2 times per minute and the pauses are short enough that it doesn't drop frames, despite the fact that it frequently allocates strings and uses 'async Task' extensively. If you tune the collector settings you can control this even more, and you also have the option to manually force GCs to happen while the game is waiting for vsync, which hides them further.
Visual Studio has a built in allocation profiler that can help if you're looking to optimize your games for fewer GCs, and to make tracing faster what you want to do is avoid having any reference types in your struct arrays (use handles instead), which will let the GC skip tracing your big arrays of structs entirely. So for example the big tables of draw calls I use refer to textures by numeric handle instead of reference, which means I can have a 50mb table without making GCs slower.
Using handles instead of references also means that copying the struct doesn't require write barriers, which will boost your overall perf a bit.
First of all, there is the runtime they are actually using.
Then the language version.
Finally, how well the devs know how to do low level optimisations.
Instead, heavy optimisation in C# is usually "high level but knowing the runtime in great detail" optimisation. Stuff like knowing the hairy details of the GC implementation (eg by reading Pro .NET Memory Management and/or the single 30,000 line C++ file that contains the GC implementation [1]) and using this knowledge to write ordinary C# code in such a way that the GC can handle its usage patterns very efficiently, or doing things like object pooling that try to cut the GC out of the picture altogether.
Essentially, it's knowing what you want the machine to do and figuring out a way to trick the runtime into doing that instead of what it wants to do. I find it massively frustrating and after doing it for a few years I moved back to languages that try to do what you tell them to do instead of the other way round. Some people seem to thrive on it, though, and manage to get impressive results - see, eg. Marc Gravell's blog.
[1] https://github.com/dotnet/runtime/blob/main/src/coreclr/gc/g...
You can get very far with a GC language like this. The part that bothers me are the built-ins that allocate. TPL, AspNetCore, et. al. If I want a truly zero-alloc C# solution, I have to go all the way to the bottom with abstractions like Socket, NetworkStream, SslStream, etc.
Based on my C and C++ years, it is hardly any difference from doing optimization, knowing what the compiler actually generates, how is the runtime and standard library actually implemented, and above all getting to use the proper data structures, algorithms and a profiler.
It clearly suits some people but it isn't for me.
Anyone that disregards languages used by them, should also have better sales numbers to go along.
Those are all games where the average frame time is so low GC pauses are basically irrelevant.
And Minecraft was re-written in C++ for a reason.
Minecraft Java, not only keeps being sold, it is where new features are tested, before the Bedrock team implements similar capabilities.
Any online shop where we can appreciate the fruits of how a better language choice translates into improved game design and increased sales?
I do a lot of gnarly stuff in C# on .NET 4.8 (no spans! it's too old!) and still almost never run into memory safety issues or crashes, it's far more stable than my experiences working with C/C++ codebases.
I recently got rid of one of the main crash sources in my codebase - a line for line port (unsafe, pointers) of a C library that I was using. I replaced it with a from scratch C# rewrite using 'ref' (no pointers) and got better performance with no memory safety crashes.
I'd distinguish between those use cases and ones where you're using unsafe C# to interface with external unmanaged code (I've done both). Such interop-like use cases are also rather hairier than writing C against those APIs because with C at least you can generally consume a header file and have some confidence that your compiler understands the binary interface you'll be interacting with at runtime, whereas in C# the burden of trying to ensure that the data types and signatures are correct against the actual binary loaded is on you, and it can be a heavy one especially if you want your code to work cross platform. (Yes, MS have come up with things like C++/CLI that should help, but have shown no commitment to them. If you want things to reliably work across platforms and in the future you're basically stuck with P/Invoke and unsafe.)
But the dangers are worse for the former kind of code. Here the worst risks arise around the interactions between the unsafe code and the safe code. Typically in this sort of situation you want to wrap your nasty unsafe code in a nice, safe API. You want to make it so that ordinary C# callers can't make mistakes with your managed API that would cause crashes, leaks, etc. In fact, it's more than "want". You really have to. It's C# and people need to be able to program in it like it's C#, without expecting that a missed "using" somewhere is going to cause a segfault. And that can be really hard to do. The crux of the problem is that the GC wants to manage the lifetimes of the managed objects and offers no way to hook into those lifetimes other than finalizers, with their well known limitations/downsides or disposal which you can't rely on callers to invoke. If you create/manipulate some unmanaged resource and wrap it in a managed API there are lots of fun little gotchas to run into, such as the fact that an object can be garbage collected while a method on it is executing. [1]
I believe it's for this reason that interesting unmanaged data structures written in unsafe C# and exposed as safe-to-use C# types aren't really a thing in the .NET world. It really is very hard indeed to do it safely and efficiently. Unsafe is more commonly used for interop glue, where it's better suited (but still a bit risky).
The need to do things like this has led to improvements such as spans, but I'm not a fan. A ref struct is a horribly, arbitrarily limited thing (it can basically only exist on the stack) and I found programming with Spans an exercise in frustration as a result. For one thing, the moment you find you need a span of spans you'll find yourself reaching for those grubby raw pointers again. And at best they really just give you bounds checking. They don't help at all with the hard problem: resource management (and neither does Memory).
So the comparison with C - what do I mean by "more dangerous"? Well, it's a subjective thing, of course. I don't really mean there are more opportunities to screw up. What I mean is that when I was writing this stuff I had to work harder, think harder and move slower not to screw up. The pitfalls were less obvious, there was less prior art, fewer established practices and a general feeling that I was going against the grain. YMMV, but it's not something I'm going to miss.
[1] https://devblogs.microsoft.com/oldnewthing/20100810-00/?p=13...