Skip to content

perf: avoid timer & worker overhead in unlimited mode - #165

Merged
Mzack9999 merged 2 commits into
mainfrom
dwisiswant0/perf/avoid-timer-worker-overhead-in-unlimited-mode
Sep 9, 2026
Merged

Mzack9999 merged 2 commits into
mainfrom
dwisiswant0/perf/avoid-timer-worker-overhead-in-unlimited-mode

Conversation

@dwisiswant0

Copy link
Copy Markdown
Member

NewUnlimited fakes unlimited access with a MaxUint32 bucket refilled
every millisecond. Each instance starts a ticker and worker that wake
even when nobody is calling Take, and Take itself still pays for a
channel hop.

Make unlimited mode explicit instead. Take returns immediately and
CanTake reports true without a ticker, token channel, or background
goroutine. That also drops the extra cost for unlimited keys in
MultiLimiter and AutoLimiter.

Keep runtime config working by spinning up a finite bucket on the first
SetLimit or SetDuration call. Keep the initial MaxUint32 burst, apply
limit changes at the next refill, and leave the default interval at
1 ms. The refill clock now starts at the first setter, not at
construction.

Serialize bucket init, config, and Stop. Drop the mutex before waiting
for a token, and don't let setters create a bucket after Stop. Keep the
original GetLimit value and reject non-positive refill durations.

PD consumers this hits:

  • subfinder, urlfinder, and tldfinder: create an unlimited bucket for
    each selected source that has no positive source-specific or global
    rate limit, so idle workers pile up on a normal run.
  • nuclei: uses an unlimited global limiter when global limiting is off
    or per-host limiting is on, plus extra SDK paths.
  • httpx: picks unlimited mode when neither the per-second nor
    per-minute rate is positive.
  • dnsx: creates an unlimited limiter before checking the configured
    rate. A positive rate replaces it without stopping the old worker, so
    the idle work still happens even with a finite limit.
  • uncover: exposes unlimited mode through sources.NewSession when
    rateLimit <= 0. The normal service path already falls back to a
    finite 30/min for a zero rate.
  • [and some internal projects that can't be named here]

Those call sites pick up the win after they adopt a release with this
change. This is about exposure, not measured app-level CPU savings.

Ten-run benchmark medians on Linux/amd64 with Go 1.26.5 and
GOMAXPROCS=4:

                          before       after
Take                     835 ns/op    23.3 ns/op
Parallel Take           1334 ns/op    60.5 ns/op
MultiLimiter Take        631 ns/op    40.0 ns/op
Idle CPU, 30 limiters    127 ms/s      2.03 ms/s
Idle CPU, 1000 limiters 1473 ms/s      2.11 ms/s
Idle workers, 1000      1000           0

Idle CPU after the change is basically the zero-limiter control
(1.92 ms/s). These numbers are unlimited mode before any setter calls;
configured limiters still use the finite bucket.

NewUnlimited fakes unlimited access with a MaxUint32 bucket refilled
every millisecond. Each instance starts a ticker and worker that wake
even when nobody is calling Take, and Take itself still pays for a
channel hop.

Make unlimited mode explicit instead. Take returns immediately and
CanTake reports true without a ticker, token channel, or background
goroutine. That also drops the extra cost for unlimited keys in
MultiLimiter and AutoLimiter.

Keep runtime config working by spinning up a finite bucket on the first
SetLimit or SetDuration call. Keep the initial MaxUint32 burst, apply
limit changes at the next refill, and leave the default interval at
1 ms. The refill clock now starts at the first setter, not at
construction.

Serialize bucket init, config, and Stop. Drop the mutex before waiting
for a token, and don't let setters create a bucket after Stop. Keep the
original GetLimit value and reject non-positive refill durations.

PD consumers this hits:

- subfinder, urlfinder, and tldfinder: create an unlimited bucket for
  each selected source that has no positive source-specific or global
  rate limit, so idle workers pile up on a normal run.
- nuclei: uses an unlimited global limiter when global limiting is off
  or per-host limiting is on, plus extra SDK paths.
- httpx: picks unlimited mode when neither the per-second nor
  per-minute rate is positive.
- dnsx: creates an unlimited limiter before checking the configured
  rate. A positive rate replaces it without stopping the old worker, so
  the idle work still happens even with a finite limit.
- uncover: exposes unlimited mode through sources.NewSession when
  rateLimit <= 0. The normal service path already falls back to a
  finite 30/min for a zero rate.
- [and some internal projects that can't be named here]

Those call sites pick up the win after they adopt a release with this
change. This is about exposure, not measured app-level CPU savings.

Ten-run benchmark medians on Linux/amd64 with Go 1.26.5 and
GOMAXPROCS=4:

                              before       after
    Take                     835 ns/op    23.3 ns/op
    Parallel Take           1334 ns/op    60.5 ns/op
    MultiLimiter Take        631 ns/op    40.0 ns/op
    Idle CPU, 30 limiters    127 ms/s      2.03 ms/s
    Idle CPU, 1000 limiters 1473 ms/s      2.11 ms/s
    Idle workers, 1000      1000           0

Idle CPU after the change is basically the zero-limiter control
(1.92 ms/s). These numbers are unlimited mode before any setter calls;
configured limiters still use the finite bucket.

Signed-off-by: Dwi Siswanto <git@dw1.io>
@dwisiswant0

Copy link
Copy Markdown
Member Author

@coderabbitai create an issue to track this, make it short, one paragraph. must be bug oriented.

@dwisiswant0 dwisiswant0 linked an issue Sep 9, 2026 that may be closed by this pull request
@dwisiswant0

Copy link
Copy Markdown
Member Author

Measured through Subfinder using pinned ratelimit@v0.0.88 vs. ratelimit@416c32d8:

Measurement Before After Improvement
Idle CPU, 950 unlimited limiters 1,337.5 ms/s 0.15 ms/s 99.99% lower
Background goroutines 950 0 All eliminated
MultiLimiter.Take 631 ns 40.8 ns 15.5x faster
In-memory HTTP request 6.32 µs 5.00 µs 20.9% less time

Six interleaved runs, with a separate dependency control.

@Mzack9999
Mzack9999 merged commit 6675698 into main Sep 9, 2026
7 checks passed
@dwisiswant0
dwisiswant0 deleted the dwisiswant0/perf/avoid-timer-worker-overhead-in-unlimited-mode branch September 9, 2026 22:22
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.

Unlimited rate limiters consume CPU while idle

2 participants