Skip to content

perf(queue): reduce retry and burst allocations - #181

Open
appleboy wants to merge 2 commits into
masterfrom
perf/queue-allocation-costs
Open

appleboy wants to merge 2 commits into
masterfrom
perf/queue-allocation-costs

Conversation

@appleboy

@appleboy appleboy commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Summary

Reduce allocations when retry informational logging is disabled, and let bursty in-memory queues opt into retaining their Ring backing array with WithQueueMinCapacity. The default remains two slots; queue limits, FIFO, retries and shutdown behavior are preserved. Existing Logger implementations remain compatible; InfoEnabled() is optional and checked on each retry.

The PR deliberately excludes pre-existing local retry-timer reuse and Jitter fixes. The performance results below compare clean master with the actual PR implementation. Retention trades idle memory for fewer allocations; this PR does not claim a general end-to-end throughput improvement.

Clean-base measurements (six samples per version): retry-10 allocations 48 -> 38; retained Ring burst allocations 18 -> 0 per 1,024-task batch, with median time reduced about 28.5%. Full measurements and ranges are included below.

Related issues: N/A (no issue supplied).

Flow

flowchart TD
    Retry[queue.go: retry completes] --> Enabled[logger.go: optional InfoEnabled]
    Enabled -->|enabled or legacy logger| Log[queue.go: construct arguments and log]
    Enabled -->|disabled| Continue[queue.go: continue retry loop]
    Option[options.go: WithQueueMinCapacity] --> Init[ring.go: clamp and allocate slots]
    Init --> Drain[ring.go: dequeue and clear references]
    Drain --> Shrink[ring.go: shrink without crossing retained minimum]
    style Enabled fill:#dff0d8,stroke:#3c763d
    style Option fill:#dff0d8,stroke:#3c763d
    style Shrink fill:#dff0d8,stroke:#3c763d
Loading

Classification and AI authorship

  • Core change: shared queue behavior and public configuration; request two reviewers including the module owner.
  • AI used: OpenAI Codex authored all task files in this PR: logger.go, options.go, queue.go, ring.go, burst_performance_test.go, performance_options_test.go.
  • Human line-by-line reviewed: None — not yet reviewed by a human.
  • Scope: disabled retry logging and optional Ring capacity retention only. Measurements and limitations are included below.

Verification

From the repository root on perf/queue-allocation-costs, use Go 1.26.10 (see go.mod). Benchmark reproduction also needs Git, tar and Python 3. No services or credentials are required.

