Using Rust Code from Unity for High Performance A Rust library called from C# over the same pointers Burst uses runs a game AI workload 6.4 times faster than Unity's default Mono runtime, 4.7 times faster than IL2CPP, and about 2.3 times faster than Unity's experimental CoreCLR backend and plain .NET 10, according to a developer's benchmark. The Rust engine allocated zero bytes in every run, and the garbage collector never touched its memory, offering a durable performance advantage. The interop requires only about a dozen lines of code, leveraging P/Invoke to pass raw pointers from NativeArray to Rust. 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. 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. 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. How your C becomes machine code how-your-c-becomes-machine-code Your C does not run as C . Something has to turn it into machine code first, and Unity offers several options. Mono compiles your code while the game runs, a piece at a time, the first time each piece is needed. IL2CPP compiles ahead of time: it converts your C into C++ and hands that to a normal C++ compiler. CoreCLR is the runtime modern .NET uses, a JIT twenty years younger than Mono, and Unity has an experimental backend for it. Burst compiles a restricted flavor of C into native code through LLVM, the same compiler machinery behind Rust and Clang. Rust is the one this post adds: native code before you ship, and no restrictions on what you can write. 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. 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. 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. What Burst makes you rewrite what-burst-makes-you-rewrite Burst comes with restrictions: your data has to be flat arrays of blittable https://learn.microsoft.com/en-us/dotnet/standard/native-interop/blittable-and-non-blittable-types 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. 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. 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. 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 caller pays for. A dozen lines of interop a-dozen-lines-of-interop Burst is fast partly because it works on raw pointers into plain native memory. A NativeArray is a block of ordinary memory with a safety wrapper around it, and Burst compiles down to code that reads and writes that block directly by address. C can pass those same addresses to a native library through P/Invoke. The only requirement is that the data is blittable, the same constraint Burst already puts on anything you hand a job. This is the dozen lines from the intro, the entire C side of the boundary: DllImport "libutility ai", CallingConvention = CallingConvention.Cdecl static extern int ai score ref float needs, ref Character characters, int characterCount, ref ActionDef actions, int actionCount, ref Scorer scorers, int scorerCount, ref uint outAction, ref float outScore ; // the whole call: first-element addresses, nothing copied Native.ai score ref needs 0 , ref chars 0 , chars.Length, ref actions 0 , actions.Length, ref scorers 0 , scorers.Length, ref outAction 0 , ref outScore 0 ; You do not need NativeArray for this. A plain managed array of blittable values is the same flat block of memory, just on the C heap, and a reference to its first element pins it in place for the duration of the call. Every number in this post comes from ordinary float and struct arrays crossing the boundary that way. The pin only lasts for the duration of the call, which is enough here because the calls are synchronous. If you schedule work and complete it later, Burst-job style, the pointer must stay valid across frames, and a compacting collector can move an unpinned array. Unity’s Boehm collector never moves objects https://docs.unity3d.com/6000.1/Documentation/Manual/performance-garbage-collector.html , which is why unpinned code works today. CoreCLR compacts. For long-lived buffers, use a NativeArray , whose memory is native and never enters the collector’s world, keep the buffer on the Rust side, or stay managed and pin with GCHandle , allocating on the Pinned Object Heap https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/poh to avoid fragmenting the heap. Rust’s own memory is invisible to the collector. The managed buffers you lend it are not. One call goes out carrying a few addresses. Rust wraps them as slices, spreads the work across every core, and writes the answers into the buffers C already owns. The buffers never move, and C reads its results out of the same memory it handed over. The receiving side is the mirror image, lightly trimmed from the repository: js unsafe no mangle pub unsafe extern "C" fn ai score needs: const f32, characters: const Character, character count: i32, / actions, scorers, and the two out pointers / - i32 { let nc = character count as usize; let needs = slice::from raw parts needs, nc INPUTS ; let chars = slice::from raw parts characters, nc ; // ...the rest wrapped the same way, then rayon fans out per character: out action.par iter mut .zip out score.par iter mut .zip chars .zip needs.par chunks exact INPUTS .for each | a, s , ch , n | { let best, score = best action n, ch, actions, scorers ; a = best; s = score; } ; 0 } One rule applies at this edge: a Rust panic must not reach it, because a panic crossing extern "C" aborts the whole player with no Unity error log. Catch it at the boundary with std::panic::catch unwind and turn it into the error code C already checks. The call itself costs tens of nanoseconds. When the work behind it takes microseconds, the boundary is a rounding error. That fixed cost is also why you ask for a lot at once: the benchmark scores all two hundred characters in one call rather than making two hundred calls. None of this is specific to Rust. Any language that can build a C-compatible library can stand on the other side of the boundary, and C++ works the same way. This post uses Rust because the point of leaving managed code is taking manual control of memory, and Rust lets you do that without a new class of crashes. It is not a new pattern either. In the browser it is Rust compiled to WebAssembly: JavaScript keeps the orchestration and a compiled module takes the hot loop. That is how Mozilla sped up its source-map library https://hacks.mozilla.org/2018/01/oxidizing-source-maps-with-rust-and-webassembly/ , how Prime Video runs its UI engine on low-powered devices https://www.amazon.science/blog/how-prime-video-updates-its-app-for-more-than-8-000-device-types , and how 1Password ships its core inside a browser extension https://1password.com/blog/1password-8-the-story-so-far . The Unity version gets a cheaper boundary, though. WebAssembly runs in its own linear memory, so the JavaScript side usually pays a copy on the way in. P/Invoke hands over addresses in the same address space, and Rust reads the heap in place. The benchmark: a utility AI the-benchmark-a-utility-ai The workload is the utility AI from earlier: two hundred characters, a thousand candidate actions each, six scorers per action. That is 1,200,000 scorer evaluations per tick against 160 KB of scorer and action data, just past the test machine’s 128 KB first-level cache and well inside the second. Both sides split the characters across the same six threads, Rust through rayon and C through Parallel.For . Both sides stay in safe code: no unsafe in the Rust, no Unsafe. in the C . The C is an interface with a class per curve, what you find in a real codebase: public interface IScorer { float Score CharacterObj ch, in ActionDef action ; } public sealed class LinearScorer : IScorer { public int Input; public float M, B, C, Weight; public float Score CharacterObj ch, in ActionDef a { float x = Engines.ReadInput Input, ch, in a ; return Math.Clamp M x - C + B, 0f, 1f Weight; } } // three more classes: Quadratic, Logistic, Gaussian The Rust is a data-carrying enum matched directly, which is what a Rust programmer writes when the variants are known up front. Each variant carries only the fields its curve reads, and the type system stops anyone touching the others: pub enum Curve { Linear { m: f32, c: f32, b: f32 }, Quadratic { m: f32, c: f32, b: f32 }, Logistic { k: f32, c: f32 }, Gaussian { k: f32, c: f32 }, } fn curve enum c: &Curve, x: f32 - f32 { let v = match c { Curve::Linear { m, c, b } = m x - c + b, Curve::Quadratic { m, c, b } = { let d = x - c; m d d + b } Curve::Logistic { k, c } = 1.0 / 1.0 + -k x - c .exp , Curve::Gaussian { k, c } = { let d = x - c; -k d d .exp } }; v.clamp 0.0, 1.0 } Neither is a translation of the other, which is the point: each language does this job the way its own practitioners write it. The idiomatic C is also not the slow choice. On Unity’s CoreCLR it beats a hand-flattened version with a switch statement, 1.158 milliseconds against 1.327, because the JIT watches which implementation turns up at each call site and compiles the indirection away. On Mono and IL2CPP the switch wins by a lot. That is a property of the newer JIT rather than of C . Know which runtime you are on before you hand-flatten anything. Making the comparison fair making-the-comparison-fair A benchmark where one side is tuned and the other is not measures the author, not the languages. The C engine in this benchmark got 35% faster after it received the same flat data layout the Rust side already had, and none of that 35% had anything to do with the language. The rest was measurement error: the machine, the warm-up, a stray counter in the timed loop. Both implementations are held to the same standard. They must do provably identical work: 200 characters × 1,000 actions × 6 scorers, every time, by construction. Every run checks that both languages pick the same action and score for all two hundred characters before it reports a time. Any optimization that wins on one side is only a hypothesis for the other until the other side tries it. The full protocol is in the repository https://github.com/oddur/blog-unityrust . Three measurement details matter. This chip has six performance cores and twelve efficiency ones, so work spread across all eighteen swung by a factor of four between runs, and everything here is capped to six threads. Managed code needs a warm-up: the tiered JIT runs up to 25% slow over the first couple of hundred batches while it recompiles the hot code. Every number is therefore the median of warm back-to-back batches, with the early ones discarded. And nothing is compared across processes: Rust and C are timed in the same program on the same data, with the identical native library landing within 2% across all six managed hosts as the control. The results, runtime by runtime the-results-runtime-by-runtime Read the chart downward: the gap tracks runtime age as much as language. Mono is a twenty-year-old JIT and loses by 6.4x. IL2CPP compiles ahead of time and loses by 4.7x. CoreCLR, a modern JIT with profile-guided optimization, loses by 2.3x, and plain .NET 10, the closest thing to Unity’s future, matches it. The gap shrinks as the runtime modernizes and stops at 2.3x: that is what remains after the runtime has caught up. Beating Burst with safe SIMD beating-burst-with-safe-simd Everything above compares Rust against C . Unity’s own answer for hot code is Burst. It is free, it ships with the engine, and it is fast: the same scorer in a Burst job runs at 0.147 ms against the plain C ’s 2.30. So the question is whether Rust can match that. It can, in safe code, and it comes out ahead. Burst’s speed comes from SIMD, so first, what that is. A processor normally works on one number at a time. SIMD applies one instruction to several numbers at once: four floats multiplied by four others in one register, in roughly the time one multiply takes. There are conditions. The four numbers have to sit together and they all have to want the same operation. The moment the code asks a question about one and not the others, it falls back to scalar. So the scorer was measured four ways, in the same IL2CPP player configuration on the same six threads. Each language appears twice: once written normally, one score at a time, and once with the four-at-a-time version written by hand. The Burst rows use Unity.Mathematics and its float4 type. The Rust rows are the enum engine and a four-wide rewrite of it. The first thing that chart says is that neither compiler vectorized anything on its own . Burst’s pitch is that it finds the loops and widens them for you. Here it did not: written as ordinary scalar code inside a Burst job, it ran at 0.703 ms. Rust was no better, with five vector instructions in the whole scoring function and all of them register moves. The four-way branch on the response curve is what stops both. That matches my experience with Burst beyond this benchmark. The promise is auto-vectorization out of the box, and in practice it delivers inconsistently. You write the code, check the Burst Inspector to see what the compiler produced, adjust, and check again, until the vectorizer emits the SIMD you were after. You end up wrestling an abstraction layer that sits between you and instructions you already know you want, and you reach the goal by trial and error rather than by stating it. In Rust, with the right crate, you state it: the SIMD operations compile to the instructions they name, exactly where you put them, with no translation layer to persuade. The four-wide versions had to be written by hand on both sides, and the trick is not the obvious one. Four scorers in a register fails, because each can be a different curve, so every lane computes all four kinds and discards three. What works is four characters , who share one scorer and therefore one curve and one set of constants. Once both sides are written that way, Rust comes out about 8% ahead, and every character still picks the same action as the scalar engines. The Rust version is also safe code . Stable Rust does not ship std::simd yet, but the fearless simd https://crates.io/crates/fearless simd crate reaches the real instructions safely, with no unsafe anywhere in the engine. And it is recognizably the curve enum from the benchmark section, gone four wide: php fn curve fs