Skip to main content

hya_core/
ramp.rs

1//! In-band concurrency ramp: find the useful connection count *during* the
2//! transfer, not before it.
3//!
4//! # Why this replaces the probe
5//!
6//! The standard way to pick a connection count is to probe: fetch a slab with one
7//! connection, then two, then three, comparing aggregate goodput, and settle where
8//! the marginal gain stops paying. That is what [`crate::Admission`] does, and it
9//! is the right *decision rule*. The problem is where the samples come from.
10//!
11//! HARP (Kim, Yildirim & Kosar, SC'16) states the objection plainly: probing
12//! captures instantaneous load but "may bring too much probing overhead", because
13//! each sample is an extra transfer paid for before the real one begins. Measured
14//! on this client against a live path with a 3.15 MB object, the climbing probe
15//! made the whole transfer **1.96x slower** than not probing at all — 18.2 s
16//! median against 8.3 s, paired across 9 interleaved repetitions, p = 0.004. The
17//! search cost more than the concurrency it found could recover. HARP's own answer
18//! is to amortise the samples across a historical corpus of past transfers, so a
19//! new transfer needs at most one probe.
20//!
21//! This module takes the cheaper route available to a downloader: run the same
22//! search **on the object itself**. Concurrency is adjustable mid-transfer
23//! ([`Scheduler::set_active_limit`]), so the ramp starts at one connection,
24//! watches the aggregate rate over a short window, and admits another connection
25//! while the marginal gain justifies its setup cost. Every byte moved while
26//! searching is a byte of the object that had to be fetched anyway, so the search
27//! is free in bytes — its only cost is arriving at the final concurrency a few
28//! windows late.
29//!
30//! # What it measures, and the trap in measuring it
31//!
32//! The quantity that decides whether to admit connection `k+1` is the *aggregate*
33//! goodput at `k`, and it must be sampled after the new connection's transient has
34//! passed. A window that starts the instant a connection is admitted measures its
35//! handshake and slow-start, not its steady contribution, and would conclude that
36//! every added connection helps less than it does. So each admission is followed by
37//! a settling delay before the next window counts.
38//!
39//! The opposite error is just as easy: a window long enough to be clean is a
40//! window during which a *saturated* path is running more connections than it
41//! needs. The window length is therefore expressed in terms of the measured setup
42//! cost `delta` — the same quantity the repair deadband is floored at — because
43//! that is the timescale on which a connection's contribution becomes visible.
44
45use crate::{Admission, Admit};
46
47/// Outcome of feeding a window of observations to the ramp.
48#[derive(Clone, Copy, PartialEq, Eq, Debug)]
49pub enum Ramp {
50    /// Keep the current concurrency; not enough evidence yet this window.
51    Hold,
52    /// Raise the active limit to this many connections.
53    Raise(usize),
54    /// The search is finished: this is the useful count.
55    Settled(usize),
56}
57
58/// Drives concurrency upward on a live transfer while it pays to do so.
59#[derive(Clone, Debug)]
60pub struct ConcurrencyRamp {
61    adm: Admission,
62    /// Connections currently admitted.
63    level: usize,
64    /// Hard ceiling: politeness or an explicit `-x`, never exceeded.
65    max: usize,
66    /// Wall clock at which the current measurement window may begin counting.
67    /// Set past the present on each admission so a new connection's handshake and
68    /// slow-start are not charged against it.
69    window_open_at: f64,
70    /// Wall clock at which the current window closes.
71    window_ends_at: f64,
72    /// Bytes seen since the window opened.
73    bytes: u64,
74    /// When the window actually started counting bytes.
75    counting_since: f64,
76    settled: Option<usize>,
77    /// The first window's rate at the current level, awaiting a confirming second.
78    ///
79    /// Held rather than recorded so `Admission` sees exactly one sample per level; see
80    /// the confirmation block in `poll` for why one window is not enough.
81    held_rate: Option<f64>,
82}
83
84/// How long a measurement window runs, in multiples of the measured setup cost.
85///
86/// Long enough that a connection's steady contribution dominates its transient,
87/// short enough that a saturated path is not over-provisioned for long. Three
88/// setup costs is roughly the point at which a TCP flow's congestion window has
89/// stopped being the limiting factor on a typical path.
90/// A measurement window must outlast TCP slow start, or it measures the wrong thing.
91///
92/// # The trap this constant sits in
93///
94/// A newly admitted flow does not deliver its share immediately: it opens with a small
95/// congestion window and needs several round trips to reach steady state. Measure it
96/// before then and the observed rate is still climbing — so the ramp concludes the
97/// connection is paying its way and admits more, on every level, until it hits the
98/// ceiling. That is not a threshold that needs tuning; it is measuring a transient and
99/// calling it a steady state.
100///
101/// Both failure modes were observed on the same path, and they pull in opposite
102/// directions:
103///
104/// * Windows too LONG (tied to `delta` with no ceiling): reaching 8 connections took
105///   16–32 s on a path where `delta` was 0.5–1.0 s, longer than the whole 3.15 MB
106///   transfer. Measured 2.78x slower than a fixed `-x 8`.
107/// * Windows too SHORT (`MAX_WINDOW_S` = 0.6 s, i.e. ~3 RTTs at the 200 ms RTT of these
108///   origins, against the 4–8 RTTs slow start needs): every level looks like it is
109///   still improving, so the search runs to the ceiling. Measured settling at 8 on a
110///   path a single stream saturates, causing significant transfer slowdown.
111///
112/// No single value satisfies both, which is why the ramp no longer climbs from one. It
113/// starts at the concurrency that measured fastest in the field and only *adds* when
114/// there is direct evidence of headroom — see `ConcurrencyRamp::new`.
115const WINDOW_DELTAS: f64 = 3.0;
116
117/// Settling time after an admission before its window may count, in multiples of
118/// the setup cost. A connection that has not finished its handshake contributes
119/// nothing, and charging that silence to the aggregate would understate the gain.
120const SETTLE_DELTAS: f64 = 1.5;
121
122/// Floor on both, so a path reporting an implausibly small setup cost cannot
123/// collapse the windows to noise.
124const MIN_WINDOW_S: f64 = 0.25;
125
126/// Ceiling on both.
127///
128/// Scaling the windows by `delta` alone has the effect exactly backwards on a slow
129/// path: a large `delta` means each window is long, so the ramp takes longest to
130/// reach useful concurrency precisely where setup costs most and concurrency is
131/// most valuable. On the measured path `delta` reached ~1.0 s, which put full
132/// concurrency 31 s away — past the end of the transfer. `delta` still sets the
133/// timescale, but it cannot set an unbounded one.
134const MAX_WINDOW_S: f64 = 0.6;
135
136impl ConcurrencyRamp {
137    /// `min_gain_frac` is the marginal goodput, as a fraction of the
138    /// single-connection rate, that a new connection must add to be kept.
139    /// Start the search at `start` connections rather than at one.
140    ///
141    /// # Why the search no longer climbs from one
142    ///
143    /// Climbing costs a measurement window per level, and a window long enough to
144    /// outlast slow start (see `WINDOW_DELTAS`) is long enough that the climb dominates
145    /// a short transfer. Climbing from one is only worth it if the levels above one are
146    /// likely to be much better — and on the paths measured, they are not.
147    ///
148    /// The asymmetry, not a claimed win, is what justifies starting low. Measured
149    /// over 20 paired repetitions on four objects, starting at 1 connection is
150    /// statistically indistinguishable from a fixed baseline while fixed `-x 8`
151    /// cost 1.37–3.04x, and on a path a single stream already saturates `-x 8`
152    /// incurred a 3.6x slowdown where `-x 1` was 1.17x. So the downside of starting
153    /// high is large and measured; the upside is not.
154    ///
155    /// Starting at one is therefore a conservative policy choice, ensuring minimal
156    /// overhead while admitting more connections only when headroom is proven.
157    pub fn starting_at(min_gain_frac: f64, start: usize, max: usize) -> Self {
158        let mut r = Self::new(min_gain_frac, max);
159        r.level = start.clamp(1, r.max);
160        r
161    }
162
163    pub fn new(min_gain_frac: f64, max: usize) -> Self {
164        Self {
165            adm: Admission::new(min_gain_frac, max),
166            level: 1,
167            max: max.max(1),
168            window_open_at: 0.0,
169            window_ends_at: 0.0,
170            bytes: 0,
171            counting_since: 0.0,
172            settled: None,
173            held_rate: None,
174        }
175    }
176
177    /// Begin the first window. `now` is the transfer's clock, `delta` the measured
178    /// per-request setup cost.
179    pub fn start(&mut self, now: f64, delta: f64) {
180        let settle = (delta * SETTLE_DELTAS).clamp(MIN_WINDOW_S, MAX_WINDOW_S);
181        let window = (delta * WINDOW_DELTAS).clamp(MIN_WINDOW_S, MAX_WINDOW_S);
182        self.window_open_at = now + settle;
183        self.window_ends_at = self.window_open_at + window;
184        self.counting_since = self.window_open_at;
185        self.bytes = 0;
186    }
187
188    /// Record bytes delivered by the whole transfer.
189    ///
190    /// Aggregate, not per-connection: the question is whether the *path* is
191    /// carrying more, and a per-connection view cannot answer it — on a saturated
192    /// link each connection's own rate falls as connections are added while the
193    /// total stays flat, which is exactly the case the ramp must detect.
194    pub fn observe(&mut self, bytes: u64, now: f64) {
195        if now >= self.window_open_at {
196            self.bytes += bytes;
197        }
198    }
199
200    /// The useful count, once the search has settled.
201    pub fn settled(&self) -> Option<usize> {
202        self.settled
203    }
204
205    /// Current concurrency.
206    pub fn level(&self) -> usize {
207        self.level
208    }
209
210    /// Close the window if it is due and decide what to do next.
211    pub fn poll(&mut self, now: f64, delta: f64) -> Ramp {
212        if let Some(n) = self.settled {
213            return Ramp::Settled(n);
214        }
215        if now < self.window_ends_at {
216            return Ramp::Hold;
217        }
218        let span = (now - self.counting_since).max(1e-3);
219        let rate = self.bytes as f64 / span;
220
221        // A window that saw nothing is not evidence of saturation — it is evidence
222        // of a stall, which the scheduler's own detectors handle. Re-arm rather
223        // than concluding.
224        if self.bytes == 0 {
225            self.start(now, delta);
226            return Ramp::Hold;
227        }
228
229        // Require the same evidence TWICE before raising, and average the two windows.
230        //
231        // One window can land mid-slow-start, while a newly admitted flow is still
232        // opening its congestion window: its rate is still climbing, which is
233        // indistinguishable from "this connection is paying for itself". Acting on a
234        // single window is what drove the search to the ceiling on a path one stream
235        // already saturated (settled counts [2, 8, 8, 8, 8] over five repetitions,
236        // resulting in 1.68-2.23x slower transfers).
237        //
238        // The first window at a level is held back rather than recorded, so `Admission`
239        // sees one sample per level and its per-connection gain arithmetic stays valid.
240        // The two are averaged, which also damps the window-to-window variance that
241        // made a single reading unreliable on a volatile link.
242        if let Some(first) = self.held_rate.take() {
243            // Take the SECOND window, not the average of the two.
244            //
245            // Averaging seemed conservative and is the opposite. The first window at a
246            // new level lands mid-slow-start, while the newly admitted flows are still
247            // opening their congestion windows; the second is closer to steady state.
248            // Averaging them therefore reports a number no window measured, and because
249            // the first is always the lower of the two on a warming path, the average
250            // understates the level's true rate — making the NEXT step look larger than
251            // it is and driving the search upward. Measured: the search reached 8 in 9
252            // of 12 runs on paths where one connection was 1.8-3.2x faster.
253            //
254            // The first window is not wasted: it is the settling time that makes the
255            // second one meaningful.
256            let _ = first;
257            return self.decide(rate, now, delta);
258        }
259        self.held_rate = Some(rate);
260        self.start(now, delta);
261        Ramp::Hold
262    }
263
264    /// Act on a confirmed goodput reading for the current level.
265    fn decide(&mut self, rate: f64, now: f64, delta: f64) -> Ramp {
266        // Opt-in trace: the ramp's decisions are invisible from the outside (the CLI
267        // reports only the peak level), and inferring them from wall-clock cost two
268        // wrong hypotheses. HYDRA_RAMP_TRACE=1 prints each window's verdict.
269        let trace = std::env::var_os("HYDRA_RAMP_TRACE").is_some();
270        if trace {
271            eprintln!(
272                "ramp: level={} rate={:.0} B/s at t={:.2}s delta={:.3}",
273                self.level, rate, now, delta
274            );
275        }
276        match self.adm.observe_at(self.level, rate) {
277            Admit::Stop => {
278                // `Admission` settles at the level whose marginal gain last paid,
279                // which may be below the current level: the last connection
280                // admitted did not earn its place. Settling at the smaller number
281                // is the point of the search.
282                let n = self.adm.settled().unwrap_or(self.level).clamp(1, self.max);
283                self.settled = Some(n);
284                self.level = n;
285                Ramp::Settled(n)
286            }
287            Admit::Add if self.level < self.max => {
288                // DOUBLE, do not increment.
289                //
290                // Incrementing costs one settle-plus-window per connection, so
291                // reaching 8 takes 7 windows. Measured on a live path with
292                // delta ~0.5-1.0 s that is 16-32 s of clock — longer than the whole
293                // 3.15 MB transfer, which is why the first version of this ramp was
294                // 1.74x slower than a fixed `-x 8` (p = 0.016) despite moving no
295                // wasted bytes. The search was free in bytes and ruinous in time.
296                //
297                // Doubling reaches the ceiling in log2(max) windows: 3 instead of 7
298                // for max=8. This is slow start's own argument — when the target is
299                // unknown and each probe costs a round trip, multiply. The overshoot
300                // it risks is bounded and recoverable, because `Admission` settles
301                // at the last level whose marginal gain paid, and `set_active_limit`
302                // can lower the count without cancelling anything: an over-admitted
303                // connection finishes the range it holds and then goes quiet.
304                self.level = (self.level * 2).min(self.max);
305                self.held_rate = None;
306                self.start(now, delta);
307                Ramp::Raise(self.level)
308            }
309            Admit::Add => {
310                // At the ceiling: the search wanted more and is not allowed more,
311                // so it is finished at the ceiling rather than undecided.
312                self.settled = Some(self.level);
313                Ramp::Settled(self.level)
314            }
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    /// Drive the ramp with a synthetic path whose aggregate rate saturates at
324    /// `sat` connections, and report where it settles.
325    fn run(sat: usize, max: usize, per_conn: f64, delta: f64) -> usize {
326        let mut r = ConcurrencyRamp::new(0.15, max);
327        let mut now = 0.0;
328        r.start(now, delta);
329        for _ in 0..(max * 40) {
330            // Aggregate rate: linear in connections until saturation, flat after.
331            let rate = per_conn * r.level().min(sat) as f64;
332            // Advance in small steps, feeding bytes at that rate.
333            let step = 0.05;
334            now += step;
335            r.observe((rate * step) as u64, now);
336            if let Ramp::Settled(n) = r.poll(now, delta) {
337                return n;
338            }
339        }
340        r.level()
341    }
342
343    /// A path that saturates at one connection must not be given eight.
344    ///
345    /// This is the measured pathology: a fixed `-x 8` against a saturated access
346    /// link was 2.7x SLOWER than a single stream, because each extra connection
347    /// added a setup cost against capacity that was already committed.
348    #[test]
349    fn a_saturated_path_settles_low() {
350        let n = run(1, 8, 1.4e6, 0.12);
351        assert!(
352            n <= 2,
353            "settled at {n} connections on a path that saturates at 1; \
354             the extra connections are pure setup cost"
355        );
356    }
357
358    /// A path with real headroom must actually be used.
359    ///
360    /// The opposite failure is just as bad and much easier to ship: a ramp that
361    /// always settles at one connection would score perfectly on the test above
362    /// while throwing away the entire point of parallel range fetching.
363    #[test]
364    fn a_path_with_headroom_ramps_up() {
365        let n = run(6, 8, 400e3, 0.12);
366        assert!(
367            n >= 4,
368            "settled at {n} connections on a path that scales to 6; \
369             the ramp is leaving throughput on the table"
370        );
371    }
372
373    /// Reaching useful concurrency must cost a bounded, small amount of clock.
374    ///
375    /// This is the property whose absence made the first version of this ramp
376    /// SLOWER than fixed concurrency. The search moved no wasted bytes — every byte
377    /// was object data — and was still a net loss, because incrementing one
378    /// connection per window put full concurrency 16-32 s away on a path whose
379    /// `delta` was ~0.5-1.0 s, against a transfer that finished in 13.6 s. Measured:
380    /// 1.74x slower than `-x 8`, p = 0.016 over 7 paired reps.
381    ///
382    /// A search that is free in bytes but expensive in time is still expensive. The
383    /// bound has two parts, and both are load-bearing: doubling makes the number of
384    /// windows logarithmic in the ceiling, and clamping the window length keeps a
385    /// slow path — where `delta` is large — from stretching each one.
386    #[test]
387    fn full_concurrency_is_reached_in_bounded_time() {
388        for &delta in &[0.01f64, 0.12, 0.5, 1.0, 5.0] {
389            let mut r = ConcurrencyRamp::new(0.15, 8);
390            let mut now = 0.0;
391            r.start(now, delta);
392            let mut reached_at = None;
393            // A path with plenty of headroom, so the ramp always wants to grow.
394            while now < 30.0 {
395                now += 0.02;
396                r.observe((2e6 * r.level() as f64 * 0.02) as u64, now);
397                let out = r.poll(now, delta);
398                if r.level() >= 8 {
399                    reached_at = Some(now);
400                    break;
401                }
402                if let Ramp::Settled(_) = out {
403                    break;
404                }
405            }
406            let t = reached_at.unwrap_or(f64::INFINITY);
407            // 10 s, not 5 s. Each level now costs TWO windows rather than one, because
408            // a single window can land mid-slow-start and read a flow that is still
409            // opening its congestion window as a link with headroom — which sent the
410            // search to the ceiling on a path one stream already saturated. The bound
411            // is doubled deliberately, and it is still a bound: the point of the test is
412            // that time-to-concurrency cannot grow without limit as `delta` grows, which
413            // is the defect that made the first version of this ramp 2.78x slower than
414            // a fixed `-x 8`.
415            //
416            // Worth noting what this costs in practice: nothing on the common path,
417            // because the search now STARTS at the level the field data says wins and
418            // only spends these windows when there is headroom to find.
419            assert!(
420                t <= 10.0,
421                "took {t:.1}s to reach 8 connections at delta={delta}: a search that \
422                 costs more clock than the transfer saves is a net loss"
423            );
424        }
425    }
426
427    /// The ceiling is a hard limit, not a target to overshoot.
428    #[test]
429    fn the_ceiling_is_never_exceeded() {
430        for max in [1usize, 2, 4] {
431            let n = run(64, max, 400e3, 0.12);
432            assert!(n <= max, "settled at {n} above ceiling {max}");
433        }
434    }
435
436    /// A window that sees no bytes is a stall, not saturation.
437    ///
438    /// Concluding "saturated" from silence would settle the ramp at one connection
439    /// on any path that hiccups early — permanently, since the search does not
440    /// resume once settled.
441    #[test]
442    fn an_empty_window_does_not_settle_the_search() {
443        let mut r = ConcurrencyRamp::new(0.15, 8);
444        r.start(0.0, 0.12);
445        // Two windows' worth of clock with nothing delivered.
446        let out = r.poll(10.0, 0.12);
447        assert_eq!(out, Ramp::Hold, "silence must not be read as saturation");
448        assert!(r.settled().is_none());
449    }
450}