Skip to content

download: enforce MaxDownload as a literal byte/sec global cap - #491

Merged
mrjimenez merged 1 commit into
amule-project:masterfrom
got3nks:fix-maxdownload-literal-cap
Apr 27, 2026
Merged

download: enforce MaxDownload as a literal byte/sec global cap#491
mrjimenez merged 1 commit into
amule-project:masterfrom
got3nks:fix-maxdownload-literal-cap

Conversation

@got3nks

@got3nks got3nks commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Summary

MaxDownload has never actually been a bandwidth cap. The old code in DownloadQueue::Process computed a downspeed percentage as a 50-200% adaptive ratio against the observed aggregate datarate:

downspeed = MaxDownload * 102400 / (m_datarate + 1), clamped to [50, 200]

then in CUpDownClient::SetDownloadLimit converted that percentage back to a per-tick byte budget by multiplying it against the client's smoothed speed. That's a closed-loop controller that nudges each peer's transfer rate by ~5%/tick relative to its own current speed — it does not enforce MaxDownload as a literal ceiling. Setting MaxDownload=20000 (20 MB/s) wouldn't reliably plateau at 20 MB/s; it just shaped traffic toward whatever the aggregate datarate happened to converge on, with the slow-ramp / plateau-below-target behaviour anyone who's set a non-zero MaxDownload has noticed.

Replaces the per-peer ratio controller with a single global token bucket — CDownloadBandwidthThrottler — that every CEMSocket consults via Reserve() before each Read(). The bucket is refilled once per CORE_TIMER_PERIOD by DownloadQueue::Process from MaxDownload * 1024 * tick_period_ms / 1000. A small carry-over (capped at 2× budget) keeps reads flowing across tick boundaries so TCP flow control doesn't see receive-side stalls and back off.

Demand-aware redistribution falls out naturally: there's no per-peer slice. A fast peer can claim unused capacity from slow peers within the same tick; the global cap is the only constraint.

What changed

file role
src/DownloadBandwidthThrottler.{h,cpp} (new) singleton token-bucket: Reserve(bytes), Refund(bytes), RefillBudget(KBps, tickMs). m_unlimited flag short-circuits when MaxDownload=0.
src/EMSocket.{h,cpp} OnReceive() consults CDownloadBandwidthThrottler::Reserve before each Read(); refunds the unused portion. SetDownloadLimit/DisableDownloadLimit removed; replaced by WakeIfPaused() that retries OnReceive(0) if the socket suspended last tick because the bucket was empty. downloadLimit/downloadLimitEnable members gone.
src/DownloadQueue.cpp one CDownloadBandwidthThrottler::Get().RefillBudget(...) call per tick replaces the entire downspeed ratio block.
src/PartFile.{h,cpp} Process(uint8 m_icounter)reducedownload parameter dropped; the loop only wakes paused sockets and sums kBpsClient for the per-file display readout.
src/BaseClient.cpp + src/updownclient.h CUpDownClient::SetDownloadLimit(uint32) renamed to TickDownloadAndMeasure() — the parameter was the legacy ratio, now nothing per-peer is being set.
src/DownloadClient.cpp the old transition-out-of-DS_DOWNLOADING m_socket->DisableDownloadLimit() call is gone — the cap is global, no per-socket state to clear on state changes.
cmake/source-vars.cmake adds DownloadBandwidthThrottler.cpp to CORE_SOURCES.

Net diff: +243 / -92 lines across 11 files, single commit.

Why a global bucket and not a per-peer share

Two reasons, both empirically validated below:

  1. Demand-aware redistribution is the natural shape. The user-facing contract for MaxDownload is "do not exceed N KB/s total"; users don't want it to mean "cap each peer at N/k KB/s and waste the slack". A global bucket gives slow peers exactly what they can fill and lets fast peers absorb the rest.
  2. TCP doesn't tolerate the equal-split alternative well. If you slice the bucket per peer up front, slow peers don't fill their slice, the leech's cumulative read pause looks to TCP flow control like a slow consumer, the seeder backs off, and the achievable rate plateaus well below the cap. The carry-over (2× budget) keeps the read loop draining smoothly across tick boundaries.

