Skip to content

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.

Processed 100000 tasks in 1m12s
Throughput: 1388.89 tasks/second
Memory Usage: 87 Mb

(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:

go test -run '^$' -bench '.' -benchmem ./runner/... ./internal/ring/... | tee before.txt
# ...make your change...
go test -run '^$' -bench '.' -benchmem ./runner/... ./internal/ring/... | tee after.txt
benchstat before.txt after.txt