OXYGENHUB

Runners send their facts with every claim: free disk, load, speed 62829d15ef09

A claim carries facts (spec §11.1): free disk where jobs' disks are made,
the 1-minute load average, the kernel, and a speed score and class from a
one-thread benchmark (BLAKE3 over 256 MiB and a sort of 4 Mi words),
taken at start and every 15 minutes while no job runs. Earth takes about
225 ms and scores 100 (class 3); each class is a factor of two. flow
runner bench prints them.

refs #40
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 21:41 UTC
Signature
✔ signed by Andy McCall
Hash
62829d15ef093c62398861ee4295e4c468462751bae06f163b1c01f2e2bfd912
Depends on
1daca0b6bb8c 3007d8f011fa 4cf0c99a1486 a083cc802892 e0918b887850 ea56c56eaf90 f98a8f7881e0 fd2d4822f7f3
Depended on by
788f57e4d266 a63d3dcd2aef f4dfd742b30d 4dd392d16454 6f66e397539c 3554afefb6aa 85275e1ef3b7 337afb8f6043 5cf1e8aefbb6 ea81796bb757 89171fca079c b14aeaae683a 8f211e20bea5
In bookmarks
main newer-pipeline push-uses-login
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
10 atoms, 1 tree operation, 5 files

Browse the tree at this change

added crates/flow/src/facts.rs +81 −0
1 +
//! What a runner measures about itself (spec §11.1, docs/jetstream.md §6):
2 +
//! free disk, load and the kernel each claim, cheaply; and its speed, from
3 +
//! a short fixed benchmark run at start and again while idle.
4 +
5 +
use crate::home::Home;
6 +
use flow_core::api::Facts;
7 +
use std::time::Instant;
8 +
9 +
/// The benchmark's time on the reference machine (Earth), in ms: a runner
10 +
/// that takes as long scores 100.
11 +
const REFERENCE_MS: f64 = 225.0;
12 +
13 +
/// The cheap facts, with the last speed measured.
14 +
pub fn now(home: &Home, speed: Option<u32>) -> Facts {
15 +
    Facts {
16 +
        free_disk_mib: free_disk_mib(&home.root),
17 +
        load_x100: load_x100(),
18 +
        speed,
19 +
        speed_class: speed.map(Facts::class_of),
20 +
        kernel: std::fs::read_to_string("/proc/sys/kernel/osrelease").ok().map(|s| s.trim().to_string()),
21 +
    }
22 +
}
23 +
24 +
/// Free space for an unprivileged writer on the file system holding `path`, in MiB.
25 +
pub fn free_disk_mib(path: &std::path::Path) -> Option<u64> {
26 +
    use std::os::unix::ffi::OsStrExt;
27 +
    let c = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
28 +
    let mut s: libc::statvfs = unsafe { std::mem::zeroed() };
29 +
    // SAFETY: `c` is a valid NUL-terminated path and `s` a writable statvfs.
30 +
    if unsafe { libc::statvfs(c.as_ptr(), &mut s) } != 0 {
31 +
        return None;
32 +
    }
33 +
    Some((s.f_bavail as u64).saturating_mul(s.f_frsize as u64) >> 20)
34 +
}
35 +
36 +
/// The 1-minute load average from /proc/loadavg, times 100.
37 +
pub fn load_x100() -> Option<u32> {
38 +
    let text = std::fs::read_to_string("/proc/loadavg").ok()?;
39 +
    let first: f64 = text.split_whitespace().next()?.parse().ok()?;
40 +
    Some((first * 100.0).round() as u32)
41 +
}
42 +
43 +
/// The benchmark, one thread: BLAKE3 over 256 MiB (spec §11.1), and a sort
44 +
/// of 4 Mi pseudo-random words for the integer and memory side. Answers
45 +
/// the score, 100 on the reference machine, and the time it took.
46 +
pub fn bench() -> (u32, u128) {
47 +
    let start = Instant::now();
48 +
    let buf: Vec<u8> = (0..32u32 << 20).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
49 +
    let mut h = blake3::Hasher::new();
50 +
    for _ in 0..8 {
51 +
        h.update(&buf);
52 +
    }
53 +
    std::hint::black_box(h.finalize());
54 +
    drop(buf);
55 +
    let mut x: u64 = 0x9e37_79b9_7f4a_7c15;
56 +
    let mut words: Vec<u64> = (0..4u64 << 20)
57 +
        .map(|_| {
58 +
            x ^= x << 13;
59 +
            x ^= x >> 7;
60 +
            x ^= x << 17;
61 +
            x
62 +
        })
63 +
        .collect();
64 +
    words.sort_unstable();
65 +
    std::hint::black_box(&words);
66 +
    let ms = start.elapsed().as_millis().max(1);
67 +
    ((REFERENCE_MS * 100.0 / ms as f64).round() as u32, ms)
68 +
}
69 +
70 +
#[cfg(test)]
71 +
mod tests {
72 +
    use super::*;
73 +
74 +
    #[test]
75 +
    fn the_cheap_facts_are_read_and_classes_double() {
76 +
        assert!(free_disk_mib(std::path::Path::new("/")).is_some());
77 +
        assert!(load_x100().is_some());
78 +
        assert_eq!(Facts::class_of(100), 3, "the reference machine");
79 +
        assert_eq!((Facts::class_of(50), Facts::class_of(200), Facts::class_of(500), Facts::class_of(10)), (2, 4, 5, 1));
80 +
    }
81 +
}
modified crates/flow-core/src/api.rs +42 −0
…
43 unchanged lines
44 44
    /// (oxygenhub#71). Absent: the runner does not say.
45 45
    #[serde(default, skip_serializing_if = "Option::is_none")]
46 46
    pub holding: Option<Vec<i64>>,
47 +
    /// What the runner measured about itself just now (spec §11.1,
48 +
    /// docs/jetstream.md §6). Absent: an older runner.
49 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
50 +
    pub facts: Option<Facts>,
51 +
}
52 +
53 +
/// A runner's own measurements, sent with every claim (spec §11.1): cheap
54 +
/// ones taken each time, the speed from a short benchmark run at start and
55 +
/// again while idle. Whole numbers, so a claim compares exactly.
56 +
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
57 +
pub struct Facts {
58 +
    /// Free disk where jobs' disks are made, in MiB.
59 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
60 +
    pub free_disk_mib: Option<u64>,
61 +
    /// The 1-minute load average, times 100.
62 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
63 +
    pub load_x100: Option<u32>,
64 +
    /// The benchmark's score: 100 on the reference machine (Earth), twice
65 +
    /// as fast scoring 200.
66 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
67 +
    pub speed: Option<u32>,
68 +
    /// The speed class, 1 (slow) to 5 (fast); each class twice the one
69 +
    /// below (spec §11.1).
70 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
71 +
    pub speed_class: Option<u8>,
72 +
    /// The kernel release.
73 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
74 +
    pub kernel: Option<String>,
75 +
}
76 +
77 +
impl Facts {
78 +
    /// The class of a speed score: 3 for the reference machine's 100, each
79 +
    /// class a factor of two.
80 +
    pub fn class_of(speed: u32) -> u8 {
81 +
        match speed {
82 +
            0..=34 => 1,
83 +
            35..=69 => 2,
84 +
            70..=139 => 3,
85 +
            140..=279 => 4,
86 +
            _ => 5,
87 +
        }
88 +
    }
47 89
}
48 90
49 91
/// A runner's description: shown on the hub's Runners page, and what
…
192 unchanged lines
modified crates/flow/src/main.rs +16 −0
…
1 unchanged lines
2 2
//! (docs/spec.md, docs/m0.md).
3 3
4 4
mod cpio;
5 +
mod facts;
5 6
/// The job-starting code `flow-init` uses in a microVM, shared with the
6 7
/// syscall tier.
7 8
#[path = "../../flow-init/src/job.rs"]
…
45 unchanged lines
53 54
        #[arg(long)]
54 55
        label: Vec<String>,
55 56
    },
57 +
    /// Measure this machine as a runner does: its speed score (100 on the
58 +
    /// reference machine) and class, free disk and load (spec §11.1)
59 +
    Bench,
