znippy-plugin-git 0.2.4

Git object-store metadata plugin for znippy (native builtin — no WASM). Carries the reserved oid / commit-graph / reachability sub-indexes.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
//! **What one OPEN STORE costs in resident memory — the slope, not the guess.**
//!
//! # The question
//!
//! gunnar's server budget prices a cached open repository at
//! `gunnar_server::budget::BYTES_PER_OPEN_REPO = 8 MiB` and derives the whole
//! handle ceiling from it:
//!
//! ```text
//! open_repos = min((soft − 256) / 3, MemAvailable × 20 % / 8 MiB)
//! ```
//!
//! An appliance with 25 repositories idles at 10.0 G of 16 G with load 0.00 and
//! nothing in flight (`gunnar/.nornir/stinky.md` S-079). 25 × 8 MiB is 200 MiB,
//! so either that divisor is wrong or the memory is somewhere else. S-079 wrote
//! down an arithmetic *hypothesis* — two 64 MiB redb page caches plus a 4 × 64 MiB
//! exploded batch cache, ~384 MiB a store — and said in the entry that it was
//! unmeasured. **This is the measurement.**
//!
//! # What it measures, and what it deliberately does not
//!
//! One process. N identical stores, opened one at a time, each read from, all
//! held open — which is exactly the state a server's handle cache is in, because
//! its bound (256) never fires over 25 repositories and nothing is ever evicted.
//! After each open it reports **anonymous** RSS out of `/proc/self/smaps_rollup`
//! and glibc's own `mallinfo2` books.
//!
//! `Anonymous:` and not `Rss:`, for the same reason
//! `tests/rss_retention_probe.rs` gives: `Rss` counts file-backed pages — the
//! mapped pack, the page cache a `pread` pulls in — which the kernel reclaims on
//! demand and which no budget change would touch. The dashboard figure S-079 is
//! about is `MemTotal − MemAvailable`, and `MemAvailable` already discounts
//! reclaimable page cache, so anonymous is the comparable quantity.
//!
//! It measures the **store**, not the server: `gunnar-server` adds one
//! `control.redb` and its own baseline, both of which are per-process and not
//! per-store, so they cancel out of a slope.
//!
//! # Counts, not clocks
//!
//! Every number here is a byte count. That is deliberate: the rig this runs on
//! is shared, and a byte count does not care what else is on the box, where a
//! latency would. The load average is printed on every row anyway so a reader
//! can see what it was taken under.
//!
//! # Run
//!
//! ```bash
//! cargo run --release -p znippy-plugin-git --example rss_per_open_store -- \
//!   <pack file> <scratch dir> <N> [reads per store]
//! ```
//!
//! The pack is pushed into store 0 once and the finished directory is **copied**
//! to the other N−1, so every store is byte-identical and the run costs one
//! index rather than N. Set `ZNIPPY_GIT_EXPLODE` / `ZNIPPY_GIT_REDB_CACHE_BYTES`
//! to measure a different arrangement; both are echoed on the header line.
//!
//! # `synthetic:<objects>` in place of a pack path
//!
//! The cost per store turns out to be **per object**, not per store, so the
//! interesting axis is object count and the packs lying around on a developer
//! box all sit within a factor of two of each other. `synthetic:400000` builds a
//! pack of that many distinct 64-byte blobs in memory — real pack framing, real
//! zlib streams, real sha1 oids, `git index-pack` would accept it — so the
//! object term can be measured over two decades instead of over 1.8×.
//!
//! Blobs and no trees or commits, deliberately: it isolates the term that scales
//! with **objects in the index** from the one that scales with *commits*, and
//! the two are folded by different code.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use git_storage_trait::GitOps as _;
use znippy_plugin_git::git_ops::GitStore;

/// Anonymous RSS of this process, in KiB, from `/proc/self/smaps_rollup`.
fn anon_rss_kb() -> u64 {
    let s = std::fs::read_to_string("/proc/self/smaps_rollup")
        .expect("/proc/self/smaps_rollup (Linux 4.14+)");
    for line in s.lines() {
        if let Some(rest) = line.strip_prefix("Anonymous:") {
            let kb = rest.trim().trim_end_matches("kB").trim();
            return kb.parse().expect("Anonymous: is a kB count");
        }
    }
    panic!("no `Anonymous:` line in smaps_rollup");
}

/// Total RSS in KiB — anonymous plus file-backed. Printed beside the anonymous
/// figure so a reader can see how much of the process is mapped pack.
fn total_rss_kb() -> u64 {
    let s = std::fs::read_to_string("/proc/self/smaps_rollup").unwrap_or_default();
    for line in s.lines() {
        if let Some(rest) = line.strip_prefix("Rss:") {
            let kb = rest.trim().trim_end_matches("kB").trim();
            return kb.parse().unwrap_or(0);
        }
    }
    0
}

