Skip to content

Commit 909da50

Browse files
authored
Merge pull request #8959 from mattsu2020/sort-memory-functions
feat(sort): auto-tune buffer sizing from available memory
1 parent 3d6b0b2 commit 909da50

File tree

7 files changed

+221
-31
lines changed

7 files changed

+221
-31
lines changed

.vscode/cspell.dictionaries/jargon.wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ symlink
131131
symlinks
132132
syscall
133133
syscalls
134+
sysconf
134135
tokenize
135136
toolchain
136137
truthy

fuzz/Cargo.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/uu/sort/Cargo.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ thiserror = { workspace = true }
3636
unicode-width = { workspace = true }
3737
uucore = { workspace = true, features = ["fs", "parser", "version-cmp"] }
3838
fluent = { workspace = true }
39-
40-
[target.'cfg(target_os = "linux")'.dependencies]
4139
nix = { workspace = true }
4240

4341
[dev-dependencies]

src/uu/sort/src/buffer_hint.rs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// This file is part of the uutils coreutils package.
2+
//
3+
// For the full copyright and license information, please view the LICENSE
4+
// file that was distributed with this source code.
5+
6+
//! Heuristics for determining buffer size for external sorting.
7+
use std::ffi::OsString;
8+
9+
use crate::{
10+
FALLBACK_AUTOMATIC_BUF_SIZE, MAX_AUTOMATIC_BUF_SIZE, MIN_AUTOMATIC_BUF_SIZE, STDIN_FILE,
11+
};
12+
13+
// Heuristics to size the external sort buffer without overcommit memory.
14+
pub(crate) fn automatic_buffer_size(files: &[OsString]) -> usize {
15+
let file_hint = file_size_hint(files);
16+
let mem_hint = available_memory_hint();
17+
18+
// Prefer the tighter bound when both hints exist, otherwise fall back to whichever hint is available.
19+
match (file_hint, mem_hint) {
20+
(Some(file), Some(mem)) => file.min(mem),
21+
(Some(file), None) => file,
22+
(None, Some(mem)) => mem,
23+
(None, None) => FALLBACK_AUTOMATIC_BUF_SIZE,
24+
}
25+
}
26+
27+
fn file_size_hint(files: &[OsString]) -> Option<usize> {
28+
// Estimate total bytes across real files; non-regular inputs are skipped.
29+
let mut total_bytes: u128 = 0;
30+
31+
for file in files {
32+
if file == STDIN_FILE {
33+
continue;
34+
}
35+
36+
let Ok(metadata) = std::fs::metadata(file) else {
37+
continue;
38+
};
39+
40+
if !metadata.is_file() {
41+
continue;
42+
}
43+
44+
total_bytes = total_bytes.saturating_add(metadata.len() as u128);
45+
46+
if total_bytes >= (MAX_AUTOMATIC_BUF_SIZE as u128) * 8 {
47+
break;
48+
}
49+
}
50+
51+
if total_bytes == 0 {
52+
return None;
53+
}
54+
55+
let desired_bytes = desired_file_buffer_bytes(total_bytes);
56+
Some(clamp_hint(desired_bytes))
57+
}
58+
59+
fn available_memory_hint() -> Option<usize> {
60+
#[cfg(target_os = "linux")]
61+
if let Some(bytes) = uucore::parser::parse_size::available_memory_bytes() {
62+
return Some(clamp_hint(bytes / 4));
63+
}
64+
65+
physical_memory_bytes().map(|bytes| clamp_hint(bytes / 4))
66+
}
67+
68+
fn clamp_hint(bytes: u128) -> usize {
69+
let min = MIN_AUTOMATIC_BUF_SIZE as u128;
70+
let max = MAX_AUTOMATIC_BUF_SIZE as u128;
71+
let clamped = bytes.clamp(min, max);
72+
clamped.min(usize::MAX as u128) as usize
73+
}
74+
75+
fn desired_file_buffer_bytes(total_bytes: u128) -> u128 {
76+
if total_bytes == 0 {
77+
return 0;
78+
}
79+
80+
let max = MAX_AUTOMATIC_BUF_SIZE as u128;
81+
82+
if total_bytes <= max {
83+
return total_bytes.saturating_mul(12).clamp(total_bytes, max);
84+
}
85+
86+
let quarter = total_bytes / 4;
87+
quarter.max(max)
88+
}
89+
90+
fn physical_memory_bytes() -> Option<u128> {
91+
#[cfg(all(
92+
target_family = "unix",
93+
not(target_os = "redox"),
94+
any(target_os = "linux", target_os = "android")
95+
))]
96+
{
97+
physical_memory_bytes_unix()
98+
}
99+
100+
#[cfg(any(
101+
not(target_family = "unix"),
102+
target_os = "redox",
103+
not(any(target_os = "linux", target_os = "android"))
104+
))]
105+
{
106+
// No portable or safe API is available here to detect total physical memory.
107+
None
108+
}
109+
}
110+
111+
#[cfg(all(
112+
target_family = "unix",
113+
not(target_os = "redox"),
114+
any(target_os = "linux", target_os = "android")
115+
))]
116+
fn physical_memory_bytes_unix() -> Option<u128> {
117+
use nix::unistd::{SysconfVar, sysconf};
118+
119+
let pages = match sysconf(SysconfVar::_PHYS_PAGES) {
120+
Ok(Some(pages)) if pages > 0 => u128::try_from(pages).ok()?,
121+
_ => return None,
122+
};
123+
124+
let page_size = match sysconf(SysconfVar::PAGE_SIZE) {
125+
Ok(Some(page_size)) if page_size > 0 => u128::try_from(page_size).ok()?,
126+
_ => return None,
127+
};
128+
129+
Some(pages.saturating_mul(page_size))
130+
}
131+
132+
#[cfg(test)]
133+
mod tests {
134+
use super::*;
135+
136+
#[test]
137+
fn desired_buffer_matches_total_when_small() {
138+
let six_mebibytes = 6 * 1024 * 1024;
139+
let expected = ((six_mebibytes as u128) * 12)
140+
.clamp(six_mebibytes as u128, crate::MAX_AUTOMATIC_BUF_SIZE as u128);
141+
assert_eq!(desired_file_buffer_bytes(six_mebibytes as u128), expected);
142+
}
143+
144+
#[test]
145+
fn desired_buffer_caps_at_max_for_large_inputs() {
146+
let large = 256 * 1024 * 1024; // 256 MiB
147+
assert_eq!(
148+
desired_file_buffer_bytes(large as u128),
149+
crate::MAX_AUTOMATIC_BUF_SIZE as u128
150+
);
151+
}
152+
}

