Benchmarks¶
The repo ships a micro-benchmark suite covering the engine's hot paths and pure functions. These complement (and informed) the design -- they isolate the per-operation cost without Redis network latency, so you can reason about the overhead TaskRunner adds on top of your own task code.
Running the benchmarks¶
# All engine + ring benchmarks
go test -run '^$' -bench '.' -benchtime=300ms ./runner/... ./internal/ring/...
# A single benchmark, longer sample
go test -run '^$' -bench 'BenchmarkDispatch_NonUnique' -benchtime=3s ./runner/...
# With CPU profiling
go test -run '^$' -bench '.' -benchtime=2s -cpuprofile=cpu.out ./runner/...
go tool pprof cpu.out
The -run '^$' filter disables the regular tests so only benchmarks run.
Reference platform¶
The numbers below were captured on:
| OS | Linux (amd64) |
| CPU | 13th Gen Intel Core i7-13620H |
| Go | 1.26.5 |
| Redis | miniredis (in-process) for Dispatch benchmarks; pure functions for the rest |
Interpret absolute numbers cautiously
Absolute timings are platform-dependent. Use them for relative reasoning (which path is expensive, how unique compares to non-unique, where allocs cluster) and for spotting regressions across changes -- not as SLAs.
Worker tuning -- computeTunedWorkers¶
The pure function at the heart of autoscaling. Run once per aggregation tick on the leader; essentially free.
| Scenario | Time/op | Notes |
|---|---|---|
in_tolerance_band |
2.4 ns | Early return when predicted wait is in band -- the common steady-state case. |
predicted_wait_zero |
2.1 ns | Empty-queue decay path. |
above_upper_boundary |
8.8 ns | Boundary tuning. |
below_lower_boundary |
8.6 ns | Boundary tuning. |
deviation_large_above_center |
8.9 ns | Large-deviation tune-to-center. |
deviation_large_below_center |
8.6 ns | Large-deviation tune-to-center. |
clamp_to_max |
8.9 ns | Hits the max clamp. |
high_replication (rep=8) |
9.0 ns | Same shape, replication factor scales linearly in the math. |
The tuner is never a bottleneck: even at the most expensive branch it's well
under 10 ns, and it runs at most once every LongQueueThreshold/2.
Message serialization (per-message hot path)¶
Dispatch marshals a TaskMessage; each worker unmarshals one. These are the
per-message costs the engine can't avoid.
| Operation | Time/op | Allocs | Bytes |
|---|---|---|---|
TaskMessage marshal |
969 ns | 9 | 576 B |
TaskMessage unmarshal |
2,850 ns | 20 | 824 B |
| Marshal + unmarshal round trip | 4,002 ns | 29 | 1,400 B |
Task.CreateMessage |
123 ns | 2 | 32 B |
Unmarshal is ~3x marshal -- a property of encoding/json's reflection-based
decode. If message throughput ever becomes allocation-bound, a code-generated
marshaller (e.g. easyjson) would cut this substantially.
Dispatch end-to-end (miniredis-backed)¶
Full Dispatch path: task lookup, CreateMessage, marshal, lock (if unique),
and XADD. The miniredis backend removes real network latency but keeps the
Redis command semantics, so these numbers are a lower bound -- real Redis adds
~0.5-2 ms per round-trip over the wire.
| Variant | Time/op | Allocs | Bytes |
|---|---|---|---|
| Non-unique | 12,971 ns | 52 | 2,582 B |
| Unique (with redsync lock) | 24,769 ns | 111 | 5,428 B |
| Concurrent (non-unique) | 10,734 ns | 52 | 2,606 B |
Observations:
- Unique ~2x non-unique. The redsync
SET NX PX+ retry loop is the difference. For high-throughput unique jobs, batch dispatches or use the non-unique path with application-level dedup. - Concurrent amortizes. Under GOMAXPROCS parallel producers the per-op cost drops because the Redis pipeline and go-redis connection pool absorb the concurrency.
- 52 allocs per dispatch is dominated by go-redis argument construction and JSON marshalling. Most are small and short-lived.
Timing bulk writer¶
storeTiming is called once per processed message. It must be cheap, so the
worker never blocks on metrics.
| Operation | Time/op |
|---|---|
TimingBulkWriter.Write (single producer) |
564 ns |
TimingBulkWriter.Write (GOMAXPROCS producers) |
523 ns |
A single channel send to the background flusher -- ~0.5 us per message. The
actual Redis HINCRBY happens off the hot path, batched once per second.
Safe map (task registry)¶
Read on every Dispatch and every worker lookup.
| Operation | Time/op |
|---|---|
Get (serial) |
18.0 ns |
Get (GOMAXPROCS concurrent) |
18.5 ns |
Set |
31.8 ns |
Snapshot |
26.1 ns |
The sync.RWMutex-guarded map scales well for the read-heavy registry
pattern. Snapshot returns the underlying map by reference (callers must not
mutate), which keeps it cheap.
Ring helpers (queue metrics)¶
Used by the Redis backend to compute claim-delay statistics.
| Operation | Time/op | Input |
|---|---|---|
AverageFloat64 (small) |
1.4 ns | 5 elements |
AverageFloat64 (large) |
502 ns | 1,024 elements |
StandardDeviationFloat64 |
3,467 ns | 256 elements |
StandardDeviationFloat64 is the most expensive ring helper (it calls
math.Pow/math.Sqrt per element). It runs once per GetMetrics call on a
1,000-element ring, not per message, so the cost is bounded.
Counters¶
| Operation | Time/op |
|---|---|
atomic.Int64.Add |
5.8 ns |
The per-message counters (inFlight, processed, fails) are negligible.
Putting it together: per-message engine overhead¶
For a non-unique message on the worker hot path, the engine adds roughly:
unmarshal ~2,850 ns
registry Get ~18 ns
storeTiming ~564 ns
counter adds ~12 ns (2 x atomic add)
ack (XACK) ~? (Redis round-trip; ~0.5-2 ms over the wire)
So the per-message engine overhead is ~3.5 us, dominated by JSON unmarshal.
For a task whose Action does meaningful work (DB call, HTTP request -- easily
milliseconds), the engine overhead is in the noise. For ultra-fast tasks
(sub-microsecond), JSON unmarshal becomes the limiting factor and a custom
serializer would help.
End-to-end load harness¶
For real-world throughput (including Redis network), use the shipped
cmd/worker
harness -- see Benchmark example. It dispatches
100,000 tasks with worker tuning enabled and reports tasks/sec and memory.
(YMMV; the harness is most useful for relative before/after comparisons.)
Regression tracking¶
The benchmark files live alongside the code:
runner/bench_test.go-- Dispatch, serialization, registry, bulk writer, counters.runner/tuning_test.go--computeTunedWorkers(single + table-driven scenarios).internal/ring/ring_helper_test.go-- ring math helpers.
Run them before and after a change to catch regressions: