Theme

alex/volcanic-mem

BRANCH main
main
SNAPSHOT Initial commit
DOWNLOAD
Download file

root / tests / virtual_block.rs

use std::collections::BTreeMap;
use volcanic_mem::{Error, VirtualAllocation, VirtualBlock};

struct Rng(u64);

impl Rng {
    fn next(&mut self) -> u64 {
        self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
        let mut z = self.0;
        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
        z ^ (z >> 31)
    }

    fn below(&mut self, n: u64) -> u64 {
        self.next() % n
    }

    /// Log-uniform size in `1..=max`, matching the skew of real GPU workloads.
    fn size(&mut self, max: u64) -> u64 {
        let bits = 64 - max.leading_zeros() as u64;
        (self.next() & ((1u64 << (self.below(bits) + 1)) - 1)).clamp(1, max)
    }
}

fn check_no_overlap(live: &BTreeMap<u64, u64>, offset: u64, size: u64) {
    if let Some((&start, &len)) = live.range(..=offset).next_back() {
        assert!(start + len <= offset, "overlaps [{start}, {})", start + len);
    }
    if let Some((&start, _)) = live.range(offset..).next() {
        assert!(offset + size <= start, "overlaps allocation at {start}");
    }
}

/// TLSF must never report out-of-memory while some gap could hold the request.
fn assert_no_fitting_gap(live: &BTreeMap<u64, u64>, block_size: u64, size: u64, alignment: u64) {
    let mut gap_start = 0u64;
    let ends = live
        .iter()
        .map(|(&start, &len)| (start, start + len))
        .chain([(block_size, block_size)]);
    for (start, end) in ends {
        let aligned = gap_start.next_multiple_of(alignment);
        assert!(
            aligned + size > start,
            "spurious OOM: [{gap_start}, {start}) fits {size} @ {alignment}"
        );
        gap_start = end;
    }
}

fn stress(seed: u64, block_size: u64, max_size: u64, steps: usize) {
    let mut rng = Rng(seed);
    let mut block = VirtualBlock::new(block_size).unwrap();
    let mut handles: Vec<(VirtualAllocation, u64)> = Vec::new();
    let mut live = BTreeMap::new();
    let (mut ok, mut full) = (0, 0);

    for step in 0..steps {
        let grow = handles.is_empty() || rng.below(100) < 55;
        if grow {
            let size = rng.size(max_size);
            let alignment = 1 << rng.below(13);
            match block.allocate(size, alignment) {
                Ok(allocation) => {
                    let offset = allocation.offset();
                    assert_eq!(offset % alignment, 0, "misaligned");
                    assert!(offset + size <= block_size, "out of bounds");
                    assert_eq!(block.allocation_size(&allocation), size);
                    check_no_overlap(&live, offset, size);
                    live.insert(offset, size);
                    handles.push((allocation, size));
                    ok += 1;
                }
                Err(Error::OutOfMemory) => {
                    assert_no_fitting_gap(&live, block_size, size, alignment);
                    full += 1;
                }
                Err(error) => panic!("unexpected error {error}"),
            }
        } else {
            let index = rng.below(handles.len() as u64) as usize;
            let (allocation, size) = handles.swap_remove(index);
            assert_eq!(live.remove(&allocation.offset()), Some(size));
            block.free(allocation);
        }
        let used: u64 = live.values().sum();
        assert_eq!(block.free_bytes(), block_size - used);
        if step % 64 == 0 {
            block.validate();
        }
    }
    assert!(
        ok > 0 && full > 0,
        "workload did not hit both paths: ok={ok} full={full}"
    );

    for (allocation, _) in handles.drain(..) {
        block.free(allocation);
    }
    block.validate();
    assert!(block.is_empty());
    assert_eq!(block.free_bytes(), block_size);
    let whole = block
        .allocate(block_size, 1)
        .expect("fully coalesced block");
    assert_eq!(whole.offset(), 0);
}

#[test]
fn small_allocations_in_small_block() {
    for seed in 0..8 {
        stress(seed, 64 << 10, 4 << 10, 50_000);
    }
}

#[test]
fn mixed_allocations_in_large_block() {
    for seed in 100..104 {
        stress(seed, 256 << 20, 16 << 20, 100_000);
    }
}

#[test]
fn non_power_of_two_block() {
    stress(7, 3 * 1000 * 1000 + 17, 64 << 10, 50_000);
}

#[test]
fn exact_fit_and_alignment_padding() {
    let mut block = VirtualBlock::new(1024).unwrap();
    let a = block.allocate(100, 1).unwrap();
    let b = block.allocate(100, 256).unwrap();
    assert_eq!((a.offset(), b.offset()), (0, 256));
    let c = block.allocate(156, 1).unwrap();
    assert_eq!(c.offset(), 100, "padding before b must be reusable");
    let d = block.allocate(668, 1).unwrap();
    assert_eq!(d.offset(), 356);
    assert!(matches!(block.allocate(1, 1), Err(Error::OutOfMemory)));
    block.validate();
    for allocation in [b, d, a, c] {
        block.free(allocation);
        block.validate();
    }
    assert_eq!(block.allocate(1024, 1024).unwrap().offset(), 0);
}

#[test]
fn rejects_invalid_requests() {
    let mut block = VirtualBlock::new(4096).unwrap();
    assert!(matches!(block.allocate(0, 1), Err(Error::ZeroSize)));
    assert!(matches!(
        block.allocate(1, 3),
        Err(Error::InvalidAlignment(3))
    ));
    assert!(matches!(block.allocate(4097, 1), Err(Error::OutOfMemory)));
    assert!(matches!(VirtualBlock::new(0), Err(Error::ZeroSize)));
}