Skip to content

Commit 8a8772a

Browse files
committed
fix(diskio): fall back to single-threaded unpacking when ram_budget < 512MB to avoid OOM on memory-constrained systems
Rustup OOMs on systems with <1GB RAM because it spawns multiple I/O threads for unpacking regardless of available memory. The Threaded executor uses far more memory than its buffer pool budget tracks. This PR adds a 512MB ram_budget threshold below which rustup falls back to single-threaded unpacking, which peaks at ~110MB. Fixes #3125
1 parent 840a8dd commit 8a8772a

5 files changed

Lines changed: 91 additions & 8 deletions

File tree

src/bin/rustup-init.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ fn main() -> Result<ExitCode> {
4040
let process = Process::os();
4141
let runtime = tokio::runtime::Builder::new_multi_thread()
4242
.enable_all()
43-
.worker_threads(process.io_thread_count()?)
43+
.worker_threads(process.io_thread_count()?.into())
4444
.build()
4545
.unwrap();
4646

src/diskio/mod.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ use std::{fmt::Debug, fs::OpenOptions};
6262
use anyhow::Result;
6363
use tracing::{error, trace, warn};
6464

65+
use crate::process::IoThreadCount;
6566
use crate::utils::units::Size;
6667

6768
pub(crate) mod immediate;
@@ -445,15 +446,32 @@ pub(crate) fn create_dir<P: AsRef<Path>>(path: P) -> io::Result<()> {
445446
/// Get the executor for disk IO.
446447
pub(crate) fn get_executor<'a>(
447448
ram_budget: usize,
448-
io_thread_count: usize,
449+
thread_count: IoThreadCount,
449450
) -> Box<dyn Executor + 'a> {
450451
// If this gets lots of use, consider exposing via the config file.
451-
match io_thread_count {
452+
let threads = effective_thread_count(ram_budget, thread_count);
453+
match threads {
452454
0 | 1 => Box::new(immediate::ImmediateUnpacker::new()),
453455
n => Box::new(threaded::Threaded::new(n, ram_budget)),
454456
}
455457
}
456458

459+
fn effective_thread_count(ram_budget: usize, thread_count: IoThreadCount) -> usize {
460+
match thread_count {
461+
IoThreadCount::Default(n) if n > 1 && ram_budget < LOW_MEMORY_THRESHOLD => {
462+
warn!(
463+
"using single-threaded unpacking due to low memory \
464+
(ram budget: {} < {} threshold), \
465+
set RUSTUP_IO_THREADS to override",
466+
Size::new(ram_budget),
467+
Size::new(LOW_MEMORY_THRESHOLD)
468+
);
469+
1
470+
}
471+
IoThreadCount::Default(n) | IoThreadCount::UserSpecified(n) => n,
472+
}
473+
}
474+
457475
pub(crate) fn unpack_ram(io_chunk_size: usize, budget: Option<usize>) -> usize {
458476
const RAM_ALLOWANCE_FOR_RUSTUP_AND_BUFFERS: usize = 200 * 1024 * 1024;
459477
let minimum_ram = io_chunk_size * 2;
@@ -505,3 +523,11 @@ pub(crate) fn unpack_ram(io_chunk_size: usize, budget: Option<usize>) -> usize {
505523
}
506524

507525
static RAM_NOTICE_SHOWN: OnceLock<()> = OnceLock::new();
526+
527+
/// The Threaded executor uses substantially more memory than its ram_budget
528+
/// accounts for (pool overhead, sharded_slab metadata, multiple in-flight
529+
/// operations, thread stacks, allocator fragmentation). On systems where the
530+
/// ram_budget is under this threshold, fall back to single-threaded unpacking
531+
/// which peaks at ~110MB.
532+
/// See https://github.com/rust-lang/rustup/issues/3125
533+
const LOW_MEMORY_THRESHOLD: usize = 512 * 1024 * 1024;

src/diskio/test.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,46 @@ fn test_complete_file_immediate() {
163163
fn test_complete_file_threaded() {
164164
test_complete_file("2").unwrap()
165165
}
166+
167+
#[test]
168+
fn test_effective_thread_count() {
169+
use super::{LOW_MEMORY_THRESHOLD, effective_thread_count};
170+
use crate::process::IoThreadCount::{Default, UserSpecified};
171+
172+
// Already single-threaded: no change regardless of budget
173+
assert_eq!(
174+
effective_thread_count(LOW_MEMORY_THRESHOLD / 16, Default(1)),
175+
1
176+
);
177+
assert_eq!(
178+
effective_thread_count(LOW_MEMORY_THRESHOLD / 16, Default(0)),
179+
0
180+
);
181+
182+
// Below threshold: forced to single-threaded
183+
assert_eq!(
184+
effective_thread_count(LOW_MEMORY_THRESHOLD / 2, Default(8)),
185+
1
186+
);
187+
assert_eq!(
188+
effective_thread_count(LOW_MEMORY_THRESHOLD / 2, Default(4)),
189+
1
190+
);
191+
192+
// At or above threshold: thread count unchanged
193+
assert_eq!(effective_thread_count(LOW_MEMORY_THRESHOLD, Default(4)), 4);
194+
assert_eq!(
195+
effective_thread_count(LOW_MEMORY_THRESHOLD * 2, Default(8)),
196+
8
197+
);
198+
199+
// User-specified threads are always respected
200+
assert_eq!(
201+
effective_thread_count(LOW_MEMORY_THRESHOLD / 16, UserSpecified(4)),
202+
4
203+
);
204+
assert_eq!(
205+
effective_thread_count(LOW_MEMORY_THRESHOLD / 2, UserSpecified(8)),
206+
8
207+
);
208+
}

src/install.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ impl InstallMethod<'_, '_> {
4141
// Initialize rayon for use by the remove_dir_all crate limiting the number of threads.
4242
// This will error if rayon is already initialized but it's fine to ignore that.
4343
let _ = rayon::ThreadPoolBuilder::new()
44-
.num_threads(self.cfg().process.io_thread_count()?)
44+
.num_threads(self.cfg().process.io_thread_count()?.into())
4545
.build_global();
4646
match &self {
4747
InstallMethod::Copy { .. }

src/process.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,24 +69,25 @@ impl Process {
6969
home::env::rustup_home_with_env(self).context("failed to determine rustup home dir")
7070
}
7171

72-
pub fn io_thread_count(&self) -> Result<usize> {
72+
pub fn io_thread_count(&self) -> Result<IoThreadCount> {
7373
if let Ok(n) = self.var("RUSTUP_IO_THREADS") {
7474
let threads = usize::from_str(&n).context(
7575
"invalid value in RUSTUP_IO_THREADS -- must be a natural number greater than zero",
7676
)?;
7777
match threads {
7878
0 => bail!("RUSTUP_IO_THREADS must be a natural number greater than zero"),
79-
_ => return Ok(threads),
79+
_ => return Ok(IoThreadCount::UserSpecified(threads)),
8080
}
8181
};
8282

83-
Ok(match thread::available_parallelism() {
83+
let count = match thread::available_parallelism() {
8484
// Don't spawn more than 8 I/O threads unless the user tells us to.
8585
// Feel free to increase this value if it improves performance.
8686
Ok(threads) => Ord::min(threads.get(), 8),
8787
// Unknown for target platform or no permission to query.
8888
Err(_) => 1,
89-
})
89+
};
90+
Ok(IoThreadCount::Default(count))
9091
}
9192

9293
pub(crate) fn unpack_ram(&self) -> Result<Option<usize>, env::VarError> {
@@ -235,6 +236,19 @@ impl Process {
235236
}
236237
}
237238

239+
pub enum IoThreadCount {
240+
Default(usize),
241+
UserSpecified(usize),
242+
}
243+
244+
impl From<IoThreadCount> for usize {
245+
fn from(c: IoThreadCount) -> Self {
246+
match c {
247+
IoThreadCount::Default(n) | IoThreadCount::UserSpecified(n) => n,
248+
}
249+
}
250+
}
251+
238252
impl home::env::Env for Process {
239253
fn home_dir(&self) -> Option<PathBuf> {
240254
match self {

0 commit comments

Comments
 (0)