The original ratio controller dodged both of these by not actually capping anything — but that's not what the pref is documented to mean.

Bench

Test rig: an aMule seeder on a separate LAN host. Leech on each of macOS (Apple Silicon, native), Ubuntu ARM (UTM), Windows 11 ARM (UTM). Same revision on seeder and every leech. 5 GB / 30 GB testfile.

Single-peer matrix

90s ramp on a host-quiescent machine (no concurrent compiles), final-tick speed reported.

platform MaxDownload=0 (unlimited) MaxDownload=20000 (20 MB/s cap) MaxDownload=1000000 (UI max, 1 GB/s)
macOS 140 MB/s+ 19.52 MB/s 140 MB/s+
Ubuntu ARM (UTM) 105 MB/s+ 19.43 MB/s 105 MB/s+
Windows 11 ARM (UTM) 40 MB/s+ 19.25 MB/s 40 MB/s+

The cap is enforced to within 2.5% of the configured value across all three platforms, with no regression on the unlimited path. The 1000000 (UI maximum) configured value is well above any of the three platforms' network ceilings, so the throttler doesn't artificially bottleneck — it just doesn't bind.

Multi-peer redistribution

Two seeder amuled instances on the same host, sharing the same testfile-30gb.bin:

  • fast seederMaxUpload=300000 (effectively unlimited)
  • slow seederMaxUpload=5000 (5 MB/s upload cap)

Mac leech, MaxDownload=20000 (20 MB/s cap), 120s run. Per-seeder upload sampled mid-test:

sample fast slow sum mac total
T+45s 15.96 MB/s 2.04 MB/s 18.0 MB/s 17.18 MB/s
T+75s 16.18 MB/s 2.09 MB/s 18.27 MB/s 19.15 MB/s
T+105s 17.19 MB/s 2.33 MB/s 19.52 MB/s 19.51 MB/s

If the throttler did naive equal-split, both peers would be allocated 10 MB/s each; the slow peer is upload-capped at 5 MB/s and would only deliver 5, so the total would plateau at ~15 MB/s. We hit ~19.5 MB/s — the fast peer absorbs the slack the slow peer can't fill. Demand-aware redistribution confirmed.

Multi-file × multi-peer

Mac leech, MaxDownload=20000, two files concurrently — testfile-5gb sourced from the slow seeder (MaxUpload=5000), testfile-30gb sourced from the fast seeder (unlimited). aMule UI readout at steady state:

file source speed
testfile-5gb.bin slow seeder 4.8 MB/s
testfile-30gb.bin fast seeder 14.4 MB/s
total 19.2 MB/s

Three properties held simultaneously:

  1. Cap held across the union of files — sum is below 20 MB/s. The bucket is a single process-wide singleton (CDownloadBandwidthThrottler::Get() returns the function-local static); every CEMSocket::OnReceive() consults the same m_bytesAvailable regardless of which CPartFile it's sourcing.
  2. Per-peer redistribution — the slow seeder is upload-capped at 5 MB/s and only delivers ~4.8; the fast seeder absorbs the slack to fill the global cap.
  3. No per-file slicing — the leech didn't pre-allocate 10 + 10 between the two files; the two sockets competed on the shared bucket and the slow one used what it could.

Note for context: aMule won't open two parallel connections from one leech to the same peer for different files (per-host connection rotation), which is why this test uses two distinct seeder instances. Same global cap behaviour would hold with N files × N peers regardless.

