OXYGENHUB

Memory per phase with a counting allocator d443639bcf35

The oxy binary's alloc-stats feature installs a counting global
allocator; with OXY_TIMINGS=1 each phase then reports live and peak
memory and what it allocated, and a summary closes the command. It is
how the Linux profile (#35) finds the structures that hold the memory.

closes #39
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 21:34 UTC
Signature
✔ signed by Andy McCall
Hash
d443639bcf35254060952ddc1838a83bae5b1f99857703acdab5e2423dfbfdae
Depends on
0bdc3f91a39f 1983686fb7a7 5b1114a04905 5b9c6ad45f6e e293c90dca62 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
Depended on by
c397a8c36a9f b582948f1912 d45dab0484ea e0d56dfca51d 19527875ebdc
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
14 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/oxy-core/src/alloc_stats.rs +79 −0
1 +
//! A counting allocator for measuring memory (`--features alloc-stats` on
2 +
//! the `oxy` binary, with `OXY_TIMINGS=1`): each timing phase then also
3 +
//! reports live bytes, the peak so far and what the phase allocated, so a
4 +
//! profile names the structure that holds the memory.
5 +
6 +
use std::alloc::{GlobalAlloc, Layout, System};
7 +
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed};
8 +
9 +
static LIVE: AtomicUsize = AtomicUsize::new(0);
10 +
static PEAK: AtomicUsize = AtomicUsize::new(0);
11 +
static TOTAL: AtomicUsize = AtomicUsize::new(0);
12 +
static COUNT: AtomicUsize = AtomicUsize::new(0);
13 +
static ACTIVE: AtomicBool = AtomicBool::new(false);
14 +
15 +
/// The system allocator, counting. Install it with
16 +
/// `#[global_allocator] static A: Counting = Counting;`.
17 +
pub struct Counting;
18 +
19 +
impl Counting {
20 +
    fn grew(n: usize) {
21 +
        ACTIVE.store(true, Relaxed);
22 +
        let live = LIVE.fetch_add(n, Relaxed) + n;
23 +
        TOTAL.fetch_add(n, Relaxed);
24 +
        COUNT.fetch_add(1, Relaxed);
25 +
        PEAK.fetch_max(live, Relaxed);
26 +
    }
27 +
}
28 +
29 +
unsafe impl GlobalAlloc for Counting {
30 +
    unsafe fn alloc(&self, l: Layout) -> *mut u8 {
31 +
        let p = System.alloc(l);
32 +
        if !p.is_null() {
33 +
            Self::grew(l.size());
34 +
        }
35 +
        p
36 +
    }
37 +
    unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 {
38 +
        let p = System.alloc_zeroed(l);
39 +
        if !p.is_null() {
40 +
            Self::grew(l.size());
41 +
        }
42 +
        p
43 +
    }
44 +
    unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
45 +
        System.dealloc(p, l);
46 +
        LIVE.fetch_sub(l.size(), Relaxed);
47 +
    }
48 +
    unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 {
49 +
        let q = System.realloc(p, l, new);
50 +
        if !q.is_null() {
51 +
            LIVE.fetch_sub(l.size(), Relaxed);
52 +
            Self::grew(new);
53 +
        }
54 +
        q
55 +
    }
