Adaptive CPU Autoscaling¶
Overview¶
The adaptive CPU autoscaler monitors system CPU utilization during long-running pipeline steps and dynamically scales the Dask worker count up when cores sit idle. This addresses chronic CPU underutilization during backtest, forecast, and distribution-fitting steps where the static worker count (set at cluster startup) leaves cores idle when the batch queue is deep.
Two Modes¶
| Mode | Applies to | Behaviour |
|---|---|---|
| Scale mode | Dask steps (forecast, backtest, distribution fitting) | Samples CPU % via psutil.cpu_percent(). When utilization is sustained below autoscale_low_cpu_threshold for autoscale_sustain_sec seconds AND queued Dask tasks remain, calls cluster.scale(N) to add workers. Caps at autoscale_max_workers (default: physical core count). Also enforces a total-threads cap: workers × threads_per_worker ≤ autoscale_max_threads. |
| Monitor mode | MEIO, supply, ProcessPool fallback | Samples CPU only, never resizes (rayon pool is fixed at startup). Emits a recommendation at step end that diagnoses whether the step is CPU-bound or I/O/DB-bound. |
Why Direct Adaptation is Safe¶
The Dask steps (forecast, backtest, distribution) submit ALL batches up-front as futures. The scheduler holds pending tasks in a queue. Calling cluster.scale(N) mid-run simply adds worker processes that drain the existing queue — no re-splitting of work is required.
Config Keys¶
Stored in cluster_config.advanced in the config_parameters table. These keys live in DEFAULT_LOCAL_CONFIG["advanced"] in files/utils/dask_cluster.py and flow through _merge_defaults() so existing DB rows get them without a migration.
| Key | Default | Description |
|---|---|---|
autoscale_enabled |
True |
Master switch. False → monitor-only everywhere |
autoscale_min_workers |
1 |
Minimum worker (process) count |
autoscale_max_workers |
0 |
Maximum worker count. 0 = auto = physical core count |
autoscale_min_threads |
0 |
Minimum total threads across cluster. 0 = unset |
autoscale_max_threads |
0 |
Maximum total threads cap. 0 = auto = logical core count |
autoscale_low_cpu_threshold |
50 |
CPU usage % below which the scaler scales UP (sustained) |
autoscale_high_cpu_threshold |
92 |
CPU usage % above which the scaler scales DOWN (sustained; shrink off by default) |
autoscale_sample_interval_sec |
5 |
How often to sample CPU |
autoscale_sustain_sec |
15 |
Must stay below/above threshold this long before acting |
autoscale_cooldown_sec |
20 |
Minimum gap between scale actions |
autoscale_step |
2 |
Workers added per scale-up event |
autoscale_allow_shrink |
False |
Enable scale-down toward min_workers above high threshold |
UI: the "⚡ Autoscale" Subtab¶
Located in the Processes → Cluster tab as the 5th subtab (alongside Overview, Workers, Tasks, Config). The AutoscalePanel component provides:
- Master enable/disable checkbox
- Three min/max control groups:
- Workers (CPU processes): min/max worker count
- Total threads: min/max total threads cap
- CPU usage %: low (scale-up trigger) / high (scale-down trigger)
- Advanced tuning section (collapsible): sample interval, sustain window, cooldown, step size, allow-shrink checkbox
- Live effective-limit chips showing: base workers×threads, scaler range, and effective max workers (factoring the thread cap)
- Save / Save & restart buttons (wired to the existing
PUT /api/cluster/configendpoint — no new API)
A warning appears if low_cpu >= high_cpu (inverted thresholds).
Scaling Logic (Scale Mode)¶
Every autoscale_sample_interval_sec:
- Sample
psutil.cpu_percent(interval=None)(non-blocking) - Push sample into a rolling deque (window =
sustain_sec) - Compute mean of the window
- Scale up if: mean CPU < low threshold for sustain window AND remaining queued tasks > current workers AND current workers < max workers AND cooldown elapsed
new_n = min(max_workers, current + step)- If max_threads > 0 and new_n × threads_per_worker > max_threads: clamp new_n down
- Call
cluster.scale(new_n) - Scale down (optional, if
autoscale_allow_shrink): if mean CPU > high threshold for sustain window AND current > min_workers AND cooldown elapsed new_n = max(min_workers, current - 1)- Call
cluster.scale(new_n)
Recommendation Engine¶
At stop() time, the scaler always emits a recommendation:
| Condition | Recommendation |
|---|---|
| Scale mode, low CPU, scaling occurred | "CPU averaged X% — autoscaler raised workers up to N. Consider setting local.n_workers=N permanently to avoid ramp-up latency." |
| Scale mode, low CPU, hit cap | "Hit worker cap (N) but CPU still only X%. Bottleneck is per-task (GIL/ML torch threads) or stragglers — reduce batch_size and/or lower threads_per_worker." |
| Scale mode, low CPU, no scaling | "CPU averaged X% but no scaling occurred (queue drained too fast or caps reached). Consider a smaller batch_size or raise autoscale_max_workers." |
| Scale mode, high CPU | "CPU saturated at X%. To go faster: reduce batch_size or raise autoscale_max_workers." |
| Monitor mode, low CPU | "CPU averaged X%. The engine appears I/O- or DB-bound; adding cores unlikely to help. Profile DB/CH round-trips." |
| Monitor mode, high CPU | "CPU saturated at X%. The engine is CPU-bound — consider raising the rayon thread count." |
| Healthy | "CPU utilization healthy (mean X%, peak Y%). No change recommended." |
Per-Step Integration¶
| Step | File | Mode | Log prefix |
|---|---|---|---|
| Forecast | step_forecast |
Scale | [AUTOSCALE:forecast] |
| Backtest | step_backtest |
Scale | [AUTOSCALE:backtest] |
| Distribution fitting | step_fit_distributions |
Scale (Dask) / Monitor (ProcessPool fallback) | [AUTOSCALE:distribution] |
| MEIO | meio_runner.py |
Monitor only | — |
| Supply | supply_runner.py |
Monitor only | — |
MEIO wraps the Rust run_optimization_batch call; supply wraps the _call_engine function (the Rust run_supply_plan call). The rayon pool is fixed at startup in both cases, so no live resize is possible.
End-of-Run Summary¶
After each instrumented step, the autoscaler prints:
- A summary line:
[AUTOSCALE:step] mode=scale cpu mean=42% peak=67% p95=55% samples=12 scale_events=[4→6] - A recommendation line.
At pipeline end, a compact per-step table is printed alongside the existing WORKER TIMING summary:
============================================================
ADAPTIVE CPU AUTOSCALING (per step)
============================================================
Step Mode CPU mean peak p95 Events
------------------------------------------------------------
forecast scale 42% 67% 55% 1
backtest scale 38% 71% 52% 2
distribution monitor 55% 80% 62% 0
============================================================
API¶
No new API endpoints. Autoscale settings are persisted through the existing PUT /api/cluster/config endpoint (which writes the full cluster_config dict, merged with defaults by set_current_config). The GET /api/cluster/config endpoint returns the config.
Caveats & Known Limitations¶
Default-on
autoscale_enabled defaults to True. Existing deployments will see live cluster.scale() on the next run after this code ships. On shared boxes intentionally under-provisioned (e.g. n_workers=2 on a 16-core host shared with ClickHouse/torch), consider explicitly setting autoscale_enabled=false or lowering autoscale_max_workers before the first run.
- Default-on:
autoscale_enableddefaults toTrue(see warning above). - Worker spawn cost:
LocalCluster(processes=True)means eachcluster.scale()spawns a worker process that re-imports the forecast stack (pandas/statsforecast/numpy) — several seconds of startup. Scaling only amortizes for long steps with many batches. - No memory check: The scaler does not check
psutil.virtual_memory()before scaling. On memory-constrained boxes, capautoscale_max_workersconservatively. - External distributed scheduler: When
mode=distributed, the scaler auto-downgrades to monitor mode (no local_clusterhandle to resize). - Scale-down off by default:
autoscale_allow_shrinkdefaults toFalse. High CPU is logged as a recommendation only; no automatic scale-down occurs unless explicitly enabled. - MEIO/supply fixed pool: The Rust rayon thread pool is fixed at process start. Monitor mode diagnoses I/O-vs-CPU bound but cannot resize.
Common Mistakes¶
| Mistake | Consequence | Fix |
|---|---|---|
Setting autoscale_max_workers too high on a shared box |
Oversubscription against CH/torch | Set to physical cores minus reserved cores |
Setting low_cpu >= high_cpu |
Inverted thresholds; scaler acts on impossible band | Ensure low < high; UI shows a warning |
| Expecting MEIO/supply to scale live | Rayon pool is fixed; only recommendation emitted | Tune meio_parallel_workers at startup instead |
Setting autoscale_max_threads without considering threads_per_worker |
Effective max workers may be lower than expected | Check the "Effective max workers" chip in the UI |
Forgetting that autoscale_enabled defaults to True |
Unexpected worker spawning on first run | Explicitly set to false if not ready |
Implementation¶
Key files¶
| File | Role |
|---|---|
files/utils/cpu_autoscaler.py |
CpuAutoscaler class, AutoscaleReport dataclass, background thread, recommendation engine |
files/utils/dask_cluster.py |
DEFAULT_LOCAL_CONFIG["advanced"] with autoscale_* keys; _merge_defaults() backfills them |
files/utils/orchestrator.py |
_make_autoscaler() helper; wraps forecast/backtest/distribution Dask loops; _format_autoscale_summary() |
files/meio_runner.py |
Wraps Rust run_optimization_batch with monitor-mode scaler |
files/supply_runner.py |
Wraps _call_engine (Rust run_supply_plan) with monitor-mode scaler |
files/frontend/src/components/ClusterTab.jsx |
AutoscalePanel component + "⚡ Autoscale" subtab |