src/uu/sort/src/chunks.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,12 @@ fn read_to_buffer<T: Read>(
271271
if max_buffer_size > buffer.len() {
272272
// we can grow the buffer
273273
let prev_len = buffer.len();
274-
if buffer.len() < max_buffer_size / 2 {
275-
buffer.resize(buffer.len() * 2, 0);
274+
let target = if buffer.len() < max_buffer_size / 2 {
275+
buffer.len().saturating_mul(2)
276276
} else {
277-
buffer.resize(max_buffer_size, 0);
278-
}
277+
max_buffer_size
278+
};
279+
buffer.resize(target.min(max_buffer_size), 0);
279280
read_target = &mut buffer[prev_len..];
280281
continue;
281282
}
@@ -295,8 +296,8 @@ fn read_to_buffer<T: Read>(
295296

296297
// We need to read more lines
297298
let len = buffer.len();
298-
// resize the vector to 10 KB more
299-
buffer.resize(len + 1024 * 10, 0);
299+
let grow_by = (len / 2).max(1024 * 1024);
300+
buffer.resize(len + grow_by, 0);
300301
read_target = &mut buffer[len..];
301302
} else {
302303
// This file has been fully read.

src/uu/sort/src/ext_sort.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,15 @@ fn reader_writer<
8686
) -> UResult<()> {
8787
let separator = settings.line_ending.into();
8888

89-
// Heuristically chosen: Dividing by 10 seems to keep our memory usage roughly
90-
// around settings.buffer_size as a whole.
91-
let buffer_size = settings.buffer_size / 10;
89+
// Cap oversized buffer requests to avoid unnecessary allocations and give the automatic
90+
// heuristic room to grow when the user does not provide an explicit value.
91+
let mut buffer_size = match settings.buffer_size {
92+
size if size <= 512 * 1024 * 1024 => size,
93+
size => size / 2,
94+
};
95+
if !settings.buffer_size_is_explicit {
96+
buffer_size = buffer_size.max(8 * 1024 * 1024);
97+
}
9298
let read_result: ReadResult<Tmp> = read_write_loop(
9399
files,
94100
tmp_dir,

src/uu/sort/src/sort.rs

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
// spell-checker:ignore (misc) HFKJFK Mbdfhn getrlimit RLIMIT_NOFILE rlim bigdecimal extendedbigdecimal hexdigit
1111

12+
mod buffer_hint;
1213
mod check;
1314
mod chunks;
1415
mod custom_str_cmp;
@@ -54,6 +55,7 @@ use uucore::show_error;
5455
use uucore::translate;
5556
use uucore::version_cmp::version_cmp;
5657

58+
use crate::buffer_hint::automatic_buffer_size;
5759
use crate::tmp_dir::TmpDirWrapper;
5860

5961
mod options {
@@ -115,10 +117,12 @@ const DECIMAL_PT: u8 = b'.';
115117
const NEGATIVE: &u8 = &b'-';
116118
const POSITIVE: &u8 = &b'+';
117119

118-
// Choosing a higher buffer size does not result in performance improvements
119-
// (at least not on my machine). TODO: In the future, we should also take the amount of
120-
// available memory into consideration, instead of relying on this constant only.
121-
const DEFAULT_BUF_SIZE: usize = 1_000_000_000; // 1 GB
120+
// The automatic buffer heuristics clamp to this range to avoid
121+
// over-committing memory on constrained systems while still keeping
122+
// reasonably large chunks for typical workloads.
123+
const MIN_AUTOMATIC_BUF_SIZE: usize = 512 * 1024; // 512 KiB
124+
const FALLBACK_AUTOMATIC_BUF_SIZE: usize = 32 * 1024 * 1024; // 32 MiB
125+
const MAX_AUTOMATIC_BUF_SIZE: usize = 1024 * 1024 * 1024; // 1 GiB
122126

123127
#[derive(Debug, Error)]
124128
pub enum SortError {
@@ -283,6 +287,7 @@ pub struct GlobalSettings {
283287
threads: String,
284288
line_ending: LineEnding,
285289
buffer_size: usize,
290+
buffer_size_is_explicit: bool,
286291
compress_prog: Option<String>,
287292
merge_batch_size: usize,
288293
precomputed: Precomputed,
@@ -359,9 +364,10 @@ impl Default for GlobalSettings {
359364
separator: None,
360365
threads: String::new(),
361366
line_ending: LineEnding::Newline,
362-
buffer_size: DEFAULT_BUF_SIZE,
367+
buffer_size: FALLBACK_AUTOMATIC_BUF_SIZE,
368+
buffer_size_is_explicit: false,
363369
compress_prog: None,
364-
merge_batch_size: 32,
370+
merge_batch_size: default_merge_batch_size(),
365371
precomputed: Precomputed::default(),
366372
}
367373
}
@@ -1036,6 +1042,31 @@ fn get_rlimit() -> UResult<usize> {
10361042
}
10371043

10381044
const STDIN_FILE: &str = "-";
1045+
#[cfg(target_os = "linux")]
1046+
const LINUX_BATCH_DIVISOR: usize = 4;
1047+
#[cfg(target_os = "linux")]
1048+
const LINUX_BATCH_MIN: usize = 32;
1049+
#[cfg(target_os = "linux")]
1050+
const LINUX_BATCH_MAX: usize = 256;
1051+
1052+
fn default_merge_batch_size() -> usize {
1053+
#[cfg(target_os = "linux")]
1054+
{
1055+
// Adjust merge batch size dynamically based on available file descriptors.
1056+
match get_rlimit() {
1057+
Ok(limit) => {
1058+
let usable_limit = limit.saturating_div(LINUX_BATCH_DIVISOR);
1059+
usable_limit.clamp(LINUX_BATCH_MIN, LINUX_BATCH_MAX)
1060+
}
1061+
Err(_) => 64,
1062+
}
1063+
}
1064+
1065+
#[cfg(not(target_os = "linux"))]
1066+
{
1067+
64
1068+
}
1069+
}
10391070

10401071
#[uucore::main]
10411072
#[allow(clippy::cognitive_complexity)]
@@ -1157,14 +1188,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
11571188
}
11581189
}
11591190

1160-
settings.buffer_size =
1161-
matches
1162-
.get_one::<String>(options::BUF_SIZE)
1163-
.map_or(Ok(DEFAULT_BUF_SIZE), |s| {
1164-
GlobalSettings::parse_byte_count(s).map_err(|e| {
1165-
USimpleError::new(2, format_error_message(&e, s, options::BUF_SIZE))
1166-
})
1167-
})?;
1191+
if let Some(size_str) = matches.get_one::<String>(options::BUF_SIZE) {
1192+
settings.buffer_size = GlobalSettings::parse_byte_count(size_str).map_err(|e| {
1193+
USimpleError::new(2, format_error_message(&e, size_str, options::BUF_SIZE))
1194+
})?;
1195+
settings.buffer_size_is_explicit = true;
1196+
} else {
1197+
settings.buffer_size = automatic_buffer_size(&files);
1198+
settings.buffer_size_is_explicit = false;
1199+
}
11681200

11691201
let mut tmp_dir = TmpDirWrapper::new(
11701202
matches

0 commit comments

Comments
 (0)