-
Notifications
You must be signed in to change notification settings - Fork 0
Benchmarks
SQ# versus Arma 3 SQF engine performance analysis. Single-fiber benchmarks, parallelism scaling, and why raw speed is not the primary goal.
SQ# executes SQF-syntax scripts on .NET runtime. On single-fiber compute-bound workloads, SQ# averages 2.4× slower than Arma 3's native SQF engine (range: 0.81× to 10.2×). SQ# is faster than Arma on 2 of 21 benchmarks: splitString/joinString and forEach-math.
This is expected, acknowledged, and not a priority to fix. Performance parity with Arma 3's single-threaded engine is neither the goal nor within practical reach given fundamental architectural constraints.
However: single-fiber benchmarks measure SQ#'s worst case. SQ#'s primary advantage is parallelism — the ability to run multiple schedulers on separate threads simultaneously. Arma 3 cannot do this at all.
Every single-fiber benchmark runs one script on one scheduler — Arma 3's exact execution model. This is SQ#'s worst case and Arma's best case. Real server workloads don't look like this.
Arma 3 executes ALL scripts on one simulation thread. There is no parallelism. At 30 FPS, scripts get ~3–6ms per frame after rendering, physics, and networking.
Consider a server with 200 AI units, each running a 1ms behavior script per tick:
200 scripts × 1ms = 200ms required
3ms per frame = 67 frames to process all scripts
At 30 FPS = 2.2 seconds of latency per AI decision cycle
Scripts queue up serially. The server's AI becomes sluggish, reaction times degrade. This is Arma 3's fundamental scalability limit.
SQ# provides named schedulers running on separate threads:
// Distribute work across scheduler threads:
[unitData] spawnOn ["AI_1", { aiBehavior(_this); }];
[unitData] spawnOn ["AI_2", { aiBehavior(_this); }];
[unitData] spawnOn ["AI_3", { aiBehavior(_this); }];
[unitData] spawnOn ["AI_4", { aiBehavior(_this); }];Same 200-unit workload, 4 schedulers:
200 scripts ÷ 4 schedulers = 50 scripts per scheduler
50 scripts × 1ms = 50ms per scheduler
3ms budget × 17 ticks = ~0.56 seconds wall-clock
200 scripts complete in 0.56s instead of 2.2s — a 4× improvement from parallelism alone. Add more schedulers (up to available cores), latency drops further.
| Scenario | Arma 3 | SQ# (4 schedulers) | Winner |
|---|---|---|---|
| 1 script, heavy math | 169 ms | 464 ms | Arma |
| 200 scripts, light AI | 2,200 ms (serial) | 560 ms | SQ# |
| 500 scripts, mixed | 5,500+ ms (serial) | ~700 ms | SQ# |
At ~50 concurrent scripts of moderate complexity, SQ#'s parallelism advantage exceeds Arma's per-fiber speed advantage. For multiplayer servers, headless clients, and dedicated AI hosts — parallelism is the dominant factor.
From bench-parallel.sqf: 8 fibers × 40,000 trig iterations (sin × cos + sqrt) = 320,000 math calls total.
| Engine | Mode | Time | vs SQ# Seq |
|---|---|---|---|
| SQ# | sequential (1 thread) | 1,556 ms | 1.0× |
| SQ# | parallel (4 threads) | 418 ms | 3.7× faster |
| Arma 3 | loading screen (bare engine) | 475 ms | 3.3× faster |
| Arma 3 | in-game (physics+render+AI) | 2,040 ms | 1.3× slower |
SQ# currently runs with zero game overhead — no rendering, physics, AI, networking. Equivalent to Arma's loading screen. The loading screen result (475 ms) is Arma's best-case SQF speed.
In-game, Arma scripts compete with everything for the single simulation thread. The 2,040 ms result is real-world Arma performance. SQ# sidesteps this — scripts get dedicated scheduler threads.
Bottom line: SQ# on 4 threads (418 ms) matches Arma's best-case single-thread speed (475 ms).
All tests: Windows 11, .NET 10, Release build. Arma 3: identical .sqf scripts in-mission.
Timer resolution note: Arma 3's
diag_tickTimeresolution is ~1ms (quantized to ~0.976ms). SQ# achieves sub-microsecond precision. Sub-10ms comparisons are not meaningful.
| Test | Scale | SQ# | Arma 3 | Ratio |
|---|---|---|---|---|
| Trig loop (sin+cos+tan+sqrt+log+pow) | 50,000 ops | 464 ms | 169 ms | 2.7× |
Each iteration: 5 trig calls + 1 sqrt + 1 log + 2 pow + 1 add. SQ#: ~108,000 math calls/sec. Arma: ~296,000 math calls/sec.
| Test | Scale | SQ# | Arma 3 | Ratio |
|---|---|---|---|---|
pushBack |
2,000 elements | 3.7 ms | 0.98 ms | 3.8× |
select (indexed read) |
2,000 elements | 7.9 ms | 2.93 ms | 2.7× |
forEach |
2,000 elements | 3.9 ms | 1.95 ms | 2.0× |
sort |
2,000 elements | 5.6 ms | 1.95 ms | 2.9× |
find (last element) |
2,000 elements | 4.1 ms | 1.95 ms | 2.1× |
| Test | Scale | SQ# | Arma 3 | Ratio |
|---|---|---|---|---|
Concatenation (+) |
1,000 ops | 2.3 ms | 1.95 ms | 1.2× |
format |
1,000 ops | 4.6 ms | 2.93 ms | 1.6× |
splitString + joinString
|
1,000 ops | 12.2 ms | 15.1 ms | 0.81× 🏆 SQ# faster! |
find (substring) |
1,000 ops | 2.1 ms | 0.98 ms | 2.1× |
toUpper + toLower
|
1,000 ops | 2.7 ms | 0.98 ms | 2.7× |
SQ# wins at splitString/joinString — .NET's string handling outperforms Arma's custom implementation.
| Test | Scale | SQ# | Arma 3 | Ratio |
|---|---|---|---|---|
set |
1,000 keys | 3.1 ms | 1.95 ms | 1.6× |
get |
1,000 keys | 6.8 ms | 2.93 ms | 2.3× |
set + get mixed |
1,000 ops | 5.1 ms | 2.93 ms | 1.7× |
| Test | Scale | SQ# | Arma 3 | Ratio |
|---|---|---|---|---|
for empty body |
30,000 iter | 39.7 ms | 3.91 ms | 10.2× |
while empty body |
30,000 iter | 42.2 ms | 25.9 ms | 1.6× |
forEach empty body |
30,000 iter | 59.5 ms | 42.0 ms | 1.4× |
for with math body |
30,000 iter | 74.0 ms | 18.1 ms | 4.1× |
while with math body |
30,000 iter | 83.2 ms | 42.0 ms | 2.0× |
forEach with math body |
30,000 iter | 59.2 ms | 62.0 ms | 0.95× 🏆 SQ# faster! |
The for-loop gap (10.2×) is the largest performance difference. Arma JIT-compiles for loops to native machine code; SQ# interprets 12+ bytecode instructions per iteration. forEach-math is essentially tied — .NET's delegate invocation cost amortizes over the math body.
Average ratio across all comparable tests: SQ# is 2.4× slower.
SQ# wins in 2 of 21 tests:
-
splitString+joinString: 0.81× (12.2ms vs 15.1ms) -
forEach-math: 0.95× (59.2ms vs 62.0ms — essentially tied)
Arma 3 has a JIT compiler for SQF bytecode. Hot scripts compile to native x86-64 machine code with register allocation and instruction scheduling.
SQ# is a stack-based bytecode interpreter. Every instruction goes through a C# switch dispatch, operand decoding, stack manipulation, and managed method calls. Zero native code generation.
Per-instruction overhead:
- Array bounds check on instruction list
- Switch table dispatch (indirect branch)
- Operand decoding (bitwise extraction)
- Stack pointer manipulation (bounds-checked access)
- Method call overhead for command handlers
Arma avoids ALL of this for hot paths.
.NET Garbage Collection. Every SqArray, SqHashMap, string, List<SqValue> buffer, and temporary allocation goes through the GC. Unpredictable pauses. Arma 3 uses arena allocators — zero GC, deterministic deallocation.
Bounds checking. Every _stack[_sp], _locals[slot], array access passes CLR bounds verification. Arma's native code omits proven-safe bounds checks.
Delegate invocations. Every SQF command is a C# delegate — an indirect call through a function pointer. Arma resolves commands to direct native function pointers, then inlines the body — completely eliminating call overhead.
Struct copying. SqValue is 24 bytes (type tag + double + object ref). Every push/pop/store/load copies 24 bytes. In a 50,000-iteration loop with 5 ops/iter, that's ~500,000 struct copies. Arma uses NaN-boxing — 8 bytes per value in a single register.
Every command in SQ# follows:
BinaryCall opcode
→ Pop() right operand (stack + bounds)
→ Pop() left operand (stack + bounds)
→ ResolveCommandId(index) (array lookup)
→ Bounds check command ID
→ Null check handler
→ Delegate invocation (indirect call)
→ UnwrapNumber (for math) (type check + extract)
→ Math.Sin / Math.Cos / ... (actual work — ~20% of path)
→ new SqValue(result) (struct construction)
→ Push(result) (stack + bounds)
~80% interpreter overhead, ~20% actual computation. Arma inlines to a single FSIN x86 instruction.
.NET strings are immutable. Every + allocates. Every format allocates a StringBuilder + result. Every splitString allocates substring array. Every toUpper/toLower allocates.
Arma uses a string interning pool with mutable temporary buffers — no per-allocation GC pressure.
SQ# for loop bytecode per iteration (12 instructions):
PushLocal _i → PushLocal _to → BinaryCall <= → JumpIfFalse exit
[body] → Pop → PushLocal _i → PushConst 1 → BinaryCall +
StoreLocal _i → Jump loopStart
Arma JIT emits 5 machine instructions:
.loop: ;body (inlined) | inc eax | cmp eax,[to] | jle .loopNo interpreter, no stack, no dispatch.
- Portability — SQF scripts anywhere .NET runs (Windows, Linux, macOS). Arma is Windows-only.
- Embeddability — Host apps embed SQ# as a scripting engine with full control. Arma's engine is hard-coupled to the game.
- Multi-threading — Cooperative fibers, named schedulers, freeze/thaw, channels, shared atomics. Arma has no threading model.
- Developer experience — Precise errors (file:line:col), CLI tooling, documented bytecode. Arma errors are famously cryptic.
- Language modernization — Type annotations, verbatim strings, interpolation, hex literals, structured errors.
SQ# scripts control game logic, orchestrate AI, manage UI state, configure servers — not perform hot-path physics. Millisecond-level loop overhead differences are imperceptible for these workloads.
When high performance is needed for a specific operation, implement it in native C# and expose it as a registered command. The interpreter dispatch cost is a one-time overhead amortized over native execution.
Arma 3's SQF engine represents two decades of optimization by Bohemia Interactive:
- Written in C++ with x86-64 platform-specific optimizations
- JIT compiler refined over 4 game releases
- Arena allocators — zero garbage collection
- Script operations on game objects are direct pointer manipulations
- SQF performance IS the product — military simulation at 50+ FPS with thousands of scripted entities
SQ# is a small-team project implementing a compatible language on a managed runtime. Comparing the two is comparing a Formula 1 car to a reliable family sedan — different purposes, different constraints.
.NET's design prioritizes safety and productivity over raw speed:
- No inline native code — C# cannot emit CPU instructions directly
-
No manual memory management —
stackalloc/Span<T>help but can't eliminate GC - No unchecked array access — safety guarantees prevent unsafe optimizations
- Delegate overhead — can't eliminate without runtime IL emission
- Cross-platform — SIMD intrinsics differ between x64 and ARM64
Architectural decisions locked in:
-
SqValue: 24-byte tagged union (vs Arma's 8-byte NaN-boxing) - Stack VM with 1024-element fixed stack (vs register VM)
- Separate compile/execute phases (enables tooling but prevents adaptive JIT)
- Cooperative scheduling with time budgets (adds context-switch overhead)
| Feature | SQ# | Arma 3 |
|---|---|---|
| Platform support | Windows, Linux, macOS | Windows only |
| Embeddable | Yes (NuGet packages) | No (engine-locked) |
| Multi-threading | Fibers + schedulers + channels | Single-thread only |
| Error messages | File(line:col): typed error | Generic, often misleading |
| Type annotations | Optional : int, : string
|
None |
| String interpolation | f"Hello {name}" |
format only |
| Verbatim strings | @"C:\path" |
None |
| Hex literals | 0xFF |
None |
| Structured errors |
try/catch with location data |
String-only errors |
| CLI tooling | lex/parse/compile/run/repl | Community tools only |
| Immutable sharing |
freeze/thaw
|
None |
| Atomic variables |
shared with CAS |
None |
SQ# is slower than Arma 3's SQF engine by 2–8× on single-fiber workloads. This is a direct consequence of running on a managed runtime with an interpreter, not a failure of implementation.
The project's value is not raw speed — it's portability, embeddability, multi-threading, modern tooling, and language improvements. For SQ#'s target workloads (scripting, configuration, game logic, server orchestration), current performance is adequate.
Closing the gap would require abandoning .NET for C++ (defeating the purpose), implementing a JIT compiler (years of work), or accepting unsafe optimizations that compromise reliability. None of these tradeoffs align with the project's goals.
For heavy computation, the host implements the operation in native C# and exposes it as a command — best of both worlds: SQF scripting ergonomics with native C# performance where it matters.
- Optimization Guide — script-level performance tips
- Concurrency — multi-scheduler architecture
- Thread Safety — freeze/thaw, shared atomics
- Host API — native C# command registration for performance-critical ops
- CLI Tool — benchmarking with the CLI
- Getting Started
- Language Guide
- Type System
- Control Flow
- Functions & Code
- Strings & Text
- Arrays & Collections
- Concurrency
- Promise System
- Thread Safety
- Host API & Embedding
- CLI Tool
- Bytecode Reference
- Multiplayer
- Syntax Sugar
- Optimization Guide
- Benchmarks
- count
- select
- pushBack
- append
- deleteAt
- deleteRange
- resize
- reverse
- sort
- find
- in
- forEach
- freeze
- thaw
- isFrozen
- currentScheduler
- clientOwner
- allSchedulers
- schedulerName
- schedulerExists
- schedulerBudget
- setSchedulerBudget
- fiberCount
- readyFiberCount
- waitingFiberCount
- schedulerLoad
- sendTo