Created
August 9, 2026 04:58
-
-
Save adammyhre/d2e5246d60df5031acdec445399306df to your computer and use it in GitHub Desktop.
Zig + Unity: Native Speed for Hot Loops
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* Same C ABI as crunch.zig — how Zig compares to basic C. */ | |
| #ifdef _WIN32 | |
| #define CRUNCH_API __declspec(dllexport) | |
| #else | |
| #define CRUNCH_API __attribute__((visibility("default"))) | |
| #endif | |
| #include <stddef.h> | |
| #include <stdint.h> | |
| CRUNCH_API void crunch_sqdist2( | |
| const float *ax, | |
| const float *ay, | |
| const float *bx, | |
| const float *by, | |
| float *out, | |
| size_t n | |
| ) { | |
| for (size_t i = 0; i < n; i++) { | |
| const float dx = ax[i] - bx[i]; | |
| const float dy = ay[i] - by[i]; | |
| out[i] = dx * dx + dy * dy; | |
| } | |
| } | |
| CRUNCH_API uint32_t crunch_version(void) { | |
| return 1; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //! Squared 2D distance kernels with a C ABI for Unity P/Invoke. | |
| const std = @import("std"); | |
| /// Single-threaded: out[i] = (ax[i]-bx[i])^2 + (ay[i]-by[i])^2 | |
| export fn crunch_sqdist2( | |
| ax: [*]const f32, | |
| ay: [*]const f32, | |
| bx: [*]const f32, | |
| by: [*]const f32, | |
| out: [*]f32, | |
| n: usize, | |
| ) callconv(.c) void { | |
| sqdistRange(ax, ay, bx, by, out, 0, n); | |
| } | |
| const ParallelCtx = struct { | |
| ax: [*]const f32, | |
| ay: [*]const f32, | |
| bx: [*]const f32, | |
| by: [*]const f32, | |
| out: [*]f32, | |
| start: usize, | |
| end: usize, | |
| }; | |
| fn worker(ctx: *ParallelCtx) void { | |
| sqdistRange(ctx.ax, ctx.ay, ctx.bx, ctx.by, ctx.out, ctx.start, ctx.end); | |
| } | |
| fn sqdistRange( | |
| ax: [*]const f32, | |
| ay: [*]const f32, | |
| bx: [*]const f32, | |
| by: [*]const f32, | |
| out: [*]f32, | |
| start: usize, | |
| end: usize, | |
| ) void { | |
| var i = start; | |
| while (i < end) : (i += 1) { | |
| const dx = ax[i] - bx[i]; | |
| const dy = ay[i] - by[i]; | |
| out[i] = dx * dx + dy * dy; | |
| } | |
| } | |
| /// Multi-threaded sibling of crunch_sqdist2 — splits [0, n) across CPU workers. | |
| export fn crunch_sqdist2_parallel( | |
| ax: [*]const f32, | |
| ay: [*]const f32, | |
| bx: [*]const f32, | |
| by: [*]const f32, | |
| out: [*]f32, | |
| n: usize, | |
| ) callconv(.c) void { | |
| if (n == 0) return; | |
| const cpu = std.Thread.getCpuCount() catch 1; | |
| // Cap worker count: enough for throughput, small stack of join handles. | |
| const max_workers = 64; | |
| const threads = @max(@min(cpu, @min(n, max_workers)), 1); | |
| if (threads == 1) { | |
| sqdistRange(ax, ay, bx, by, out, 0, n); | |
| return; | |
| } | |
| var contexts: [max_workers]ParallelCtx = undefined; | |
| var handles: [max_workers]?std.Thread = .{null} ** max_workers; | |
| const chunk = (n + threads - 1) / threads; | |
| var t: usize = 0; | |
| while (t < threads) : (t += 1) { | |
| const start = t * chunk; | |
| if (start >= n) break; | |
| const end = @min(start + chunk, n); | |
| contexts[t] = .{ | |
| .ax = ax, | |
| .ay = ay, | |
| .bx = bx, | |
| .by = by, | |
| .out = out, | |
| .start = start, | |
| .end = end, | |
| }; | |
| // If spawn fails, run the chunk on this thread so we still finish correctly. | |
| handles[t] = std.Thread.spawn(.{}, worker, .{&contexts[t]}) catch null; | |
| if (handles[t] == null) worker(&contexts[t]); | |
| } | |
| t = 0; | |
| while (t < threads) : (t += 1) { | |
| if (handles[t]) |h| h.join(); | |
| } | |
| } | |
| /// Smoke-test symbol — if this returns 1, the plugin loaded. | |
| export fn crunch_version() callconv(.c) u32 { | |
| return 1; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Diagnostics; | |
| using Unity.Burst; | |
| using Unity.Collections; | |
| using Unity.Jobs; | |
| using UnityEngine; | |
| using Debug = UnityEngine.Debug; | |
| /// <summary> | |
| /// Compares naive C#, Burst (1T + optional MT), and Zig (1T + optional MT) on the same sqdist kernel. | |
| /// </summary> | |
| public class CrunchBenchmark : MonoBehaviour { | |
| #region Fields | |
| [Header("Workload")] | |
| [SerializeField, Min(1)] int count = 10_000_000; | |
| [SerializeField] bool runOnStart = true; | |
| [SerializeField] bool showOnGui = true; | |
| [SerializeField] bool includeBurst = true; | |
| [Header("Local only (not video)")] | |
| [SerializeField] bool includeBurstParallel = true; | |
| [SerializeField] bool includeZigParallel = true; | |
| [SerializeField, Min(1)] int burstBatchSize = 64; | |
| [Header("Last Result")] | |
| [SerializeField] double lastCsharpMs; | |
| [SerializeField] double lastBurstMs; | |
| [SerializeField] double lastBurstParallelMs; | |
| [SerializeField] double lastZigMs; | |
| [SerializeField] double lastZigParallelMs; | |
| [SerializeField] uint lastVersion; | |
| [SerializeField] float lastDeltaCsZig; | |
| [SerializeField] float lastDeltaCsBurst; | |
| [SerializeField] float lastDeltaCsBurstParallel; | |
| [SerializeField] float lastDeltaCsZigParallel; | |
| [SerializeField] string lastStatus = "Not run"; | |
| float[] ax, ay, bx, by, outCs, outZig, outZigParallel, outBurst, outBurstParallel; | |
| NativeArray<float> nAx, nAy, nBx, nBy, nOut; | |
| #endregion | |
| protected void Start() { | |
| if (runOnStart) Run(); | |
| } | |
| protected void OnDestroy() => DisposeNative(); | |
| [ContextMenu("Run Benchmark")] | |
| public void Run() { | |
| try { | |
| AllocateManaged(); | |
| Fill(); | |
| AllocateNativeFromManaged(); | |
| // Warmup — exclude JIT / first-call native load / Burst compile from the timed pass | |
| SqDistCsharp(ax, ay, bx, by, outCs, count); | |
| CrunchNative.crunch_sqdist2(ax, ay, bx, by, outZig, (UIntPtr)count); | |
| if (includeZigParallel) { | |
| CrunchNative.crunch_sqdist2_parallel(ax, ay, bx, by, outZigParallel, (UIntPtr)count); | |
| } | |
| if (includeBurst) RunBurst(); | |
| if (includeBurstParallel) RunBurstParallel(); | |
| var sw = Stopwatch.StartNew(); | |
| SqDistCsharp(ax, ay, bx, by, outCs, count); | |
| sw.Stop(); | |
| lastCsharpMs = sw.Elapsed.TotalMilliseconds; | |
| if (includeBurst) { | |
| sw.Restart(); | |
| RunBurst(); | |
| sw.Stop(); | |
| lastBurstMs = sw.Elapsed.TotalMilliseconds; | |
| nOut.CopyTo(outBurst); | |
| lastDeltaCsBurst = Mathf.Abs(outCs[0] - outBurst[0]); | |
| } | |
| else { | |
| lastBurstMs = -1; | |
| lastDeltaCsBurst = -1; | |
| } | |
| if (includeBurstParallel) { | |
| sw.Restart(); | |
| RunBurstParallel(); | |
| sw.Stop(); | |
| lastBurstParallelMs = sw.Elapsed.TotalMilliseconds; | |
| nOut.CopyTo(outBurstParallel); | |
| lastDeltaCsBurstParallel = Mathf.Abs(outCs[0] - outBurstParallel[0]); | |
| } | |
| else { | |
| lastBurstParallelMs = -1; | |
| lastDeltaCsBurstParallel = -1; | |
| } | |
| sw.Restart(); | |
| CrunchNative.crunch_sqdist2(ax, ay, bx, by, outZig, (UIntPtr)count); | |
| sw.Stop(); | |
| lastZigMs = sw.Elapsed.TotalMilliseconds; | |
| lastDeltaCsZig = Mathf.Abs(outCs[0] - outZig[0]); | |
| if (includeZigParallel) { | |
| sw.Restart(); | |
| CrunchNative.crunch_sqdist2_parallel(ax, ay, bx, by, outZigParallel, (UIntPtr)count); | |
| sw.Stop(); | |
| lastZigParallelMs = sw.Elapsed.TotalMilliseconds; | |
| lastDeltaCsZigParallel = Mathf.Abs(outCs[0] - outZigParallel[0]); | |
| } | |
| else { | |
| lastZigParallelMs = -1; | |
| lastDeltaCsZigParallel = -1; | |
| } | |
| lastVersion = CrunchNative.crunch_version(); | |
| lastStatus = "OK"; | |
| var burstText = includeBurst ? $" Burst1T={lastBurstMs:F2}ms" : ""; | |
| var burstMtText = includeBurstParallel ? $" BurstMT={lastBurstParallelMs:F2}ms" : ""; | |
| var zigParText = includeZigParallel ? $" ZigMT={lastZigParallelMs:F2}ms" : ""; | |
| Debug.Log( | |
| $"[ZigCrunch] n={count:N0} C#={lastCsharpMs:F2}ms{burstText}{burstMtText} " + | |
| $"Zig1T={lastZigMs:F2}ms{zigParText} version={lastVersion} " + | |
| $"Δcs-zig={lastDeltaCsZig} Δcs-burst={lastDeltaCsBurst} " + | |
| $"Δcs-burstMT={lastDeltaCsBurstParallel} Δcs-zigMT={lastDeltaCsZigParallel}" | |
| ); | |
| } | |
| catch (DllNotFoundException e) { | |
| lastStatus = "DllNotFoundException — check Assets/Plugins/x86_64/crunch.dll"; | |
| Debug.LogError($"[ZigCrunch] {lastStatus}\n{e}"); | |
| } | |
| catch (EntryPointNotFoundException e) { | |
| lastStatus = "EntryPointNotFoundException — export name / calling convention mismatch"; | |
| Debug.LogError($"[ZigCrunch] {lastStatus}\n{e}"); | |
| } | |
| } | |
| // Single-threaded IJob — fair vs Zig1T (video path). | |
| void RunBurst() { | |
| var job = new SqDistBurstJob { | |
| Ax = nAx, | |
| Ay = nAy, | |
| Bx = nBx, | |
| By = nBy, | |
| Out = nOut | |
| }; | |
| job.Run(); | |
| } | |
| // Multi-threaded IJobParallelFor — fair vs ZigMT | |
| void RunBurstParallel() { | |
| var job = new SqDistBurstParallelJob { | |
| Ax = nAx, | |
| Ay = nAy, | |
| Bx = nBx, | |
| By = nBy, | |
| Out = nOut | |
| }; | |
| job.Schedule(count, burstBatchSize).Complete(); | |
| } | |
| static void SqDistCsharp(float[] ax, float[] ay, float[] bx, float[] by, float[] output, int n) { | |
| for (var i = 0; i < n; i++) { | |
| var dx = ax[i] - bx[i]; | |
| var dy = ay[i] - by[i]; | |
| output[i] = dx * dx + dy * dy; | |
| } | |
| } | |
| void AllocateManaged() { | |
| ax = new float[count]; | |
| ay = new float[count]; | |
| bx = new float[count]; | |
| by = new float[count]; | |
| outCs = new float[count]; | |
| outZig = new float[count]; | |
| outZigParallel = new float[count]; | |
| outBurst = new float[count]; | |
| outBurstParallel = new float[count]; | |
| } | |
| void AllocateNativeFromManaged() { | |
| DisposeNative(); | |
| nAx = new NativeArray<float>(ax, Allocator.Domain); | |
| nAy = new NativeArray<float>(ay, Allocator.Domain); | |
| nBx = new NativeArray<float>(bx, Allocator.Domain); | |
| nBy = new NativeArray<float>(by, Allocator.Domain); | |
| nOut = new NativeArray<float>(count, Allocator.Domain); | |
| } | |
| void DisposeNative() { | |
| if (nAx.IsCreated) nAx.Dispose(); | |
| if (nAy.IsCreated) nAy.Dispose(); | |
| if (nBx.IsCreated) nBx.Dispose(); | |
| if (nBy.IsCreated) nBy.Dispose(); | |
| if (nOut.IsCreated) nOut.Dispose(); | |
| } | |
| void Fill() { | |
| var rng = new System.Random(42); | |
| for (var i = 0; i < count; i++) { | |
| ax[i] = (float)rng.NextDouble(); | |
| ay[i] = (float)rng.NextDouble(); | |
| bx[i] = (float)rng.NextDouble(); | |
| by[i] = (float)rng.NextDouble(); | |
| } | |
| } | |
| protected void OnGUI() { | |
| if (!showOnGui) return; | |
| const int w = 460; | |
| var lines = 3 | |
| + (includeBurst ? 1 : 0) | |
| + (includeBurstParallel ? 1 : 0) | |
| + (includeZigParallel ? 1 : 0); | |
| var h = 56 + lines * 20; | |
| GUI.Box(new Rect(12, 12, w, h), "Zig Crunch Benchmark"); | |
| GUI.Label(new Rect(24, 36, w - 24, 20), $"n = {count:N0} status = {lastStatus}"); | |
| var y = 56; | |
| GUI.Label(new Rect(24, y, w - 24, 20), $"C#: {lastCsharpMs:F2} ms"); | |
| y += 20; | |
| if (includeBurst) { | |
| GUI.Label(new Rect(24, y, w - 24, 20), $"Burst1T: {lastBurstMs:F2} ms"); | |
| y += 20; | |
| } | |
| if (includeBurstParallel) { | |
| GUI.Label(new Rect(24, y, w - 24, 20), $"BurstMT: {lastBurstParallelMs:F2} ms"); | |
| y += 20; | |
| } | |
| GUI.Label(new Rect(24, y, w - 24, 20), $"Zig1T: {lastZigMs:F2} ms ver={lastVersion}"); | |
| y += 20; | |
| if (includeZigParallel) { | |
| GUI.Label(new Rect(24, y, w - 24, 20), $"ZigMT: {lastZigParallelMs:F2} ms"); | |
| y += 20; | |
| } | |
| if (GUI.Button(new Rect(24, y, 100, 20), "Re-run")) Run(); | |
| } | |
| [BurstCompile] | |
| struct SqDistBurstJob : IJob { | |
| [ReadOnly] public NativeArray<float> Ax; | |
| [ReadOnly] public NativeArray<float> Ay; | |
| [ReadOnly] public NativeArray<float> Bx; | |
| [ReadOnly] public NativeArray<float> By; | |
| public NativeArray<float> Out; | |
| public void Execute() { | |
| var n = Out.Length; | |
| for (var i = 0; i < n; i++) { | |
| var dx = Ax[i] - Bx[i]; | |
| var dy = Ay[i] - By[i]; | |
| Out[i] = dx * dx + dy * dy; | |
| } | |
| } | |
| } | |
| [BurstCompile] | |
| struct SqDistBurstParallelJob : IJobParallelFor { | |
| [ReadOnly] public NativeArray<float> Ax; | |
| [ReadOnly] public NativeArray<float> Ay; | |
| [ReadOnly] public NativeArray<float> Bx; | |
| [ReadOnly] public NativeArray<float> By; | |
| public NativeArray<float> Out; | |
| public void Execute(int i) { | |
| var dx = Ax[i] - Bx[i]; | |
| var dy = Ay[i] - By[i]; | |
| Out[i] = dx * dx + dy * dy; | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Runtime.InteropServices; | |
| /// <summary> | |
| /// P/Invoke bindings for the Zig (or C) crunch native plugin. | |
| /// Library name must match Assets/Plugins/.../crunch.dll (Windows) / libcrunch (Unix). | |
| /// </summary> | |
| public static class CrunchNative { | |
| const string Lib = "crunch"; | |
| const string LibParallel = "crunch_parallel"; | |
| [DllImport(Lib, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] | |
| public static extern void crunch_sqdist2( | |
| float[] ax, float[] ay, | |
| float[] bx, float[] by, | |
| float[] output, | |
| UIntPtr n); | |
| [DllImport(LibParallel, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] | |
| public static extern void crunch_sqdist2_parallel( | |
| float[] ax, float[] ay, | |
| float[] bx, float[] by, | |
| float[] output, | |
| UIntPtr n); | |
| [DllImport(Lib, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] | |
| public static extern uint crunch_version(); | |
| } |
Comments are disabled for this gist.