Check Expected behavior Observed
go test -race ./... All packages pass, including leak detection and the new behavior scenarios below Passed
go tool -modfile=go.tools.mod golangci-lint run No lint issues Passed, 0 issues
Example module go build -mod=readonly ./... in each _example/* module Both examples compile Passed
git diff --check No whitespace errors Passed

Behavioral scenarios (all Passed as part of the race suite):

  1. Run go test -race -run '^TestRetryInfoEnabled$' .: legacy/enabled loggers emit three retry messages; disabled loggers emit none; dynamic enablement emits one; the task still makes four attempts.
  2. Run go test -race -run '^TestRingMinCapacity$' .: repeated fill/drain and wraparound preserve FIFO, clear task references, retain the requested minimum, support non-power-of-two values, cap retention against queue limits, and still reject full/shut-down queues. The NewPool path completes its submitted task.

Run the following from the PR checkout to reproduce the candidate benchmarks:

go test -run '^$' -bench '^(BenchmarkHandleRetry|BenchmarkQueueEndToEnd|BenchmarkQueueAfterFnEndToEnd|BenchmarkRingBurstDrain|BenchmarkQueueBurstProducers)$' -benchmem -benchtime=300ms -count=6 -cpu=12 .

To reproduce the baseline, extract adf9a1c into a separate temporary directory, copy burst_performance_test.go from this PR, and add this test-only adapter in retention_compat_test.go:

package queue

func WithQueueMinCapacity(int) Option {
    return OptionFunc(func(*Options) {})
}

This adapter lets the same benchmark compile against the baseline without changing its old capacity policy. Run the same command there. The other benchmark files already exist at the base commit. Run versions sequentially, alternating order; do not run race instrumentation or lint alongside timings. Remove the temporary baseline directory after inspection. Tests do not create external data.

Performance results: clean base versus PR

Measured on Apple M4 Pro, darwin/arm64, Go 1.26.10, GOMAXPROCS=12 (-cpu=12). Base: adf9a1c; measured implementation: f1fd2a4 (subsequent removal of report files does not change production code or tests).

Six samples per version: two rounds of three samples, 300ms per scenario. Order: base -> PR, then PR -> base. Runs were sequential, without concurrent Go tests/lint. All tasks are no-ops and use the empty logger; retries use a 1ns delay to expose framework overhead. End-to-end batches contain at most 1,024 tasks, include completion and final Release, and verify successful task counts. Producer goroutine creation is included in the multi-producer benchmarks.

Numbers below are medians. Negative time change means faster; throughput change is calculated as base time / PR time - 1. These are average processing costs, not per-task latency or p99. Ring results are per complete batch of 1,024 enqueue/dequeue pairs; other results are per completed task/handler.

Scenario Base ns/op PR ns/op Time change Throughput change B/op base -> PR allocs/op base -> PR
Ring burst/drain, default retention (per 1,024-task batch) 14,293.0 14,329.0 +0.3% -0.3% 55968 -> 55968 18 -> 18
Ring burst/drain, retain 1,024 slots (per batch) 14,229.0 10,170.0 -28.5% +39.9% 55968 -> 0 18 -> 0
1 producer / 4 workers, default 1,357.5 1,385.0 +2.0% -2.0% 726 -> 726 9 -> 9
1 producer / 4 workers, retain 1,024 1,360.5 1,408.0 +3.5% -3.4% 726 -> 672 9 -> 9
4 producers / 4 workers, default 1,412.0 1,452.5 +2.9% -2.8% 727 -> 727 9 -> 9
4 producers / 4 workers, retain 1,024 1,482.5 1,446.5 -2.4% +2.5% 727 -> 672 9 -> 9
Handler retries=0 753.2 769.0 +2.1% -2.1% 576 -> 576 8 -> 8
Handler retries=1 1,081.5 1,040.5 -3.8% +3.9% 856 -> 824 12 -> 11
Handler retries=3 1,704.5 1,619.0 -5.0% +5.3% 1416 -> 1320 20 -> 17
Handler retries=10 3,705.0 3,471.5 -6.3% +6.7% 3376 -> 3056 48 -> 38
End-to-end workers=1 1,196.5 1,157.0 -3.3% +3.4% 726 -> 726 9 -> 9
End-to-end workers=4 1,412.5 1,371.5 -2.9% +3.0% 726 -> 726 9 -> 9
End-to-end workers=12 1,859.5 1,863.0 +0.2% -0.2% 726 -> 726 9 -> 9
4 workers with afterFn, default 1,458.0 1,347.0 -7.6% +8.2% 766 -> 766 11 -> 11

Sample ranges and limits

Scenario Base ns/op range PR ns/op range
Ring burst/drain, default retention (per 1,024-task batch) 14050-14449 13951-14447
Ring burst/drain, retain 1,024 slots (per batch) 13945-14566 10080-10326
1 producer / 4 workers, default 1298-1452 1333-1458
1 producer / 4 workers, retain 1,024 1272-1460 1327-1434
4 producers / 4 workers, default 1369-1535 1377-1536
4 producers / 4 workers, retain 1,024 1397-1561 1321-1549
HandleRetry/retries=0 734.3-796.5 747.9-800.7
HandleRetry/retries=1 1028-1137 1020-1064
HandleRetry/retries=3 1613-1785 1609-1680
HandleRetry/retries=10 3519-3881 3414-3552
QueueEndToEnd/workers=1 1131-1212 1132-1191
QueueEndToEnd/workers=4 1297-1506 1273-1450
QueueEndToEnd/workers=12 1718-2086 1776-1901
4 workers with afterFn, default 1281-1717 1325-1369
  • Retry logging: at 10 retries, allocations decrease from 48 to 38 and bytes from 3,376 to 3,056 per handler. Median time falls about 6.3%. Existing loggers without InfoEnabled still emit logs; enabled/disabled/dynamic behavior is regression-tested.
  • Ring retention: opting into WithQueueMinCapacity(1024) eliminates 18 steady-state allocations and 55,968 allocated bytes per 1,024-task burst. Median batch time falls about 28.5%. The default remains 2 slots and does not receive this opt-in benefit.
  • Memory tradeoff: after draining, backing-array slots increase from 32 B to 16,384 B (16 KiB). Initial allocation is before ResetTimer and excluded from B/op. Dequeued task references are cleared. This is backing storage, not RSS.
  • End-to-end: no general throughput improvement is established. Both faster and slower medians are reported, and ranges overlap in several cases. Six samples do not establish statistical significance or performance equivalence. Integer allocs/op can hide amortized reductions in Ring allocations.
  • Not measured: real I/O/CPU-heavy jobs, external brokers, p95/p99, RSS, and long-running soak behavior. Tune retention to the actual burst size and number of queue instances.
  • Earlier working-tree experiment: previously discussed 21 -> 11 allocations at 10 retries included a pre-existing local timer-reuse change. That change is excluded from this PR, so those absolute figures are not the clean-base PR results. The table above is the applicable comparison.

Reports, raw-data files and reproduction scripts are kept locally and are not part of the PR's final file diff; benchmarks and regression tests remain in the PR.

Risk and rollback

No secrets, credentials, dependencies or network-facing interfaces are added. Custom InfoEnabled implementations must be concurrency-safe. Excessive retention settings allocate memory up front; default behavior is unchanged. Review minimum-capacity normalization, shrink/FIFO behavior and logger compatibility carefully. Revert the PR commits to roll back the options and logging guard.

- Skip retry log argument allocation when informational logging is disabled
- Add optional initial and minimum Ring capacity while preserving defaults
- Cover compatibility and capacity boundaries with regression tests
- Record reproducible benchmarks and memory tradeoffs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant