I just had a use case where we have an NXP SoC that has less RAM built in than actually reported to the OS (Yocto linux - checked with /proc/meminfo).
So in order to provoke the system to enter a memory region to provoke a failure, I wrote a short program to check for this by continuously allocating memory. It worked as intended and crashed the system after consuming about the expected amount of memory.
My question is, is there a more elegant way to write such a program in Rust?
use std::num::NonZero;
use clap::Parser;
#[derive(Debug, Parser)]
struct Args {
#[arg(help = "Step in bytes to increase on each loop.")]
step: usize,
#[arg(help = "Report each n>0 iterations.")]
report: NonZero<usize>,
}
fn main() {
let args = Args::parse();
let mut buffer = vec![];
let mut iterations = 0;
loop {
iterations += 1;
buffer.reserve_exact(args.step);
(0..args.step).for_each(|_| buffer.push(0u8));
if iterations % args.report == 0 {
println!("Reserved {} bytes", buffer.capacity());
}
}
}
Maybe use Vec::leak(vec![0u8; args.step])? reserve_exact will allocate new memory, copy the old data over and then free the new memory. So actually every step you are allocating buffer.len() + args.step bytes and then freeing buffer.len() bytes. So the Reserved {} bytes log would only show a little under half of the actual amount of memory that was allocated before allocation fails.
I'm not sure if it's more elegant, but the loop could alternatively be written with iterator combinators as
(0..)
.map(|_| Vec::leak(vec![0u8; args.step]))
.scan(vec![], |buffee, chunk| {
buffer.push(chunk);
Some((buffer.len(), buffer.capacity()))
})
.step_by(args.report)
.for_each(|(len, cap)| {
let size = len * args.step + cap * size_of::<Box<[u8]>>();
println!("Allocated {size} bytes");
});
An actual potential improvement could be to avoid the manual size calculation for the collection buffer, instead using something like (unstable in this form)
let (slice, spare) = buffer.split_at_spare_mut();
let buffer_size = size_of_val(slice) + size_of_val(spare);
Wanting to count the collection buffer size in the report makes more "pure" approaches difficult.
To force the virtual memory system to allocate actual pages, data needs to be written. Moreover, to outsmart some clever kernel optimizations, simply writing all zeros or even the same repeated data might not be enough, as the kernel could apply page deduplication. So, you might need to bring in a crate like fastrand or something similar to randomize the written data.
It’s always fascinating how many dependencies and hidden implications exist in the software world…
This is why we leak() the Box. It will return a mutable slice and the OS will assume that the memory is used. If you remove the call to Box::leak(), no memory will be allocated, indeed.
I believe this conclusion is not entirely correct.
Calling leak() (whether on a Vec or Box) prevents drop() from running and ultimately skips std::alloc::dealloc(), ensuring that virtual memory is not released back to the allocator.
However, an operating system kernel independently decides when to assign physical memory to a virtual memory region.
The OS is required to allocate physical memory when you write data to that region.
However, if the OS detects that you are only writing zeros, it can simply mark the virtual region as "all zeros" without actually assigning physical pages. Many OS kernels implement such optimizations. Some even scan memory for duplicate pages (pages containing the same data) and apply some sort of reference counting (copy-on-write) techniques to save physical memory.
The real world is always more complex than it seems...
Also a heads up that Linux at least won't actually report an out of memory error in most default configurations, it will just kill something.
(I had the "fun" experience a long time ago of a Ubuntu server always deciding that sshd was obviously not doing anything useful when the web server chewed up all the memory.)
I know. But that's not the issue I'm triggering. I am actually triggering Linux to access memory it thinks it has, but actually hasn't. In our case there are 512 MB of physical RAM built in, but they are reported as being 1G. When the program reaches the critical amount of RAM that was available before it started, the kernel panics when trying to access memory that is not there. We wanted to confirm that this actually happens and that the RAM chip is indeed only half of what's reported to the OS, which we did.
Regarding your Ubuntu SSH issue, this seems like improperly distributed OOM score.
Yeah, but if you're testing locally without knowing that it might be a Fun Surprise
I can't speak to the OOM configuration, it was whatever was out of the box on both the host os and app (I gave up trying to figure out what the issue there was), I "solved" the issue by whacking every memory limit dial on the app down until the issue went away and added an alarm for memory usage. And then by us dropping the app
simple code that constructs a doubly-linked list of pages, ensuring they're allocated and not duplicates, so linux has to actually use that many physical pages:
use std::cell::Cell;
#[derive(Default)]
#[repr(align(4096))]
struct Page {
next: Cell<Option<&'static Page>>,
prev: Cell<Option<&'static Page>>,
}
fn main() {
let head = &*Box::leak(Box::new(Page::default()));
let mut tail = head;
for memory_usage in (2u64 * 4096..).step_by(4096) {
let next = &*Box::leak(Box::new(Page::default()));
tail.next.set(Some(next));
next.prev.set(Some(tail));
tail = next;
if memory_usage % (16 << 20) == 0 {
println!("{memory_usage} bytes");
}
}
}
Does writing code this way add any overhead to the program, and is it worth using constructs like this in everyday code? I ask because I like the style in which it is written.
It is fun to write but is less readable. The reader has to figure out the type of each intermediate expression in their head, rather than having intermediate variables with names that make them easier to see.