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 |