/// `(in_use, from_kernel)` in KiB, via `mallinfo2`. `uordblks + hblkhd` is what
/// the program has asked for and not given back — live, reachable bytes — which
/// is the reading that says whether resident growth is a structure somebody owns
/// or an allocator that has not handed pages back.
fn mallinfo_kb() -> Option<(u64, u64)> {
    #[cfg(all(target_os = "linux", target_env = "gnu"))]
    {
        // SAFETY: `mallinfo2` takes no arguments and returns a plain POD struct.
        let mi = unsafe { libc::mallinfo2() };
        return Some((
            (mi.uordblks as u64 + mi.hblkhd as u64) / 1024,
            (mi.arena as u64 + mi.hblkhd as u64) / 1024,
        ));
    }
    #[cfg(not(all(target_os = "linux", target_env = "gnu")))]
    None
}

fn malloc_trim() {
    #[cfg(all(target_os = "linux", target_env = "gnu"))]
    unsafe {
        libc::malloc_trim(0)
    };
}

fn mb(kb: u64) -> f64 {
    kb as f64 / 1024.0
}

fn loadavg() -> String {
    std::fs::read_to_string("/proc/loadavg")
        .unwrap_or_default()
        .split_whitespace()
        .take(3)
        .collect::<Vec<_>>()
        .join(" ")
}

/// Copy a directory tree. Two `File`s and `std::io::copy`; no shelling (LAW).
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
    std::fs::create_dir_all(to)?;
    for e in std::fs::read_dir(from)? {
        let e = e?;
        let src = e.path();
        let dst = to.join(e.file_name());
        if e.file_type()?.is_dir() {
            copy_tree(&src, &dst)?;
        } else {
            std::fs::copy(&src, &dst)
                .with_context(|| format!("copying {} to {}", src.display(), dst.display()))?;
        }
    }
    Ok(())
}

