Files
vdm/core/docs/buffer-sizing.md
samiandClaude Sonnet 5 dfb02afc52 core: redo bufferBytes default and RSS reconciliation (per-segment)
First pass counted one buffer per download; it is one per segment. 20
active downloads at the default 8 segments = 160 buffers, so at 20 tasks
the binding constraint is the global cap, not the per-segment default —
256 MiB and the "<=60 MB RSS / 20 downloads" target (line 125) cannot both
hold whatever the default is.

Floor (64 KiB) and ceiling (16 MiB) unchanged — the 256/64 unreachability
argument is stronger under per-segment accounting. Changes:
 - default 1 MiB (was 2): with the cap below, 32 live segments x 1 MiB =
   32 MiB buffers -> ~45-50 MiB RSS, line 125 holds with margin.
 - NEW maxActiveSegments (default 32): a global concurrent-segment cap is
   the actual mechanism that bounds "20 active downloads"; docs/01 §2
   implies it, docs/04 never states it. Without it no buffer policy hits
   60 MB.
 - maxTotalBufferBytes 128 MiB (was 256) and it must be ADDED to the
   contract — currently absent, so the clamp CORE implements has no wire
   representation and Options can't show/set it. Folded into B2a.
 - line 125: keep 60 MB "given maxActiveSegments=32 and default buffers",
   or explicitly raise to 120 MB — ADR records which. Flagged that
   changing it is a defensible outcome CORE owns, not a number that
   quietly loses.
 - bufferBytes bounds are in FOUR schema files, not three:
   Settings.schema.json connection.bufferBytes also has 4096-8388608.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
2026-09-09 20:08:36 +04:00