56 60
}
57 61
58 62
#[derive(Subcommand)]
…
168 unchanged lines
227 231
            let (vars, secrets) = (pairs("var", &vars)?, pairs("secret", &secrets)?);
228 232
            run_local(&home, &dir, &jobs, Duration::from_secs(timeout * 60), &vars, &secrets)
229 233
        }
234 +
        Cmd::Runner { cmd: Some(RunnerCmd::Bench) } => {
235 +
            let home = Home::locate(cli.home.as_deref())?;
236 +
            let (speed, ms) = facts::bench();
237 +
            let f = facts::now(&home, Some(speed));
238 +
            println!("speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
239 +
            println!("free disk {} MiB at {}", f.free_disk_mib.unwrap_or(0), home.root.display());
240 +
            println!("load {:.2}", f.load_x100.unwrap_or(0) as f64 / 100.0);
241 +
            if let Some(k) = f.kernel {
242 +
                println!("kernel {k}");
243 +
            }
244 +
            Ok(())
245 +
        }
230 246
        Cmd::Runner { cmd: Some(RunnerCmd::Register { hub, token, name, ca, label }) } => {
231 247
            let home = Home::locate(cli.home.as_deref())?;
232 248
            if std::env::var_os("OXY_HOME").is_none() {
…
317 unchanged lines
modified crates/flow/src/runner.rs +14 −0
…
438 unchanged lines
439 439
                held.lock().unwrap().remove(&job);
440 440
            });
441 441
        }
442 +
        // The speed (spec §11.1): measured now, and again every 15 minutes
443 +
        // while no job runs, so a job never shares the CPU with it.
444 +
        let measure = || {
445 +
            let (speed, ms) = crate::facts::bench();
446 +
            eprintln!("flow runner {name}: speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
447 +
            speed
448 +
        };
449 +
        let mut speed = measure();
450 +
        let mut benched = Instant::now();
442 451
        loop {
452 +
            if used.lock().unwrap().jobs == 0 && benched.elapsed() >= Duration::from_secs(15 * 60) {
453 +
                speed = measure();
454 +
                benched = Instant::now();
455 +
            }
443 456
            let room = budget.room(*used.lock().unwrap());
444 457
            let Some((free_memory_mib, free_cpus)) = room else {
445 458
                std::thread::sleep(Duration::from_millis(500));
…
13 unchanged lines
459 472
                image_archs: Some(home.image_archs()),
460 473
                holding: Some(held.lock().unwrap().iter().copied().collect()),
461 474
                fetches_images: true,
475 +
                facts: Some(crate::facts::now(home, Some(speed))),
462 476
            };
463 477
            match hub.claim(&req) {
464 478
                Ok(Some(claim)) => {
…
602 unchanged lines
modified docs/spec.md +2 −2
…
454 unchanged lines
455 455
| `memory_mib`, `cpus`, `max_jobs` | What its jobs share |
456 456
| `images` | The rootfs images it holds, each with the architecture its manifest records (§11.2) |
457 457
| `labels` | As §3.1 |
458 -
| `speed` | A class from 1 (slow) to 5 (fast), measured at `flow runner register` and `flow runner bench` by a fixed workload (BLAKE3 over 256 MiB and the compilation of a fixed C file), bucketed so that two machines of the same class finish a job within a factor of two of each other |
459 -
| `load` | The 1-minute load average divided by `cpus`, memory free now, jobs running |
458 +
| `speed` | A score and a class from 1 (slow) to 5 (fast), measured by the runner at start, again every 15 minutes while it runs no job, and by `flow runner bench`: a fixed one-thread workload (BLAKE3 over 256 MiB, and a sort of 4 Mi words, where a C compiler cannot be assumed) whose time on the reference machine (Earth, about 225 ms) scores 100; class 3 is 70–139, each class a factor of two (done 2026-10-11, `facts` on the claim) |
459 +
| `load` | The 1-minute load average (×100), and the free disk where jobs' disks are made, sent with every claim (`facts`); memory free and jobs running as before |
460 460
461 461
### 11.2 Image Manifests Carry the Architecture
462 462
…
60 unchanged lines