/// **A v2 packfile of `n` distinct blobs**, framed by hand.
///
/// Written here rather than taken from `src/pack_fixture.rs` because that module
/// is `pub(crate)` — an example cannot see it — and because what is wanted is
/// the opposite of a fixture: not a fixed corpus with a named delta shape, but
/// one knob, the object count, over two decades. It is deliberately delta-free,
/// so the number that comes out is the cost of *an oid in the index* and not the
/// cost of a chain.
fn synthetic_pack(n: usize) -> Vec<u8> {
    use std::io::Write as _;

    use sha1::Digest as _;

    let mut out = Vec::with_capacity(n * 80 + 32);
    out.extend_from_slice(b"PACK");
    out.extend_from_slice(&2u32.to_be_bytes());
    out.extend_from_slice(&(n as u32).to_be_bytes());
    for i in 0..n {
        // 64 bytes, distinct, and incompressible enough that zlib does not turn
        // the whole corpus into one repeated stream: the pack's size is not the
        // subject here, but a corpus that deflates to nothing would put the
        // whole thing in one page and stop resembling a repository.
        let mut body = [0u8; 64];
        let mut s = (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
        for c in body.chunks_mut(8) {
            s ^= s << 13;
            s ^= s >> 7;
            s ^= s << 17;
            c.copy_from_slice(&s.to_le_bytes());
        }
        // Entry header: 3 = OBJ_BLOB, then the uncompressed size, 4 bits in the
        // first byte and 7 per byte after it.
        let mut size = body.len() as u64;
        let mut b0 = ((3u8) << 4) | (size & 0x0F) as u8;
        size >>= 4;
        if size > 0 {
            b0 |= 0x80;
        }
        out.push(b0);
        while size > 0 {
            let mut b = (size & 0x7F) as u8;
            size >>= 7;
            if size > 0 {
                b |= 0x80;
            }
            out.push(b);
        }
        let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
        z.write_all(&body).expect("deflate a blob");
        out.extend_from_slice(&z.finish().expect("finish a blob"));
    }
    let mut h = sha1::Sha1::new();
    h.update(&out);
    out.extend_from_slice(&h.finalize());
    out
}

/// Every `n`th oid of `all`, at most `want` of them.
fn stride(all: &[Vec<u8>], want: usize) -> Vec<Vec<u8>> {
    if all.is_empty() || want == 0 {
        return Vec::new();
    }
    let step = (all.len() / want).max(1);
    all.iter().step_by(step).take(want).cloned().collect()
}

fn main() -> Result<()> {
    let mut args = std::env::args().skip(1);
    let spec = args
        .next()
        .context("usage: <pack | synthetic:OBJECTS> <scratch dir> <N> [reads]")?;
    let root = PathBuf::from(args.next().context("usage: <pack> <scratch dir> <N> [reads]")?);
    let n: usize = args.next().unwrap_or_else(|| "25".into()).parse().unwrap_or(25);
    let reads: usize = args.next().unwrap_or_else(|| "64".into()).parse().unwrap_or(64);

    if root.exists() {
        std::fs::remove_dir_all(&root).with_context(|| format!("clearing {}", root.display()))?;
    }
    std::fs::create_dir_all(&root)?;

    // Either arm hands the push the same thing: a `&[u8]` of pack bytes. The
    // real one is mapped rather than read so an 8 GiB pack is not 8 GiB of anon
    // this measurement did not want to be about; the synthetic one is built.
    let (map, built, label): (Option<memmap2::Mmap>, Vec<u8>, String) =
        match spec.strip_prefix("synthetic:") {
            Some(count) => {
                let objects: usize = count.parse().context("synthetic:<objects>")?;
                (None, synthetic_pack(objects), format!("synthetic, {objects} blobs"))
            }
            None => {
                let pack_path = PathBuf::from(&spec);
                let f = std::fs::File::open(&pack_path)
                    .with_context(|| format!("opening {}", pack_path.display()))?;
                let m = unsafe { memmap2::Mmap::map(&f)? };
                let label = pack_path.display().to_string();
                (Some(m), Vec::new(), label)
            }
        };
    let pack: &[u8] = match &map {
        Some(m) => &m[..],
        None => &built[..],
    };

    println!("=== RSS PER OPEN STORE ===");
    println!("pack        {:>14} B  {label}", pack.len());
    println!("scratch     {}", root.display());
    println!("stores      {n}   reads per store {reads}");
    println!(
        "explode     {}   redb cache {}",
        std::env::var("ZNIPPY_GIT_EXPLODE").unwrap_or_else(|_| "(default)".into()),
        std::env::var("ZNIPPY_GIT_REDB_CACHE_BYTES").unwrap_or_else(|_| "(default 64 MiB)".into()),
    );
    println!("loadavg     {}", loadavg());

    // ── PHASE A — build ONE store, then copy it ─────────────────────────────
    //
    // Copied and not pushed N times, so every store is byte-identical: a slope
    // over stores that differ is a slope over the difference, not over the
    // handle.
    let seed = root.join("store-0");
    let (objects, picks) = {
        let store = GitStore::open(&seed, "alice").context("opening the seed store")?;
        let tx = store.put_pack(pack).context("the push")?;
        store.wait_indexed();
        let objects = store.object_count();
        let extent = tx.extent.context("the push reported no extent")?;
        let oids = store.oids_in_extent(extent).context("listing the pack's oids")?;
        let picks = stride(&oids, reads);
        (objects, picks)
    };
    drop(map);
    drop(built);
    println!("seeded      {objects} objects, {} oids picked for reads", picks.len());
    for i in 1..n {
        copy_tree(&seed, &root.join(format!("store-{i}")))?;
    }

    // The seed store's own open, its push and its index all ran in this
    // process. Trim first so the baseline is the process AFTER that work, not
    // during it — otherwise every store's delta is measured against a high
    // baseline and the slope comes out flatter than it is.
    malloc_trim();
    let base = anon_rss_kb();
    let base_total = total_rss_kb();
    let (base_use, _) = mallinfo_kb().unwrap_or((0, 0));
    println!(
        "\nbaseline    anon {:8.1} MB   rss {:8.1} MB   malloc in-use {:8.1} MB\n",
        mb(base),
        mb(base_total),
        mb(base_use)
    );

    // ── PHASE B — open them one at a time, holding every handle ─────────────
    println!(
        "{:>5}  {:>12}  {:>12}  {:>12}  {:>12}  {:>12}",
        "open", "anon MB", "Δ anon MB", "per store MB", "malloc MB", "rss MB"
    );
    let mut held = Vec::with_capacity(n);
    let mut rows: Vec<(usize, u64)> = Vec::with_capacity(n);
    let mut live: Vec<(usize, u64)> = Vec::with_capacity(n);
    for i in 0..n {
        let dir = root.join(format!("store-{i}"));
        let store = GitStore::open(&dir, "alice")
            .with_context(|| format!("opening store {i}"))?;
        // READ. Without it the exploded batch cache holds nothing and the redb
        // page cache holds only what the open itself touched — the exact way a
        // memory probe under-reports a cache.
        for oid in &picks {
            store
                .content(oid)?
                .with_context(|| format!("store {i}: an object of its own pack is absent"))?;
        }
        // QUIESCE. A reopen adopts the journal and may re-queue index work, and
        // a read admits write-backs into the lazy exploded pool; both run on
        // their own threads. Sampling without waiting reads a random point
        // inside that work and is what made the per-open increments scatter
        // 11.5–43.5 MB on the first synthetic run.
        store.wait_indexed();
        held.push(store);

        // TRIM BEFORE SAMPLING, and it is not cosmetic. Without it the first
        // synthetic run read 23.7 / 36.4 / 42.6 / 37.8 / 39.9 MB for five
        // identical stores — a 44 % spread, which `locked_on_target.md` §6 calls
        // noise rather than a result. The variance is the arena: an open
        // allocates and frees a transient O(objects) scan, and whether those
        // pages are on a free list at the instant of the reading depends on
        // nothing the measurement is about. Trimming first makes every row the
        // same question — what is still HELD — and `malloc in-use`, which is
        // reachable bytes and is not moved by a trim at all, is printed beside
        // it as the control: if the two disagree, the anonymous figure is
        // fragmentation and not a structure.
        malloc_trim();
        let now = anon_rss_kb();
        let (in_use, _) = mallinfo_kb().unwrap_or((0, 0));
        rows.push((i + 1, now));
        live.push((i + 1, in_use));
        println!(
            "{:>5}  {:>12.1}  {:>12.1}  {:>12.1}  {:>12.1}  {:>12.1}",
            i + 1,
            mb(now),
            mb(now.saturating_sub(base)),
            mb(now.saturating_sub(base)) / (i + 1) as f64,
            mb(in_use),
            mb(total_rss_kb()),
        );
    }

    // ── THE SLOPE ───────────────────────────────────────────────────────────
    //
    // The marginal cost of the LAST store rather than the average, because the
    // first open also pays this process's one-off allocations and the question
    // is what the 26th repository costs, not what the 1st did.
    let first = rows.first().copied().unwrap_or((1, base));
    let last = rows.last().copied().unwrap_or((1, base));
    let marginal = if last.0 > first.0 {
        (last.1.saturating_sub(first.1)) as f64 / (last.0 - first.0) as f64
    } else {
        (last.1.saturating_sub(base)) as f64
    };
    // The same slope over `mallinfo`'s in-use figure. Printed beside the
    // resident one because it answers a different question: resident says what
    // the box is paying, in-use says whether the program still OWNS it. A slope
    // that is real in both is a structure; one that is real only in the resident
    // figure is the allocator.
    let live_first = live.first().copied().unwrap_or((1, 0));
    let live_last = live.last().copied().unwrap_or((1, 0));
    let live_marginal = if live_last.0 > live_first.0 {
        (live_last.1.saturating_sub(live_first.1)) as f64 / (live_last.0 - live_first.0) as f64
    } else {
        live_last.1 as f64
    };
    // The spread over the per-store increments, so a reader can see whether the
    // number above is a line or an average of scatter. Over 25 % is noise.
    let mut steps: Vec<f64> = Vec::new();
    for w in rows.windows(2) {
        steps.push(w[1].1.saturating_sub(w[0].1) as f64);
    }
    let (lo, hi) = steps.iter().fold((f64::MAX, 0.0f64), |(l, h), s| (l.min(*s), h.max(*s)));
    println!(
        "\nSLOPE       {:.1} MB per additional open store  (stores {}→{}, \
         {:.1} MB total over baseline)",
        marginal / 1024.0,
        first.0,
        last.0,
        mb(last.1.saturating_sub(base))
    );
    println!(
        "            {:.1} MB by malloc in-use (reachable bytes; agreement with the line \
         above means a structure, not the arena)",
        live_marginal / 1024.0
    );
    if !steps.is_empty() {
        println!(
            "            per-open increments {:.1}–{:.1} MB, spread {:.1} % of the mean",
            lo / 1024.0,
            hi / 1024.0,
            100.0 * (hi - lo) / (steps.iter().sum::<f64>() / steps.len() as f64).max(1.0)
        );
    }
    println!(
        "            {:.1} bytes per object per open store ({objects} objects)",
        marginal * 1024.0 / objects.max(1) as f64
    );
    println!(
        "            gunnar prices this at BYTES_PER_OPEN_REPO = 8.0 MB → measured/budgeted \
         = {:.1}x",
        marginal / 1024.0 / 8.0
    );

    // ── PHASE C — does releasing a handle give the bytes back? ──────────────
    drop(held);
    let dropped = anon_rss_kb();
    malloc_trim();
    let trimmed = anon_rss_kb();
    println!(
        "\nafter drop  {:8.1} MB anon ({:.1}% of the growth still resident)\nafter trim  \
         {:8.1} MB anon ({:.1}% still resident)",
        mb(dropped),
        100.0 * dropped.saturating_sub(base) as f64 / last.1.saturating_sub(base).max(1) as f64,
        mb(trimmed),
        100.0 * trimmed.saturating_sub(base) as f64 / last.1.saturating_sub(base).max(1) as f64,
    );
    println!("loadavg     {}", loadavg());
    Ok(())
}