Using Rust Code from Unity for High Performance | Oddur MagnussonTable of ContentsHow your C# becomes machine code<br>What Burst makes you rewrite<br>A dozen lines of interop<br>The benchmark: a utility AI<br>Making the comparison fair<br>The results, runtime by runtime<br>Beating Burst with safe SIMD<br>Zero allocations, zero collections<br>Shipping it to every platform<br>Living with it<br>What this adds up to<br>AI disclosure
Unity gives you several ways to turn C# into machine code. This post is about one it does not ship: a Rust library, called from C# over the same pointers Burst already uses.<br>The interop is about a dozen lines. On the Mono that Unity desktop games ship by default, a game AI workload runs 6.4 times faster in Rust. Against IL2CPP it is 4.7 times faster, and against Unity’s experimental CoreCLR backend and plain .NET 10 about 2.3 times.<br>The more durable argument is memory. In every run, on every runtime, the Rust engine allocated zero bytes and the collector never touched it. A collector cannot walk memory it cannot see, and that stays true however good the runtime gets.<br>How your C# becomes machine code#<br>Your C# does not run as C#. Something has to turn it into machine code first, and Unity offers several options.<br>Mono compiles your code while the game runs, a piece at a time, the first time each piece is needed.<br>IL2CPP compiles ahead of time: it converts your C# into C++ and hands that to a normal C++ compiler.<br>CoreCLR is the runtime modern .NET uses, a JIT twenty years younger than Mono, and Unity has an experimental backend for it.<br>Burst compiles a restricted flavor of C# into native code through LLVM, the same compiler machinery behind Rust and Clang.<br>Rust is the one this post adds: native code before you ship, and no restrictions on what you can write.<br>The first three are managed, which means a garbage collector owns your memory. You never free anything. Instead, every so often, the collector walks through everything you have allocated, works out what is still in use, and throws away the rest. While it works, your game waits.<br>The last two have no collector. Burst avoids one by forbidding you to allocate the kind of memory that needs it. Rust avoids one because its memory belongs to Rust, and Unity’s collector never learns it exists.<br>Five ways to turn your code into machine codeevery one of them ships inside the same Unity gameMonoC#JIT, whilethe game runsmachine codegarbagecollectedyou can writethe whole languageIL2CPPC#C++, beforeyou shipmachine codegarbagecollectedyou can writethe whole languageCoreCLRC#JIT, and againonce it has watchedmachine codegarbagecollectedyou can writethe whole languageBursta C# subsetLLVM, beforeyou shipSIMD codegarbagenoneyou can writeflat arrays onlyRustRustLLVM, beforeyou shipmachine codegarbagenoneyou can writethe whole languageThe three on the left are the easy road with a collector attached. The two on the right have no collector,and only one of them lets you write whatever you want.The managed runtimes let you write anything and attach a collector. Burst removes the collector and takes away most of the language. Rust removes the collector and keeps the language.<br>What Burst makes you rewrite#<br>Burst comes with restrictions: your data has to be flat arrays of blittable values: types laid out the same way in managed and native memory, so integers, floats and structs of them. Nothing can grow while a parallel job runs. Containers cannot hold other containers.<br>For a loop that multiplies a million floats, none of that is a problem, and Burst will beat anything you write by hand. The trouble starts with code whose natural shape is not a flat array.<br>Take the system this post benchmarks, a utility AI, the pattern game characters use to pick what to do next. Every candidate action gets a score from a set of scorers, and each scorer reads one input and pushes it through a response curve. The natural C# is an interface with a class per curve, and adding a curve means adding a class. Burst rejects the interface, every class, the array that holds them, and even the plain float[] holding a character’s stats. To get in, the polymorphism has to be re-encoded as a byte tag and a switch, which is a different program that happens to compute the same answer.<br>What Burst makes you rewritethe natural C#interface IScorerLinearScorerQuadraticLogisticScorerGaussianIScorer[] scorersa new curve is one new classBurst rejects the interface, the classes and the arraywhat Burst acceptsstruct Scorer {byte curve; // 0..3float m, k, b, c, w;}switch (curve) {case 0: /* linear */case 1: /* quadratic */case 2: /* logistic */case 3: /* gaussian */a new curve is a new casein every switch that scoresSame answers, different program. The polymorphism is re-encoded by hand, and the extension point is gone.That rewrite is the price of entry, and the restriction follows every helper the job calls. It also removes the extension point: a new curve was a new class, and becomes a new case in a switch every...