Notes for review

  • Reserve(0) returns 0 by construction; OnReceive() only consults the throttler when readMax > 0 so empty-payload control packets still flow through the do-while loop's packet-completion path. (This was a real bug discovered during the rewrite — calling Reserve unconditionally and treating 0 as "bucket exhausted" silently broke control-packet processing on the first iteration of the read loop, which manifested as connected peers showing 0 MB/s indefinitely. Fixed by gating the Reserve call on if (readMax).)
  • The <min>...<max> carry-over (2× budget) was the difference between a 10–17 MB/s noisy plateau and a 19.5 MB/s steady state on the same configured cap. Strict overwrite-on-refill starves TCP; permissive accumulation across many ticks would let a quiet period bank capacity that bursts well past the average. 2× budget is the smallest carry that smooths out the per-tick variance without inviting bursts that exceed the user's cap meaningfully.
  • The m_sendLocker already serialises Read() against Send() per socket; adding Reserve/Refund calls inside the same critical section was deliberate so the per-socket bookkeeping (pendingOnReceive) and the global accounting can't race against a concurrent Send-side refund of upload budget.
  • Thread safety: m_bytesAvailable is std::atomic<int64_t> with CAS-based decrement; m_unlimited is std::atomic<bool> with acquire/release semantics. RefillBudget runs on the wxApp main thread (timer event); Reserve/Refund run on the asio I/O threads. No locks needed.

The download throttle has never actually been a bandwidth cap. The
old code in DownloadQueue::Process computed `downspeed` as a
50-200% adaptive ratio against the observed aggregate datarate:

    downspeed = MaxDownload * 102400 / (m_datarate + 1), clamped to [50, 200]

then in CUpDownClient::SetDownloadLimit converted that percentage
back to a per-tick byte budget by multiplying it against the
client's smoothed speed. That's a closed-loop controller that
nudges each peer's transfer rate by ~5%/tick relative to its own
current speed -- it does *not* enforce MaxDownload as a literal
ceiling. Setting MaxDownload=20000 (20 MB/s) wouldn't reliably
plateau at 20 MB/s; it just shaped traffic toward whatever the
aggregate datarate happened to converge on.

Replaced with a single global token bucket --
CDownloadBandwidthThrottler -- that every CEMSocket consults via
Reserve() before each Read(). The bucket is refilled once per
CORE_TIMER_PERIOD by DownloadQueue::Process from
MaxDownload * 1024 * tick_period / 1000. A small carry-over
(capped at 2x budget) keeps reads flowing across tick boundaries
so TCP flow control doesn't see receive-side stalls and back off.

Demand-aware redistribution falls out naturally: there's no
per-peer slice. A fast peer can claim unused capacity from slow
peers within the same tick; the global cap is the only constraint.

Knock-on cleanups:
- CEMSocket::SetDownloadLimit / DisableDownloadLimit replaced by
  CEMSocket::WakeIfPaused -- the sole per-socket bit left is
  pendingOnReceive (set when the bucket emptied mid-read, cleared
  on the next successful read or wake).
- CUpDownClient::SetDownloadLimit(uint32) renamed to
  TickDownloadAndMeasure() -- the parameter was the legacy ratio,
  now nothing per-peer is being set.
- CPartFile::Process drops its reducedownload parameter; its only
  job per-tick is to wake paused sockets and sum kBpsClient for
  the kBpsDown display.
- DownloadClient.cpp's transition-out-of-DS_DOWNLOADING no longer
  touches a per-socket cap; the global throttler is unaffected
  by individual peer state changes.

Verified on macOS:
- MaxDownload=0 (UNLIMITED): 140.79 MB/s peak (was 140.27, no
  regression on the unlimited path).
- MaxDownload=20000 (20 MB/s cap): 19.52 MB/s steady-state
  (matches the per-peer strict-cap approach within 0.05 MB/s,
  which itself was a meaningful improvement over the broken
  ratio controller).
@mrjimenez
mrjimenez merged commit 8c3dbf8 into amule-project:master Apr 27, 2026
9 checks passed
@got3nks
got3nks deleted the fix-maxdownload-literal-cap branch May 3, 2026 15:19
got3nks added a commit to got3nks/amule that referenced this pull request May 5, 2026
Two tables at the top of the Performance section in 3.0.0:

