Skip to main content

data_beans/sparse_io/
helpers.rs

1#[cfg(feature = "ndarray")]
2use ndarray::prelude::*;
3use rustc_hash::FxHashMap as HashMap;
4
5use super::DMatrix;
6
7pub fn build_name2index_map(_names: &[Box<str>]) -> HashMap<Box<str>, usize> {
8    _names
9        .iter()
10        .enumerate()
11        .map(|(r, name)| (name.clone(), r))
12        .collect()
13}
14
15pub fn take_subset_indices_names(
16    new_indices: &[usize],
17    ntot: usize,
18    old_names: Vec<Box<str>>,
19) -> (HashMap<u64, u64>, Vec<Box<str>>) {
20    let mut old2new: HashMap<u64, u64> = Default::default();
21    let mut new2old = vec![];
22    debug_assert!(ntot == old_names.len());
23    let mut k = 0_u64;
24    for idx in new_indices.iter() {
25        if *idx < ntot {
26            old2new.insert(*idx as u64, k);
27            new2old.push(*idx);
28            k += 1;
29        }
30    }
31
32    let new_names = new2old
33        .iter()
34        .map(|&i| old_names[i].clone())
35        .collect::<Vec<Box<str>>>();
36
37    (old2new, new_names)
38}
39
40pub fn take_subset_indices_names_if_needed(
41    new_indices: Option<&Vec<usize>>,
42    ntot: Option<usize>,
43    old_names: Vec<Box<str>>,
44) -> (HashMap<u64, u64>, Vec<Box<str>>) {
45    let ntot = ntot.unwrap_or(old_names.len());
46    if let Some(new_indices) = new_indices {
47        take_subset_indices_names(new_indices, ntot, old_names)
48    } else {
49        let names = old_names;
50        let identity = (0..(ntot as u64))
51            .zip(0..(ntot as u64))
52            .collect::<HashMap<u64, u64>>();
53        (identity, names)
54    }
55}
56
57#[cfg(feature = "ndarray")]
58pub fn ndarray_to_triplets(array: &Array2<f32>) -> Vec<(u64, u64, f32)> {
59    let eps = 1e-6;
60    array
61        .indexed_iter()
62        .filter(|(_, &elem)| elem.abs() > eps)
63        .map(|((row, col), &value)| (row as u64, col as u64, value))
64        .collect::<Vec<(u64, u64, f32)>>()
65}
66
67pub fn dmatrix_to_triplets(matrix: &DMatrix<f32>) -> Vec<(u64, u64, f32)> {
68    let (nrow, _) = matrix.shape();
69    let eps = 1e-6;
70    matrix
71        .iter() // column-major
72        .enumerate()
73        .filter(|(_, &elem)| elem.abs() > eps)
74        .map(|(idx, &value)| {
75            let row = idx % nrow;
76            let col = idx / nrow;
77            (row as u64, col as u64, value)
78        })
79        .collect::<Vec<(u64, u64, f32)>>()
80}
81
82/// Remove a backend at `path`, whether it is a file (`.h5`, `.zarr.zip`) or a
83/// directory (`.zarr`). Nothing happens when the path does not exist.
84pub fn remove_backend_path(path: &str) -> anyhow::Result<()> {
85    let p = std::path::Path::new(path);
86    if p.exists() {
87        if p.is_file() {
88            std::fs::remove_file(p)?;
89        } else {
90            std::fs::remove_dir_all(p)?;
91        }
92    }
93    Ok(())
94}
95
96/// Bytes reserved by live preloads in this process; see [`reserve_preload`].
97static RESERVED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
98
99/// Preloading costs 12 bytes per non-zero (a `u64` index and an `f32` value).
100const PRELOAD_BYTES_PER_NNZ: usize = 12;
101
102/// A preload's share of the process-wide preload budget, given back when it
103/// is dropped. Keep it next to the preloaded arrays and drop it with them,
104/// so a later preload in the same process sees the memory as free again.
105///
106/// Cloning reserves the same amount again: a clone carries its own copy of
107/// the arrays.
108#[derive(Debug)]
109pub struct PreloadReservation {
110    bytes: usize,
111    counter: &'static std::sync::atomic::AtomicUsize,
112}
113
114impl PreloadReservation {
115    /// Bytes this reservation holds against the budget (0 under a
116    /// `LEGUME_PRELOAD_BUDGET_BYTES` override, which is per preload).
117    #[allow(dead_code)] // library API; the bin has no caller
118    pub fn bytes(&self) -> usize {
119        self.bytes
120    }
121}
122
123impl Clone for PreloadReservation {
124    fn clone(&self) -> Self {
125        self.counter
126            .fetch_add(self.bytes, std::sync::atomic::Ordering::Relaxed);
127        Self {
128            bytes: self.bytes,
129            counter: self.counter,
130        }
131    }
132}
133
134impl Drop for PreloadReservation {
135    fn drop(&mut self) {
136        self.counter
137            .fetch_sub(self.bytes, std::sync::atomic::Ordering::Relaxed);
138    }
139}
140
141/// Bytes currently held by live [`PreloadReservation`]s in this process.
142#[allow(dead_code)] // library API; the bin has no caller
143pub fn preload_reserved_bytes() -> usize {
144    RESERVED.load(std::sync::atomic::Ordering::Relaxed)
145}
146
147/// Reserve room for a preload of `nnz` entries, or `None` when it does not
148/// fit the budget.
149///
150/// An unchecked preload at imaging scale is an OOM order, not a request. The
151/// budget turns that into a logged skip: every read path already handles the
152/// not-preloaded state, it is just slower.
153///
154/// The budget is half the memory available when the process first asks,
155/// shared by every live preload of the process (8 GiB when the system does
156/// not say). The reservation is given back when the returned guard drops.
157/// `LEGUME_PRELOAD_BUDGET_BYTES` overrides it per preload, following the
158/// `LEGUME_ZARR_CACHE_CAP` precedent for memory knobs.
159pub fn reserve_preload(nnz: usize, what: &str) -> Option<PreloadReservation> {
160    let cost = nnz.saturating_mul(PRELOAD_BYTES_PER_NNZ);
161    if let Some(budget) = std::env::var("LEGUME_PRELOAD_BUDGET_BYTES")
162        .ok()
163        .and_then(|v| v.parse::<usize>().ok())
164    {
165        return fits_or_warn(cost, budget, nnz, what).then_some(PreloadReservation {
166            bytes: 0,
167            counter: &RESERVED,
168        });
169    }
170    reserve_against(&RESERVED, cost, auto_preload_budget_bytes(), nnz, what)
171}
172
173fn reserve_against(
174    counter: &'static std::sync::atomic::AtomicUsize,
175    cost: usize,
176    budget: usize,
177    nnz: usize,
178    what: &str,
179) -> Option<PreloadReservation> {
180    use std::sync::atomic::Ordering;
181    let reserved = counter.fetch_add(cost, Ordering::Relaxed);
182    if fits_or_warn(reserved.saturating_add(cost), budget, nnz, what) {
183        Some(PreloadReservation {
184            bytes: cost,
185            counter,
186        })
187    } else {
188        counter.fetch_sub(cost, Ordering::Relaxed);
189        None
190    }
191}
192
193/// Whether a preload of `nnz` entries fits the budget. Unlike
194/// [`reserve_preload`], the reservation is held for the life of the process;
195/// prefer [`reserve_preload`] and keep its guard with the preloaded arrays.
196#[allow(dead_code)] // library API; the bin has no caller
197pub fn preload_within_budget(nnz: usize, what: &str) -> bool {
198    reserve_preload(nnz, what).map(std::mem::forget).is_some()
199}
200
201fn fits_or_warn(cost: usize, budget: usize, nnz: usize, what: &str) -> bool {
202    if cost > budget {
203        log::warn!(
204            "skipping {what} preload of {nnz} nnz: {cost} bytes would exceed the \
205             {budget}-byte budget (LEGUME_PRELOAD_BUDGET_BYTES to raise); reads stay on the \
206             streaming path"
207        );
208        false
209    } else {
210        true
211    }
212}
213
214/// Half the memory available at the first call, or 8 GiB when unknown.
215fn auto_preload_budget_bytes() -> usize {
216    static BUDGET: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
217    *BUDGET.get_or_init(|| {
218        const FALLBACK_BYTES: usize = 8 << 30;
219        let mut sys = sysinfo::System::new();
220        sys.refresh_memory();
221        match usize::try_from(sys.available_memory()) {
222            Ok(avail) if avail > 0 => avail / 2,
223            _ => FALLBACK_BYTES,
224        }
225    })
226}
227
228/// Whether loaders preload columns without being asked. On unless
229/// `LEGUME_AUTO_PRELOAD` is `0`, `false` or `off`; the preload itself still
230/// answers to [`preload_within_budget`], so data that does not fit in memory
231/// stays on the streaming path.
232#[allow(dead_code)] // called by library loaders; the bin has none
233pub fn auto_preload_enabled() -> bool {
234    !matches!(
235        std::env::var("LEGUME_AUTO_PRELOAD").as_deref(),
236        Ok("0") | Ok("false") | Ok("off")
237    )
238}
239
240/// Bytes of `(u64, u64, f32)` triplets a streaming-write slab may hold, and the
241/// padded size of one such triplet. One definition, because the two streaming
242/// pipelines (the subset trait method and the handlers' column-selection
243/// writer) each carried their own copy and two memory ceilings drift apart.
244pub const SLAB_BUDGET_BYTES: usize = 256 << 20;
245/// `(u64, u64, f32)` padded.
246pub const TRIPLET_BYTES: usize = 24;
247
248#[cfg(test)]
249mod preload_reservation_tests {
250    use super::*;
251    use std::sync::atomic::{AtomicUsize, Ordering};
252
253    #[test]
254    fn dropping_a_reservation_gives_it_back() {
255        static COUNTER: AtomicUsize = AtomicUsize::new(0);
256        let a = reserve_against(&COUNTER, 60, 100, 5, "test").expect("fits");
257        assert_eq!(COUNTER.load(Ordering::Relaxed), 60);
258        // A second preload that would overflow the budget is refused and
259        // leaves nothing behind.
260        assert!(reserve_against(&COUNTER, 60, 100, 5, "test").is_none());
261        assert_eq!(COUNTER.load(Ordering::Relaxed), 60);
262        drop(a);
263        assert_eq!(COUNTER.load(Ordering::Relaxed), 0);
264        // With the first one gone, the same preload fits again.
265        let b = reserve_against(&COUNTER, 60, 100, 5, "test").expect("fits after release");
266        assert_eq!(b.bytes(), 60);
267        drop(b);
268        assert_eq!(COUNTER.load(Ordering::Relaxed), 0);
269    }
270
271    #[test]
272    fn cloning_a_reservation_reserves_again() {
273        static COUNTER: AtomicUsize = AtomicUsize::new(0);
274        let a = reserve_against(&COUNTER, 30, 100, 5, "test").expect("fits");
275        let b = a.clone();
276        assert_eq!(COUNTER.load(Ordering::Relaxed), 60);
277        drop(a);
278        assert_eq!(COUNTER.load(Ordering::Relaxed), 30);
279        drop(b);
280        assert_eq!(COUNTER.load(Ordering::Relaxed), 0);
281    }
282}