56 +
}
57 +
58 +
/// What the counting allocator has seen, when it is installed.
59 +
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
60 +
pub struct Stats {
61 +
    /// Bytes allocated and not yet freed.
62 +
    pub live: usize,
63 +
    /// The most `live` has been.
64 +
    pub peak: usize,
65 +
    /// Bytes ever allocated.
66 +
    pub total: usize,
67 +
    /// Allocations ever made.
68 +
    pub count: usize,
69 +
}
70 +
71 +
/// The counters, or `None` when the counting allocator is not installed.
72 +
pub fn stats() -> Option<Stats> {
73 +
    ACTIVE.load(Relaxed).then(|| Stats {
74 +
        live: LIVE.load(Relaxed),
75 +
        peak: PEAK.load(Relaxed),
76 +
        total: TOTAL.load(Relaxed),
77 +
        count: COUNT.load(Relaxed),
78 +
    })
79 +
}
modified Cargo.toml +4 −0
…
28 unchanged lines
29 29
indicatif = "0.17"
30 30
serde_json = "1"
31 31
32 +
[features]
33 +
# A counting global allocator: OXY_TIMINGS=1 then reports memory per phase.
34 +
alloc-stats = []
35 +
32 36
[dev-dependencies]
33 37
tempfile = "3"
modified crates/oxy-core/src/lib.rs +1 −0
…
4 unchanged lines
5 5
/// This library's version, for hosts that report what they are built on.
6 6
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
7 7
8 +
pub mod alloc_stats;
8 9
pub mod attest;
9 10
pub mod attributes;
10 11
pub mod cbor;
…
21 unchanged lines
modified crates/oxy-core/src/timing.rs +43 −7
1 1
//! Opt-in phase timings (`OXY_TIMINGS=1`): each named phase prints its
2 2
//! duration to stderr, for measuring where a command spends its time
3 -
//! without a profiler.
3 +
//! without a profiler. With the counting allocator installed (the `oxy`
4 +
//! binary's `alloc-stats` feature), each also prints live and peak memory
5 +
//! and what the phase allocated.
4 6
5 7
use std::sync::OnceLock;
6 8
use std::time::Instant;
…
14 unchanged lines
21 23
pub struct Phase {
22 24
    name: &'static str,
23 25
    started: Option<Instant>,
26 +
    /// Allocator counters at the start, when counting.
27 +
    mem: Option<crate::alloc_stats::Stats>,
24 28
}
25 29
26 30
/// Start timing a phase; the guard reports when it goes out of scope.
27 31
pub fn phase(name: &'static str) -> Phase {
32 +
    let on = enabled();
28 33
    Phase {
29 34
        name,
30 -
        started: enabled().then(Instant::now),
35 +
        started: on.then(Instant::now),
36 +
        mem: if on {
37 +
            crate::alloc_stats::stats()
38 +
        } else {
39 +
            None
40 +
        },
41 +
    }
42 +
}
43 +
44 +
fn mib(n: usize) -> f64 {
45 +
    n as f64 / (1024.0 * 1024.0)
46 +
}
47 +
48 +
/// The allocator's counters as one line, when counting: for the end of a
49 +
/// command.
50 +
pub fn memory_summary() {
51 +
    if let (true, Some(s)) = (enabled(), crate::alloc_stats::stats()) {
52 +
        eprintln!(
53 +
            "memory peak {:.1} MiB, live {:.1} MiB, {:.1} MiB in {} allocations",
54 +
            mib(s.peak),
55 +
            mib(s.live),
56 +
            mib(s.total),
57 +
            s.count
58 +
        );
31 59
    }
32 60
}
33 61
34 62
impl Drop for Phase {
35 63
    fn drop(&mut self) {
36 64
        if let Some(t) = self.started {
37 -
            eprintln!(
38 -
                "timing {:<28} {:>9.2} ms",
39 -
                self.name,
40 -
                t.elapsed().as_secs_f64() * 1000.0
41 -
            );
65 +
            let ms = t.elapsed().as_secs_f64() * 1000.0;
66 +
            match (self.mem, crate::alloc_stats::stats()) {
67 +
                (Some(a), Some(b)) => eprintln!(
68 +
                    "timing {:<28} {:>9.2} ms  live {:>8.1} MiB  peak {:>8.1} MiB  +{:.1} MiB in {} allocs",
69 +
                    self.name,
70 +
                    ms,
71 +
                    mib(b.live),
72 +
                    mib(b.peak),
73 +
                    mib(b.total - a.total),
74 +
                    b.count - a.count
75 +
                ),
76 +
                _ => eprintln!("timing {:<28} {:>9.2} ms", self.name, ms),
77 +
            }
42 78
        }
43 79
    }
44 80
}
modified docs/guide.md +1 −1
…
373 unchanged lines
374 374
375 375
Two files, both `key = "value"` lines: `~/.config/oxy/config.toml`
376 376
(`oxy config --user KEY VALUE`) and `.oxy/config` (`oxy config KEY VALUE`).
377 -
The repository file wins. `OXY_HOME` relocates the user directory. `OXY_TIMINGS=1` prints how long each phase of a snapshot takes (loading the base state, rendering it, reading the working copy, building the change) to standard error, for finding out where a slow command spends its time.
377 +
The repository file wins. `OXY_HOME` relocates the user directory. `OXY_TIMINGS=1` prints how long each phase of a snapshot takes (loading the base state, rendering it, reading the working copy, building the change) to standard error, for finding out where a slow command spends its time. Built with `cargo build --release --features alloc-stats`, oxy also reports each phase's live and peak memory and what it allocated, and a summary at the end.
378 378
379 379
| Key | Meaning |
380 380
|---|---|
…
51 unchanged lines
modified src/main.rs +7 −1
…
4 unchanged lines
5 5
6 6
mod cli;
7 7
8 +
#[cfg(feature = "alloc-stats")]
9 +
#[global_allocator]
10 +
static ALLOC: oxy_core::alloc_stats::Counting = oxy_core::alloc_stats::Counting;
11 +
8 12
fn main() {
9 13
    // Behave like other CLI tools when output is piped into `head`: die
10 14
    // quietly on SIGPIPE instead of panicking on a failed write.
…
1 unchanged lines
12 16
    unsafe {
13 17
        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
14 18
    }
15 -
    match cli::run() {
19 +
    let result = cli::run();
20 +
    oxy_core::timing::memory_summary();
21 +
    match result {
16 22
        Ok(code) => std::process::exit(code),
17 23
        Err(e) => {
18 24
            cli::ui::init(false);
…
5 unchanged lines