142 lines
8.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CORE → PROTO — `bufferBytes` range + the RSS-budget reconciliation
`contracts/` froze `bufferBytes` at **4 KiB 8 MiB** in four places; `docs/04` §4 line 70
says **64 KiB 64 MiB, default 4 MiB**. The floor and ceiling are settled below (64 KiB /
16 MiB — my earlier reasoning holds). The default and `max_total_buffer_bytes` are
**not** — my first pass counted one buffer per download; it is one per *segment*
("Per-segment ring buffer", line 68; "Write buffer per connection", line 70), which
changes the whole RSS picture. This version fixes that.
PROTO to land schema + `docs/04` §4 + §8 + an ADR together.
## Recommendation
| Knob | Value | |
|---|---|---|
| `bufferBytes` minimum | **65536** (64 KiB) | frozen 4 KiB is below one libcurl callback — see §Floor |
| `bufferBytes` maximum | **16777216** (16 MiB) | see §Ceiling; the `256/64` unreachability argument is *stronger* per-segment |
| `bufferBytes` default | **1048576** (1 MiB) | see §Default+RSS; 2 MiB and 4 MiB both bust line 125 |
| `maxActiveSegments` (**new**) | **32** | global cap on concurrently-transferring segments — the actual bound on "20 active downloads" |
| `maxTotalBufferBytes` | **134217728** (128 MiB), down from 256 | backstop for the buffer knob; RSS-safe given the 1 MiB default + the segment cap |
## Floor — 64 KiB, not 4 KiB
The buffer's first job is to coalesce libcurl write-callback deliveries into one `pwrite`.
Over HTTP/2 a single write callback is routinely 1664 KiB, up to ~256 KiB. A 4 KiB ring
buffer is **smaller than one callback**: the "one `pwrite` per fill" design degrades to a
syscall per curl chunk — the exact thing the buffer exists to prevent. 64 KiB (≈4 typical
chunks) is the smallest floor that buys anything. 4 KiB is a page size that wandered into
a throughput knob.
## Ceiling — 16 MiB, not 64 MiB
Two things the buffer buys past coalescing:
1. **Large sequential writes.** On NVMe, throughput vs. write size is flat by ~14 MiB.
4→8 MiB gains a little on syscall overhead at multi-Gbit; past 8 MiB there is no
throughput left to get, only `fdatasync` latency and page-cache pressure (a 40 GB ISO
must not evict the user's working set — `docs/04` §4).
2. **Absorbing a disk stall without stalling the socket** — the only reason to exceed
8 MiB. Fast link + bursty storage (HDD, SMR, USB, network mount): 1 Gbit ≈ 125 MB/s,
so 16 MiB/segment ≈ 130 ms of write-stall cover, ~0.5 s across 4 segments — enough to
ride out a seek storm. Beyond 16 MiB the marginal cover isn't worth the footprint.
**8 MiB captures all throughput; 816 MiB is stall-absorption headroom for the
fast-pipe/slow-disk case; >16 MiB is waste.**
### `maxTotalBufferBytes / segments` unreachability — worse per-segment
`effective = clamp(requested, 64 KiB, floor(maxTotalBufferBytes / live_segment_count))`.
With a **64 MiB** ceiling and the old 256 MiB cap, 64 MiB is unreachable once total live
segments exceed 4 (256/64). Under correct per-segment accounting "4 total segments" is
*half of one default download* (8 segments) — so the frozen-doc ceiling is unreachable in
essentially every real configuration. 16 MiB with a 128 MiB cap is reachable up to 8 live
segments (128/16) — i.e. exactly the single-download slow-disk case the ceiling exists
for — and clamps predictably beyond that, where per-segment buffering has stopped
mattering because each segment holds a small fraction of the link.
## Default + RSS — the part that didn't land
`docs/04` line 125: **"≤ 60 MB RSS with 20 active downloads at default buffers."**
Per-segment accounting: 20 downloads × the default 8 segments = **160 buffers**, not 20.
| default | 160 buffers | after a 256 MiB cap | vs 60 MB RSS |
|---|---|---|---|
| 4 MiB | 640 MiB | 256 MiB | ~4× over |
| 2 MiB | 320 MiB | 256 MiB | ~4× over |
| 1 MiB | 160 MiB | 160 MiB (cap not binding) | still ~3× over |
| any | — | **cap is the binding constraint at 20 tasks, not the default** | — |
So the per-segment default is *not* what decides the 20-task case — the global cap is,
whatever the default. **256 MiB and 60 MB RSS cannot both hold.** Hitting 60 MB across
160 segments is ~48 MiB of buffers total (≈300 KiB each), leaving ~12 MB for 160 curl
handles + TLS + the daemon — which is not achievable; 160 live TLS connections alone are
~1015 MB.
### The real fix: cap concurrent segments, not just total buffer bytes
No download manager runs 160 simultaneous connections for 20 downloads. `docs/01` §2
already implies this ("1 curl-multi transfer thread per ~8 active segments (capped)");
`docs/04` never states the cap. Add **`maxActiveSegments` (default 32)** — a global
ceiling on segments actually transferring at once. 20 "active" downloads then means ~32
live connections with the rest of each download's segments queued, not 160.
RSS with `maxActiveSegments = 32`, default `bufferBytes = 1 MiB`:
- buffers: 32 × 1 MiB = **32 MiB** (the 128 MiB cap isn't even engaged at the default)
- 32 curl/HTTP2/TLS connections: ~23 MiB
- daemon base (RPC loop, event batching, SQLite cache + WAL, ~8 thread stacks resident,
task table): ~812 MiB
- **total ≈ 4550 MiB RSS — line 125 holds**, at the default, with margin.
`docs/04` §8 keeps the 60 MB number but must state it now depends on
`maxActiveSegments = 32` and default buffers. A power user who overrides every download to
16 MiB gets clamped by `maxTotalBufferBytes` to `128 / 32 = 4 MiB` per live segment →
128 MiB of buffers, ~140 MiB RSS — deliberately, and outside "at default buffers", so
line 125 is unaffected.
### If 60 MB is the wrong target
It's defensible to raise it instead. 160 (or even 32) live TLS connections have an
irreducible cost, and IDM itself uses more. If PROTO/docs prefer, change line 125 to
**"≤ 120 MB RSS with 20 active downloads at default buffers"** and keep
`maxActiveSegments` higher (64). CORE's recommendation is the 60 MB + cap-at-32 route
because "lean daemon" is in the brief and 4550 MiB is comfortably achievable — but it
must be an explicit decision in the ADR, not the number that quietly loses.
## Clamp behaviour CORE will implement
- On task start and on any change to `live_segment_count` (new task, task finishing, a
steal), recompute `effective` for every live segment by the formula above.
- Never below the 64 KiB floor. If `maxTotalBufferBytes / live_segment_count` < 64 KiB
(needs >2048 live segments at a 128 MiB cap — not reachable under
`maxActiveSegments = 32`), CORE admits fewer concurrent segments rather than shipping a
sub-floor buffer.
- `effective` and `requested` both reported upward so the GUI shows "16 MiB (using
4 MiB)" — request **B2a**.
## Net contract delta for PROTO
1. **`bufferBytes` bounds in FOUR schema files** (not three): `DownloadSpec`,
`TaskDetail`, `download.update` patch, **and `Settings.schema.json`
`connection.bufferBytes`** — all currently `4096``8388608`. Change every one to
`minimum: 65536`, `maximum: 16777216`, `default: 1048576`.
2. **`maxTotalBufferBytes` is absent from the contract entirely.** The clamp CORE
implements has no wire representation, so the Options dialog can neither show nor set
it. Add `Settings.schema.json connection.maxTotalBufferBytes` (default `134217728`) —
fold into the **B2a** follow-up alongside `effectiveBufferBytes`.
3. **`maxActiveSegments` is new.** Add `Settings.schema.json connection.maxActiveSegments`
(default `32`). CORE enforces it; DAEMON's scheduler needs to know it to decide what to
start.
4. **`docs/04` §4 line ~70:** "Default 1 MiB, range 64 KiB 16 MiB. Reduced to fit
`maxTotalBufferBytes` (128 MiB) across all live segments; effective value reported
back."
5. **`docs/04` §8 line 125:** keep "≤ 60 MB RSS / 20 downloads" but add "given
`maxActiveSegments = 32` and default buffers" — or raise to 120 MB (see above). ADR
records which and why.
6. **ADR:** throughput-plateau reasoning for the ceiling; the per-segment RSS arithmetic;
the `maxActiveSegments` addition as the mechanism that makes line 125 hold; 8 MiB
considered and rejected for the ceiling in favour of 16 MiB stall absorption.