Skip to main content

polars_io/cloud/concurrency/
mod.rs

1//! Adaptive in-flight concurrency controller for cloud IO (Input only).
2//!
3//! Admission control for concurrency uses two budgets:
4//! - A (primary) bytes-based budget to model the bandwidth-delay product (BDP)
5//! - A (secondary) count-based budget to limit the number of in-flight requests
6//!
7//! The bytes-based budget models the BDP as
8//!   BDP = BW_max * TTFB_est
9//!
10//! Three components cooperate:
11//! - Model: records IO observations and models the network (BW_max, TTFB_est, BDP)
12//! - Regime: state machine driving the admission (Init / RampUp / Stable / ProbeUp)
13//! - Admission: admission control, enforces byte-based + request-based budgets
14
15// Loosely based on BBR: Congestion-Based Congestion Control
16// see https://queue.acm.org/detail.cfm?id=3022184
17
18mod admission;
19mod model;
20mod regime;
21
22use std::num::NonZeroU64;
23use std::sync::Arc;
24use std::sync::atomic::AtomicU64;
25use std::sync::atomic::Ordering::Relaxed;
26use std::time::{Duration, Instant};
27
28pub use admission::{InFlightBudget, InFlightPermit, InFlightStats};
29use crossbeam_queue::ArrayQueue;
30pub use model::Model;
31use polars_core::runtime::ASYNC;
32use polars_utils::relaxed_cell::RelaxedCell;
33pub use regime::{Regime, RegimeState};
34
35// Number of samples in the queue, which gets drained on every tick.
36// At 50k requests per second and 100 ms tick window, we need 5k.
37const SAMPLE_QUEUE_CAPACITY: usize = 8192;
38
39use crate::cloud::concurrency_config::get_random_access_chunk_size;
40use crate::cloud::http_rate_limit::PacingBudget;
41
42#[derive(Clone, Copy, Debug)]
43pub struct IoSample {
44    pub n_bytes: u64,
45    // Time-to-first-byte.
46    pub ttfb: Duration,
47    // TODO: Factor out as we only care about per-tick_window stats.
48    pub completion_time: Instant,
49}
50
51#[derive(Debug, Clone)]
52pub struct ControllerConfig {
53    // Sliding window over which the most recent round-trip-time (RTT) and bandwidth (BW)
54    // will be calculated. Also acts as the retention window.
55    window: Duration,
56    // Byte-based budget during the Init phase, and as the base for the RampUp phase.
57    init_byte_budget: u64,
58    // Lower limit for the byte-based budget - needed to avoid deadlock.
59    floor_byte_budget: u64,
60    // Count-based request budget.
61    request_budget: u64,
62    // Lower limit for the count-based budget.
63    floor_request_budget: u64,
64    // Controller regime update frequency.
65    control_interval: Duration,
66    // Total budget only resizes if the relative changes exceeds this threshold
67    budget_resize_threshold: f64,
68}
69
70impl Default for ControllerConfig {
71    fn default() -> Self {
72        // Only used for bytes-based budget.
73        let target_chunk_size = get_random_access_chunk_size() as u64;
74        Self {
75            window: Duration::from_millis(1000),
76
77            // Byte-based budget during the ramp-up phase.
78            // Starting too low results in lost opportunity (time) during ramp-up.
79            // Starting too high results in early congestion, delayed completion of the first chunk,
80            // and inflated bandwidth estimation.
81            //
82            // Some BDP numbers for reference:
83            //   1 Gbps x 20 ms = 2.5 MB
84            //   1 Gbps x 50 ms = 6.25 MB
85            //   10 Gbps x 50 ms = 62.5 MB
86            //   100 Gbps x 50 ms = 625 MB
87            init_byte_budget: get_init_byte_budget(target_chunk_size),
88
89            // Byte-based budget floor.
90            // Must be >=larger than target_chunk_size to avoid potential deadlock.
91            floor_byte_budget: target_chunk_size,
92
93            // Count-based budget.
94            request_budget: get_request_budget(),
95            floor_request_budget: get_floor_request_budget(),
96            control_interval: Duration::from_millis(100),
97            budget_resize_threshold: 0.05,
98        }
99    }
100}
101
102/// Max number of bytes concurrently in flight during the init and start of rampup phase.
103fn get_init_byte_budget(target_chunk_size: u64) -> u64 {
104    let init_byte_budget = std::env::var("POLARS_INFLIGHT_INIT_BYTE_BUDGET")
105        .map(|x| {
106            x.parse::<NonZeroU64>()
107                .unwrap_or_else(|_| {
108                    panic!("invalid value for POLARS_INFLIGHT_INIT_BYTE_BUDGET: {x}")
109                })
110                .get()
111        })
112        .unwrap_or_else(|_| {
113            // This should be lower than the expected BDP so it can ramp-up, but
114            // too low a value delays the transition to stable.
115            // Heuristic: higher bandwidth is expected on larger instances.
116            let n = polars_config::config().max_threads() as u64;
117            n.div_ceil(8).max(4) * target_chunk_size
118        })
119        .max(1);
120
121    if init_byte_budget < target_chunk_size {
122        panic!("in-flight byte budget init must be larger than the target_chunk_size");
123    }
124
125    init_byte_budget
126}
127
128/// Maximum number of requests concurrently in flight.
129pub fn get_request_budget() -> u64 {
130    // Since object_store/reqwest use HTTP/1 with a connection pool, this value controls the
131    // max concurrent TCP sessions to S3 for the pipeline.
132    // When modifying this value, consider the max_thread count configuration(s), the OS limitations
133    // (e.g., ulimit -n), and any cloud infrastructure limitations.
134    std::env::var("POLARS_INFLIGHT_REQUEST_BUDGET")
135        .map(|x| {
136            x.parse::<NonZeroU64>()
137                .unwrap_or_else(|_| panic!("invalid value for POLARS_INFLIGHT_REQUEST_BUDGET: {x}"))
138                .get()
139        })
140        .unwrap_or(512)
141        .max(1)
142}
143
144/// Minimum number of requests concurrently in flight, if demand is there.
145pub fn get_floor_request_budget() -> u64 {
146    // Since object_store/reqwest use HTTP/1 with a connection pool, this value controls the
147    // max concurrent TCP sessions to S3 for the pipeline.
148    // When modifying this value, consider the max_thread count configuration(s), the OS limitations
149    // (e.g., ulimit -n), and any cloud infrastructure limitations.
150    std::env::var("POLARS_INFLIGHT_FLOOR_REQUEST_BUDGET")
151        .map(|x| {
152            x.parse::<NonZeroU64>()
153                .unwrap_or_else(|_| {
154                    panic!("invalid value for POLARS_INFLIGHT_FLOOR_REQUEST_BUDGET: {x}")
155                })
156                .get()
157        })
158        .unwrap_or(polars_config::config().max_threads() as u64)
159        .max(1)
160}
161
162/// Windowed round-trip time of 0-byte metadata (HEAD) requests.
163/// Diagnostic only, not to be used for BDP estimation.
164#[derive(Debug)]
165pub struct HeadRttChannel {
166    min_ns: AtomicU64,
167    sum_ns: AtomicU64,
168    count: AtomicU64,
169    /// Never reset, unlike `count`: distinguishes the first request of the process from
170    /// the first of a window.
171    total: AtomicU64,
172}
173
174/// One control tick's worth of [`HeadRttChannel`] observations.
175#[derive(Clone, Copy, Debug)]
176pub struct HeadRttWindow {
177    pub min: Option<Duration>,
178    pub avg: Option<Duration>,
179    pub count: u64,
180}
181
182impl HeadRttChannel {
183    fn new() -> Self {
184        Self {
185            min_ns: AtomicU64::new(u64::MAX),
186            sum_ns: AtomicU64::new(0),
187            count: AtomicU64::new(0),
188            total: AtomicU64::new(0),
189        }
190    }
191
192    /// Hot path: four relaxed ops, no queue.
193    fn record(&self, rtt: Duration) {
194        let ns = rtt.as_nanos() as u64;
195        self.min_ns.fetch_min(ns, Relaxed);
196        self.sum_ns.fetch_add(ns, Relaxed);
197        self.count.fetch_add(1, Relaxed);
198
199        if self.total.fetch_add(1, Relaxed) == 0 && polars_config::config().verbose() {
200            eprintln!(
201                "[InFlightConcurrency]: observed first RTT sample (metadata): {} ms",
202                rtt.as_millis()
203            );
204        }
205    }
206
207    /// Read and reset.
208    fn take(&self) -> HeadRttWindow {
209        let min_ns = self.min_ns.swap(u64::MAX, Relaxed);
210        let sum_ns = self.sum_ns.swap(0, Relaxed);
211        let count = self.count.swap(0, Relaxed);
212
213        HeadRttWindow {
214            min: (min_ns != u64::MAX).then(|| Duration::from_nanos(min_ns)),
215            avg: (count > 0).then(|| Duration::from_nanos(sum_ns / count)),
216            count,
217        }
218    }
219}
220
221#[derive(Debug)]
222pub struct ConcurrencyController {
223    config: ControllerConfig,
224    sample_queue: Arc<ArrayQueue<IoSample>>,
225    samples_dropped: Arc<RelaxedCell<u64>>,
226    head_rtt: Arc<HeadRttChannel>,
227    inflight_budget: Arc<InFlightBudget>,
228    _control_task: tokio::task::JoinHandle<()>,
229}
230
231impl ConcurrencyController {
232    pub fn new(config: ControllerConfig, pacing_budget: Option<PacingBudget>) -> Self {
233        let sample_queue = Arc::new(ArrayQueue::new(SAMPLE_QUEUE_CAPACITY));
234        let samples_dropped = Arc::new(RelaxedCell::new_u64(0));
235        let head_rtt = Arc::new(HeadRttChannel::new());
236
237        let inflight_budget = Arc::new(InFlightBudget::new(
238            config.init_byte_budget,
239            config.floor_byte_budget,
240            config.request_budget,
241            config.floor_request_budget,
242        ));
243
244        let control_task = Self::spawn_control_loop(
245            sample_queue.clone(),
246            samples_dropped.clone(),
247            head_rtt.clone(),
248            inflight_budget.clone(),
249            config.clone(),
250            pacing_budget,
251        );
252
253        Self {
254            config,
255            sample_queue,
256            samples_dropped,
257            head_rtt,
258            inflight_budget,
259            _control_task: control_task,
260        }
261    }
262
263    pub fn config(&self) -> &ControllerConfig {
264        &self.config
265    }
266
267    /// Record IO for a completed data request. Hot path.
268    pub fn record_io(&self, sample: IoSample) {
269        if self.sample_queue.push(sample).is_err() {
270            // Queue full: drop. Samples are statistics is considered acceptable.
271            self.samples_dropped.fetch_add(1);
272        }
273    }
274
275    /// Record the round-trip time of a completed 0-byte metadata request. Hot path.
276    pub fn record_head_rtt(&self, rtt: Duration) {
277        self.head_rtt.record(rtt);
278    }
279
280    pub fn inflight_budget(&self) -> &Arc<InFlightBudget> {
281        &self.inflight_budget
282    }
283
284    pub async fn acquire(&self, bytes: u64) -> InFlightPermit {
285        self.inflight_budget.acquire(bytes).await
286    }
287
288    fn spawn_control_loop(
289        sample_queue: Arc<ArrayQueue<IoSample>>,
290        samples_dropped: Arc<RelaxedCell<u64>>,
291        head_rtt: Arc<HeadRttChannel>,
292        admission: Arc<InFlightBudget>,
293        config: ControllerConfig,
294        pacing_budget: Option<PacingBudget>,
295    ) -> tokio::task::JoinHandle<()> {
296        if polars_config::config().verbose() {
297            eprintln!(
298                "[InFlightConcurrency]: spawn control loop: control_interval: {}ms",
299                config.control_interval.as_millis()
300            );
301        }
302        ASYNC.spawn(async move {
303            let mut model = Model::new(config.window);
304            let mut regime = Regime::new(Instant::now());
305
306            let mut ticker = tokio::time::interval(config.control_interval);
307            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
308
309            loop {
310                ticker.tick().await;
311                let now = Instant::now();
312
313                // Limit concurrency to the pacing budget from the rate-limiter.
314                if let Some(ref pacing_budget) = pacing_budget {
315                    let rate = pacing_budget.rate();
316                    let horizon_s = pacing_budget.horizon().as_secs_f64();
317                    let request_budget = rate * horizon_s;
318                    admission.resize_request_budget(request_budget as u64);
319                }
320
321                // Update model statistics and step regime.
322                let (state, signal, dropped, bw_hwm_held) = {
323                    for _ in 0..SAMPLE_QUEUE_CAPACITY {
324                        let Some(s) = sample_queue.pop() else { break };
325                        model.record(s);
326                    }
327                    let dropped = samples_dropped.swap(0);
328                    model.update(now);
329                    let signal = model.signal();
330                    let state = regime.step(signal, now);
331                    let bw_hwm_held = model.bw_hwm_bps();
332                    (state, signal, dropped, bw_hwm_held)
333                };
334
335                if !matches!(state, RegimeState::WarmIdle { .. }) {
336                    // Compute base BDP
337                    let base_byte_budget = match (state, signal) {
338                        (RegimeState::Init, _) | (_, None) => config.init_byte_budget,
339                        (_, Some(signal)) => signal.bdp_bytes().max(config.init_byte_budget),
340                    };
341
342                    // Compute target BDP using the gain multiplier. This is similar to BBR cwnd_gain.
343                    let gain = match state {
344                        RegimeState::Init => 1.0,
345                        RegimeState::RampUp { .. } => 2.0,
346                        // NOTE: >> 1.0 for the purpose of absorbing environment noise.
347                        RegimeState::Stable => 2.0,
348                        RegimeState::ProbeUp { .. } => 3.0,
349                        // Unreachable.
350                        RegimeState::WarmIdle { .. } => 1.0,
351                    };
352                    let target_byte_budget = (base_byte_budget as f64 * gain) as u64;
353
354                    // Resize if needed.
355                    let current_byte_budget = admission.current_byte_budget();
356                    let threshold = config.budget_resize_threshold;
357                    let should_resize = match current_byte_budget {
358                        0 => target_byte_budget > 0,
359                        current => {
360                            let ratio = target_byte_budget as f64 / current as f64;
361                            ratio < (1.0 - threshold) || ratio > (1.0 + threshold)
362                        },
363                    };
364
365                    if should_resize {
366                        admission.resize_byte_budget(target_byte_budget);
367                    }
368                }
369
370                // Drained every tick. Diagnostic only.
371                let head_rtt_window = head_rtt.take();
372
373                // Log snapshot.
374                if std::env::var("POLARS_LOG_CONCURRENCY").is_ok() {
375                    let stats = admission.stats();
376                    eprintln!(
377                        "[InFlightConcurrency {}] regime={}, \
378                        bw_hwm={:.1} MB/s, \
379                        bw_avg={:.1} MB/s, \
380                        rtt_min={:.1} ms, \
381                        rtt_avg={:.1} ms, \
382                        head_rtt_min={:.1} ms, \
383                        head_rtt_avg={:.1} ms, \
384                        head_n={}, \
385                        bdp_obs={:.1} MB, \
386                        bytes_budget={:.1} MB, \
387                        bytes_in_use={:.1} MB, \
388                        bytes_sat={:.2}, \
389                        req_budget={}, \
390                        req_in_use={}, \
391                        req_sat={:.2}",
392                        chrono::Utc::now(),
393                        state.label(),
394                        signal.map(|s| s.bw_hwm_bps).or(bw_hwm_held).unwrap_or(0.0) / 1e6,
395                        signal.map_or(0.0, |s| s.bw_avg_bps) / 1e6,
396                        signal.map_or(0, |s| s.ttfb_min.as_millis()),
397                        signal.map_or(0, |s| s.ttfb_avg.as_millis()),
398                        head_rtt_window.min.map_or(0.0, |d| d.as_secs_f64() * 1e3),
399                        head_rtt_window.avg.map_or(0.0, |d| d.as_secs_f64() * 1e3),
400                        head_rtt_window.count,
401                        signal.map_or(0, |s| s.bdp_bytes()) as f64 / 1e6,
402                        stats.bytes_budget as f64 / 1e6,
403                        stats.bytes_in_use as f64 / 1e6,
404                        stats.bytes_saturation,
405                        stats.request_budget,
406                        stats.requests_in_use,
407                        stats.requests_saturation
408                    );
409                    if dropped > 0 {
410                        eprintln!(
411                            "[InFlightConcurrency] WARN: {dropped} samples dropped (queue full)"
412                        );
413                    }
414                }
415            }
416        })
417    }
418}
419
420impl Drop for ConcurrencyController {
421    fn drop(&mut self) {
422        self._control_task.abort();
423    }
424}