Skip to content

Commit d5589b7

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 4cc92aa commit d5589b7

5 files changed

Lines changed: 81 additions & 9 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()?.count())
4444
.build()
4545
.unwrap();
4646

src/diskio/mod.rs

Lines changed: 31 additions & 3 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;
@@ -442,18 +443,45 @@ pub(crate) fn create_dir<P: AsRef<Path>>(path: P) -> io::Result<()> {
442443
std::fs::create_dir(path)
443444
}
444445

446+
/// The Threaded executor uses substantially more memory than its ram_budget
447+
/// accounts for (pool overhead, sharded_slab metadata, multiple in-flight
448+
/// operations, thread stacks, allocator fragmentation). On systems where the
449+
/// ram_budget is under this threshold, fall back to single-threaded unpacking
450+
/// which peaks at ~110MB.
451+
/// See https://github.com/rust-lang/rustup/issues/3125
452+
const LOW_MEMORY_THRESHOLD: usize = 512 * 1024 * 1024;
453+
445454
/// Get the executor for disk IO.
455+
// If this gets lots of use, consider exposing via the config file.
446456
pub(crate) fn get_executor<'a>(
447457
ram_budget: usize,
448-
io_thread_count: usize,
458+
thread_count: IoThreadCount,
449459
) -> Box<dyn Executor + 'a> {
450-
// If this gets lots of use, consider exposing via the config file.
451-
match io_thread_count {
460+
let threads = effective_thread_count(ram_budget, thread_count);
461+
match threads {
452462
0 | 1 => Box::new(immediate::ImmediateUnpacker::new()),
453463
n => Box::new(threaded::Threaded::new(n, ram_budget)),
454464
}
455465
}
456466

467+
fn effective_thread_count(ram_budget: usize, thread_count: IoThreadCount) -> usize {
468+
match thread_count {
469+
IoThreadCount::UserSpecified(n) => n,
470+
IoThreadCount::Default(n) if n <= 1 => n,
471+
IoThreadCount::Default(n) if ram_budget < LOW_MEMORY_THRESHOLD => {
472+
warn!(
473+
"Using single-threaded unpacking due to low memory \
474+
(ram budget: {} < {} threshold). \
475+
Set RUSTUP_IO_THREADS to override.",
476+
Size::new(ram_budget),
477+
Size::new(LOW_MEMORY_THRESHOLD)
478+
);
479+
1
480+
}
481+
IoThreadCount::Default(n) => n,
482+
}
483+
}
484+
457485
pub(crate) fn unpack_ram(io_chunk_size: usize, budget: Option<usize>) -> usize {
458486
const RAM_ALLOWANCE_FOR_RUSTUP_AND_BUFFERS: usize = 200 * 1024 * 1024;
459487
let minimum_ram = io_chunk_size * 2;

src/diskio/test.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,34 @@ 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!(effective_thread_count(32 * 1024 * 1024, Default(1)), 1);
174+
assert_eq!(effective_thread_count(32 * 1024 * 1024, Default(0)), 0);
175+
176+
// Below threshold: forced to single-threaded
177+
assert_eq!(effective_thread_count(256 * 1024 * 1024, Default(8)), 1);
178+
assert_eq!(
179+
effective_thread_count(LOW_MEMORY_THRESHOLD - 1, Default(4)),
180+
1
181+
);
182+
183+
// At or above threshold: thread count unchanged
184+
assert_eq!(effective_thread_count(LOW_MEMORY_THRESHOLD, Default(4)), 4);
185+
assert_eq!(effective_thread_count(1024 * 1024 * 1024, Default(8)), 8);
186+
187+
// User-specified threads are always respected
188+
assert_eq!(
189+
effective_thread_count(32 * 1024 * 1024, UserSpecified(4)),
190+
4
191+
);
192+
assert_eq!(
193+
effective_thread_count(LOW_MEMORY_THRESHOLD - 1, UserSpecified(8)),
194+
8
195+
);
196+
}

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()?.count())
4545
.build_global();
4646
match &self {
4747
InstallMethod::Copy { .. }

src/process.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ mod file_source;
3131
mod terminal_source;
3232
pub use terminal_source::ColorableTerminal;
3333

34+
pub enum IoThreadCount {
35+
Default(usize),
36+
UserSpecified(usize),
37+
}
38+
39+
impl IoThreadCount {
40+
pub fn count(&self) -> usize {
41+
match self {
42+
Self::Default(n) | Self::UserSpecified(n) => *n,
43+
}
44+
}
45+
}
46+
3447
/// Allows concrete types for the process abstraction.
3548
#[derive(Clone, Debug)]
3649
pub enum Process {
@@ -69,23 +82,23 @@ impl Process {
6982
home::env::rustup_home_with_env(self).context("failed to determine rustup home dir")
7083
}
7184

72-
pub fn io_thread_count(&self) -> Result<usize> {
85+
pub fn io_thread_count(&self) -> Result<IoThreadCount> {
7386
if let Ok(n) = self.var("RUSTUP_IO_THREADS") {
7487
let threads = usize::from_str(&n).context(
7588
"invalid value in RUSTUP_IO_THREADS -- must be a natural number greater than zero",
7689
)?;
7790
match threads {
7891
0 => bail!("RUSTUP_IO_THREADS must be a natural number greater than zero"),
79-
_ => return Ok(threads),
92+
_ => return Ok(IoThreadCount::UserSpecified(threads)),
8093
}
8194
};
8295

8396
Ok(match thread::available_parallelism() {
8497
// Don't spawn more than 8 I/O threads unless the user tells us to.
8598
// Feel free to increase this value if it improves performance.
86-
Ok(threads) => Ord::min(threads.get(), 8),
99+
Ok(threads) => IoThreadCount::Default(Ord::min(threads.get(), 8)),
87100
// Unknown for target platform or no permission to query.
88-
Err(_) => 1,
101+
Err(_) => IoThreadCount::Default(1),
89102
})
90103
}
91104

0 commit comments

Comments
 (0)