Skip to content

Instantly share code, notes, and snippets.

@adammyhre
Created August 9, 2026 04:58
Show Gist options
  • Select an option

  • Save adammyhre/d2e5246d60df5031acdec445399306df to your computer and use it in GitHub Desktop.

Select an option

Save adammyhre/d2e5246d60df5031acdec445399306df to your computer and use it in GitHub Desktop.
Zig + Unity: Native Speed for Hot Loops
/* 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;
}
//! 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;
}
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;
}
}
}
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.