1. Cross-platform end-to-end (2.3.3 vs 3.0.0) — sustained download
   throughput on macOS / Linux ARM / Windows ARM, peer-to-peer over
   LAN, 30 GB file:
     macOS:   0.35 -> 135 MB/s   (381x)
     Linux:   0.34 -> 117 MB/s   (345x)
     Windows: 0.36 ->  39 MB/s   (107x)

   Plus a per-platform seeder/leecher contribution split that shows
   the seeder-side fix (amule-project#451) dominates and the leecher-side stack
   (amule-project#454/amule-project#484/amule-project#491) adds another 4.5-5.8x on top.

2. vs eMule 0.70b on Windows — same UTM hardware, both directions:
     Upload   (Windows seeds -> Mac leecher):  22 vs 106 MB/s  (~4.8x)
     Download (Linux seeds -> Windows leech):  20 vs  39 MB/s  (~1.9x)

   Methodology footnote on the table since eMule has no remote-
   control protocol and is measured leecher-side via aMule's EC
   channel on the receiving box.

Highlights line 16 also updated to mention both directions of the
eMule comparison instead of just download.

The existing per-PR benches in #### Upload / #### Download stay
unchanged — those are PR-specific numbers documenting how each
single change contributed; the new tables document the end-to-end
2.3.3-to-3.0.0 user experience.

Numbers measured with the bench-matrix.sh dispatcher
(scripts/bench-matrix.sh) — 12-run aMule-vs-aMule matrix plus 2
manual aMule-vs-eMule runs. Raw logs in
bench-results/bench-matrix-20260505T141651Z/.
got3nks added a commit to got3nks/amule that referenced this pull request May 5, 2026
Two tables at the top of the Performance section in 3.0.0:

1. Cross-platform end-to-end (2.3.3 vs 3.0.0) — sustained download
   throughput on macOS / Linux ARM / Windows ARM, peer-to-peer over
   LAN, 30 GB file:
     macOS:   0.35 -> 135 MB/s   (381x)
     Linux:   0.34 -> 117 MB/s   (345x)
     Windows: 0.36 ->  39 MB/s   (107x)

   Plus a per-platform seeder/leecher contribution split that shows
   the seeder-side fix (amule-project#451) dominates and the leecher-side stack
   (amule-project#454/amule-project#484/amule-project#491) adds another 4.5-5.8x on top.

2. vs eMule 0.70b on Windows — same UTM hardware, both directions:
     Upload   (Windows seeds -> Mac leecher):  22 vs 106 MB/s  (~4.8x)
     Download (Linux seeds -> Windows leech):  20 vs  39 MB/s  (~1.9x)

   Methodology footnote on the table since eMule has no remote-
   control protocol and is measured leecher-side via aMule's EC
   channel on the receiving box.

Highlights line 16 also updated to mention both directions of the
eMule comparison instead of just download.

The existing per-PR benches in #### Upload / #### Download stay
unchanged — those are PR-specific numbers documenting how each
single change contributed; the new tables document the end-to-end
2.3.3-to-3.0.0 user experience.

Numbers measured with the bench-matrix.sh dispatcher
(scripts/bench-matrix.sh) — 12-run aMule-vs-aMule matrix plus 2
manual aMule-vs-eMule runs. Raw logs in
bench-results/bench-matrix-20260505T141651Z/.
got3nks added a commit to got3nks/amule that referenced this pull request May 5, 2026
Two tables at the top of the Performance section in 3.0.0:

1. Cross-platform end-to-end (2.3.3 vs 3.0.0) — sustained download
   throughput on macOS / Linux ARM / Windows ARM, peer-to-peer over
   LAN, 30 GB file:
     macOS:   0.35 -> 135 MB/s   (381x)
     Linux:   0.34 -> 117 MB/s   (345x)
     Windows: 0.36 ->  39 MB/s   (107x)

   Plus a per-platform seeder/leecher contribution split that shows
   the seeder-side fix (amule-project#451) dominates and the leecher-side stack
   (amule-project#454/amule-project#484/amule-project#491) adds another 4.5-5.8x on top.

2. vs eMule 0.70b on Windows — same UTM hardware, both directions:
     Upload   (Windows seeds -> Mac leecher):  22 vs 106 MB/s  (~4.8x)
     Download (Linux seeds -> Windows leech):  20 vs  39 MB/s  (~1.9x)

   Methodology footnote on the table since eMule has no remote-
   control protocol and is measured leecher-side via aMule's EC
   channel on the receiving box.

Highlights line 16 also updated to mention both directions of the
eMule comparison instead of just download.

The existing per-PR benches in #### Upload / #### Download stay
unchanged — those are PR-specific numbers documenting how each
single change contributed; the new tables document the end-to-end
2.3.3-to-3.0.0 user experience.

Numbers measured with the bench-matrix.sh dispatcher
(scripts/bench-matrix.sh) — 12-run aMule-vs-aMule matrix plus 2
manual aMule-vs-eMule runs. Raw logs in
bench-results/bench-matrix-20260505T141651Z/.
got3nks added a commit to got3nks/amule that referenced this pull request May 5, 2026
Two tables at the top of the Performance section in 3.0.0:

1. Cross-platform end-to-end (2.3.3 vs 3.0.0) — sustained download
   throughput on macOS / Linux ARM / Windows ARM, peer-to-peer over
   LAN, 30 GB file:
     macOS:   0.35 -> 135 MB/s   (381x)
     Linux:   0.34 -> 117 MB/s   (345x)
     Windows: 0.36 ->  39 MB/s   (107x)

   Plus a per-platform seeder/leecher contribution split that shows
   the seeder-side fix (amule-project#451) dominates and the leecher-side stack
   (amule-project#454/amule-project#484/amule-project#491) adds another 4.5-5.8x on top.

2. vs eMule 0.70b on Windows — same UTM hardware, both directions:
     Upload   (Windows seeds -> Mac leecher):  22 vs 106 MB/s  (~4.8x)
     Download (Linux seeds -> Windows leech):  20 vs  39 MB/s  (~1.9x)

   Methodology footnote on the table since eMule has no remote-
   control protocol and is measured leecher-side via aMule's EC
   channel on the receiving box.

Highlights line 16 also updated to mention both directions of the
eMule comparison instead of just download.

The existing per-PR benches in #### Upload / #### Download stay
unchanged — those are PR-specific numbers documenting how each
single change contributed; the new tables document the end-to-end
2.3.3-to-3.0.0 user experience.

Numbers measured with the bench-matrix.sh dispatcher
(scripts/bench-matrix.sh) — 12-run aMule-vs-aMule matrix plus 2
manual aMule-vs-eMule runs. Raw logs in
bench-results/bench-matrix-20260505T141651Z/.
mrjimenez pushed a commit that referenced this pull request May 6, 2026
Two tables at the top of the Performance section in 3.0.0:

1. Cross-platform end-to-end (2.3.3 vs 3.0.0) — sustained download
   throughput on macOS / Linux ARM / Windows ARM, peer-to-peer over
   LAN, 30 GB file:
     macOS:   0.35 -> 135 MB/s   (381x)
     Linux:   0.34 -> 117 MB/s   (345x)
     Windows: 0.36 ->  39 MB/s   (107x)

   Plus a per-platform seeder/leecher contribution split that shows
   the seeder-side fix (#451) dominates and the leecher-side stack
   (#454/#484/#491) adds another 4.5-5.8x on top.

2. vs eMule 0.70b on Windows — same UTM hardware, both directions:
     Upload   (Windows seeds -> Mac leecher):  22 vs 106 MB/s  (~4.8x)
     Download (Linux seeds -> Windows leech):  20 vs  39 MB/s  (~1.9x)

   Methodology footnote on the table since eMule has no remote-
   control protocol and is measured leecher-side via aMule's EC
   channel on the receiving box.

Highlights line 16 also updated to mention both directions of the
eMule comparison instead of just download.

The existing per-PR benches in #### Upload / #### Download stay
unchanged — those are PR-specific numbers documenting how each
single change contributed; the new tables document the end-to-end
2.3.3-to-3.0.0 user experience.

Numbers measured with the bench-matrix.sh dispatcher
(scripts/bench-matrix.sh) — 12-run aMule-vs-aMule matrix plus 2
manual aMule-vs-eMule runs. Raw logs in
bench-results/bench-matrix-20260505T141651Z/.
mrjimenez pushed a commit to mrjimenez/amule that referenced this pull request Jul 16, 2026
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.

2 participants