mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(dev): library-mode benchmark environment, fleet/budget gates, and profiling optimizations (#5107)
This commit is contained in:
Generated
+38
-3
@@ -533,7 +533,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.2",
|
||||
"shlex",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
@@ -1738,6 +1738,22 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dhat"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98cd11d84628e233de0ce467de10b8633f4ddaecafadefc86e13b84b8739b827"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"lazy_static",
|
||||
"mintex",
|
||||
"parking_lot",
|
||||
"rustc-hash 1.1.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thousands",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -4003,6 +4019,12 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mintex"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
@@ -4659,6 +4681,7 @@ dependencies = [
|
||||
"cron",
|
||||
"crossterm",
|
||||
"curve25519-dalek",
|
||||
"dhat",
|
||||
"directories",
|
||||
"dirs 5.0.1",
|
||||
"docx-rs",
|
||||
@@ -5499,7 +5522,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.2",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
@@ -5520,7 +5543,7 @@ dependencies = [
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.2",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
@@ -6084,6 +6107,12 @@ version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -7232,6 +7261,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thousands"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820"
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
|
||||
+16
@@ -54,6 +54,13 @@ name = "rss-bench"
|
||||
path = "src/bin/rss_bench.rs"
|
||||
required-features = ["rss-bench"]
|
||||
|
||||
# Stateful library profiling workloads (memory ingestion + real sub-agent
|
||||
# delegation under a hermetic mock provider). Local/dev only.
|
||||
[[bin]]
|
||||
name = "library-profile"
|
||||
path = "src/bin/library_profile/main.rs"
|
||||
required-features = ["rss-bench"]
|
||||
|
||||
[lib]
|
||||
name = "openhuman_core"
|
||||
crate-type = ["rlib"]
|
||||
@@ -112,6 +119,10 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_repr = "0.1"
|
||||
serde_yaml = "0.9"
|
||||
# dhat — heap profiler for the offline `library-profile` benchmark binary only.
|
||||
# Default-OFF, dev-only: pulled in solely by the `rss-bench-dhat` feature so it
|
||||
# never enters the shipped desktop/library build (same posture as `rss-bench`).
|
||||
dhat = { version = "0.3", optional = true }
|
||||
# (Removed `html2md` dep. dhat-rs profiling on real Gmail inboxes
|
||||
# showed `html2md::walk` and `html2md::tables::handle` allocating
|
||||
# ~894 MB peak heap on a 10 KB HTML input from Otter.ai-style emails
|
||||
@@ -601,6 +612,11 @@ e2e-test-support = []
|
||||
# part of the shipped desktop/library build and the feature-forwarding gate
|
||||
# (which only inspects the `default` list) never requires forwarding it.
|
||||
rss-bench = []
|
||||
# Adds dhat heap profiling on top of `rss-bench` for the `library-profile`
|
||||
# binary. Default-OFF, dev-only: installs dhat's global allocator, which
|
||||
# perturbs RSS/timing numbers, so it is a separate opt-in feature rather than
|
||||
# folded into `rss-bench`. Never forwarded to the shipped build.
|
||||
rss-bench-dhat = ["rss-bench", "dep:dhat"]
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/target/
|
||||
Generated
+292
@@ -0,0 +1,292 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.188"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openhuman-tauri-resource-profiler"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-result",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "openhuman-tauri-resource-profiler"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["system"] }
|
||||
|
||||
# Keep this offline dev tool independent from both the root core crate and the
|
||||
# Tauri package so profiling never changes the shipped dependency graph.
|
||||
[workspace]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Tauri resource profiler
|
||||
|
||||
Offline developer tool for measuring a locally running OpenHuman desktop app.
|
||||
It is a standalone Cargo crate and is not linked into, registered with, or
|
||||
shipped in the Tauri app.
|
||||
|
||||
## Capture
|
||||
|
||||
Start the current checkout:
|
||||
|
||||
pnpm dev:app
|
||||
|
||||
Find the main host PID, excluding OpenHuman Helper processes:
|
||||
|
||||
pgrep -fl '/OpenHuman$'
|
||||
|
||||
Then capture a representative workload:
|
||||
|
||||
pnpm profile:tauri --pid <PID> --duration 15
|
||||
|
||||
Useful options:
|
||||
|
||||
--interval-ms 250
|
||||
--out target/profile/my-scenario
|
||||
--no-stacks
|
||||
|
||||
The default output directory is target/profile/tauri-resources-<timestamp>.
|
||||
Each run writes:
|
||||
|
||||
- resources.md: summary table for the Rust host, CEF process roles, and total
|
||||
desktop process tree.
|
||||
- resources.json: raw time series plus mean and peak values.
|
||||
- cpu-stacks.txt: macOS sample report for the host process. This is enabled by
|
||||
default on macOS and can be disabled with --no-stacks.
|
||||
|
||||
## Attribution boundary
|
||||
|
||||
The Rust core runs inside the Tauri host process. Operating-system CPU and RAM
|
||||
metrics therefore cannot split the shell from openhuman_core, or assign heap
|
||||
pages to individual Rust modules. The profiler reports that combined process
|
||||
honestly as Tauri host + embedded Rust core.
|
||||
|
||||
CEF renderer, GPU, utility, and other helper processes are separate and are
|
||||
reported independently. On macOS, the tool also parses the recursive stack
|
||||
counts from cpu-stacks.txt and groups OpenHuman symbols by Rust domain, such as
|
||||
openhuman_core::openhuman::agent or openhuman::core_process.
|
||||
|
||||
CPU percentages are percentages of one logical CPU and may exceed 100 when a
|
||||
component uses multiple cores. RAM is resident memory reported by sysinfo.
|
||||
|
||||
For the smaller embedded-core-only Linux RSS/PSS benchmark, use the existing
|
||||
root rss-bench harness:
|
||||
|
||||
cargo build --release --features rss-bench --bin rss-bench
|
||||
@@ -0,0 +1,813 @@
|
||||
//! Offline CPU/RAM profiler for a locally running OpenHuman Tauri process.
|
||||
//!
|
||||
//! This binary is gated by the default-OFF dev-resource-profiler feature and
|
||||
//! is never linked into the shipped app. It samples the Tauri host, which also
|
||||
//! embeds openhuman_core, and its CEF descendants. On macOS it also captures an
|
||||
//! Apple sample report for Rust module-level CPU attribution.
|
||||
//!
|
||||
//! Run from the repository root:
|
||||
//! pnpm profile:tauri --pid PID --duration 15
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Serialize;
|
||||
use sysinfo::{Pid, ProcessesToUpdate, System, MINIMUM_CPU_UPDATE_INTERVAL};
|
||||
|
||||
const DEFAULT_DURATION_SECS: u64 = 15;
|
||||
const DEFAULT_INTERVAL_MS: u64 = 250;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
pid: u32,
|
||||
duration: Duration,
|
||||
interval: Duration,
|
||||
out_dir: PathBuf,
|
||||
capture_stacks: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum Component {
|
||||
TauriHostAndEmbeddedCore,
|
||||
CefRenderer,
|
||||
CefGpu,
|
||||
CefUtility,
|
||||
CefOther,
|
||||
OtherChild,
|
||||
}
|
||||
|
||||
impl Component {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::TauriHostAndEmbeddedCore => "Tauri host + embedded Rust core",
|
||||
Self::CefRenderer => "CEF renderer",
|
||||
Self::CefGpu => "CEF GPU",
|
||||
Self::CefUtility => "CEF utility",
|
||||
Self::CefOther => "CEF other",
|
||||
Self::OtherChild => "Other child process",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ProcessSample {
|
||||
pid: u32,
|
||||
parent_pid: Option<u32>,
|
||||
name: String,
|
||||
command: String,
|
||||
memory_bytes: u64,
|
||||
cpu_percent: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ComponentSample {
|
||||
component: Component,
|
||||
process_count: usize,
|
||||
memory_bytes: u64,
|
||||
/// Percentage of one logical CPU. Above 100 means multiple logical CPUs.
|
||||
cpu_percent: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct TimeSample {
|
||||
elapsed_ms: u64,
|
||||
components: Vec<ComponentSample>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
struct ComponentSummary {
|
||||
component: Option<Component>,
|
||||
sample_count: usize,
|
||||
peak_process_count: usize,
|
||||
mean_memory_bytes: u64,
|
||||
peak_memory_bytes: u64,
|
||||
mean_cpu_percent: f32,
|
||||
peak_cpu_percent: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
struct RustModuleCpu {
|
||||
module: String,
|
||||
recursive_samples: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ProfileReport {
|
||||
schema_version: u32,
|
||||
host_pid: u32,
|
||||
duration_ms: u64,
|
||||
interval_ms: u64,
|
||||
logical_cpu_count: usize,
|
||||
sampled_at_unix_ms: u128,
|
||||
attribution_note: &'static str,
|
||||
rust_binary: ComponentSummary,
|
||||
desktop_total: ComponentSummary,
|
||||
components: Vec<ComponentSummary>,
|
||||
samples: Vec<TimeSample>,
|
||||
rust_cpu_modules: Vec<RustModuleCpu>,
|
||||
cpu_stack_report: Option<String>,
|
||||
cpu_stack_error: Option<String>,
|
||||
}
|
||||
|
||||
struct ProfileCapture {
|
||||
host_pid: u32,
|
||||
duration: Duration,
|
||||
interval: Duration,
|
||||
logical_cpu_count: usize,
|
||||
samples: Vec<TimeSample>,
|
||||
rust_cpu_modules: Vec<RustModuleCpu>,
|
||||
cpu_stack_report: Option<String>,
|
||||
cpu_stack_error: Option<String>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let arguments = env::args().skip(1).collect::<Vec<_>>();
|
||||
if arguments
|
||||
.iter()
|
||||
.any(|argument| argument == "-h" || argument == "--help")
|
||||
{
|
||||
println!("{}", usage());
|
||||
return;
|
||||
}
|
||||
if let Err(err) = run(arguments) {
|
||||
eprintln!("tauri-resource-profiler: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(arguments: Vec<String>) -> Result<(), String> {
|
||||
let args = parse_args(arguments)?;
|
||||
fs::create_dir_all(&args.out_dir)
|
||||
.map_err(|err| format!("create output directory {}: {err}", args.out_dir.display()))?;
|
||||
|
||||
let stack_path = args.out_dir.join("cpu-stacks.txt");
|
||||
let (mut stack_child, initial_stack_error) = if args.capture_stacks {
|
||||
start_stack_capture(args.pid, args.duration, &stack_path)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let (logical_cpu_count, samples) = capture_samples(&args)?;
|
||||
let final_stack_error = finish_stack_capture(stack_child.as_mut(), initial_stack_error);
|
||||
let stack_report = stack_path.exists().then(|| {
|
||||
stack_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
});
|
||||
let rust_cpu_modules = fs::read_to_string(&stack_path)
|
||||
.map(|contents| parse_rust_module_cpu(&contents))
|
||||
.unwrap_or_default();
|
||||
|
||||
let report = build_report(ProfileCapture {
|
||||
host_pid: args.pid,
|
||||
duration: args.duration,
|
||||
interval: args.interval,
|
||||
logical_cpu_count,
|
||||
samples,
|
||||
rust_cpu_modules,
|
||||
cpu_stack_report: stack_report,
|
||||
cpu_stack_error: final_stack_error,
|
||||
})?;
|
||||
let json_path = args.out_dir.join("resources.json");
|
||||
let markdown_path = args.out_dir.join("resources.md");
|
||||
fs::write(
|
||||
&json_path,
|
||||
serde_json::to_string_pretty(&report).map_err(|err| format!("serialize report: {err}"))?,
|
||||
)
|
||||
.map_err(|err| format!("write {}: {err}", json_path.display()))?;
|
||||
let markdown = render_markdown(&report);
|
||||
fs::write(&markdown_path, &markdown)
|
||||
.map_err(|err| format!("write {}: {err}", markdown_path.display()))?;
|
||||
|
||||
println!("{markdown}");
|
||||
println!("Raw report: {}", json_path.display());
|
||||
if stack_path.exists() {
|
||||
println!("CPU stacks: {}", stack_path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args(arguments: impl IntoIterator<Item = String>) -> Result<Args, String> {
|
||||
let mut pid = None;
|
||||
let mut duration_secs = DEFAULT_DURATION_SECS;
|
||||
let mut interval_ms = DEFAULT_INTERVAL_MS;
|
||||
let mut out_dir = None;
|
||||
let mut capture_stacks = cfg!(target_os = "macos");
|
||||
let mut arguments = arguments.into_iter();
|
||||
|
||||
while let Some(argument) = arguments.next() {
|
||||
match argument.as_str() {
|
||||
"--pid" => pid = Some(parse_value::<u32>("--pid", arguments.next())?),
|
||||
"--duration" => {
|
||||
duration_secs = parse_value::<u64>("--duration", arguments.next())?;
|
||||
}
|
||||
"--interval-ms" => {
|
||||
interval_ms = parse_value::<u64>("--interval-ms", arguments.next())?;
|
||||
}
|
||||
"--out" => {
|
||||
out_dir = Some(PathBuf::from(
|
||||
arguments
|
||||
.next()
|
||||
.ok_or_else(|| "--out requires a path".to_string())?,
|
||||
));
|
||||
}
|
||||
"--no-stacks" => capture_stacks = false,
|
||||
"--stacks" => capture_stacks = true,
|
||||
"--" => {}
|
||||
"-h" | "--help" => unreachable!("main handles help before parsing"),
|
||||
unknown => return Err(format!("unknown argument {unknown}\n\n{}", usage())),
|
||||
}
|
||||
}
|
||||
|
||||
let pid = pid.ok_or_else(|| format!("--pid is required\n\n{}", usage()))?;
|
||||
if duration_secs == 0 {
|
||||
return Err("--duration must be greater than zero".into());
|
||||
}
|
||||
if interval_ms == 0 {
|
||||
return Err("--interval-ms must be greater than zero".into());
|
||||
}
|
||||
let interval = Duration::from_millis(interval_ms).max(MINIMUM_CPU_UPDATE_INTERVAL);
|
||||
|
||||
Ok(Args {
|
||||
pid,
|
||||
duration: Duration::from_secs(duration_secs),
|
||||
interval,
|
||||
out_dir: out_dir.unwrap_or_else(default_out_dir),
|
||||
capture_stacks,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_value<T>(flag: &str, value: Option<String>) -> Result<T, String>
|
||||
where
|
||||
T: std::str::FromStr,
|
||||
{
|
||||
let raw = value.ok_or_else(|| format!("{flag} requires a value"))?;
|
||||
raw.parse()
|
||||
.map_err(|_| format!("invalid {flag} value {raw}"))
|
||||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"Usage: pnpm profile:tauri --pid PID [--duration SECONDS] [--interval-ms MS] [--out PATH] [--stacks|--no-stacks]\n\nAttach to the main OpenHuman Tauri PID, not a CEF helper PID. On macOS, CPU stack sampling is enabled by default."
|
||||
}
|
||||
|
||||
fn default_out_dir() -> PathBuf {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
PathBuf::from("target")
|
||||
.join("profile")
|
||||
.join(format!("tauri-resources-{timestamp}"))
|
||||
}
|
||||
|
||||
fn capture_samples(args: &Args) -> Result<(usize, Vec<TimeSample>), String> {
|
||||
let root_pid = Pid::from_u32(args.pid);
|
||||
let mut system = System::new_all();
|
||||
if system.process(root_pid).is_none() {
|
||||
return Err(format!("pid {} is not running", args.pid));
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
let mut samples = Vec::new();
|
||||
while started.elapsed() < args.duration {
|
||||
std::thread::sleep(args.interval);
|
||||
system.refresh_processes(ProcessesToUpdate::All, true);
|
||||
if system.process(root_pid).is_none() {
|
||||
return Err(format!("pid {} exited during profiling", args.pid));
|
||||
}
|
||||
let processes = collect_processes(&system);
|
||||
samples.push(TimeSample {
|
||||
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||
components: group_process_tree(args.pid, &processes)?,
|
||||
});
|
||||
}
|
||||
Ok((system.cpus().len(), samples))
|
||||
}
|
||||
|
||||
fn collect_processes(system: &System) -> Vec<ProcessSample> {
|
||||
system
|
||||
.processes()
|
||||
.iter()
|
||||
.map(|(pid, process)| ProcessSample {
|
||||
pid: pid.as_u32(),
|
||||
parent_pid: process.parent().map(Pid::as_u32),
|
||||
name: process.name().to_string_lossy().into_owned(),
|
||||
command: process
|
||||
.cmd()
|
||||
.iter()
|
||||
.map(|part| part.to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
memory_bytes: process.memory(),
|
||||
cpu_percent: process.cpu_usage(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn group_process_tree(
|
||||
root_pid: u32,
|
||||
samples: &[ProcessSample],
|
||||
) -> Result<Vec<ComponentSample>, String> {
|
||||
let by_pid = samples
|
||||
.iter()
|
||||
.map(|sample| (sample.pid, sample))
|
||||
.collect::<HashMap<_, _>>();
|
||||
if !by_pid.contains_key(&root_pid) {
|
||||
return Err(format!(
|
||||
"host pid {root_pid} disappeared from the process table"
|
||||
));
|
||||
}
|
||||
|
||||
let mut grouped = BTreeMap::<Component, ComponentSample>::new();
|
||||
for sample in samples
|
||||
.iter()
|
||||
.filter(|sample| belongs_to_tree(sample.pid, root_pid, &by_pid))
|
||||
{
|
||||
let component = classify_process(sample, root_pid);
|
||||
let entry = grouped.entry(component).or_insert(ComponentSample {
|
||||
component,
|
||||
process_count: 0,
|
||||
memory_bytes: 0,
|
||||
cpu_percent: 0.0,
|
||||
});
|
||||
entry.process_count += 1;
|
||||
entry.memory_bytes = entry.memory_bytes.saturating_add(sample.memory_bytes);
|
||||
entry.cpu_percent += sample.cpu_percent;
|
||||
}
|
||||
Ok(grouped.into_values().collect())
|
||||
}
|
||||
|
||||
fn belongs_to_tree(pid: u32, root_pid: u32, by_pid: &HashMap<u32, &ProcessSample>) -> bool {
|
||||
let mut current = Some(pid);
|
||||
let mut seen = HashSet::new();
|
||||
while let Some(candidate) = current {
|
||||
if candidate == root_pid {
|
||||
return true;
|
||||
}
|
||||
if !seen.insert(candidate) {
|
||||
return false;
|
||||
}
|
||||
current = by_pid.get(&candidate).and_then(|sample| sample.parent_pid);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn classify_process(sample: &ProcessSample, root_pid: u32) -> Component {
|
||||
if sample.pid == root_pid {
|
||||
return Component::TauriHostAndEmbeddedCore;
|
||||
}
|
||||
let identity = format!("{} {}", sample.name, sample.command).to_ascii_lowercase();
|
||||
if identity.contains("--type=renderer") || identity.contains("renderer") {
|
||||
Component::CefRenderer
|
||||
} else if identity.contains("--type=gpu-process")
|
||||
|| identity.contains("gpu process")
|
||||
|| identity.contains("gpu-process")
|
||||
{
|
||||
Component::CefGpu
|
||||
} else if identity.contains("--type=utility") || identity.contains("utility") {
|
||||
Component::CefUtility
|
||||
} else if identity.contains("--type=zygote")
|
||||
|| identity.contains("--type=broker")
|
||||
|| identity.contains("crashpad")
|
||||
|| identity.contains("cef")
|
||||
{
|
||||
Component::CefOther
|
||||
} else {
|
||||
Component::OtherChild
|
||||
}
|
||||
}
|
||||
|
||||
fn build_report(capture: ProfileCapture) -> Result<ProfileReport, String> {
|
||||
if capture.samples.is_empty() {
|
||||
return Err("profiling produced no samples".into());
|
||||
}
|
||||
|
||||
let mut by_component = BTreeMap::<Component, Vec<ComponentSample>>::new();
|
||||
let mut totals = Vec::new();
|
||||
for sample in &capture.samples {
|
||||
for component in &sample.components {
|
||||
by_component
|
||||
.entry(component.component)
|
||||
.or_default()
|
||||
.push(component.clone());
|
||||
}
|
||||
totals.push(ComponentSample {
|
||||
component: Component::TauriHostAndEmbeddedCore,
|
||||
process_count: sample
|
||||
.components
|
||||
.iter()
|
||||
.map(|value| value.process_count)
|
||||
.sum(),
|
||||
memory_bytes: sample
|
||||
.components
|
||||
.iter()
|
||||
.map(|value| value.memory_bytes)
|
||||
.sum(),
|
||||
cpu_percent: sample
|
||||
.components
|
||||
.iter()
|
||||
.map(|value| value.cpu_percent)
|
||||
.sum(),
|
||||
});
|
||||
}
|
||||
let components = by_component
|
||||
.iter()
|
||||
.map(|(component, values)| summarize(Some(*component), values))
|
||||
.collect::<Vec<_>>();
|
||||
let rust_binary = components
|
||||
.iter()
|
||||
.find(|summary| summary.component == Some(Component::TauriHostAndEmbeddedCore))
|
||||
.cloned()
|
||||
.ok_or_else(|| "host process was not present in samples".to_string())?;
|
||||
|
||||
Ok(ProfileReport {
|
||||
schema_version: 1,
|
||||
host_pid: capture.host_pid,
|
||||
duration_ms: capture.duration.as_millis() as u64,
|
||||
interval_ms: capture.interval.as_millis() as u64,
|
||||
logical_cpu_count: capture.logical_cpu_count,
|
||||
sampled_at_unix_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis(),
|
||||
attribution_note: "The Rust core is embedded in the Tauri host, so OS process metrics report them together. CEF roles are separate child processes. Use cpu-stacks.txt to attribute host CPU samples to Rust modules.",
|
||||
rust_binary,
|
||||
desktop_total: summarize(None, &totals),
|
||||
components,
|
||||
samples: capture.samples,
|
||||
rust_cpu_modules: capture.rust_cpu_modules,
|
||||
cpu_stack_report: capture.cpu_stack_report,
|
||||
cpu_stack_error: capture.cpu_stack_error,
|
||||
})
|
||||
}
|
||||
|
||||
fn summarize(component: Option<Component>, values: &[ComponentSample]) -> ComponentSummary {
|
||||
let count = values.len().max(1);
|
||||
ComponentSummary {
|
||||
component,
|
||||
sample_count: values.len(),
|
||||
peak_process_count: values
|
||||
.iter()
|
||||
.map(|value| value.process_count)
|
||||
.max()
|
||||
.unwrap_or(0),
|
||||
mean_memory_bytes: values.iter().map(|value| value.memory_bytes).sum::<u64>()
|
||||
/ count as u64,
|
||||
peak_memory_bytes: values
|
||||
.iter()
|
||||
.map(|value| value.memory_bytes)
|
||||
.max()
|
||||
.unwrap_or(0),
|
||||
mean_cpu_percent: values.iter().map(|value| value.cpu_percent).sum::<f32>() / count as f32,
|
||||
peak_cpu_percent: values
|
||||
.iter()
|
||||
.map(|value| value.cpu_percent)
|
||||
.fold(0.0, f32::max),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_markdown(report: &ProfileReport) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut output = String::new();
|
||||
let _ = writeln!(output, "### Tauri resource profile");
|
||||
let _ = writeln!(output);
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"PID {} - {:.1}s - {}ms interval - {} logical CPUs",
|
||||
report.host_pid,
|
||||
report.duration_ms as f64 / 1000.0,
|
||||
report.interval_ms,
|
||||
report.logical_cpu_count
|
||||
);
|
||||
let _ = writeln!(output);
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"| component | processes (peak) | RAM mean | RAM peak | CPU mean | CPU peak |"
|
||||
);
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"| --------- | ---------------- | -------- | -------- | -------- | -------- |"
|
||||
);
|
||||
for summary in &report.components {
|
||||
let label = summary
|
||||
.component
|
||||
.map(Component::label)
|
||||
.unwrap_or("Desktop total");
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"| {label} | {} | {:.1} MiB | {:.1} MiB | {:.1}% | {:.1}% |",
|
||||
summary.peak_process_count,
|
||||
to_mib(summary.mean_memory_bytes),
|
||||
to_mib(summary.peak_memory_bytes),
|
||||
summary.mean_cpu_percent,
|
||||
summary.peak_cpu_percent,
|
||||
);
|
||||
}
|
||||
let total = &report.desktop_total;
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"| **Desktop total** | **{}** | **{:.1} MiB** | **{:.1} MiB** | **{:.1}%** | **{:.1}%** |",
|
||||
total.peak_process_count,
|
||||
to_mib(total.mean_memory_bytes),
|
||||
to_mib(total.peak_memory_bytes),
|
||||
total.mean_cpu_percent,
|
||||
total.peak_cpu_percent,
|
||||
);
|
||||
let _ = writeln!(output);
|
||||
let _ = writeln!(output, "> {}", report.attribution_note);
|
||||
if !report.rust_cpu_modules.is_empty() {
|
||||
let _ = writeln!(output);
|
||||
let _ = writeln!(output, "| Rust module | recursive CPU samples |");
|
||||
let _ = writeln!(output, "| ----------- | --------------------- |");
|
||||
for module in report.rust_cpu_modules.iter().take(20) {
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"| {} | {} |",
|
||||
module.module, module.recursive_samples
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(error) = &report.cpu_stack_error {
|
||||
let _ = writeln!(output);
|
||||
let _ = writeln!(output, "CPU stack capture unavailable: {error}");
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn to_mib(bytes: u64) -> f64 {
|
||||
bytes as f64 / (1024.0 * 1024.0)
|
||||
}
|
||||
|
||||
fn parse_rust_module_cpu(contents: &str) -> Vec<RustModuleCpu> {
|
||||
let recursive_section = contents
|
||||
.split("Total number in stack (recursive counted multiple")
|
||||
.nth(1)
|
||||
.and_then(|tail| tail.split("Sort by top of stack").next())
|
||||
.unwrap_or("");
|
||||
let mut modules = BTreeMap::<String, u64>::new();
|
||||
for line in recursive_section.lines() {
|
||||
let trimmed = line.trim();
|
||||
let Some((count, symbol_line)) = trimmed.split_once(char::is_whitespace) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(count) = count.parse::<u64>() else {
|
||||
continue;
|
||||
};
|
||||
let symbol = symbol_line
|
||||
.split(" (in ")
|
||||
.next()
|
||||
.unwrap_or(symbol_line)
|
||||
.trim();
|
||||
let Some(module) = own_rust_module(symbol) else {
|
||||
continue;
|
||||
};
|
||||
*modules.entry(module).or_default() += count;
|
||||
}
|
||||
let mut modules = modules
|
||||
.into_iter()
|
||||
.map(|(module, recursive_samples)| RustModuleCpu {
|
||||
module,
|
||||
recursive_samples,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
modules.sort_by(|left, right| {
|
||||
right
|
||||
.recursive_samples
|
||||
.cmp(&left.recursive_samples)
|
||||
.then_with(|| left.module.cmp(&right.module))
|
||||
});
|
||||
modules
|
||||
}
|
||||
|
||||
fn own_rust_module(symbol: &str) -> Option<String> {
|
||||
const CORE_PREFIX: &str = "openhuman_core::openhuman::";
|
||||
const TAURI_PREFIX: &str = "openhuman::";
|
||||
if let Some(start) = symbol.find(CORE_PREFIX) {
|
||||
let tail = &symbol[start + CORE_PREFIX.len()..];
|
||||
let domain = tail
|
||||
.split("::")
|
||||
.next()?
|
||||
.trim_matches(|value| value == '<' || value == '>');
|
||||
if !domain.is_empty() {
|
||||
return Some(format!("{CORE_PREFIX}{domain}"));
|
||||
}
|
||||
}
|
||||
if let Some(start) = symbol.find(TAURI_PREFIX) {
|
||||
let tail = &symbol[start + TAURI_PREFIX.len()..];
|
||||
let module = tail
|
||||
.split("::")
|
||||
.next()?
|
||||
.trim_matches(|value| value == '<' || value == '>');
|
||||
if !module.is_empty() {
|
||||
return Some(format!("{TAURI_PREFIX}{module}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn start_stack_capture(
|
||||
pid: u32,
|
||||
duration: Duration,
|
||||
output: &Path,
|
||||
) -> (Option<Child>, Option<String>) {
|
||||
match Command::new("/usr/bin/sample")
|
||||
.arg(pid.to_string())
|
||||
.arg(duration.as_secs().max(1).to_string())
|
||||
.arg("10")
|
||||
.arg("-mayDie")
|
||||
.arg("-file")
|
||||
.arg(output)
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => (Some(child), None),
|
||||
Err(err) => (None, Some(format!("start /usr/bin/sample: {err}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn start_stack_capture(
|
||||
_pid: u32,
|
||||
_duration: Duration,
|
||||
_output: &Path,
|
||||
) -> (Option<Child>, Option<String>) {
|
||||
(
|
||||
None,
|
||||
Some("automatic stack capture is currently available on macOS only".into()),
|
||||
)
|
||||
}
|
||||
|
||||
fn finish_stack_capture(child: Option<&mut Child>, prior_error: Option<String>) -> Option<String> {
|
||||
if prior_error.is_some() {
|
||||
return prior_error;
|
||||
}
|
||||
let child = child?;
|
||||
match child.wait() {
|
||||
Ok(status) if status.success() => None,
|
||||
Ok(status) => Some(format!("stack sampler exited with {status}")),
|
||||
Err(err) => Some(format!("wait for stack sampler: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn process(
|
||||
pid: u32,
|
||||
parent_pid: Option<u32>,
|
||||
name: &str,
|
||||
command: &str,
|
||||
memory_bytes: u64,
|
||||
cpu_percent: f32,
|
||||
) -> ProcessSample {
|
||||
ProcessSample {
|
||||
pid,
|
||||
parent_pid,
|
||||
name: name.into(),
|
||||
command: command.into(),
|
||||
memory_bytes,
|
||||
cpu_percent,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_cef_roles() {
|
||||
assert_eq!(
|
||||
classify_process(
|
||||
&process(2, Some(1), "OpenHuman Helper", "--type=renderer", 1, 1.0),
|
||||
1
|
||||
),
|
||||
Component::CefRenderer
|
||||
);
|
||||
assert_eq!(
|
||||
classify_process(
|
||||
&process(3, Some(1), "OpenHuman Helper", "--type=gpu-process", 1, 1.0),
|
||||
1
|
||||
),
|
||||
Component::CefGpu
|
||||
);
|
||||
assert_eq!(
|
||||
classify_process(
|
||||
&process(4, Some(1), "OpenHuman Helper", "--type=utility", 1, 1.0),
|
||||
1
|
||||
),
|
||||
Component::CefUtility
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_only_host_process_tree() {
|
||||
let processes = vec![
|
||||
process(10, Some(1), "OpenHuman", "OpenHuman", 100, 20.0),
|
||||
process(11, Some(10), "Helper", "--type=renderer", 40, 30.0),
|
||||
process(12, Some(11), "Helper", "--type=utility", 10, 5.0),
|
||||
process(99, Some(1), "unrelated", "unrelated", 1_000, 100.0),
|
||||
];
|
||||
|
||||
let grouped = group_process_tree(10, &processes).unwrap();
|
||||
assert_eq!(grouped.len(), 3);
|
||||
assert_eq!(
|
||||
grouped.iter().map(|value| value.memory_bytes).sum::<u64>(),
|
||||
150
|
||||
);
|
||||
assert_eq!(
|
||||
grouped.iter().map(|value| value.cpu_percent).sum::<f32>(),
|
||||
55.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_separates_rust_binary_from_desktop_total() {
|
||||
let samples = vec![TimeSample {
|
||||
elapsed_ms: 250,
|
||||
components: vec![
|
||||
ComponentSample {
|
||||
component: Component::TauriHostAndEmbeddedCore,
|
||||
process_count: 1,
|
||||
memory_bytes: 100,
|
||||
cpu_percent: 20.0,
|
||||
},
|
||||
ComponentSample {
|
||||
component: Component::CefRenderer,
|
||||
process_count: 2,
|
||||
memory_bytes: 50,
|
||||
cpu_percent: 30.0,
|
||||
},
|
||||
],
|
||||
}];
|
||||
let report = build_report(ProfileCapture {
|
||||
host_pid: 10,
|
||||
duration: Duration::from_secs(1),
|
||||
interval: Duration::from_millis(250),
|
||||
logical_cpu_count: 8,
|
||||
samples,
|
||||
rust_cpu_modules: Vec::new(),
|
||||
cpu_stack_report: None,
|
||||
cpu_stack_error: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.rust_binary.mean_memory_bytes, 100);
|
||||
assert_eq!(report.desktop_total.mean_memory_bytes, 150);
|
||||
assert_eq!(report.desktop_total.peak_process_count, 3);
|
||||
assert!(render_markdown(&report).contains("Tauri host + embedded Rust core"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_requires_pid_and_clamps_cpu_interval() {
|
||||
assert!(parse_args(Vec::<String>::new()).is_err());
|
||||
let args = parse_args([
|
||||
"--pid".into(),
|
||||
"123".into(),
|
||||
"--duration".into(),
|
||||
"2".into(),
|
||||
"--interval-ms".into(),
|
||||
"1".into(),
|
||||
"--no-stacks".into(),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(args.pid, 123);
|
||||
assert_eq!(args.duration, Duration::from_secs(2));
|
||||
assert_eq!(args.interval, MINIMUM_CPU_UPDATE_INTERVAL);
|
||||
assert!(!args.capture_stacks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_recursive_stack_counts_into_openhuman_modules() {
|
||||
let sample = r#"
|
||||
Total number in stack (recursive counted multiple, when >=5):
|
||||
81 openhuman_core::openhuman::agent::run (in OpenHuman) + 10
|
||||
34 <openhuman_core::openhuman::agent::Tool as core::future::Future>::poll (in OpenHuman) + 2
|
||||
17 openhuman_core::openhuman::memory::search (in OpenHuman) + 4
|
||||
9 openhuman::core_process::ensure_running (in OpenHuman) + 1
|
||||
200 tokio::runtime::park (in OpenHuman) + 3
|
||||
|
||||
Sort by top of stack, same collapsed (when >= 5):
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_rust_module_cpu(sample),
|
||||
vec![
|
||||
RustModuleCpu {
|
||||
module: "openhuman_core::openhuman::agent".into(),
|
||||
recursive_samples: 115,
|
||||
},
|
||||
RustModuleCpu {
|
||||
module: "openhuman_core::openhuman::memory".into(),
|
||||
recursive_samples: 17,
|
||||
},
|
||||
RustModuleCpu {
|
||||
module: "openhuman::core_process".into(),
|
||||
recursive_samples: 9,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# Agent harness resource-footprint comparison
|
||||
|
||||
Date: 2026-07-22
|
||||
Method: web research against primary sources where they exist (GitHub repos and
|
||||
issue trackers, official docs), with every weakly-sourced figure flagged. Our
|
||||
own numbers come from the measured benchmarks in
|
||||
[`library-benchmarking.md`](library-benchmarking.md) and
|
||||
[`resource-profiling-session-2026-07-21.md`](resource-profiling-session-2026-07-21.md).
|
||||
|
||||
The single most important source-quality finding: **the only fully measured,
|
||||
reproducible numbers in this comparison are ours.** Codex publishes binary
|
||||
size but no RSS; ZeroClaw's numbers are vendor marketing with no third-party
|
||||
verification; Claude Code's dramatic figures are leak bugs, not steady state;
|
||||
Hermes' figure is self-reported documentation.
|
||||
|
||||
## Comparison table
|
||||
|
||||
| Harness | Language / runtime | Deployment shape | RAM idle | RAM under load | Startup | Binary / install | N-agent scaling | Source quality |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| **OpenHuman core** (ours) | Rust, embeddable library | Library or one RPC process; agents share the process | 44-51 MiB settled (default); 35-44 MiB slim | Cold turn +26-31 MiB (first-use); warm turn +0.5-1.9 MiB | ~100-140 ms cold turn; ~0 idle CPU | 116 MiB default / 81 MiB library-minimal / 60 MiB stripped | **In-process**: ~0.4 MiB/agent cold roster, ~1.8 MiB warm marginal | Measured, reproducible (this repo) |
|
||||
| OpenAI Codex CLI (codex-rs) | Rust, single native binary | CLI process per session | no published RSS | no published RSS (qualitative claims only) | "milliseconds" (qualitative) | **80 MB** (macOS arm64, primary: issue #13091) | N independent processes | Binary size primary; RSS unpublished |
|
||||
| Codex CLI (old Node/TS) | Node.js / V8 | CLI process per session | no published data | no published data | Node startup | npm + Node runtime | N processes | none published |
|
||||
| ZeroClaw | Rust, static binary | CLI + optional daemon | **< 5 MB (self-reported, unverified)** | **no verified figure** (the oft-quoted "7.8-12 MiB" has no locatable primary source) | "< 10 ms" (self-reported) | 3.4 MB (one page says ~8.8 MB — internally inconsistent) | "multiple concurrently", no numbers | Marketing only; provenance suspect (SEO domain cluster) |
|
||||
| OpenClaw (Clawdbot → Moltbot → OpenClaw) | TypeScript / Node.js | Local daemon + channel bridge | "> 1 GB" claimed only by competitor marketing | no neutral figure | slow (Node + heavy deps) | ~28 MB (per competitor comparison) | N processes | Rebrand history primary (TechCrunch/CNBC/Forbes); RAM figure biased |
|
||||
| Claude Code | Node.js / V8 CLI | CLI process per session | ~500 MB claimed (weak SEO source) | documented **leak bugs**: 400-500 MB/min idle growth, multi-GB, extremes 14-93 GB | Node startup | npm + Node runtime | N processes | Leak bugs primary (issues #67433, #28731, #22188); baseline weak |
|
||||
| Hermes Agent (Nous Research) | **Python 81% / TS 16%** (not Rust; it bundles the Rust-written `uv`) | CLI + gateway daemon; subagents are isolated subprocesses | no granular RSS; **4 GB RAM minimum** system req | "< 500 MB without a local LLM" (self-reported docs) | not published | Python 3.11 env | N subprocesses | Repo/languages primary; RAM self-reported |
|
||||
|
||||
## Per-harness notes
|
||||
|
||||
**OpenAI Codex CLI.** Confirmed Rust rewrite (~June 2025) shipping one
|
||||
self-contained binary. The only hard number is 80 MB binary size on macOS
|
||||
arm64, from OpenAI's own tracker (openai/codex#13091) — which proposes
|
||||
feature-gating heavy dependencies to reach ~55-60 MB, directly analogous to
|
||||
our Cargo domain gates. Memory claims are qualitative ("no unbounded Node heap
|
||||
growth"). Scope: coding agent only — no persistent curated cross-session
|
||||
memory core, no multi-agent orchestration, no channels, no workflow engine.
|
||||
|
||||
**ZeroClaw.** Rust single-binary positioned against OpenClaw. All numbers are
|
||||
vendor self-reported (`/usr/bin/time -l` on their own build) with zero
|
||||
third-party verification, promoted across a cluster of lookalike SEO domains.
|
||||
The "7.8-12 MiB under load" figure previously cited in our docs could not be
|
||||
found in any source and has been downgraded to unverified. ZeroClaw is a
|
||||
separate project from OpenClaw, not a rebrand.
|
||||
|
||||
**OpenClaw lineage.** The Clawdbot → Moltbot → OpenClaw rebrand chain is
|
||||
well-sourced (TechCrunch, CNBC, Forbes, Jan 2026). The ">1 GB RAM" figure
|
||||
appears only in ZeroClaw's competitive marketing; plausible for a Node daemon
|
||||
with browser automation, but there is no neutral benchmark.
|
||||
|
||||
**Claude Code.** Node/V8 CLI. No clean published idle baseline; ~500 MB comes
|
||||
from a third-party SEO article and leak-report starting points. What is
|
||||
well-documented (primary GitHub issues) is a family of off-heap RSS leak bugs:
|
||||
400-500 MB/min growth while idle (#67433), 14 GB OOM (#28731), 93 GB heap
|
||||
(#22188), idle CPU thrash (#18280). Those are bugs, not steady state — but
|
||||
they are a cautionary tale about native-buffer discipline in long-running
|
||||
Node agent processes.
|
||||
|
||||
**Hermes Agent.** The closest scope match to OpenHuman (SQLite + FTS5 + WAL
|
||||
curated memory, parent/child subagent lineage, cron, unified
|
||||
Telegram/Discord/Slack/Signal/WhatsApp/WeChat gateway) — and it is Python 81% /
|
||||
TypeScript 16%, not Rust. Subagents run as isolated subprocesses, so it pays
|
||||
its base footprint per agent. Self-reported "under 500 MB without a local
|
||||
LLM", 4 GB RAM minimum.
|
||||
|
||||
## What this means for OpenHuman
|
||||
|
||||
**Today.** Against honest scope-matched peers we are clearly leaner: Hermes at
|
||||
similar capability self-reports ~10x our settled RSS and requires 4 GB
|
||||
minimum; Claude Code starts around a claimed ~500 MB with documented multi-GB
|
||||
leaks; Codex's binary (80 MB) is larger than our stripped library-minimal
|
||||
build (60 MB). The only harness claiming to be dramatically smaller —
|
||||
ZeroClaw at "<5 MB" — is unverified marketing carrying far less capability.
|
||||
|
||||
**End-state.** The library-minimal + shared-services target (~15 MiB private
|
||||
footprint + ~2 MiB per in-process agent) is not a stretch goal: today's
|
||||
~42 MiB slim RSS already decomposes to 15.2 MiB private / 3.2 MiB live heap,
|
||||
the rest being reclaimable executable text and allocator high-water. State
|
||||
that with the RSS-vs-private-footprint caveat attached.
|
||||
|
||||
**Worth borrowing / leaning into:**
|
||||
|
||||
1. Feature-gating heavy deps is now industry practice (Codex #13091) —
|
||||
external validation of our domain-gate investment.
|
||||
2. Rust + single self-contained binary is the market direction; the
|
||||
Node-based peers are the ones with RSS horror stories.
|
||||
3. **In-process shared-services scaling is our differentiator.** Every
|
||||
scope-matched peer scales agents as N OS processes, paying the fixed base
|
||||
N times. Our ~0.4-1.8 MiB marginal per in-process agent is the entire
|
||||
basis of the 1000-agents-in-2-GB story; nobody else has it.
|
||||
4. Internalize (not borrow) Claude Code's leak history: keep the warmed
|
||||
repeated-turn plateau benchmark as a standing regression gate.
|
||||
|
||||
## Sources
|
||||
|
||||
- OpenAI codex#13091 — 80 MB binary / feature-gating proposal
|
||||
- devclass (2025-06) — Codex Rust rewrite announcement coverage
|
||||
- anthropics/claude-code#67433, #28731, #22188, #18280 — leak/idle-CPU bugs
|
||||
- zeroclaw.net; openclawconsult.com "lab" comparison (self-reported marketing)
|
||||
- TechCrunch / Forbes (2026-01) — OpenClaw rebrand lineage
|
||||
- github.com/nousresearch/hermes-agent — language split, architecture
|
||||
- hermes-agent.nousresearch.com docs — memory features, footprint claim
|
||||
@@ -0,0 +1,345 @@
|
||||
# Library benchmarking environment
|
||||
|
||||
## Purpose
|
||||
|
||||
"opencompany" wants to embed the OpenHuman Rust core as a library: no always-on
|
||||
RPC server, no Tauri shell, just the core linked in-process and driven
|
||||
directly. That changes what "resource usage" means. There is no single steady
|
||||
process to profile; there are per-use-case workloads (a long-running agent
|
||||
loop, a delegated multi-agent turn, a saved workflow run, a background
|
||||
subconscious pass, a memory ingest, a bare embed) that each have their own
|
||||
startup cost, steady-state footprint, and growth curve.
|
||||
|
||||
This document describes the benchmark environment built to measure that: a
|
||||
pinned `library-profile` binary with eight scenarios, four driver scripts
|
||||
under `scripts/profile/`, and the comparison point the team cares about
|
||||
(ZeroClaw). It builds on the manual investigation in
|
||||
[`docs/resource-profiling-session-2026-07-21.md`](resource-profiling-session-2026-07-21.md);
|
||||
read that document for the deep memory/CPU attribution work. This document is
|
||||
about running repeatable benchmarks, not re-deriving those findings.
|
||||
|
||||
## The eight scenarios
|
||||
|
||||
All scenarios run in `target/release/library-profile <scenario>`, replace
|
||||
network inference with a deterministic provider (`rss-bench` feature), and
|
||||
print one pretty-printed JSON result object to stdout (diagnostics go to
|
||||
stderr). Each models a distinct embedding use case:
|
||||
|
||||
| Scenario | Models |
|
||||
| --- | --- |
|
||||
| `memory-ingest` | Canonicalizing and ingesting a batch of chat messages through the real extraction/admission/tree-queue pipeline. |
|
||||
| `subagents` | A delegation turn: an orchestrator session spawns real subagents via `spawn_parallel_agents` and merges their findings. |
|
||||
| `agent-turn` | The minimal embed case: one agent, one turn, no delegation, no workflow. The smallest useful "hello world" for a host that just wants a single reply. |
|
||||
| `long-agent` | A long-running agent loop (`OPENHUMAN_PROFILE_TURNS`, default 25) in one process, to see whether RSS plateaus or grows per turn. |
|
||||
| `workflow` | A saved automation run (`flows_create` + `flows_run`), representing the flows/automation embedding path rather than ad hoc chat. |
|
||||
| `subconscious` | A background subconscious turn (the always-on reflective pass), distinct from an interactive chat turn. |
|
||||
| `cold-phases` | Bootstrap attribution: per-phase checkpoints (config load, registry init, agent build, memory construction, first turn) so cold-start cost can be attributed to a phase instead of one lump sum. |
|
||||
| `fleet` | N concurrent live agents with latency-realistic mock inference — the "100-1000 agents in a 2 GB / 2 vCPU server" question. See [below](#the-2-gb--2-vcpu-server-budget). |
|
||||
|
||||
## How to run
|
||||
|
||||
Five scripts under `scripts/profile/` (each has `-h`/`--help`):
|
||||
|
||||
- **`library-bench.sh`** — the primary RSS/duration benchmark. Builds the
|
||||
binaries, runs each scenario N fresh-process repeats (default 5), and
|
||||
aggregates median/min/max into `summary.json` + `summary.md`.
|
||||
|
||||
```bash
|
||||
./scripts/profile/library-bench.sh # default build, all scenarios
|
||||
./scripts/profile/library-bench.sh --slim # --no-default-features recipe
|
||||
./scripts/profile/library-bench.sh --scenarios "long-agent,subagents" --turns 50 --warm
|
||||
```
|
||||
|
||||
- **`library-cpu.sh`** — a `samply` wrapper for one scenario's CPU profile,
|
||||
isolated from persistence/timezone noise by default.
|
||||
|
||||
```bash
|
||||
./scripts/profile/library-cpu.sh subagents
|
||||
samply load target/profile/rust-library/subagents-cpu.json.gz
|
||||
```
|
||||
|
||||
- **`library-heap.sh`** — builds the `rss-bench-dhat` variant and runs a
|
||||
scenario under dhat for live-heap attribution (allocation sites, retained
|
||||
bytes). RSS/timing under dhat are perturbed; don't compare those numbers to
|
||||
`library-bench.sh` output.
|
||||
|
||||
```bash
|
||||
./scripts/profile/library-heap.sh memory-ingest
|
||||
# load target/profile/rust-library/dhat-memory-ingest.json at
|
||||
# https://nnethercote.github.io/dh_view/dh_view.html
|
||||
```
|
||||
|
||||
- **`library-fleet.sh`** — sweeps the `fleet` scenario across a list of agent
|
||||
counts and gates the result against the 2 GB / 2 vCPU server budget (see
|
||||
below).
|
||||
|
||||
```bash
|
||||
./scripts/profile/library-fleet.sh --agents 100 --latency-ms 200
|
||||
./scripts/profile/library-fleet.sh --agents "50,100,500" --target 1000 --budget-mib 2048
|
||||
```
|
||||
|
||||
- **`library-instances.sh`** — sweeps N independent *processes* (not agents
|
||||
in one process) of a scenario, held alive via `OPENHUMAN_PROFILE_HOLD_SECS`,
|
||||
and measures per-instance/aggregate cost — the many-processes counterpart
|
||||
to `library-fleet.sh`'s one-process model (see
|
||||
[below](#fleet-one-process-vs-instances-many-processes)).
|
||||
|
||||
```bash
|
||||
./scripts/profile/library-instances.sh --instances "10,25,50" --hold-secs 30
|
||||
```
|
||||
|
||||
### Default vs slim builds
|
||||
|
||||
Default-feature builds link every compile-time domain gate (`voice`, `web3`,
|
||||
`media`, `meet`, `skills`, `flows`, `mcp`, `tui`) — the byte-identical desktop
|
||||
recipe. The slim recipe drops everything not required by the harness:
|
||||
|
||||
```bash
|
||||
GGML_NATIVE=OFF cargo build --release \
|
||||
--no-default-features --features rss-bench \
|
||||
--bin library-profile --bin rss-bench
|
||||
```
|
||||
|
||||
`--slim` on `library-bench.sh` builds this recipe. Per the prior session,
|
||||
compile-time gates shrink the binary substantially but only move settled RSS
|
||||
by a few MiB — most of the RSS story is initialization and allocator
|
||||
behavior, not linked code size.
|
||||
|
||||
### Useful env knobs
|
||||
|
||||
| Variable | Effect |
|
||||
| --- | --- |
|
||||
| `OPENHUMAN_PROFILE_TURNS` | Turn count for `long-agent` (default 25). |
|
||||
| `OPENHUMAN_PROFILE_PREWARM_SUBAGENTS=1` | Run one warm-up turn before measuring (`subagents`/`subconscious`), isolating first-use cost from steady state. |
|
||||
| `OPENHUMAN_PROFILE_DISABLE_MEMORY_WRITES=1` | Disable `memory.auto_save` and episodic capture, isolating orchestration from persistence. |
|
||||
| `OPENHUMAN_PROFILE_FORCE_UTC=1` | Skip `iana_time_zone`/CoreFoundation timezone resolution. |
|
||||
| `OPENHUMAN_PROFILE_HOLD_SECS` / `HOLD_BEFORE_SECS` | Pause the process at settled/baseline state for external inspection (`vmmap`, `heap`, `malloc_history`, Instruments). |
|
||||
| `OPENHUMAN_PROFILE_DHAT_OUT` | Output path for dhat JSON (set by `library-heap.sh`). |
|
||||
|
||||
## Metrics and interpretation
|
||||
|
||||
Every run reports `baseline`/`settled`/`peak_rss_kib` (macOS: `proc_pid_rusage`
|
||||
RSS, `getrusage` peak, `proc_pidinfo` thread count), `retained_delta_kib`
|
||||
(settled minus baseline), `peak_delta_kib`, and `duration_ms`. `long-agent`
|
||||
additionally reports `checkpoints[]` so first-turn vs. last-turn growth is
|
||||
visible directly.
|
||||
|
||||
**RSS is not private heap.** The prior session's deep attribution found a
|
||||
~42 MiB slim-build snapshot broken down as roughly 15.2 MiB private physical
|
||||
footprint, 3.18 MiB live heap, 18.7 MiB resident executable text, and ~9.4 MiB
|
||||
of resident-but-mostly-inactive malloc pages (allocator high-water
|
||||
retention). See
|
||||
[`docs/resource-profiling-session-2026-07-21.md`](resource-profiling-session-2026-07-21.md#deep-memory-attribution)
|
||||
for the full breakdown, the executable-paging finding (a cold turn faults in
|
||||
~15 MiB of previously nonresident OpenHuman code), and the warmed-process
|
||||
control showing steady-state turns cost ~0.5-1.9 MiB once warm rather than
|
||||
the ~26-31 MiB a cold turn costs. Use `library-bench.sh` for the RSS/duration
|
||||
headline numbers, `library-cpu.sh` when CPU attribution is the question, and
|
||||
`library-heap.sh` only when RSS numbers need live-allocation attribution
|
||||
(accepting the dhat perturbation).
|
||||
|
||||
## The ZeroClaw comparison
|
||||
|
||||
ZeroClaw self-reports idling under 5 MiB RAM; the "7.8-12 MiB under load"
|
||||
figure sometimes quoted alongside it has no locatable primary source, and even
|
||||
the idle figure is vendor marketing with no third-party verification (see
|
||||
[`docs/harness-comparison-2026-07-22.md`](harness-comparison-2026-07-22.md)).
|
||||
OpenHuman's Rust core currently settles around 35-50 MiB depending on
|
||||
scenario and feature set (see the baseline table below).
|
||||
|
||||
Treat this as a **north star, not an apples-to-apples benchmark**. ZeroClaw's
|
||||
scope and feature set differ substantially from the OpenHuman core: OpenHuman
|
||||
links a full agent/memory/tool/orchestration stack (SQLite-backed unified
|
||||
memory, TinyCortex PII detection, prompt-injection detection, a builtin-agent
|
||||
registry, tool catalogs, provider routing) that a narrower harness may not
|
||||
carry at all. A closer gap is a meaningful signal that the initialization
|
||||
graph is leaner; it is not evidence of feature parity, and a wider gap is not
|
||||
automatically a regression if it comes from carrying more capability. Every
|
||||
`library-bench.sh` summary includes a labeled comparison row/note for exactly
|
||||
this reason: visible, but explicitly called out as external.
|
||||
|
||||
## The 2 GB / 2 vCPU server budget
|
||||
|
||||
"opencompany" wants a single server to host 100-1000 live agents inside a 2 GB
|
||||
RAM / 2 vCPU box. That is a budget question, not a per-scenario RSS question:
|
||||
2048 MiB / 1000 agents is roughly 2 MiB per agent all-in, but the fixed
|
||||
per-process base (allocator high water, code paging, registries, detectors —
|
||||
the same ~30 MiB every scenario above pays once) amortizes across however many
|
||||
agents share the process. What actually determines whether 1000 agents fit is
|
||||
the **marginal** cost per additional agent once that base is paid, not the
|
||||
per-agent average. `library-fleet.sh` runs the `fleet` scenario (N concurrent
|
||||
live agents, latency-realistic mock inference so idle time looks like real
|
||||
network waits rather than a busy loop) across a sweep of N and reports that
|
||||
marginal cost directly (`marginal_rss_kib_per_agent`), alongside idle CPU over
|
||||
a parked 10s window, thread count, and open FD count — all of which should
|
||||
stay roughly flat as N grows if per-agent state is cheap and idle agents cost
|
||||
~zero CPU.
|
||||
|
||||
Working targets: marginal cost ≤ 1.5 MiB/agent, threads and FDs flat (not
|
||||
linear) in N, and idle CPU low regardless of N — an agent that isn't mid-turn
|
||||
should not be spending cycles. `OPENHUMAN_PROFILE_WORKER_THREADS=2` pins the
|
||||
scenario's tokio runtime to 2 worker threads to simulate the 2 vCPU box rather
|
||||
than scaling with the host's actual core count. The `budget` block in each
|
||||
run's JSON (`target_agents`, `ram_budget_mib`, `projected_rss_mib_at_target`,
|
||||
`fits`) projects the swept marginal cost out to the real target (default 1000
|
||||
agents / 2048 MiB); `library-fleet.sh` aggregates medians per N into
|
||||
`summary.md` and exits nonzero if any swept N projects `fits: false`, making
|
||||
it usable as a CI-style regression gate (`--no-gate` to disable).
|
||||
|
||||
**Caveats, stated plainly:** these numbers are gathered on macOS, which has no
|
||||
cgroup memory limit to enforce or observe locally — the budget check is a
|
||||
projection from measured marginal cost, not a live "did it actually get
|
||||
OOM-killed at N agents" test. macOS also lacks Linux's `/proc/<pid>/smaps_rollup`,
|
||||
which would give true PSS (proportional shared memory) instead of RSS; RSS
|
||||
overcounts shared pages (executable text, shared library mappings) in a way
|
||||
that matters more as agent count grows and more of the process footprint is
|
||||
genuinely shared. Treat the macOS numbers as an approximation of the target
|
||||
Linux server, not a substitute for it. The JSON schema already has a Linux
|
||||
path — `proc_metrics` reads `/proc/<pid>/status` and `/proc/<pid>/stat` on
|
||||
Linux — so true validation should eventually mean running the same
|
||||
`library-profile fleet` binary on a cgroup-limited Linux box (matching the 2
|
||||
vCPU / 2 GB target) rather than trusting the macOS projection alone.
|
||||
|
||||
### Fleet (one process) vs instances (many processes)
|
||||
|
||||
The budget section above measures one deployment shape: N agents sharing a
|
||||
single process. But "opencompany" may instead run OpenHuman as **N
|
||||
independent processes or containers** — one per tenant — rather than N
|
||||
agents inside one process. Those are different cost models and the fleet
|
||||
number does not answer the second one.
|
||||
|
||||
- **Fleet (`library-fleet.sh`)** pays the ~30-50 MiB fixed base (allocator
|
||||
high water, code paging, registries, detectors) **once**, and amortizes it
|
||||
across however many agents share that process. Marginal cost per agent is
|
||||
what matters, and it can be well under 1 MiB once the base is paid.
|
||||
- **Instances (`library-instances.sh`)** pays that same fixed base **N
|
||||
times**, once per process — minus whatever the OS actually shares across
|
||||
processes (resident executable text, shared library mappings). Summed RSS
|
||||
across instances therefore **double-counts** those shared pages; it is an
|
||||
upper bound, not the true footprint. True per-instance marginal cost is
|
||||
better read from summed PSS (Linux only — macOS has no PSS-equivalent
|
||||
metric), which divides shared pages across the processes that share them.
|
||||
|
||||
`library-instances.sh` spawns N held `library-profile` processes staggered
|
||||
on startup, samples aggregate sum-RSS every 2s while they hold at settled
|
||||
state, and reports median settled RSS/instance, mean and peak aggregate
|
||||
sum-RSS, and summed PSS when available, plus a labeled 2 GB-box
|
||||
extrapolation estimate:
|
||||
|
||||
```bash
|
||||
./scripts/profile/library-instances.sh --instances "10,25,50" --hold-secs 30
|
||||
```
|
||||
|
||||
**This is still a macOS proxy, not container validation.** True validation
|
||||
means running the same binary under real `cgroup` memory limits (e.g.
|
||||
`docker run --memory=2g`) on a Linux host and observing whether it survives
|
||||
or gets OOM-killed at the target instance count — not projecting from local
|
||||
sum-RSS. That is follow-up work, and it belongs on a Linux box: this repo's
|
||||
own `openhuman-core` Docker build is currently blocked on Apple Silicon (the
|
||||
`whisper-rs-sys`/whisper.cpp NEON fp16 intrinsics fail to compile under
|
||||
arm64-Linux emulation with GCC 12 — see the umbrella repo's root `CLAUDE.md`
|
||||
gotchas and `docs/resource-profiling-session-2026-07-21.md`). The path
|
||||
around that blocker is either building for `linux/amd64` under emulation (the
|
||||
whisper AVX path has no NEON bug) or running the validation on a native Linux
|
||||
host rather than macOS Docker Desktop.
|
||||
|
||||
## Profiling escalation path
|
||||
|
||||
Start cheap, escalate only as needed:
|
||||
|
||||
1. **`library-bench.sh`** — RSS/duration medians across fresh processes. Answers "did this change move the needle" for most changes.
|
||||
2. **`library-cpu.sh` (samply)** — symbolized CPU profile when a scenario is slower than expected, or to attribute cold-path CPU to a specific phase (registry init, agent build, memory construction, SQLite init, TinyAgents turn runner were the top contributors in the prior session).
|
||||
3. **`library-heap.sh` (dhat)** — live-heap allocation sites and retained bytes when RSS is high but the cause isn't obvious from CPU alone (e.g. the TinyCortex PII `RegexSet` finding came from stack-logged allocation attribution, not CPU sampling).
|
||||
4. **Instruments / `vmmap` / `heap` / `malloc_history`** — deepest macOS-native attribution, using the `OPENHUMAN_PROFILE_HOLD_SECS` / `HOLD_BEFORE_SECS` hooks to pause the process at baseline or settled state:
|
||||
|
||||
```bash
|
||||
OPENHUMAN_PROFILE_HOLD_SECS=120 target/release/library-profile subagents &
|
||||
vmmap -summary <pid>
|
||||
heap -sH <pid>
|
||||
|
||||
MallocStackLogging=1 OPENHUMAN_PROFILE_HOLD_SECS=120 \
|
||||
target/release/library-profile subagents &
|
||||
malloc_history <pid> -allBySize
|
||||
```
|
||||
|
||||
This is what surfaced the PII-sanitizer regex cache and the first-turn
|
||||
executable-paging finding in the prior session; reach for it only once
|
||||
`library-bench.sh`/`library-cpu.sh`/`library-heap.sh` have narrowed the
|
||||
question to a specific scenario and metric.
|
||||
|
||||
## Current baseline numbers
|
||||
|
||||
From the 2026-07-21 profiling session (medians over five fresh processes
|
||||
unless noted; see that document for methodology and caveats):
|
||||
|
||||
| Scenario | Build | Median settled RSS | Median retained Δ |
|
||||
| --- | --- | ---: | ---: |
|
||||
| 1-agent roster | default | 38.7 MiB | - |
|
||||
| 8-agent roster | default | 41.5 MiB | +2.8 MiB total (~0.40 MiB/agent) |
|
||||
| 1-agent roster | slim | 35.5 MiB | - |
|
||||
| 8-agent roster | slim | 38.7 MiB | +3.2 MiB total |
|
||||
| `memory-ingest` (100 msgs) | default | 25.5 MiB | 9.31 MiB |
|
||||
| `memory-ingest` (100 msgs) | slim | 23.8 MiB | 8.58 MiB |
|
||||
| `subagents` (cold, 2 children) | default | 48.5 MiB | 30.8 MiB |
|
||||
| `subagents` (cold, 2 children) | slim | 42.4 MiB | 25.7 MiB |
|
||||
| `subagents` (warmed repeat, persistence off) | default | - | 0.52 MiB |
|
||||
| `subagents` (warmed repeat, normal capture) | default | - | 1.84 MiB |
|
||||
| ZeroClaw (external, idle, self-reported/unverified) | - | < 5 MiB | - |
|
||||
|
||||
First full `library-bench.sh` run of the new scenarios (default build, 5
|
||||
fresh-process repeats, 2026-07-21, Apple Silicon macOS):
|
||||
|
||||
| Scenario | Build | Median settled RSS | Median retained Δ | Median duration |
|
||||
| --- | --- | ---: | ---: | ---: |
|
||||
| `agent-turn` (cold, 1 turn) | default | 47.6 MiB | 29.5 MiB | 102 ms |
|
||||
| `subconscious` (cold, no delegation) | default | 47.9 MiB | 29.8 MiB | 138 ms |
|
||||
| `subagents` (cold, 2 children) | default | 48.0 MiB | 29.9 MiB | 142 ms |
|
||||
| `workflow` (`flows_create` + `flows_run`) | default | 50.9 MiB | 29.9 MiB | 110 ms |
|
||||
| `long-agent` (25 warmed turns) | default | 65.8 MiB | 18.5 MiB | 1,361 ms |
|
||||
| `cold-phases` (9 bootstrap phases) | default | 51.2 MiB | 36.5 MiB | 476 ms |
|
||||
| `memory-ingest` (100 msgs) | default | 25.8 MiB | 9.3 MiB | 2,099 ms |
|
||||
|
||||
Notable structure behind these medians:
|
||||
|
||||
- The `long-agent` per-turn series plateaus: typical turns add 30-150 KiB,
|
||||
and the 25-turn total (~16.8 MiB first-to-last) is dominated by two async
|
||||
persistence/compaction bursts of 6-8 MiB each, matching the prior session's
|
||||
warmed-repeat outlier observation. Steady-state growth is not linear.
|
||||
- Cold `agent-turn`, `subconscious`, `subagents`, and `workflow` all retain
|
||||
approximately the same ~29-30 MiB, confirming the cost is shared bootstrap
|
||||
(code paging, registries, detectors, allocator high water), not the
|
||||
specific workload on top of it.
|
||||
- A dhat run of `agent-turn` measured 33.4 MB total allocated across 135,756
|
||||
blocks, but only 5.0 MB peak live heap and 3.1 MB live at exit, again
|
||||
showing RSS is mostly not live heap data.
|
||||
|
||||
### Fleet, instances, and runtime baselines (2026-07-22, post-PII-prefilter)
|
||||
|
||||
`library-fleet.sh` sweep (default build, 3 repeats, 3 turns/agent, 200 ms
|
||||
mock latency, 2 worker threads, target 1000 agents / 2048 MiB):
|
||||
|
||||
| N agents | Marginal KiB/agent | Settled MiB | Idle CPU ms/10s | Threads | fds | p95 turn ms | Projected MiB @1000 | Fits |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | :---: |
|
||||
| 50 | 1,985 | 223 | 3 | 71 | 420 | 2,848 | 1,956 | yes |
|
||||
| 100 | 1,866 | 356 | 3 | 123 | 820 | 5,402 | 1,840 | yes |
|
||||
| 500 | 1,770 | 1,393 | 3 | 211 | 3,220 | 25,484 | 1,747 | yes |
|
||||
|
||||
`library-instances.sh` (many-processes model, `agent-turn` held 20 s):
|
||||
~47.8-48.2 MiB per instance flat at N=10/25/50 → roughly 42 instances per
|
||||
2 GiB by sum-RSS (upper bound; macOS has no PSS). The one-process fleet
|
||||
model is ~25x denser than the per-process model.
|
||||
|
||||
Runtime/subagent scenarios: `skill-run` measures a real `node` child at
|
||||
~72-75 MB RSS (~121 MB process tree vs ~51 MB self) — the basis of the
|
||||
runtime-pooling issue (tinyhumansai/openhuman#5106); `subagent-storm` shows
|
||||
~0.78 MiB marginal per additional parallel subagent (K=8→32 cross-width).
|
||||
|
||||
Watch-items from the sweep: thread count grows ~0.35/agent (needs
|
||||
attribution + cap before real 1000-agent runs), and p95 latency at N=500 on
|
||||
2 workers shows CPU saturation is the load constraint, not memory.
|
||||
|
||||
## See also
|
||||
|
||||
- [`docs/resource-profiling-session-2026-07-21.md`](resource-profiling-session-2026-07-21.md) — the full manual investigation (deep attribution, cold-path CPU, library-design implications, recommended optimization order).
|
||||
- [`scripts/profile/README.md`](../scripts/profile/README.md) — script quick reference.
|
||||
- `src/bin/library_profile/main.rs` — the scenario implementations.
|
||||
@@ -0,0 +1,246 @@
|
||||
# Library-minimal feature recipe
|
||||
|
||||
A **supported, measured** compile-time feature recipe for embedding the OpenHuman
|
||||
Rust core as a library in "opencompany" — headless, no RPC server, no Tauri
|
||||
shell, targeting 100-1000 live agents in a 2 GB RAM / 2 vCPU box.
|
||||
|
||||
It follows the repo's existing slim convention (`cargo build --no-default-features
|
||||
--features "<explicit list>"`, see AGENTS.md "Compile-time domain gates") and keeps
|
||||
only the domains the opencompany use cases actually exercise: **agent turns,
|
||||
subagent delegation, memory ingest, workflow (flows) runs, and python/js skill
|
||||
execution.**
|
||||
|
||||
## The build command
|
||||
|
||||
Opencompany recipe (production embed — no benchmark/harness code):
|
||||
|
||||
```bash
|
||||
GGML_NATIVE=OFF cargo build --release \
|
||||
--no-default-features --features "skills,flows"
|
||||
```
|
||||
|
||||
- `GGML_NATIVE=OFF` is the Apple-Silicon dev workaround for whisper-rs/llama; on
|
||||
the x86-64 Linux target it is unnecessary (the always-on whisper build uses the
|
||||
AVX path). Keep it in the command for macOS developers.
|
||||
- To build the profiling harness against the same recipe, add the dev-only
|
||||
`rss-bench` feature and the two bench bins:
|
||||
|
||||
```bash
|
||||
GGML_NATIVE=OFF cargo build --release \
|
||||
--no-default-features --features "rss-bench,skills,flows" \
|
||||
--bin library-profile --bin rss-bench
|
||||
```
|
||||
|
||||
There is **no** `library-minimal` meta-feature in `Cargo.toml`, on purpose — see
|
||||
[Why no alias](#why-no-cargotoml-alias) below.
|
||||
|
||||
## Keep / drop table
|
||||
|
||||
`default = ["tokenjuice-treesitter","voice","web3","media","meet","skills","flows","mcp","desktop-automation","tui"]`
|
||||
|
||||
| Gate | Default | Decision | Why | Deps shed |
|
||||
| --- | :---: | :---: | --- | --- |
|
||||
| `skills` | ON | **KEEP** | python/js `SKILL.md` execution is a stated opencompany use case | none (surface/prompt/startup only) |
|
||||
| `flows` | ON | **KEEP** | saved-workflow (`flows_create`+`flows_run`) runs are a stated use case | — (adds `tinyflows`, `jaq-*`, `rhai`; see cost note) |
|
||||
| `tokenjuice-treesitter` | ON | **DROP** | AST-aware code compression → gracefully falls back to the brace-depth heuristic. Functional, only compression *quality* degrades. | tree-sitter Rust/TS/Python grammars + their C build |
|
||||
| `voice` | ON | **DROP** | STT/TTS/dictation/podcast — a headless host does no audio I/O | `hound`, `lettre` |
|
||||
| `web3` | ON | **DROP** | crypto wallet / swap / x402 machine payments — not an opencompany path | `bitcoin`, `curve25519-dalek` |
|
||||
| `media` | ON | **DROP** | `media_generate_*` image/video tools — surface-only | none (backend-proxied) |
|
||||
| `meet` | ON | **DROP** | Google-Meet join/live-STT/TTS bot — no headless use | none |
|
||||
| `mcp` | ON | **DROP** | MCP stdio/HTTP server + Smithery registry (~20k LOC, ~19 tools) — a library host is not an MCP host | none (hand-rolled over tokio/reqwest/axum) |
|
||||
| `desktop-automation` | ON | **DROP** | AX / screen-capture / `computer` tool family drives a **local desktop UI** — meaningless headless | `uiautomation` |
|
||||
| `tui` | ON | **DROP** | `openhuman tui`/`chat` terminal UI — no terminal in a library host | `ratatui`, `crossterm`, `unicode-width` |
|
||||
|
||||
**Non-default optional features** (`sandbox-landlock`, `sandbox-bubblewrap`,
|
||||
`peripheral-rpi`, `browser-native`/`fantoccini`, `landlock`, `whatsapp-web`,
|
||||
`e2e-test-support`, `rss-bench`, `rss-bench-dhat`) are all default-OFF, so a
|
||||
`--no-default-features` build never links them unless explicitly added. None are
|
||||
needed for opencompany; `rss-bench`/`rss-bench-dhat` are dev/benchmark-only.
|
||||
|
||||
### On `tokenjuice-treesitter`
|
||||
|
||||
This is the one judgment call. Dropping it removes the largest *native C build*
|
||||
in the domain gates (three tree-sitter grammars) and shrinks the binary, at the
|
||||
cost of coarser code-context compression (brace-depth heuristic instead of AST).
|
||||
For a memory/binary-minimal library host it is dropped here. **If token budget
|
||||
per agent turn matters more than binary size, add `tokenjuice-treesitter` back**
|
||||
— it sheds no runtime behavior beyond compression fidelity.
|
||||
|
||||
## Measured results
|
||||
|
||||
All numbers gathered on this branch, Apple-Silicon macOS, `--release` profile
|
||||
(`optimized + debuginfo`). "default" = the prior 2026-07-21 session baselines in
|
||||
[`docs/library-benchmarking.md`](library-benchmarking.md); "pure slim" =
|
||||
`--no-default-features --features rss-bench` (drops everything). Both slim numbers
|
||||
were reproduced on this machine and match the prior doc exactly (68.4 MiB).
|
||||
|
||||
### Binary size
|
||||
|
||||
| Build | Features | Unstripped | Stripped |
|
||||
| --- | --- | ---: | ---: |
|
||||
| default | (all gates) | 115.9 MiB¹ | — |
|
||||
| **library-minimal** | `skills,flows` | **~81.1 MiB** | **~60.4 MiB** |
|
||||
| pure slim | (none) | 68.4 MiB | 51.0 MiB |
|
||||
|
||||
¹ from the prior session (unstripped, same profile). library-minimal bins measured
|
||||
directly: `rss-bench` 81.1 MiB, `library-profile` 83.0 MiB unstripped (the extra
|
||||
~2 MiB is the harness itself). The domain recipe (`skills,flows`, no `rss-bench`)
|
||||
matches the `rss-bench` figure — the bench feature adds negligible code.
|
||||
|
||||
- **library-minimal vs default: -34.8 MiB (~30% smaller)**, and a correspondingly
|
||||
narrower code-paging surface (the dominant cold-turn RSS factor per the prior
|
||||
session's executable-paging finding).
|
||||
- **library-minimal vs pure slim: +12.7 MiB unstripped / +9.4 MiB stripped — all
|
||||
of it `flows`.** `cargo tree` confirms the delta is `rhai 1.25` + `rhai_codegen`
|
||||
+ `jaq-core/std/json` + `tinyflows`; `skills` sheds **zero** deps (its value is
|
||||
tool-surface/prompt/startup, not size). `flows` is by far the most expensive
|
||||
domain we *keep* — see follow-up #2.
|
||||
|
||||
### Per-scenario RSS (5 fresh-process repeats, median, `OPENHUMAN_PROFILE_FORCE_UTC=1`)
|
||||
|
||||
| Scenario | minimal settled | minimal retained Δ | default settled² | default retained² | Δ settled |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| `agent-turn` (cold, 1 turn) | 44.0 MiB | 26.6 MiB | 47.6 MiB | 29.5 MiB | **-3.6 MiB** |
|
||||
| `subagents` (cold, 2 children) | 44.5 MiB | 27.1 MiB | 48.0 MiB | 29.9 MiB | **-3.5 MiB** |
|
||||
| `workflow` (`flows_create`+`flows_run`) | 46.2 MiB | 26.0 MiB | 50.9 MiB | 29.9 MiB | **-4.7 MiB** |
|
||||
| `memory-ingest` (100 msgs) | 24.7 MiB | 8.8 MiB | 25.8 MiB | 9.3 MiB | **-1.1 MiB** |
|
||||
| `long-agent` (10 turns) | 46.4 MiB | 2.9 MiB | — (25-turn: 65.8 MiB) | — | n/a³ |
|
||||
|
||||
² default column from `docs/library-benchmarking.md` (2026-07-21). Those medians
|
||||
may not have used `OPENHUMAN_PROFILE_FORCE_UTC=1`, so treat the Δ as approximate
|
||||
(±~1 MiB). The direction and magnitude match the prior session's "slim saves
|
||||
~3.2 MiB settled RSS" finding.
|
||||
|
||||
³ `long-agent` was run at 10 turns here vs 25 in the default baseline, so the
|
||||
absolute settled figures aren't comparable. The low 2.9 MiB retained Δ confirms
|
||||
per-turn growth plateaus (matches the prior "not linear" observation).
|
||||
|
||||
**Takeaway (consistent with the prior session):** compile-time gates shrink the
|
||||
*binary* substantially (-30%) but move *settled RSS* by only ~3-5 MiB per
|
||||
scenario. Most of the RSS story is initialization + allocator high-water, not
|
||||
linked code size. The binary/code-paging win is the primary reason to prefer this
|
||||
recipe; the RSS win is real but secondary.
|
||||
|
||||
## What is functionally absent in this build
|
||||
|
||||
Summarized from the per-gate behavior notes in AGENTS.md. Dropped domains fail
|
||||
*closed and cleanly* — controllers become unknown-method, tools are simply absent
|
||||
from the tool list (not degraded to runtime errors), CLI subcommands report a
|
||||
build-fact error:
|
||||
|
||||
- **voice/audio:** voice + audio controllers unregistered (unknown-method over
|
||||
RPC, absent from `/schema`); `audio_generate_podcast` tools absent; `openhuman
|
||||
voice` returns "voice disabled".
|
||||
- **web3:** wallet / web3 / x402 controllers unregistered; swap/bridge/dapp agent
|
||||
tools absent; the x402 402-retry path returns unpaid; tinyplace on-chain
|
||||
payments + Polymarket *writes* degrade to graceful "wallet disabled" errors
|
||||
(tinyplace comms + ed25519 signing are unaffected).
|
||||
- **media:** `media_generate_*` agent tools absent.
|
||||
- **meet:** meet controllers unregistered; live Meet bot / STT-LLM-TTS loop absent.
|
||||
- **mcp:** `mcp_server` / `mcp_registry` (`mcp_clients` namespace) / `mcp_audit`
|
||||
controllers unknown-method; ~19 MCP agent tools absent; `openhuman mcp` CLI
|
||||
returns a "rebuild with --features mcp" build-fact error. (`McpHttpClient` +
|
||||
`sanitize` stay compiled — the gitbooks docs tool and the orchestrator prompt
|
||||
sanitizer still work.)
|
||||
- **desktop-automation:** `accessibility` / `screen_intelligence` / `autocomplete`
|
||||
/ `desktop_companion` domains + the `computer` tool family (`ax_interact`,
|
||||
`automate`, mouse/keyboard) absent.
|
||||
- **tui:** `openhuman tui` / `chat` returns "tui feature disabled at compile time".
|
||||
- **tokenjuice-treesitter:** code compression falls back to the brace-depth
|
||||
heuristic — degraded fidelity, not absent.
|
||||
|
||||
Everything the opencompany use cases need remains: the agent harness + turn
|
||||
runner, subagent delegation (`spawn_parallel_agents`), the full memory stack
|
||||
(TinyCortex store/tree/queue/ingest + PII/injection detectors), threads, config,
|
||||
security policy, provider routing/inference, `skills` (SKILL.md discovery/install
|
||||
+ node/python execution + `run_workflow`/`await_workflow`), and `flows` (saved
|
||||
graph create/run/schedule + `workflow_builder`/`flow_discovery` agents).
|
||||
|
||||
## Test verification
|
||||
|
||||
The disabled-build test gotcha (AGENTS.md: CI's smoke lane runs `cargo check`
|
||||
only and never compiles `--no-default-features` test code) was checked directly:
|
||||
|
||||
```bash
|
||||
GGML_NATIVE=OFF cargo test --lib --no-default-features --features "skills,flows" core::
|
||||
# result: ok. 660 passed; 0 failed; 1 ignored; 10513 filtered out
|
||||
```
|
||||
|
||||
The both-ways gate tests in `src/core/all_tests.rs` (which assert dropped domains
|
||||
become unknown-method) pass under this recipe. No pre-existing failures.
|
||||
|
||||
## CI note
|
||||
|
||||
Nothing is added to the `default` feature list — this is a **subtractive**
|
||||
`--no-default-features` recipe, not a new default-ON gate. The **Feature
|
||||
Forwarding Gate** (`scripts/ci/check-feature-forwarding.mjs`) only inspects the
|
||||
`default` list and its forwarding into the desktop shell's `Cargo.toml`, so it
|
||||
**does not apply** here: there is nothing to forward. This recipe carries no CI
|
||||
risk and needs no `INTENTIONALLY_NOT_FORWARDED` entry.
|
||||
|
||||
## Why no `Cargo.toml` alias
|
||||
|
||||
The repo convention (AGENTS.md "Slim-profile convention") is deliberate: **no
|
||||
`full` meta-feature; build slim variants with an explicit feature list.** A
|
||||
`library-minimal = ["skills","flows"]` alias would be convenient, but it:
|
||||
|
||||
- duplicates the `default` list's maintenance burden — a new default-ON gate that
|
||||
opencompany *should* pick up would silently be missing from a frozen alias
|
||||
(the exact failure mode the "no meta-feature" rule exists to avoid), and
|
||||
- hides the subtractive intent behind a name, making the drop set invisible at
|
||||
the call site.
|
||||
|
||||
**Recommendation: document the explicit list (this file), do not add the alias.**
|
||||
If maintainers later decide an alias is worth it, the minimal-drift option is to
|
||||
express it *subtractively* in tooling rather than as a frozen additive list —
|
||||
but that is a follow-up decision, not part of this recipe.
|
||||
|
||||
## Follow-up shed list (ranked)
|
||||
|
||||
Largest remaining always-on costs a headless library host does not need. These
|
||||
are **not implemented here** — they require new gates/refactors — listed for
|
||||
prioritization.
|
||||
|
||||
1. **`inference` gate → shed `whisper-rs` + `whisper-rs-sys` (+ `cpal`/`coreaudio`).**
|
||||
`whisper-rs-sys` statically links the whisper.cpp + GGML C++ inference library
|
||||
— the single largest always-on *native* chunk in the binary and the reason for
|
||||
the `GGML_NATIVE=OFF` build dance. `cargo tree` confirms `whisper-rs 0.16` is a
|
||||
**direct always-on dependency of `openhuman`** (not gated by `voice`, per the
|
||||
AGENTS.md scope note), pulling `whisper-rs-sys 0.15`; `cpal 0.15` + `coreaudio`
|
||||
ride alongside for audio capture. A headless library host does no local STT, so
|
||||
an `inference` gate would shed all of this — the biggest remaining binary +
|
||||
native-build win by far. Bonus: `cpal` is shared only with `accessibility`,
|
||||
which `desktop-automation` (already dropped here) owns — so with this recipe,
|
||||
`cpal` becomes sheddable the moment the inference gate lands.
|
||||
*(No `llama`/`candle`/`tokenizers`/`onnx` crates appear in the recipe's tree, so
|
||||
the local-LLM path is either already optional or absent — whisper is the target.)*
|
||||
|
||||
2. **Split `rhai` out of the `flows` gate.** `flows` is the most expensive domain
|
||||
we *keep* (+12.7 MiB, dominated by `rhai 1.25` — a full scripting engine).
|
||||
`rhai` arrives only via `tinyagents/repl`, which powers the `.ragsh`
|
||||
language-workflow tool (`rhai_workflows`). If opencompany needs `tinyflows`
|
||||
saved-graph runs but **not** the `.ragsh` rhai tool, splitting `rhai_workflows`
|
||||
into its own sub-gate would reclaim most of that 12.7 MiB while keeping the
|
||||
flows graph engine. Currently all-or-nothing.
|
||||
|
||||
3. **`git2` (vendored libgit2).** Always-on native dependency of the `memory_diff`
|
||||
change-ledger (git-backed snapshots/checkpoints/diffs). A large vendored C lib.
|
||||
If a library host does not need git-backed memory diffs, this is a candidate for
|
||||
a future gate.
|
||||
|
||||
4. **`reqwest` dual TLS backends.** The root `reqwest` enables both `rustls-tls`
|
||||
**and** `native-tls` — two full TLS stacks linked simultaneously. A headless
|
||||
host on a known target could pick one, shedding the other.
|
||||
|
||||
5. **Node/Python runtime bootstrap deps** (`tar`, `xz2`+liblzma, `zip`, `flate2`).
|
||||
Only needed if `skills`/`flows` actually execute node/python workloads; kept
|
||||
here because `skills` is on. If a deployment runs only pure-LLM skills, these
|
||||
archive/decompression deps become sheddable.
|
||||
|
||||
## See also
|
||||
|
||||
- [`docs/library-benchmarking.md`](library-benchmarking.md) — the benchmark
|
||||
environment, scenario definitions, and default/slim baselines.
|
||||
- [`docs/resource-profiling-session-2026-07-21.md`](resource-profiling-session-2026-07-21.md)
|
||||
— deep memory/CPU attribution (why RSS is mostly not live heap).
|
||||
- AGENTS.md "Compile-time domain gates" — the per-gate behavior and dependency notes.
|
||||
@@ -0,0 +1,503 @@
|
||||
# OpenHuman resource profiling session
|
||||
|
||||
Date: 2026-07-21
|
||||
Platform: Apple Silicon macOS 26.5.1
|
||||
Worktree: `worktrees/tauri-resource-profiler`
|
||||
Primary question: What CPU and RAM does OpenHuman consume, which components account for it, and what would it take to use the Rust core as an efficient embedded library?
|
||||
|
||||
## Executive summary
|
||||
|
||||
The desktop application's large footprint is primarily outside the Rust core. The full Tauri/CEF process family measured about 1.2-1.4 GiB depending on CEF prewarming and spaCy. Disabling CEF prewarming saved about 86 MiB, and disabling spaCy after that saved another 146 MiB.
|
||||
|
||||
The Rust core is much smaller:
|
||||
|
||||
- A warmed one-agent roster used 38.7 MiB RSS with default features and 35.5 MiB in a slim build.
|
||||
- Growing from one to eight warmed agents cost only about 0.40 MiB per additional agent.
|
||||
- Ingesting 100 representative chat messages retained about 9.3 MiB and completed in about 2.25 seconds.
|
||||
- A cold chat turn that spawned two real subagents increased RSS by about 26-31 MiB, depending on feature selection and run conditions.
|
||||
- That subagent increase is overwhelmingly a first-use cost. After one complete warm-up turn, another equivalent turn added only 0.52 MiB with persistence disabled or 1.84 MiB with normal memory capture enabled.
|
||||
|
||||
There is no single Rust module holding 45 MiB of live data. In the clean slim-build snapshot at approximately 42 MiB total RSS:
|
||||
|
||||
- only 15.2 MiB was private physical footprint;
|
||||
- only 3.18 MiB was active heap allocation;
|
||||
- 18.7 MiB was resident executable code from the OpenHuman binary;
|
||||
- the malloc zones retained 9.4 MiB despite only about 3.2 MiB being live, indicating substantial allocator high-water retention/fragmentation.
|
||||
|
||||
The most actionable module-level findings are:
|
||||
|
||||
1. Parent and child agents each initialize full memory/SQLite infrastructure.
|
||||
2. TinyCortex's multilingual PII `RegexSet` and its regex caches dominate the identified live Rust heap growth during normal memory capture.
|
||||
3. The first turn touches about 15 MiB of previously nonresident OpenHuman executable code.
|
||||
4. Built-in agent TOML parsing, agent construction, unified-memory construction, and SQLite initialization dominate cold-path CPU.
|
||||
5. Compile-time feature selection reduces binary size dramatically but reduces live RSS only moderately.
|
||||
|
||||
## Scope and methodology
|
||||
|
||||
The session used three progressively narrower boundaries:
|
||||
|
||||
1. The complete Tauri desktop process family, including CEF and helper processes.
|
||||
2. The standalone Rust core with no Tauri host.
|
||||
3. Direct Rust library paths for agent construction, memory ingestion, chat orchestration, and subagent delegation.
|
||||
|
||||
All Rust measurements used release builds. Network inference was replaced by a deterministic provider behind the default-off `rss-bench` feature. The subagent scenario still used the real `LongLivedSession`, built-in agent registry, agent builder, `spawn_parallel_agents` tool, two researcher agents, memory infrastructure, prompt enforcement, and post-turn hooks.
|
||||
|
||||
RSS was sampled every 5 ms during measured workloads. On macOS, the profiler obtains:
|
||||
|
||||
- current RSS from `proc_pid_rusage`;
|
||||
- peak RSS from `getrusage`;
|
||||
- thread count from `proc_pidinfo`;
|
||||
- binary size from the running executable.
|
||||
|
||||
macOS does not expose Linux `/proc`-style PSS and private clean/dirty page fields through the same interface, so those JSON fields remain zero. `vmmap`, `heap`, `malloc_history`, Instruments Allocations, and Samply were used for deeper attribution.
|
||||
|
||||
Unless stated otherwise, summarized Rust results are medians from five fresh processes. CPU samples from `/usr/bin/time` are representative runs rather than five-run medians.
|
||||
|
||||
## Desktop/Tauri measurements
|
||||
|
||||
These measurements aggregate the desktop process family rather than only the Rust process.
|
||||
|
||||
| Configuration | Mean RAM | Change |
|
||||
| ------------------------------ | ----------: | ---------------: |
|
||||
| Default CEF prewarm and spaCy | 1,439.8 MiB | baseline |
|
||||
| CEF prewarm disabled | 1,353.5 MiB | -86.3 MiB |
|
||||
| CEF prewarm and spaCy disabled | 1,207.6 MiB | -232.2 MiB total |
|
||||
|
||||
The clearest desktop optimizations are therefore:
|
||||
|
||||
- initialize spaCy lazily only when memory-tree operations require it;
|
||||
- avoid permanent CEF prewarming, or make the prewarm process short-lived;
|
||||
- keep accessibility snapshots and `osascript`-based probes event-driven and rate-limited rather than continuously polling.
|
||||
|
||||
These desktop results motivated isolating the Rust core: most of the shipped application's memory is not explained by core agent objects.
|
||||
|
||||
## Bare Rust agent roster
|
||||
|
||||
The existing `rss-bench` binary constructs real OpenHuman agents without Tauri and measures stable RSS in fresh child processes.
|
||||
|
||||
### Default feature build
|
||||
|
||||
| Roster | Median RSS | Threads | Binary size |
|
||||
| -------- | --------------------: | ------: | ----------: |
|
||||
| 1 agent | 39,616 KiB / 38.7 MiB | 22 | 115.9 MiB |
|
||||
| 8 agents | 42,480 KiB / 41.5 MiB | 24 | 115.9 MiB |
|
||||
|
||||
The seven additional agents added 2,864 KiB in total, or approximately 409 KiB per agent. The fixed runtime and linked-code cost is much larger than the marginal agent object cost.
|
||||
|
||||
### Slim feature build
|
||||
|
||||
The slim binaries were built with:
|
||||
|
||||
```bash
|
||||
GGML_NATIVE=OFF cargo build --release \
|
||||
--no-default-features --features rss-bench \
|
||||
--bin rss-bench --bin library-profile
|
||||
```
|
||||
|
||||
| Roster | Median RSS | Binary size |
|
||||
| -------- | --------------------: | ----------: |
|
||||
| 1 agent | 36,368 KiB / 35.5 MiB | 68.4 MiB |
|
||||
| 8 agents | 39,584 KiB / 38.7 MiB | 68.4 MiB |
|
||||
|
||||
Feature selection reduced the profiling binary by about 40%, but reduced one-agent RSS by only 3.2 MiB. Compile-time gates are very valuable for download and embedding size, but they are not sufficient by themselves to minimize the active working set.
|
||||
|
||||
## Rust-only memory ingestion
|
||||
|
||||
The `memory-ingest` scenario creates an isolated workspace, disables local inference, Python, spaCy, and embeddings, then sends 100 representative chat messages through the real canonicalization, ingestion, admission, persistence, and memory-queue drain paths.
|
||||
|
||||
### Default features
|
||||
|
||||
| Metric | Median |
|
||||
| ---------------------- | -----------------------: |
|
||||
| Duration | 2,251 ms |
|
||||
| Baseline RSS | 16,624 KiB / 16.2 MiB |
|
||||
| Settled RSS | 26,144 KiB / 25.5 MiB |
|
||||
| Retained/peak increase | 9,536 KiB / 9.31 MiB |
|
||||
| Throughput | about 44 messages/second |
|
||||
|
||||
### Slim features
|
||||
|
||||
| Metric | Median |
|
||||
| ---------------------- | --------------------: |
|
||||
| Duration | 2,313 ms |
|
||||
| Baseline RSS | 15,536 KiB / 15.2 MiB |
|
||||
| Settled RSS | 24,320 KiB / 23.8 MiB |
|
||||
| Retained/peak increase | 8,784 KiB / 8.58 MiB |
|
||||
|
||||
The feature change reduced settled RSS by about 1.8 MiB and had no meaningful throughput benefit.
|
||||
|
||||
A representative default-feature `/usr/bin/time -l` run reported 0.18 seconds user CPU and 0.69 seconds system CPU. This should be treated as an upper bound: sampling RSS every 5 ms adds macOS process-inspection system calls, and the workload also performs real temporary-workspace I/O.
|
||||
|
||||
## Rust-only subagent chat
|
||||
|
||||
The `subagents` scenario uses a deterministic local provider but otherwise follows the real orchestration path:
|
||||
|
||||
1. Build a long-lived subconscious session.
|
||||
2. Submit a promoted chat message.
|
||||
3. Call the real `spawn_parallel_agents` tool.
|
||||
4. Spawn two real `researcher` agents with separate ownership scopes.
|
||||
5. Verify that both child prompts were executed.
|
||||
6. Measure baseline, peak, and settled RSS.
|
||||
|
||||
### Cold default-feature result
|
||||
|
||||
| Metric | Median |
|
||||
| ---------------------- | --------------------: |
|
||||
| Duration | 163 ms |
|
||||
| Baseline RSS | 18,192 KiB / 17.8 MiB |
|
||||
| Settled RSS | 49,712 KiB / 48.5 MiB |
|
||||
| Retained/peak increase | 31,520 KiB / 30.8 MiB |
|
||||
|
||||
A representative process used 0.04 seconds user CPU and 0.06 seconds system CPU. Model and network latency are deliberately excluded.
|
||||
|
||||
### Cold slim-feature result
|
||||
|
||||
| Metric | Median |
|
||||
| ---------------------- | --------------------: |
|
||||
| Duration | 158 ms |
|
||||
| Baseline RSS | 17,072 KiB / 16.7 MiB |
|
||||
| Settled RSS | 43,392 KiB / 42.4 MiB |
|
||||
| Retained/peak increase | 26,304 KiB / 25.7 MiB |
|
||||
|
||||
The slim build saved about 6.2 MiB of settled RSS in this richer workload.
|
||||
|
||||
## Deep memory attribution
|
||||
|
||||
### Clean normal snapshot
|
||||
|
||||
A non-instrumented slim-build run with normal memory capture settled at 43,104 KiB RSS, or 42.1 MiB.
|
||||
|
||||
| VM/heap measurement | Result |
|
||||
| ------------------------------------- | ------------: |
|
||||
| Total RSS | 42.1 MiB |
|
||||
| Private physical footprint | 15.2 MiB |
|
||||
| Approximate clean/file-backed portion | 26.9 MiB |
|
||||
| Live heap allocations | 3.18 MiB |
|
||||
| Resident malloc regions | about 9.4 MiB |
|
||||
| Resident stacks | 0.97 MiB |
|
||||
| Resident OpenHuman executable text | 18.7 MiB |
|
||||
|
||||
These categories overlap and must not be added together. For example, active heap and stacks are part of the private footprint, while executable text is part of clean/file-backed RSS.
|
||||
|
||||
The key interpretation is that RSS is not equivalent to private heap. The process reports roughly 42 MiB RSS while holding only about 3.2 MiB of live heap allocations.
|
||||
|
||||
### First-use executable paging
|
||||
|
||||
Before the chat turn, only about 3.3 MiB of the profiling executable's `__TEXT` segment was resident. After the turn, 18.7 MiB was resident. The first turn therefore faulted in approximately 15.4 MiB of OpenHuman's own executable code.
|
||||
|
||||
CoreFoundation, Foundation, ICU, Security, CoreAudio, and other macOS framework `__TEXT` residency was effectively identical in the controlled baseline and post-turn snapshots. The increase came from the OpenHuman executable, not from a single newly loaded macOS framework.
|
||||
|
||||
Executable pages are clean, file-backed, reclaimable under pressure, and shareable between identical processes. They count toward RSS but are not the same as permanently retained private data.
|
||||
|
||||
### Allocator retention
|
||||
|
||||
The normal snapshot had about 3.2 MiB of active allocations inside approximately 9.4 MiB of resident malloc pages. Roughly 6 MiB was therefore allocator slack, fragmentation, size-class pages, or high-water retention after the burst of first-turn allocations.
|
||||
|
||||
This does not prove a leak. A repeated-turn plateau test is more meaningful than expecting macOS RSS to immediately return to its pre-turn value.
|
||||
|
||||
### Memory capture and PII cost
|
||||
|
||||
A controlled profiler switch disabled both:
|
||||
|
||||
- `memory.auto_save`;
|
||||
- `learning.episodic_capture_enabled` and its archivist hook.
|
||||
|
||||
With those writes disabled, the median cold-turn increase fell from about 25.9 MiB to 22.0 MiB, saving approximately 3.8 MiB.
|
||||
|
||||
Stack-logged allocations identified TinyCortex's multilingual PII sanitizer as the dominant live Rust allocation family during normal memory capture. Important allocations originated from:
|
||||
|
||||
- `tinycortex::memory::store::safety::pii::SCREEN`;
|
||||
- the combined `RegexSet` NFA;
|
||||
- per-thread hybrid-DFA regex caches;
|
||||
- calls through `sanitize_text`, document upsert, FTS5 episodic insertion, autosave, and the archivist hook.
|
||||
|
||||
The `SCREEN` implementation is described as a cheap prefilter, but its large multilingual, Unicode-aware combined automaton is not cheap in retained memory. A byte-oriented candidate scan followed by targeted regex evaluation is a promising replacement.
|
||||
|
||||
### Prompt-injection detector
|
||||
|
||||
With memory writes disabled, the next visible regex allocation family came from `prompt_injection::detector::DETECTION_RULE_SET`. It was materially smaller than the TinyCortex PII machinery, on the order of a few hundred KiB in this workload.
|
||||
|
||||
This is not currently a first-priority RAM optimization, but its scratch/cache strategy should be considered if agent turns become highly parallel.
|
||||
|
||||
### Timezone control
|
||||
|
||||
`current_datetime_line` normally calls `iana_time_zone`, which uses CoreFoundation on macOS. Forcing the profiling build to use UTC saved only about 0.5-0.6 MiB in the isolated subagent scenario. This is measurable but not a primary explanation for the 42-49 MiB working set.
|
||||
|
||||
## Cold-path CPU attribution
|
||||
|
||||
A symbolized Samply profile was recorded across repeated no-network, no-memory-write, UTC-controlled runs. Inclusive percentages overlap because a sample contributes to every parent frame in its call stack.
|
||||
|
||||
| Cold-path component | Approximate inclusive CPU |
|
||||
| -------------------------------------- | ------------------------: |
|
||||
| Built-in agent registry initialization | 17% |
|
||||
| `LongLivedSession::build_agent` | 16% |
|
||||
| `Agent::from_config_for_agent` | 15% |
|
||||
| Built-in agent TOML parsing/loading | 13% |
|
||||
| Unified-memory construction | 10% |
|
||||
| SQLite/unified-memory initialization | 7% |
|
||||
| Actual TinyAgents turn runner | 5% |
|
||||
|
||||
The profile also showed `Config::load_or_init`, config serialization/migrations, filesystem synchronization, tool-policy cloning, prompt-injection initialization, and runtime task scheduling.
|
||||
|
||||
The built-in registry is initialized before the profiler's RSS baseline, so its CPU appears in the whole-process profile but its already-resident pages are part of the baseline rather than the measured turn delta.
|
||||
|
||||
## Warmed-process control
|
||||
|
||||
The most important experiment prewarmed one complete two-subagent turn, dropped that warm-up session, then measured a new session performing the same workload in the same process.
|
||||
|
||||
| Subsequent equivalent turn | Median duration | Median added RSS |
|
||||
| ----------------------------------- | --------------: | -------------------: |
|
||||
| Persistence disabled and UTC forced | 46 ms | 528 KiB / 0.52 MiB |
|
||||
| Normal memory capture | 60 ms | 1,888 KiB / 1.84 MiB |
|
||||
|
||||
One normal-memory repetition was an 8.3 MiB outlier, consistent with asynchronous persistence or allocator behavior; the other four were between about 1.4 and 1.9 MiB.
|
||||
|
||||
This result changes the interpretation of the cold 26-31 MiB increase. It is overwhelmingly initialization, code paging, global regex/cache construction, and allocator high-water behavior. It is not a linear 26 MiB cost per chat turn.
|
||||
|
||||
## Library-design implications
|
||||
|
||||
OpenHuman is feasible as a Rust library, but the current construction path behaves like an application bootstrap rather than a lightweight per-instance library API.
|
||||
|
||||
### Share services between agents
|
||||
|
||||
`Agent::build_session_agent_inner` constructs session memory and obtains a SQLite connection for the agent. Parent and child agents should instead receive shared instance services such as:
|
||||
|
||||
```text
|
||||
OpenHumanLibrary
|
||||
Arc<ConfigSnapshot>
|
||||
Arc<AgentDefinitionRegistry>
|
||||
Arc<ToolCatalog>
|
||||
Arc<MemoryServices>
|
||||
Arc<ProviderRegistry>
|
||||
Arc<EventBus>
|
||||
```
|
||||
|
||||
Subagents should borrow or clone these `Arc` handles rather than rebuilding configuration, memory stores, schema state, providers, and tool catalogs.
|
||||
|
||||
### Offer explicit warm-up
|
||||
|
||||
A library API should expose an optional warm-up method that initializes predictable first-use costs:
|
||||
|
||||
- built-in agent definitions;
|
||||
- prompt and PII detectors;
|
||||
- memory schemas and connection pools;
|
||||
- tool catalogs and policy snapshots;
|
||||
- provider routing;
|
||||
- commonly used prompt fragments.
|
||||
|
||||
This lets latency-sensitive hosts choose between low startup work and predictable first-message latency.
|
||||
|
||||
### Separate runtime and compile-time slimness
|
||||
|
||||
`DomainSet` is useful for runtime surface selection, but runtime-disabled code remains linked. Library consumers need documented compile-time feature recipes or a dedicated library/harness feature set so unrelated domains never enter the binary.
|
||||
|
||||
### Avoid mandatory globals
|
||||
|
||||
The profiling harness currently has to initialize a global event bus, a global built-in registry, environment-derived workspace selection, and a global provider override. Instance-owned state would make it safer to embed multiple OpenHuman instances in one process and would improve deterministic testing.
|
||||
|
||||
## Recommended optimization order
|
||||
|
||||
1. **Share unified-memory and SQLite services between parent and child agents.** This is the clearest architectural duplication in the cold path.
|
||||
2. **Replace the TinyCortex PII `RegexSet` screen with a lightweight candidate scan.** Preserve the strict regex/checksum validation after a candidate is found.
|
||||
3. **Add a warmed repeated-turn benchmark to CI or the local profiling suite.** Track both cold-start and steady-state behavior so one does not obscure the other.
|
||||
4. **Provide a supported library-minimal feature recipe.** Measure binary size, cold code paging, and steady RSS for that exact recipe.
|
||||
5. **Reuse temporary prompt, tool-schema, and sanitizer buffers.** Reduce burst allocation and malloc-zone fragmentation.
|
||||
6. **Benchmark an alternate allocator only in the profiling binary.** This can reveal how much of the 6 MiB malloc slack is allocator-specific, but the library should not impose a global allocator on consumers.
|
||||
7. **Add per-phase checkpoints.** Measure config load, agent build, memory construction, prompt render, delegation, child execution, merge, hooks, and teardown separately.
|
||||
|
||||
## Functional observation
|
||||
|
||||
In the deterministic `LongLivedSession` workload, both parallel child agents executed, but the session returned the last subagent result rather than performing the prepared parent synthesis response. Direct `Agent::turn` tests cover synthesis behavior elsewhere, so the long-lived-session boundary deserves a focused correctness audit. This is separate from the resource findings but was exposed by the same harness.
|
||||
|
||||
## Reproducing the measurements
|
||||
|
||||
Build the default-feature profiling binaries:
|
||||
|
||||
```bash
|
||||
GGML_NATIVE=OFF cargo build --release --features rss-bench \
|
||||
--bin rss-bench --bin library-profile
|
||||
```
|
||||
|
||||
Build the slim versions:
|
||||
|
||||
```bash
|
||||
GGML_NATIVE=OFF cargo build --release \
|
||||
--no-default-features --features rss-bench \
|
||||
--bin rss-bench --bin library-profile
|
||||
```
|
||||
|
||||
Run the bare roster benchmark:
|
||||
|
||||
```bash
|
||||
target/release/rss-bench --repeat 5 \
|
||||
--out target/profile/rust-library/bare-agents.json
|
||||
```
|
||||
|
||||
Run stateful library workloads:
|
||||
|
||||
```bash
|
||||
target/release/library-profile memory-ingest
|
||||
target/release/library-profile subagents
|
||||
```
|
||||
|
||||
Measure a warmed subagent turn:
|
||||
|
||||
```bash
|
||||
OPENHUMAN_PROFILE_PREWARM_SUBAGENTS=1 \
|
||||
target/release/library-profile subagents
|
||||
```
|
||||
|
||||
Isolate orchestration from persistence and local timezone initialization:
|
||||
|
||||
```bash
|
||||
OPENHUMAN_PROFILE_DISABLE_MEMORY_WRITES=1 \
|
||||
OPENHUMAN_PROFILE_FORCE_UTC=1 \
|
||||
target/release/library-profile subagents
|
||||
```
|
||||
|
||||
Hold a process at its baseline or settled state for `vmmap`, `heap`, or `malloc_history`:
|
||||
|
||||
```bash
|
||||
OPENHUMAN_PROFILE_HOLD_BEFORE_SECS=120 \
|
||||
target/release/library-profile subagents
|
||||
|
||||
OPENHUMAN_PROFILE_HOLD_SECS=120 \
|
||||
target/release/library-profile subagents
|
||||
```
|
||||
|
||||
Example live inspection:
|
||||
|
||||
```bash
|
||||
vmmap -summary <pid>
|
||||
heap -sH <pid>
|
||||
|
||||
MallocStackLogging=1 OPENHUMAN_PROFILE_HOLD_SECS=120 \
|
||||
target/release/library-profile subagents
|
||||
malloc_history <pid> -allBySize
|
||||
```
|
||||
|
||||
Record a symbolized CPU profile:
|
||||
|
||||
```bash
|
||||
OPENHUMAN_PROFILE_DISABLE_MEMORY_WRITES=1 \
|
||||
OPENHUMAN_PROFILE_FORCE_UTC=1 \
|
||||
samply record --save-only --unstable-presymbolicate \
|
||||
--rate 1000 --iteration-count 5 \
|
||||
--output target/profile/rust-library/subagents-cpu.json.gz \
|
||||
-- target/release/library-profile subagents
|
||||
```
|
||||
|
||||
## Artifacts and code added during the session
|
||||
|
||||
- `src/bin/library_profile/main.rs`: hermetic memory-ingestion and subagent workloads, peak RSS sampler, isolation controls, warm-up control, and debugger hold points.
|
||||
- `src/openhuman/proc_metrics/mod.rs`: macOS RSS, peak RSS, thread-count, and binary-size sampling.
|
||||
- `src/openhuman/inference/provider/factory.rs`: deterministic provider override enabled under the default-off profiling feature.
|
||||
- `src/openhuman/inference/provider/ops/provider_factory.rs`: routed-provider support for the same profiling override.
|
||||
- `src/openhuman/agent/prompts/render_helpers.rs`: profiling-only UTC control under `rss-bench`.
|
||||
- `target/profile/rust-library/`: ignored JSON, Instruments, and Samply artifacts from local runs.
|
||||
|
||||
None of the profiling binaries or provider overrides enter normal shipped builds because they require the default-off `rss-bench` feature.
|
||||
|
||||
## Validation completed
|
||||
|
||||
- Default-feature release builds of `rss-bench` and `library-profile`.
|
||||
- Slim release builds using `--no-default-features --features rss-bench`.
|
||||
- Five-run fresh-process repetitions for the main scenarios.
|
||||
- macOS process-metric unit test.
|
||||
- Existing datetime prompt test with `rss-bench` enabled.
|
||||
- `cargo fmt --check`.
|
||||
- `git diff --check`.
|
||||
|
||||
Repository warnings observed during builds were pre-existing unused-import/dead-code and future-incompatibility warnings; no new build failure was introduced by the profiling harness.
|
||||
|
||||
## Bottom line
|
||||
|
||||
OpenHuman's Rust core is not holding 45 MiB of agent objects. The steady process is mostly executable working set plus runtime/allocator pages, with a small live heap. The cold first turn is expensive because it initializes and touches a broad application-oriented path. Once warmed, subagent turns are inexpensive and appear to plateau rather than grow linearly.
|
||||
|
||||
The best route to an efficient library is therefore not micro-optimizing every agent struct. It is narrowing and sharing the initialization graph: reuse memory and SQLite services, avoid rebuilding agent infrastructure for children, simplify the PII prefilter, expose an explicit warm-up lifecycle, and provide a compile-time library-minimal profile.
|
||||
|
||||
---
|
||||
|
||||
## Addendum: library benchmarking session (2026-07-22)
|
||||
|
||||
The follow-up session turned the manual investigation above into a permanent
|
||||
benchmark environment, executed two of the recommended optimizations, and
|
||||
answered the deployment-density question. Full detail lives in
|
||||
[`library-benchmarking.md`](library-benchmarking.md); this addendum records
|
||||
the deltas against this document.
|
||||
|
||||
### What was built
|
||||
|
||||
- **Ten hermetic scenarios** in `library-profile` (was two): `agent-turn`,
|
||||
`long-agent`, `workflow`, `subconscious`, `cold-phases`, `fleet`,
|
||||
`skill-run`, `subagent-storm` joined `memory-ingest`/`subagents`. All
|
||||
offline, mock-provider, JSON schema v2 with per-phase/per-turn checkpoints.
|
||||
- **Five driver scripts** under `scripts/profile/`: `library-bench.sh`
|
||||
(medians + summary), `library-fleet.sh` (agent-count sweep with a
|
||||
2 GB/2 vCPU pass/fail gate), `library-instances.sh` (many-processes model),
|
||||
`library-cpu.sh` (samply), `library-heap.sh` (dhat).
|
||||
- **Professional profilers wired in**: dhat behind the default-off
|
||||
`rss-bench-dhat` feature; samply scripted; `proc_metrics` extended with
|
||||
CPU-time, fd counts, and a descendant process-tree sampler (`tree.rs`).
|
||||
|
||||
### Headline results (Apple Silicon, default build unless noted)
|
||||
|
||||
| Question | Answer |
|
||||
| --- | --- |
|
||||
| Cold turn cost, any shape (chat/subconscious/delegation/workflow) | ~29-30 MiB retained — shared bootstrap, not workload |
|
||||
| Warmed long-agent growth | 30-150 KiB/turn plateau; occasional 6-8 MiB async persistence bursts |
|
||||
| Live heap vs RSS (dhat, agent-turn) | 33.4 MB total allocated, 5.0 MB peak live, 3.1 MB at exit |
|
||||
| Fleet marginal per in-process agent | ~1.7-2.0 MiB (N=50/100/500 sweep) |
|
||||
| Idle CPU, parked fleet | ~3 ms per 10 s at every N |
|
||||
| **1000 agents in 2 GiB (one process)** | **PASS — projected ~1747 MiB @ 1000; real 500-agent run settled 1393 MiB** |
|
||||
| Same workload as N processes | ~48 MiB/instance flat → only ~42 instances per 2 GiB; one-process model is ~25x denser |
|
||||
| True cost of a JS skill run | node child ~72-75 MB RSS (tree ~121 MB vs ~51 MB self) |
|
||||
| Marginal per parallel subagent (storm K=8→32) | ~0.78 MiB |
|
||||
| Library-minimal build (`--no-default-features --features skills,flows`) | 81 MiB binary (60 stripped) vs 116; RSS -3.5 to -4.7 MiB |
|
||||
|
||||
### Changes landed
|
||||
|
||||
- **PII prefilter replaced upstream** (recommendation 2 above):
|
||||
tinycortex#119 swaps the resident 17-pattern `RegexSet` + per-thread DFA
|
||||
caches for a single-pass byte candidate scan gating lazily-compiled
|
||||
per-class regexes; superset-verified against the old set, no common-path
|
||||
heap regression, wins scale with thread/agent concurrency. Merged with
|
||||
#120 (unrelated rustdoc fix); submodule bumped.
|
||||
- **Library-minimal recipe** documented and measured
|
||||
([`library-minimal-recipe.md`](library-minimal-recipe.md)); ranked
|
||||
follow-up sheds identified (an `inference` gate for whisper/GGML first).
|
||||
- **Harness comparison** ([`harness-comparison-2026-07-22.md`](harness-comparison-2026-07-22.md)):
|
||||
the scope-matched peer (Hermes, Python) self-reports ~10x our RSS; the
|
||||
ZeroClaw "7.8-12 MiB under load" figure has no locatable primary source and
|
||||
is now flagged unverified wherever cited. Our in-process ~2 MiB marginal
|
||||
scaling has no equivalent among the surveyed harnesses.
|
||||
|
||||
### New watch-items surfaced by the fleet sweep
|
||||
|
||||
1. **Thread growth ~0.35/agent** (71 @ N=50 → 211 @ N=500, →~420 projected at
|
||||
1000). Attribute (SQLite? blocking pool?) and cap before 1000-agent runs.
|
||||
2. **CPU, not memory, is the load constraint on 2 workers**: p95 turn latency
|
||||
25.5 s at N=500 under 200 ms mock latency. Scheduling/backpressure design
|
||||
matters more than RAM once the fleet is dense.
|
||||
3. **Interpreter children break the budget**: pooling/sharing the node and
|
||||
python runtimes is filed as tinyhumansai/openhuman#5106 — without it,
|
||||
~25 concurrent JS skill runs exhaust the whole 2 GiB box.
|
||||
|
||||
### Updated optimization order
|
||||
|
||||
1. Shared services between parent/child agents (unchanged, still first; the
|
||||
fleet benchmark is its regression instrument).
|
||||
2. ~~PII prefilter~~ — done, merged upstream.
|
||||
3. Runtime pooling for node/python (#5106) — new, promoted to near-top by the
|
||||
skill-run measurement.
|
||||
4. Thread-growth attribution and cap (new).
|
||||
5. Warm-up API, allocator experiment, per-phase checkpoints in CI — the
|
||||
checkpoints now exist (`cold-phases`); CI wiring remains.
|
||||
6. `inference` compile gate (whisper/GGML) for the library-minimal profile.
|
||||
|
||||
### Next: the live desktop app
|
||||
|
||||
The same rigor now needs to reach the shipped Tauri/CEF app (the ~1.2-1.4 GiB
|
||||
family this document opened with). The prepared brief for that session —
|
||||
scenarios, reusable assets, gates — is
|
||||
[`tauri-live-profiling-brief.md`](tauri-live-profiling-brief.md).
|
||||
@@ -0,0 +1,85 @@
|
||||
# Brief: Tauri live-app benchmark and profiling
|
||||
|
||||
Audience: the next agent/session taking on desktop-app (live Tauri + CEF)
|
||||
profiling. The Rust-core library side is done and documented in
|
||||
[`library-benchmarking.md`](library-benchmarking.md); this brief covers what
|
||||
to build for the shipped desktop app, what already exists, and what to reuse.
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the desktop app to the same standard the core now has: named,
|
||||
repeatable scenarios; fresh-process repeats with median aggregation; JSON
|
||||
results; pass/fail budget gates; and an escalation path for attribution. The
|
||||
app is a process *family* (Tauri host + CEF helpers + utility/GPU/renderer
|
||||
processes + node/python runtime children), so every measurement must be
|
||||
family-wide, not single-pid.
|
||||
|
||||
## What already exists (reuse, do not rebuild)
|
||||
|
||||
- **`app/src-tauri/profiling/`** — an offline Tauri process-family profiler
|
||||
(own small crate: `src/main.rs` ~813 lines, README) added in commit
|
||||
`ce6cd2291`. Start here; extend it rather than writing a new sampler.
|
||||
- **`src/openhuman/proc_metrics/`** — RSS/peak/threads/CPU-ms/fds sampling
|
||||
plus `tree.rs` (descendant process-tree walker, macOS `proc_listchildpids`
|
||||
+ Linux `/proc` ppid walk). The tree sampler is exactly what family-wide
|
||||
measurement needs.
|
||||
- **`scripts/profile/`** — the driver-script pattern (build → N fresh runs →
|
||||
jq medians → summary.md + gate exit code). Copy `library-bench.sh`'s shape
|
||||
for an `app-bench.sh`.
|
||||
- **JSON schema v2** (`library-profile` output) — reuse the field names
|
||||
(`baseline`/`settled`/`peak_rss_kib`/`checkpoints[]`/`budget`) so existing
|
||||
aggregation and future CI tooling work on both suites.
|
||||
- **Env-var conventions** — `OPENHUMAN_PROFILE_*` knobs, `HOLD_SECS`-style
|
||||
inspection points.
|
||||
|
||||
## Prior findings to build on (2026-07-21 session)
|
||||
|
||||
| Finding | Number |
|
||||
| --- | ---: |
|
||||
| Full desktop process family | 1,207-1,440 MiB |
|
||||
| CEF prewarm cost | ~86 MiB (disable/short-lived candidate) |
|
||||
| spaCy cost | ~146 MiB (lazy-init candidate) |
|
||||
| Rust core share of the family | ~40-50 MiB |
|
||||
|
||||
The gap between the ~50 MiB core and the ~1.2-1.4 GiB family is the entire
|
||||
story: CEF/renderer processes, prewarm policy, spaCy, and shell-side polling.
|
||||
|
||||
## Suggested scenarios
|
||||
|
||||
1. **cold-boot** — launch to interactive UI; family RSS + wall time,
|
||||
checkpointed (host start, core ready, CEF first frame, UI route mounted).
|
||||
2. **idle-drift** — 10-30 min idle; family RSS + CPU sampled continuously.
|
||||
This is where scanner polling, heartbeat, accessibility probes, and
|
||||
`osascript` probes show up (keep them event-driven per prior findings).
|
||||
3. **chat-turn-e2e** — one full chat turn through the real UI (drive via CDP
|
||||
on the CEF debug port or the Appium harness in `openhuman/e2e/`); compare
|
||||
against the core-only `agent-turn` baseline to attribute shell overhead.
|
||||
4. **webview-cycle** — open/close provider webviews (N cycles); CEF child
|
||||
process lifecycle, leak check on repeat.
|
||||
5. **prewarm-matrix** — CEF prewarm on/off × spaCy on/off, reproducing and
|
||||
pinning the prior session's ~86/~146 MiB findings as a regression gate.
|
||||
6. **overlay-surfaces** — mascot/notch/companion windows up vs down.
|
||||
|
||||
## Method notes
|
||||
|
||||
- Family enumeration: union of the Tauri host's descendant tree (use
|
||||
`proc_metrics::tree`) plus CEF helper processes, which may re-parent —
|
||||
match by bundle path/name as `app/src-tauri/profiling` already does.
|
||||
- Sum-RSS double-counts shared CEF framework pages across helpers; on macOS
|
||||
record `footprint` output alongside ps-style sums (the instances driver
|
||||
in `scripts/profile/library-instances.sh` shows the pattern and caveat
|
||||
wording); on Linux use PSS.
|
||||
- Drive the UI mechanically, not by hand: CDP against CEF (`:19222` per the
|
||||
e2e harness) or the WDIO/Appium specs. Every scenario must run
|
||||
unattended.
|
||||
- Gate suggestion: family budget per scenario (e.g. cold-boot ≤ X MiB,
|
||||
idle-drift slope ≈ 0), same PASS/FAIL summary style as
|
||||
`library-fleet.sh`.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- `scripts/profile/app-bench.sh` (or equivalent) running the scenarios
|
||||
above unattended with median aggregation and gates.
|
||||
- Baselines recorded in a doc table (like `library-benchmarking.md`).
|
||||
- The prewarm/spaCy findings converted from one-off observations into
|
||||
standing regression gates.
|
||||
@@ -51,6 +51,7 @@
|
||||
"agent-batch": "node scripts/agent-batch/cli.mjs",
|
||||
"agent-batch:test": "node --test scripts/agent-batch/__tests__/lib.test.mjs scripts/agent-batch/__tests__/cli.test.mjs",
|
||||
"debug": "bash scripts/debug/cli.sh",
|
||||
"profile:tauri": "cargo run --manifest-path app/src-tauri/profiling/Cargo.toml --",
|
||||
"test:install-ps1": "pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1",
|
||||
"rust:check": "pnpm --filter openhuman-app rust:check",
|
||||
"rust:clippy": "cargo clippy -p openhuman -- -D warnings && pnpm --filter openhuman-app rust:clippy",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# `scripts/profile/`
|
||||
|
||||
Reproducible benchmarking scripts for the OpenHuman Rust core as an embedded
|
||||
library (no RPC server), built around the `library-profile` and `rss-bench`
|
||||
binaries (see `src/bin/library_profile/main.rs`). Full write-up:
|
||||
[`docs/library-benchmarking.md`](../../docs/library-benchmarking.md). Prior
|
||||
findings: [`docs/resource-profiling-session-2026-07-21.md`](../../docs/resource-profiling-session-2026-07-21.md).
|
||||
|
||||
Five driver scripts: `library-bench.sh` (per-scenario RSS/duration),
|
||||
`library-cpu.sh` (samply), `library-heap.sh` (dhat), `library-fleet.sh`
|
||||
(fleet-scale sweep + budget gate), and `library-instances.sh` (multi-process
|
||||
instance sweep).
|
||||
|
||||
## Scripts
|
||||
|
||||
### `library-bench.sh` — RSS/duration benchmark
|
||||
|
||||
Builds `library-profile` + `rss-bench`, runs each scenario N times as a fresh
|
||||
process, and aggregates median/min/max duration, settled RSS, retained delta,
|
||||
and peak delta into `summary.json` + `summary.md`.
|
||||
|
||||
```bash
|
||||
./scripts/profile/library-bench.sh # all 7 scenarios, default build, 5 repeats
|
||||
./scripts/profile/library-bench.sh --slim --repeat 7 # slim (no-default-features) build
|
||||
./scripts/profile/library-bench.sh --scenarios "long-agent,subagents" --turns 50 --warm
|
||||
```
|
||||
|
||||
Results land in `target/profile/rust-library/bench-<timestamp>/` (or `--out DIR`).
|
||||
|
||||
### `library-cpu.sh` — CPU profile via samply
|
||||
|
||||
Wraps `samply record` around one scenario, isolated from persistence/timezone
|
||||
noise by default (matching the documented cold-path CPU recipe).
|
||||
|
||||
```bash
|
||||
./scripts/profile/library-cpu.sh subagents
|
||||
./scripts/profile/library-cpu.sh long-agent -- OPENHUMAN_PROFILE_TURNS=50
|
||||
samply load target/profile/rust-library/subagents-cpu.json.gz
|
||||
```
|
||||
|
||||
### `library-heap.sh` — live heap attribution via dhat
|
||||
|
||||
Builds the `rss-bench-dhat` variant and runs one scenario under dhat. RSS and
|
||||
timing numbers from this build are perturbed by instrumentation; use it only
|
||||
for allocation-site/retained-bytes attribution, not for RSS comparisons.
|
||||
|
||||
```bash
|
||||
./scripts/profile/library-heap.sh memory-ingest
|
||||
# open https://nnethercote.github.io/dh_view/dh_view.html and load
|
||||
# target/profile/rust-library/dhat-memory-ingest.json
|
||||
```
|
||||
|
||||
### `library-fleet.sh` — fleet sweep + 2 GB / 2 vCPU budget gate
|
||||
|
||||
Builds `library-profile` + `rss-bench`, sweeps the `fleet` scenario (N
|
||||
concurrent live agents with latency-realistic mock inference) across a list
|
||||
of agent counts, aggregates medians per N, and gates on whether the
|
||||
projected footprint at the target agent count fits the RAM budget.
|
||||
|
||||
```bash
|
||||
./scripts/profile/library-fleet.sh --agents 100 --latency-ms 200
|
||||
./scripts/profile/library-fleet.sh --agents "50,100,500" --target 1000 --budget-mib 2048
|
||||
```
|
||||
|
||||
Results land in `target/profile/rust-library/fleet-<timestamp>/` (or
|
||||
`--out DIR`). Exits nonzero if any swept N reports `fits: false` (use
|
||||
`--no-gate` to report only). See
|
||||
[`docs/library-benchmarking.md`](../../docs/library-benchmarking.md#the-2-gb--2-vcpu-server-budget)
|
||||
for the budget math.
|
||||
|
||||
### `library-instances.sh` — multi-instance (many-processes) sweep
|
||||
|
||||
Spawns N independent `library-profile` processes (each a live instance held
|
||||
alive via `OPENHUMAN_PROFILE_HOLD_SECS`), staggered on startup, and measures
|
||||
**per-process** cost and box survivability — the opencompany "N independent
|
||||
processes/containers" deployment model, as opposed to `library-fleet.sh`'s
|
||||
"N agents in one process" model. Samples aggregate sum-RSS + live count every
|
||||
2s while instances hold, captures a `vm_stat` snapshot at peak (and a
|
||||
best-effort `footprint` sample if that macOS tool is present), then
|
||||
aggregates per swept N: launched/ok counts, median settled RSS per instance,
|
||||
mean and peak aggregate sum-RSS, and — on Linux, where it's meaningful —
|
||||
summed PSS.
|
||||
|
||||
```bash
|
||||
./scripts/profile/library-instances.sh --instances "10,50" --hold-secs 30
|
||||
./scripts/profile/library-instances.sh --instances "100,500" --max-instances 500 --gate
|
||||
```
|
||||
|
||||
Results land in `target/profile/rust-library/instances-<timestamp>/` (or
|
||||
`--out DIR`). Refuses to spawn more than `--max-instances` (default 200)
|
||||
without an explicit raise — see the script's `--help` for the RAM math. Exits
|
||||
nonzero with `--gate` if any instance failed to complete cleanly (nonzero
|
||||
exit or missing/invalid JSON); default is report-only. See
|
||||
[`docs/library-benchmarking.md`](../../docs/library-benchmarking.md#fleet-one-process-vs-instances-many-processes)
|
||||
for the fleet-vs-instances framing.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. Baseline RSS/duration across all scenarios
|
||||
./scripts/profile/library-bench.sh
|
||||
|
||||
# 2. CPU attribution for the slowest/most interesting scenario
|
||||
./scripts/profile/library-cpu.sh subagents
|
||||
|
||||
# 3. If a scenario's RSS looks off, drill into live heap
|
||||
./scripts/profile/library-heap.sh subagents
|
||||
```
|
||||
|
||||
All scripts require `jq` for JSON parsing/aggregation; `library-cpu.sh` also
|
||||
requires `samply` (`cargo install samply`). Build commands use
|
||||
`GGML_NATIVE=OFF` to work around the Apple Silicon whisper-rs/llama.cpp NEON
|
||||
fp16 build issue.
|
||||
Executable
+310
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env bash
|
||||
# library-bench.sh — reproducible RSS/duration benchmark for the OpenHuman
|
||||
# core as an embedded library, using the `library-profile` binary.
|
||||
#
|
||||
# Runs each scenario N times as a FRESH process (no state shared across
|
||||
# repeats), captures each run's JSON, and aggregates medians/min/max into a
|
||||
# markdown + JSON summary. Companion scripts: library-cpu.sh (samply),
|
||||
# library-heap.sh (dhat).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/profile/library-bench.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --slim Build with --no-default-features (slim library recipe)
|
||||
# --repeat N Fresh-process repeats per scenario (default: 5)
|
||||
# --scenarios "a,b,c" Comma-separated scenario list (default: all seven)
|
||||
# --turns N OPENHUMAN_PROFILE_TURNS for long-agent (default binary default: 25)
|
||||
# --skip-build Reuse the existing target/release binaries
|
||||
# --warm Also run PREWARM_SUBAGENTS=1 variants for subagents + subconscious
|
||||
# --out DIR Output directory (default: target/profile/rust-library/bench-<timestamp>)
|
||||
# -h, --help Show this help
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/profile/library-bench.sh
|
||||
# ./scripts/profile/library-bench.sh --slim --repeat 7
|
||||
# ./scripts/profile/library-bench.sh --scenarios "long-agent,subagents" --turns 50 --warm
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
ALL_SCENARIOS="memory-ingest,subagents,agent-turn,long-agent,workflow,subconscious,cold-phases"
|
||||
WARM_ELIGIBLE=("subagents" "subconscious")
|
||||
|
||||
SLIM=0
|
||||
REPEAT=5
|
||||
SCENARIOS="$ALL_SCENARIOS"
|
||||
TURNS=""
|
||||
SKIP_BUILD=0
|
||||
WARM=0
|
||||
OUT_DIR=""
|
||||
|
||||
usage() {
|
||||
sed -n '2,26p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--slim) SLIM=1; shift ;;
|
||||
--repeat)
|
||||
REPEAT="${2:?--repeat requires a value}"; shift 2 ;;
|
||||
--scenarios)
|
||||
SCENARIOS="${2:?--scenarios requires a value}"; shift 2 ;;
|
||||
--turns)
|
||||
TURNS="${2:?--turns requires a value}"; shift 2 ;;
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
--warm) WARM=1; shift ;;
|
||||
--out)
|
||||
OUT_DIR="${2:?--out requires a value}"; shift 2 ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*)
|
||||
echo "ERROR: unknown argument: $1" >&2
|
||||
usage 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "ERROR: jq is required (aggregation over run JSON). Install it (e.g. 'brew install jq')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$OUT_DIR" ]]; then
|
||||
OUT_DIR="$REPO_ROOT/target/profile/rust-library/bench-$(date +%Y%m%d-%H%M%S)"
|
||||
fi
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
BIN="$REPO_ROOT/target/release/library-profile"
|
||||
|
||||
log() { echo "[library-bench] $*" >&2; }
|
||||
|
||||
build_binaries() {
|
||||
if [[ "$SKIP_BUILD" -eq 1 ]]; then
|
||||
log "skipping build (--skip-build)"
|
||||
return
|
||||
fi
|
||||
local feature_args=(--features rss-bench)
|
||||
if [[ "$SLIM" -eq 1 ]]; then
|
||||
feature_args=(--no-default-features --features rss-bench)
|
||||
log "building slim library-profile + rss-bench (GGML_NATIVE=OFF)"
|
||||
else
|
||||
log "building default-feature library-profile + rss-bench (GGML_NATIVE=OFF)"
|
||||
fi
|
||||
(
|
||||
cd "$REPO_ROOT"
|
||||
GGML_NATIVE=OFF cargo build --release \
|
||||
"${feature_args[@]}" \
|
||||
--bin library-profile --bin rss-bench
|
||||
)
|
||||
}
|
||||
|
||||
run_scenario() {
|
||||
local scenario="$1"
|
||||
local variant="$2" # "" or "warm"
|
||||
local label="$scenario"
|
||||
[[ -n "$variant" ]] && label="${scenario}-${variant}"
|
||||
|
||||
local scenario_dir="$OUT_DIR/$label"
|
||||
mkdir -p "$scenario_dir"
|
||||
|
||||
log "running scenario '$label' x$REPEAT (fresh process each run)"
|
||||
|
||||
local i
|
||||
for ((i = 1; i <= REPEAT; i++)); do
|
||||
local run_file="$scenario_dir/run-$i.json"
|
||||
local env_args=()
|
||||
if [[ "$scenario" == "long-agent" && -n "$TURNS" ]]; then
|
||||
env_args+=(env "OPENHUMAN_PROFILE_TURNS=$TURNS")
|
||||
fi
|
||||
if [[ "$variant" == "warm" ]]; then
|
||||
env_args+=(env "OPENHUMAN_PROFILE_PREWARM_SUBAGENTS=1")
|
||||
fi
|
||||
|
||||
if [[ ${#env_args[@]} -gt 0 ]]; then
|
||||
"${env_args[@]}" "$BIN" "$scenario" >"$run_file"
|
||||
else
|
||||
"$BIN" "$scenario" >"$run_file"
|
||||
fi
|
||||
|
||||
if ! jq empty "$run_file" >/dev/null 2>&1; then
|
||||
echo "ERROR: run $i for scenario '$label' did not produce valid JSON: $run_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# library-heap.sh clobbers target/release/library-profile with the
|
||||
# rss-bench-dhat build, whose allocator perturbs RSS/timing. The dhat
|
||||
# binary marks its output, so refuse to benchmark it.
|
||||
if [[ "$(jq -r '.dhat // false' "$run_file")" == "true" ]]; then
|
||||
echo "ERROR: $BIN was built with rss-bench-dhat (library-heap.sh clobbered it)." >&2
|
||||
echo " Re-run without --skip-build to rebuild the plain rss-bench binary." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Emit the median (lower-middle for even N), min, and max of a jq numeric
|
||||
# field across the run files for one scenario, as a JSON object on stdout.
|
||||
aggregate_field() {
|
||||
local scenario_dir="$1"
|
||||
local jq_path="$2"
|
||||
jq -s "
|
||||
[ .[] | $jq_path | select(. != null) ] as \$vals |
|
||||
(\$vals | sort) as \$sorted |
|
||||
(\$sorted | length) as \$n |
|
||||
{
|
||||
median: (if \$n == 0 then null else \$sorted[(( \$n - 1) / 2 | floor)] end),
|
||||
min: (\$sorted[0] // null),
|
||||
max: (\$sorted[-1] // null),
|
||||
n: \$n
|
||||
}
|
||||
" "$scenario_dir"/run-*.json
|
||||
}
|
||||
|
||||
aggregate_scenario() {
|
||||
local label="$1"
|
||||
local scenario_dir="$OUT_DIR/$label"
|
||||
|
||||
local duration settled_rss retained_delta peak_delta
|
||||
duration=$(aggregate_field "$scenario_dir" ".duration_ms")
|
||||
settled_rss=$(aggregate_field "$scenario_dir" ".settled.rss_kib")
|
||||
retained_delta=$(aggregate_field "$scenario_dir" ".retained_delta_kib")
|
||||
peak_delta=$(aggregate_field "$scenario_dir" ".peak_delta_kib")
|
||||
|
||||
local turn_growth="null"
|
||||
if [[ "$label" == long-agent* ]]; then
|
||||
# first-turn vs last-turn checkpoint rss delta (steady-state growth).
|
||||
turn_growth=$(jq -s '
|
||||
[ .[] |
|
||||
(.checkpoints // []) as $cps |
|
||||
if ($cps | length) >= 2 then
|
||||
($cps[-1].rss_kib - $cps[0].rss_kib)
|
||||
else empty end
|
||||
] as $vals |
|
||||
($vals | sort) as $sorted |
|
||||
($sorted | length) as $n |
|
||||
if $n == 0 then null
|
||||
else $sorted[((($n - 1) / 2) | floor)]
|
||||
end
|
||||
' "$scenario_dir"/run-*.json)
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg scenario "$label" \
|
||||
--argjson duration "$duration" \
|
||||
--argjson settled_rss "$settled_rss" \
|
||||
--argjson retained_delta "$retained_delta" \
|
||||
--argjson peak_delta "$peak_delta" \
|
||||
--argjson turn_growth_kib "$turn_growth" \
|
||||
'{
|
||||
scenario: $scenario,
|
||||
duration_ms: $duration,
|
||||
settled_rss_kib: $settled_rss,
|
||||
retained_delta_kib: $retained_delta,
|
||||
peak_delta_kib: $peak_delta,
|
||||
long_agent_turn_growth_kib: $turn_growth_kib
|
||||
}'
|
||||
}
|
||||
|
||||
kib_to_mib() {
|
||||
# $1: kib value (may be null). Prints "n/a" for null.
|
||||
local kib="$1"
|
||||
if [[ "$kib" == "null" || -z "$kib" ]]; then
|
||||
echo "n/a"
|
||||
return
|
||||
fi
|
||||
jq -n --argjson kib "$kib" '($kib / 1024 * 100 | round) / 100'
|
||||
}
|
||||
|
||||
write_summary() {
|
||||
local summary_json="$OUT_DIR/summary.json"
|
||||
local summary_md="$OUT_DIR/summary.md"
|
||||
|
||||
jq -s '{ generated_at: (now | todate), build: ($ENV.LIBRARY_BENCH_BUILD // "default"), repeat: ($ENV.LIBRARY_BENCH_REPEAT | tonumber), scenarios: . }' \
|
||||
"$OUT_DIR"/*.scenario.json >"$summary_json"
|
||||
|
||||
{
|
||||
echo "# Library benchmark summary"
|
||||
echo
|
||||
echo "Build: \`${LIBRARY_BENCH_BUILD}\` "
|
||||
echo "Repeats per scenario: ${LIBRARY_BENCH_REPEAT} "
|
||||
echo "Generated: $(date)"
|
||||
echo
|
||||
echo "| Scenario | Median settled RSS (MiB) | Median retained Δ (MiB) | Median peak Δ (MiB) | Median duration (ms) |"
|
||||
echo "| --- | ---: | ---: | ---: | ---: |"
|
||||
local f
|
||||
for f in "$OUT_DIR"/*.scenario.json; do
|
||||
local scenario settled retained peak duration
|
||||
scenario=$(jq -r '.scenario' "$f")
|
||||
settled=$(kib_to_mib "$(jq -r '.settled_rss_kib.median' "$f")")
|
||||
retained=$(kib_to_mib "$(jq -r '.retained_delta_kib.median' "$f")")
|
||||
peak=$(kib_to_mib "$(jq -r '.peak_delta_kib.median' "$f")")
|
||||
duration=$(jq -r '.duration_ms.median // "n/a"' "$f")
|
||||
echo "| $scenario | $settled | $retained | $peak | $duration |"
|
||||
done
|
||||
echo
|
||||
local long_growth_file
|
||||
for long_growth_file in "$OUT_DIR"/long-agent*.scenario.json; do
|
||||
[[ -e "$long_growth_file" ]] || continue
|
||||
local growth
|
||||
growth=$(jq -r '.long_agent_turn_growth_kib // "n/a"' "$long_growth_file")
|
||||
if [[ "$growth" != "n/a" && "$growth" != "null" ]]; then
|
||||
local growth_mib
|
||||
growth_mib=$(kib_to_mib "$growth")
|
||||
echo "**$(jq -r '.scenario' "$long_growth_file") steady-state growth** (first-turn to last-turn checkpoint delta, median across runs): ${growth_mib} MiB."
|
||||
echo
|
||||
fi
|
||||
done
|
||||
cat <<'EOF'
|
||||
## External comparison point (not apples-to-apples)
|
||||
|
||||
ZeroClaw self-reports (unverified) idling under 5 MiB RAM and roughly 8-12 MiB
|
||||
under load. OpenHuman's Rust core currently settles around 35-50 MiB depending
|
||||
on scenario and feature set (see docs/library-benchmarking.md and
|
||||
docs/resource-profiling-session-2026-07-21.md for scope/caveats). Treat this as
|
||||
a north star, not a like-for-like comparison: ZeroClaw's feature surface and
|
||||
scope differ substantially from the OpenHuman core.
|
||||
EOF
|
||||
} >"$summary_md"
|
||||
}
|
||||
|
||||
main() {
|
||||
build_binaries
|
||||
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "ERROR: $BIN not found or not executable. Build it or drop --skip-build." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a scenario_list <<<"$SCENARIOS"
|
||||
|
||||
for scenario in "${scenario_list[@]}"; do
|
||||
run_scenario "$scenario" ""
|
||||
if [[ "$WARM" -eq 1 ]]; then
|
||||
for eligible in "${WARM_ELIGIBLE[@]}"; do
|
||||
if [[ "$scenario" == "$eligible" ]]; then
|
||||
run_scenario "$scenario" "warm"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
# Build one *.scenario.json per label for the summary step.
|
||||
for scenario_dir in "$OUT_DIR"/*/; do
|
||||
[[ -d "$scenario_dir" ]] || continue
|
||||
local_label="$(basename "$scenario_dir")"
|
||||
aggregate_scenario "$local_label" >"$OUT_DIR/${local_label}.scenario.json"
|
||||
done
|
||||
|
||||
export LIBRARY_BENCH_BUILD
|
||||
LIBRARY_BENCH_BUILD=$([[ "$SLIM" -eq 1 ]] && echo "slim" || echo "default")
|
||||
export LIBRARY_BENCH_REPEAT="$REPEAT"
|
||||
|
||||
write_summary
|
||||
|
||||
log "results: $OUT_DIR"
|
||||
cat "$OUT_DIR/summary.md"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
# library-cpu.sh — samply wrapper for a CPU profile of one library-profile
|
||||
# scenario, following the recipe used in docs/resource-profiling-session-2026-07-21.md.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/profile/library-cpu.sh <scenario> [-- <extra env VAR=value>...]
|
||||
# ./scripts/profile/library-cpu.sh --no-isolate <scenario>
|
||||
#
|
||||
# Options:
|
||||
# --no-isolate Do not force OPENHUMAN_PROFILE_DISABLE_MEMORY_WRITES=1 /
|
||||
# OPENHUMAN_PROFILE_FORCE_UTC=1 (default: isolated, matching
|
||||
# the documented cold-path CPU recipe)
|
||||
# --skip-build Reuse the existing target/release/library-profile binary
|
||||
# -h, --help Show this help
|
||||
#
|
||||
# Extra environment variables can be passed after `--`, e.g.:
|
||||
# ./scripts/profile/library-cpu.sh long-agent -- OPENHUMAN_PROFILE_TURNS=50
|
||||
#
|
||||
# Output: target/profile/rust-library/<scenario>-cpu.json.gz
|
||||
# View it with: samply load target/profile/rust-library/<scenario>-cpu.json.gz
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
ISOLATE=1
|
||||
SKIP_BUILD=0
|
||||
SCENARIO=""
|
||||
EXTRA_ENV=()
|
||||
|
||||
usage() {
|
||||
sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-isolate) ISOLATE=0; shift ;;
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
--)
|
||||
shift
|
||||
EXTRA_ENV=("$@")
|
||||
break
|
||||
;;
|
||||
*)
|
||||
if [[ -z "$SCENARIO" ]]; then
|
||||
SCENARIO="$1"
|
||||
shift
|
||||
else
|
||||
echo "ERROR: unexpected argument: $1" >&2
|
||||
usage 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$SCENARIO" ]]; then
|
||||
echo "ERROR: scenario is required" >&2
|
||||
usage 1
|
||||
fi
|
||||
|
||||
if ! command -v samply >/dev/null 2>&1; then
|
||||
echo "ERROR: samply is required. Install it with 'cargo install samply'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BIN="$REPO_ROOT/target/release/library-profile"
|
||||
|
||||
if [[ "$SKIP_BUILD" -eq 0 ]]; then
|
||||
echo "[library-cpu] building library-profile (GGML_NATIVE=OFF)" >&2
|
||||
(
|
||||
cd "$REPO_ROOT"
|
||||
GGML_NATIVE=OFF cargo build --release --features rss-bench --bin library-profile
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "ERROR: $BIN not found or not executable. Build it or drop --skip-build." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUT_DIR="$REPO_ROOT/target/profile/rust-library"
|
||||
mkdir -p "$OUT_DIR"
|
||||
OUT_FILE="$OUT_DIR/${SCENARIO}-cpu.json.gz"
|
||||
|
||||
ENV_ARGS=()
|
||||
if [[ "$ISOLATE" -eq 1 ]]; then
|
||||
ENV_ARGS+=(OPENHUMAN_PROFILE_DISABLE_MEMORY_WRITES=1 OPENHUMAN_PROFILE_FORCE_UTC=1)
|
||||
fi
|
||||
ENV_ARGS+=("${EXTRA_ENV[@]+"${EXTRA_ENV[@]}"}")
|
||||
|
||||
echo "[library-cpu] recording scenario '$SCENARIO' -> $OUT_FILE" >&2
|
||||
env "${ENV_ARGS[@]+"${ENV_ARGS[@]}"}" samply record \
|
||||
--save-only \
|
||||
--unstable-presymbolicate \
|
||||
--rate 1000 \
|
||||
--iteration-count 5 \
|
||||
--output "$OUT_FILE" \
|
||||
-- "$BIN" "$SCENARIO"
|
||||
|
||||
echo "[library-cpu] done: $OUT_FILE" >&2
|
||||
echo "[library-cpu] view it with: samply load $OUT_FILE" >&2
|
||||
Executable
+371
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env bash
|
||||
# library-fleet.sh — sweep the `fleet` scenario of library-profile across
|
||||
# agent counts and gate the result against the 2 GB RAM / 2 vCPU server
|
||||
# budget (100-1000 live agents). Companion scripts: library-bench.sh
|
||||
# (per-scenario RSS/duration), library-cpu.sh (samply), library-heap.sh
|
||||
# (dhat).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/profile/library-fleet.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --agents "50,100,500" Comma-separated agent-count sweep (default: "50,100,500")
|
||||
# --turns N OPENHUMAN_PROFILE_TURNS per agent (default: 3)
|
||||
# --latency-ms N OPENHUMAN_PROFILE_MOCK_LATENCY_MS (default: 200)
|
||||
# --workers N OPENHUMAN_PROFILE_WORKER_THREADS, simulates the
|
||||
# 2 vCPU box (default: 2)
|
||||
# --repeat N Fresh-process repeats per agent count (default: 3)
|
||||
# --target N OPENHUMAN_PROFILE_TARGET_AGENTS (default: 1000)
|
||||
# --budget-mib N OPENHUMAN_PROFILE_RAM_BUDGET_MIB (default: 2048)
|
||||
# --skip-build Reuse the existing target/release binaries
|
||||
# --slim Build with --no-default-features (slim library recipe)
|
||||
# --out DIR Output directory (default: target/profile/rust-library/fleet-<timestamp>)
|
||||
# --no-gate Do not fail the exit code on fits==false (report only)
|
||||
# -h, --help Show this help
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/profile/library-fleet.sh
|
||||
# ./scripts/profile/library-fleet.sh --agents 100 --latency-ms 200
|
||||
# ./scripts/profile/library-fleet.sh --agents "100,1000" --target 1000 --budget-mib 2048
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
AGENTS="50,100,500"
|
||||
TURNS=3
|
||||
LATENCY_MS=200
|
||||
WORKERS=2
|
||||
REPEAT=3
|
||||
TARGET=1000
|
||||
BUDGET_MIB=2048
|
||||
SKIP_BUILD=0
|
||||
SLIM=0
|
||||
OUT_DIR=""
|
||||
GATE=1
|
||||
|
||||
# Idle CPU is measured over a 10s parked window and should be ~flat
|
||||
# regardless of agent count (idle agents cost ~zero CPU). 500ms of CPU
|
||||
# across that window (5% of one core) is the "low" threshold used in the
|
||||
# PASS/FAIL verdict line; see docs/library-benchmarking.md.
|
||||
IDLE_CPU_MS_MAX=500
|
||||
|
||||
usage() {
|
||||
sed -n '2,27p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--agents)
|
||||
AGENTS="${2:?--agents requires a value}"; shift 2 ;;
|
||||
--turns)
|
||||
TURNS="${2:?--turns requires a value}"; shift 2 ;;
|
||||
--latency-ms)
|
||||
LATENCY_MS="${2:?--latency-ms requires a value}"; shift 2 ;;
|
||||
--workers)
|
||||
WORKERS="${2:?--workers requires a value}"; shift 2 ;;
|
||||
--repeat)
|
||||
REPEAT="${2:?--repeat requires a value}"; shift 2 ;;
|
||||
--target)
|
||||
TARGET="${2:?--target requires a value}"; shift 2 ;;
|
||||
--budget-mib)
|
||||
BUDGET_MIB="${2:?--budget-mib requires a value}"; shift 2 ;;
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
--slim) SLIM=1; shift ;;
|
||||
--out)
|
||||
OUT_DIR="${2:?--out requires a value}"; shift 2 ;;
|
||||
--no-gate) GATE=0; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*)
|
||||
echo "ERROR: unknown argument: $1" >&2
|
||||
usage 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "ERROR: jq is required (aggregation over run JSON). Install it (e.g. 'brew install jq')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$OUT_DIR" ]]; then
|
||||
OUT_DIR="$REPO_ROOT/target/profile/rust-library/fleet-$(date +%Y%m%d-%H%M%S)"
|
||||
fi
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
BIN="$REPO_ROOT/target/release/library-profile"
|
||||
|
||||
log() { echo "[library-fleet] $*" >&2; }
|
||||
|
||||
build_binaries() {
|
||||
if [[ "$SKIP_BUILD" -eq 1 ]]; then
|
||||
log "skipping build (--skip-build)"
|
||||
return
|
||||
fi
|
||||
local feature_args=(--features rss-bench)
|
||||
if [[ "$SLIM" -eq 1 ]]; then
|
||||
feature_args=(--no-default-features --features rss-bench)
|
||||
log "building slim library-profile + rss-bench (GGML_NATIVE=OFF)"
|
||||
else
|
||||
log "building default-feature library-profile + rss-bench (GGML_NATIVE=OFF)"
|
||||
fi
|
||||
(
|
||||
cd "$REPO_ROOT"
|
||||
GGML_NATIVE=OFF cargo build --release \
|
||||
"${feature_args[@]}" \
|
||||
--bin library-profile --bin rss-bench
|
||||
)
|
||||
}
|
||||
|
||||
run_sweep_point() {
|
||||
local n="$1"
|
||||
local point_dir="$OUT_DIR/fleet-$n"
|
||||
mkdir -p "$point_dir"
|
||||
|
||||
log "running fleet scenario with $n agents x$REPEAT (fresh process each run)"
|
||||
|
||||
local i
|
||||
for ((i = 1; i <= REPEAT; i++)); do
|
||||
local run_file="$point_dir/run-$i.json"
|
||||
|
||||
env \
|
||||
"OPENHUMAN_PROFILE_AGENTS=$n" \
|
||||
"OPENHUMAN_PROFILE_TURNS=$TURNS" \
|
||||
"OPENHUMAN_PROFILE_MOCK_LATENCY_MS=$LATENCY_MS" \
|
||||
"OPENHUMAN_PROFILE_WORKER_THREADS=$WORKERS" \
|
||||
"OPENHUMAN_PROFILE_TARGET_AGENTS=$TARGET" \
|
||||
"OPENHUMAN_PROFILE_RAM_BUDGET_MIB=$BUDGET_MIB" \
|
||||
"$BIN" fleet >"$run_file"
|
||||
|
||||
if ! jq empty "$run_file" >/dev/null 2>&1; then
|
||||
echo "ERROR: run $i for agents=$n did not produce valid JSON: $run_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# library-heap.sh clobbers target/release/library-profile with the
|
||||
# rss-bench-dhat build, whose allocator perturbs RSS/timing. The dhat
|
||||
# binary marks its output, so refuse to benchmark it.
|
||||
if [[ "$(jq -r '.dhat // false' "$run_file")" == "true" ]]; then
|
||||
echo "ERROR: $BIN was built with rss-bench-dhat (library-heap.sh clobbered it)." >&2
|
||||
echo " Re-run without --skip-build to rebuild the plain rss-bench binary." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Emit the median (lower-middle for even N), min, and max of a jq numeric
|
||||
# field across the run files for one sweep point, as a JSON object on stdout.
|
||||
aggregate_field() {
|
||||
local point_dir="$1"
|
||||
local jq_path="$2"
|
||||
jq -s "
|
||||
[ .[] | $jq_path | select(. != null) ] as \$vals |
|
||||
(\$vals | sort) as \$sorted |
|
||||
(\$sorted | length) as \$n |
|
||||
{
|
||||
median: (if \$n == 0 then null else \$sorted[(( \$n - 1) / 2 | floor)] end),
|
||||
min: (\$sorted[0] // null),
|
||||
max: (\$sorted[-1] // null),
|
||||
n: \$n
|
||||
}
|
||||
" "$point_dir"/run-*.json
|
||||
}
|
||||
|
||||
# fits is a boolean per run; report it as "fits" only when every repeat
|
||||
# agreed it fits (a single unlucky repeat should not hide a marginal case).
|
||||
aggregate_fits() {
|
||||
local point_dir="$1"
|
||||
jq -s '
|
||||
[ .[] | .budget.fits // false ] as $vals |
|
||||
($vals | length) as $n |
|
||||
($vals | map(select(. == true)) | length) as $true_n |
|
||||
{ all_fit: ($n > 0 and $true_n == $n), true_n: $true_n, n: $n }
|
||||
' "$point_dir"/run-*.json
|
||||
}
|
||||
|
||||
aggregate_point() {
|
||||
local n="$1"
|
||||
local point_dir="$OUT_DIR/fleet-$n"
|
||||
|
||||
local marginal settled_rss idle_cpu threads open_fds p50 p95 p99 projected fits
|
||||
marginal=$(aggregate_field "$point_dir" ".marginal_rss_kib_per_agent")
|
||||
settled_rss=$(aggregate_field "$point_dir" ".settled.rss_kib")
|
||||
idle_cpu=$(aggregate_field "$point_dir" ".idle_cpu_ms")
|
||||
threads=$(aggregate_field "$point_dir" ".settled.threads")
|
||||
open_fds=$(aggregate_field "$point_dir" ".settled.open_fds")
|
||||
p50=$(aggregate_field "$point_dir" ".turn_latency_ms.p50")
|
||||
p95=$(aggregate_field "$point_dir" ".turn_latency_ms.p95")
|
||||
p99=$(aggregate_field "$point_dir" ".turn_latency_ms.p99")
|
||||
projected=$(aggregate_field "$point_dir" ".budget.projected_rss_mib_at_target")
|
||||
fits=$(aggregate_fits "$point_dir")
|
||||
|
||||
jq -n \
|
||||
--argjson agents "$n" \
|
||||
--argjson marginal_rss_kib_per_agent "$marginal" \
|
||||
--argjson settled_rss_kib "$settled_rss" \
|
||||
--argjson idle_cpu_ms "$idle_cpu" \
|
||||
--argjson threads "$threads" \
|
||||
--argjson open_fds "$open_fds" \
|
||||
--argjson p50 "$p50" \
|
||||
--argjson p95 "$p95" \
|
||||
--argjson p99 "$p99" \
|
||||
--argjson projected_rss_mib_at_target "$projected" \
|
||||
--argjson fits "$fits" \
|
||||
'{
|
||||
agents: $agents,
|
||||
marginal_rss_kib_per_agent: $marginal_rss_kib_per_agent,
|
||||
settled_rss_kib: $settled_rss_kib,
|
||||
idle_cpu_ms: $idle_cpu_ms,
|
||||
threads: $threads,
|
||||
open_fds: $open_fds,
|
||||
turn_latency_ms: { p50: $p50, p95: $p95, p99: $p99 },
|
||||
projected_rss_mib_at_target: $projected_rss_mib_at_target,
|
||||
fits: $fits
|
||||
}'
|
||||
}
|
||||
|
||||
kib_to_mib() {
|
||||
# $1: kib value (may be null/"null"). Prints "n/a" for null.
|
||||
local kib="$1"
|
||||
if [[ "$kib" == "null" || -z "$kib" ]]; then
|
||||
echo "n/a"
|
||||
return
|
||||
fi
|
||||
jq -n --argjson kib "$kib" '($kib / 1024 * 100 | round) / 100'
|
||||
}
|
||||
|
||||
num_or_na() {
|
||||
local v="$1"
|
||||
[[ "$v" == "null" || -z "$v" ]] && echo "n/a" || echo "$v"
|
||||
}
|
||||
|
||||
write_summary() {
|
||||
local summary_json="$OUT_DIR/summary.json"
|
||||
local summary_md="$OUT_DIR/summary.md"
|
||||
|
||||
jq -s \
|
||||
--argjson turns "$TURNS" \
|
||||
--argjson latency_ms "$LATENCY_MS" \
|
||||
--argjson workers "$WORKERS" \
|
||||
--argjson repeat "$REPEAT" \
|
||||
--argjson target "$TARGET" \
|
||||
--argjson budget_mib "$BUDGET_MIB" \
|
||||
--arg build "$([[ "$SLIM" -eq 1 ]] && echo "slim" || echo "default")" \
|
||||
'{
|
||||
generated_at: (now | todate),
|
||||
build: $build,
|
||||
config: {
|
||||
turns: $turns,
|
||||
mock_latency_ms: $latency_ms,
|
||||
worker_threads: $workers,
|
||||
repeat: $repeat,
|
||||
target_agents: $target,
|
||||
ram_budget_mib: $budget_mib
|
||||
},
|
||||
sweep: .
|
||||
}' \
|
||||
"$OUT_DIR"/*.point.json >"$summary_json"
|
||||
|
||||
local any_fail=0
|
||||
|
||||
{
|
||||
echo "# Fleet benchmark summary"
|
||||
echo
|
||||
echo "Build: \`$([[ "$SLIM" -eq 1 ]] && echo "slim" || echo "default")\` "
|
||||
echo "Repeats per agent count: ${REPEAT} "
|
||||
echo "Turns/agent: ${TURNS}, mock latency: ${LATENCY_MS}ms, worker threads: ${WORKERS} "
|
||||
echo "Target: ${TARGET} agents, budget: ${BUDGET_MIB} MiB "
|
||||
echo "Generated: $(date)"
|
||||
echo
|
||||
echo "| N | marginal KiB/agent | settled MiB | idle CPU ms/10s | threads | fds | p95 ms | projected MiB @ target | fits |"
|
||||
echo "| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | :---: |"
|
||||
|
||||
local f
|
||||
for f in "$OUT_DIR"/*.point.json; do
|
||||
local n marginal settled idle threads fds p95 projected fits_all
|
||||
n=$(jq -r '.agents' "$f")
|
||||
marginal=$(num_or_na "$(jq -r '.marginal_rss_kib_per_agent.median' "$f")")
|
||||
settled=$(kib_to_mib "$(jq -r '.settled_rss_kib.median' "$f")")
|
||||
idle=$(num_or_na "$(jq -r '.idle_cpu_ms.median' "$f")")
|
||||
threads=$(num_or_na "$(jq -r '.threads.median' "$f")")
|
||||
fds=$(num_or_na "$(jq -r '.open_fds.median' "$f")")
|
||||
p95=$(num_or_na "$(jq -r '.turn_latency_ms.p95.median' "$f")")
|
||||
projected=$(num_or_na "$(jq -r '.projected_rss_mib_at_target.median' "$f")")
|
||||
fits_all=$(jq -r '.fits.all_fit' "$f")
|
||||
echo "| $n | $marginal | $settled | $idle | $threads | $fds | $p95 | $projected | $fits_all |"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "## Verdict"
|
||||
echo
|
||||
|
||||
for f in "$OUT_DIR"/*.point.json; do
|
||||
local n fits_all idle_median verdict reasons
|
||||
n=$(jq -r '.agents' "$f")
|
||||
fits_all=$(jq -r '.fits.all_fit' "$f")
|
||||
idle_median=$(jq -r '.idle_cpu_ms.median // "null"' "$f")
|
||||
|
||||
reasons=""
|
||||
verdict="PASS"
|
||||
if [[ "$fits_all" != "true" ]]; then
|
||||
verdict="FAIL"
|
||||
reasons="${reasons}projected RSS at target does not fit ${BUDGET_MIB} MiB budget; "
|
||||
any_fail=1
|
||||
fi
|
||||
local idle_over
|
||||
idle_over=0
|
||||
if [[ "$idle_median" != "null" ]]; then
|
||||
idle_over=$(jq -n --argjson v "$idle_median" --argjson max "$IDLE_CPU_MS_MAX" 'if $v > $max then 1 else 0 end')
|
||||
fi
|
||||
if [[ "$idle_over" -eq 1 ]]; then
|
||||
verdict="FAIL"
|
||||
reasons="${reasons}idle CPU ${idle_median}ms/10s exceeds ${IDLE_CPU_MS_MAX}ms threshold; "
|
||||
fi
|
||||
|
||||
if [[ "$verdict" == "PASS" ]]; then
|
||||
echo "- **N=$n: PASS** — fits budget, idle CPU low."
|
||||
else
|
||||
echo "- **N=$n: FAIL** — ${reasons}"
|
||||
fi
|
||||
done
|
||||
} >"$summary_md"
|
||||
|
||||
if [[ "$any_fail" -eq 1 ]]; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
main() {
|
||||
build_binaries
|
||||
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "ERROR: $BIN not found or not executable. Build it or drop --skip-build." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a agent_list <<<"$AGENTS"
|
||||
|
||||
local n
|
||||
for n in "${agent_list[@]}"; do
|
||||
run_sweep_point "$n"
|
||||
done
|
||||
|
||||
for n in "${agent_list[@]}"; do
|
||||
aggregate_point "$n" >"$OUT_DIR/fleet-$n.point.json"
|
||||
done
|
||||
|
||||
local gate_status=0
|
||||
write_summary || gate_status=1
|
||||
|
||||
log "results: $OUT_DIR"
|
||||
cat "$OUT_DIR/summary.md"
|
||||
|
||||
if [[ "$GATE" -eq 1 && "$gate_status" -ne 0 ]]; then
|
||||
log "GATE FAILED: at least one swept N does not fit the ${BUDGET_MIB} MiB / ${TARGET}-agent budget"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# library-heap.sh — builds the rss-bench-dhat variant of library-profile and
|
||||
# runs one scenario under dhat heap profiling.
|
||||
#
|
||||
# Note: dhat instrumentation perturbs RSS/timing measurements. Use
|
||||
# library-bench.sh / library-cpu.sh for RSS and CPU numbers; use this script
|
||||
# only for live-heap attribution (allocation sites, retained bytes).
|
||||
#
|
||||
# This build REPLACES target/release/library-profile with the dhat variant.
|
||||
# A later `library-bench.sh --skip-build` would pick it up; the bench script
|
||||
# detects the dhat marker in the output JSON and refuses, but the clean fix
|
||||
# is to rerun library-bench.sh without --skip-build afterwards.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/profile/library-heap.sh <scenario> [-- <extra env VAR=value>...]
|
||||
#
|
||||
# Options:
|
||||
# --skip-build Reuse the existing target/release/library-profile binary
|
||||
# -h, --help Show this help
|
||||
#
|
||||
# Output: target/profile/rust-library/dhat-<scenario>.json
|
||||
# View it at: https://nnethercote.github.io/dh_view/dh_view.html
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
SKIP_BUILD=0
|
||||
SCENARIO=""
|
||||
EXTRA_ENV=()
|
||||
|
||||
usage() {
|
||||
sed -n '2,17p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
--)
|
||||
shift
|
||||
EXTRA_ENV=("$@")
|
||||
break
|
||||
;;
|
||||
*)
|
||||
if [[ -z "$SCENARIO" ]]; then
|
||||
SCENARIO="$1"
|
||||
shift
|
||||
else
|
||||
echo "ERROR: unexpected argument: $1" >&2
|
||||
usage 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$SCENARIO" ]]; then
|
||||
echo "ERROR: scenario is required" >&2
|
||||
usage 1
|
||||
fi
|
||||
|
||||
BIN="$REPO_ROOT/target/release/library-profile"
|
||||
OUT_DIR="$REPO_ROOT/target/profile/rust-library"
|
||||
mkdir -p "$OUT_DIR"
|
||||
OUT_FILE="$OUT_DIR/dhat-${SCENARIO}.json"
|
||||
|
||||
if [[ "$SKIP_BUILD" -eq 0 ]]; then
|
||||
echo "[library-heap] building library-profile with rss-bench-dhat (GGML_NATIVE=OFF)" >&2
|
||||
(
|
||||
cd "$REPO_ROOT"
|
||||
GGML_NATIVE=OFF cargo build --release --features rss-bench-dhat --bin library-profile
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "ERROR: $BIN not found or not executable. Build it or drop --skip-build." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[library-heap] running scenario '$SCENARIO' under dhat" >&2
|
||||
echo "[library-heap] WARNING: dhat instrumentation perturbs RSS and timing; do not compare these numbers against library-bench.sh output" >&2
|
||||
|
||||
env OPENHUMAN_PROFILE_DHAT_OUT="$OUT_FILE" "${EXTRA_ENV[@]+"${EXTRA_ENV[@]}"}" "$BIN" "$SCENARIO" >/dev/null
|
||||
|
||||
if [[ ! -f "$OUT_FILE" ]]; then
|
||||
echo "ERROR: expected dhat output was not written: $OUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[library-heap] done: $OUT_FILE" >&2
|
||||
echo "[library-heap] view it at https://nnethercote.github.io/dh_view/dh_view.html (load the JSON file)" >&2
|
||||
Executable
+548
@@ -0,0 +1,548 @@
|
||||
#!/usr/bin/env bash
|
||||
# library-instances.sh — multi-instance fuzz driver: spawn N independent
|
||||
# `library-profile` processes (one live instance each, held alive at settled
|
||||
# state via OPENHUMAN_PROFILE_HOLD_SECS) and measure per-INSTANCE cost and
|
||||
# box survivability under the "N processes" deployment model, as opposed to
|
||||
# the "N agents in one process" model that `library-fleet.sh` measures.
|
||||
#
|
||||
# Where library-fleet.sh answers "how many agents fit in one process", this
|
||||
# answers "how many independent processes/containers fit on one box" — the
|
||||
# opencompany per-tenant-instance question. Companion scripts: library-bench.sh
|
||||
# (per-scenario RSS/duration), library-fleet.sh (one-process fleet sweep).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/profile/library-instances.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --instances "10,25,50" Comma-separated instance-count sweep (default: "10,25,50")
|
||||
# --hold-secs N Seconds each instance holds alive at settled state
|
||||
# after finishing its workload (default: 30)
|
||||
# --scenario NAME library-profile scenario to run per instance
|
||||
# (default: agent-turn)
|
||||
# --stagger-ms N Delay between spawning consecutive instances, in
|
||||
# milliseconds (default: 100)
|
||||
# --skip-build Reuse the existing target/release binary
|
||||
# --slim Build with --no-default-features (slim library recipe)
|
||||
# --max-instances N Hard safety cap on any swept N (default: 200).
|
||||
# Each held instance settles ~47 MiB RSS on a
|
||||
# default build, so sum-RSS scales roughly
|
||||
# linearly: 1000 instances (at default settings)
|
||||
# is on the order of 1000 x 47 MiB ~= 47 GB
|
||||
# aggregate RSS. Raising this cap is an explicit
|
||||
# "yes, I mean to spawn that many processes" — it
|
||||
# will not happen implicitly.
|
||||
# --out DIR Output directory (default:
|
||||
# target/profile/rust-library/instances-<timestamp>)
|
||||
# --gate Exit nonzero if any instance failed (nonzero exit
|
||||
# or missing/invalid JSON). Default: report only,
|
||||
# always exit 0 unless argument/build errors occur.
|
||||
# -h, --help Show this help
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/profile/library-instances.sh
|
||||
# ./scripts/profile/library-instances.sh --instances "10,50" --hold-secs 30
|
||||
# ./scripts/profile/library-instances.sh --instances "500" --max-instances 500 --gate
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
INSTANCES="10,25,50"
|
||||
HOLD_SECS=30
|
||||
SCENARIO="agent-turn"
|
||||
STAGGER_MS=100
|
||||
SKIP_BUILD=0
|
||||
SLIM=0
|
||||
MAX_INSTANCES=200
|
||||
OUT_DIR=""
|
||||
GATE=0
|
||||
|
||||
# Default-build settled RSS per held instance, used only for the safety-cap
|
||||
# explanation message and the summary.md extrapolation fallback when PSS is
|
||||
# unavailable (macOS). This is a rule-of-thumb constant, not a measurement —
|
||||
# the script's own measured settled RSS supersedes it once real data exists.
|
||||
ASSUMED_PER_INSTANCE_RSS_MIB=47
|
||||
BUDGET_MIB=2048
|
||||
|
||||
usage() {
|
||||
sed -n '2,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--instances)
|
||||
INSTANCES="${2:?--instances requires a value}"; shift 2 ;;
|
||||
--hold-secs)
|
||||
HOLD_SECS="${2:?--hold-secs requires a value}"; shift 2 ;;
|
||||
--scenario)
|
||||
SCENARIO="${2:?--scenario requires a value}"; shift 2 ;;
|
||||
--stagger-ms)
|
||||
STAGGER_MS="${2:?--stagger-ms requires a value}"; shift 2 ;;
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
--slim) SLIM=1; shift ;;
|
||||
--max-instances)
|
||||
MAX_INSTANCES="${2:?--max-instances requires a value}"; shift 2 ;;
|
||||
--out)
|
||||
OUT_DIR="${2:?--out requires a value}"; shift 2 ;;
|
||||
--gate) GATE=1; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*)
|
||||
echo "ERROR: unknown argument: $1" >&2
|
||||
usage 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "ERROR: jq is required (aggregation over run JSON). Install it (e.g. 'brew install jq')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$OUT_DIR" ]]; then
|
||||
OUT_DIR="$REPO_ROOT/target/profile/rust-library/instances-$(date +%Y%m%d-%H%M%S)"
|
||||
fi
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
BIN="$REPO_ROOT/target/release/library-profile"
|
||||
|
||||
log() { echo "[library-instances] $*" >&2; }
|
||||
|
||||
# --- safety cap -------------------------------------------------------------
|
||||
|
||||
check_cap() {
|
||||
local n="$1"
|
||||
if [[ "$n" -gt "$MAX_INSTANCES" ]]; then
|
||||
local naive_gib
|
||||
naive_gib=$(jq -n --argjson n "$n" --argjson mib "$ASSUMED_PER_INSTANCE_RSS_MIB" '(($n * $mib) / 1024 * 100 | round) / 100')
|
||||
echo "ERROR: requested $n instances exceeds --max-instances cap ($MAX_INSTANCES)." >&2
|
||||
echo " RAM math: each held instance settles ~${ASSUMED_PER_INSTANCE_RSS_MIB} MiB RSS on a" >&2
|
||||
echo " default build (many-processes model pays that base N times, unlike the" >&2
|
||||
echo " one-process fleet model, which amortizes it once). $n instances is roughly" >&2
|
||||
echo " $n x ${ASSUMED_PER_INSTANCE_RSS_MIB} MiB ~= ${naive_gib} GiB of aggregate sum-RSS — likely more" >&2
|
||||
echo " than this machine has. Pass --max-instances $n (or higher) to confirm you" >&2
|
||||
echo " intend to spawn that many processes." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- build -------------------------------------------------------------------
|
||||
|
||||
build_binaries() {
|
||||
if [[ "$SKIP_BUILD" -eq 1 ]]; then
|
||||
log "skipping build (--skip-build)"
|
||||
return
|
||||
fi
|
||||
local feature_args=(--features rss-bench)
|
||||
if [[ "$SLIM" -eq 1 ]]; then
|
||||
feature_args=(--no-default-features --features rss-bench)
|
||||
log "building slim library-profile + rss-bench (GGML_NATIVE=OFF)"
|
||||
else
|
||||
log "building default-feature library-profile + rss-bench (GGML_NATIVE=OFF)"
|
||||
fi
|
||||
(
|
||||
cd "$REPO_ROOT"
|
||||
GGML_NATIVE=OFF cargo build --release \
|
||||
"${feature_args[@]}" \
|
||||
--bin library-profile --bin rss-bench
|
||||
)
|
||||
}
|
||||
|
||||
# One quick, short-lived run before the sweep: confirms the binary emits
|
||||
# valid schema JSON and is not the dhat-instrumented build (library-heap.sh
|
||||
# clobbers target/release/library-profile with rss-bench-dhat, whose
|
||||
# allocator perturbs RSS in a way that would corrupt every number below).
|
||||
probe_binary() {
|
||||
log "probe run: validating '$BIN $SCENARIO' before the sweep"
|
||||
local probe_file="$OUT_DIR/probe.json"
|
||||
local probe_log="$OUT_DIR/probe.log"
|
||||
|
||||
OPENHUMAN_PROFILE_HOLD_SECS=0 "$BIN" "$SCENARIO" >"$probe_file" 2>"$probe_log"
|
||||
|
||||
if ! jq empty "$probe_file" >/dev/null 2>&1; then
|
||||
echo "ERROR: probe run did not produce valid JSON: $probe_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(jq -r '.dhat // false' "$probe_file")" == "true" ]]; then
|
||||
echo "ERROR: $BIN was built with rss-bench-dhat (library-heap.sh clobbered it)." >&2
|
||||
echo " Re-run without --skip-build to rebuild the plain rss-bench binary." >&2
|
||||
exit 1
|
||||
fi
|
||||
log "probe OK: settled rss_kib=$(jq -r '.settled.rss_kib' "$probe_file")"
|
||||
}
|
||||
|
||||
# --- spawn + sample ----------------------------------------------------------
|
||||
|
||||
sleep_ms() {
|
||||
local ms="$1"
|
||||
[[ "$ms" -le 0 ]] && return 0
|
||||
local secs
|
||||
secs=$(awk -v ms="$ms" 'BEGIN { printf "%.3f", ms / 1000 }')
|
||||
sleep "$secs"
|
||||
}
|
||||
|
||||
# Best-effort per-process attribution via the macOS `footprint` tool, run
|
||||
# against a handful of pids for one sweep point only (it is slow and
|
||||
# invasive; ignore any failure — this is a bonus artifact, not load-bearing).
|
||||
capture_footprint_sample() {
|
||||
local point_dir="$1"; shift
|
||||
local pids=("$@")
|
||||
|
||||
if ! command -v footprint >/dev/null 2>&1; then
|
||||
log "footprint tool not present, skipping footprint sample"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local sampled=0
|
||||
local pid
|
||||
for pid in "${pids[@]}"; do
|
||||
[[ "$sampled" -ge 3 ]] && break
|
||||
footprint "$pid" >"$point_dir/footprint-pid$pid.txt" 2>&1 || true
|
||||
sampled=$((sampled + 1))
|
||||
done
|
||||
log "footprint: sampled $sampled pid(s) (best-effort, see footprint-pid*.txt)"
|
||||
}
|
||||
|
||||
# Samples aggregate sum-RSS + live count every 2s via `ps -o pid=,rss=` until
|
||||
# every pid in the list has exited, appending to samples.csv. Records the
|
||||
# peak sum-RSS observed and takes one `vm_stat` system-memory snapshot at
|
||||
# that peak.
|
||||
sample_while_holding() {
|
||||
local point_dir="$1"; shift
|
||||
local -a pids=("$@")
|
||||
|
||||
local samples_csv="$point_dir/samples.csv"
|
||||
echo "elapsed_s,sum_rss_kib,live_count" >"$samples_csv"
|
||||
|
||||
local pid_csv
|
||||
pid_csv=$(IFS=,; echo "${pids[*]}")
|
||||
|
||||
local start_ts
|
||||
start_ts=$(date +%s)
|
||||
local peak_sum=0
|
||||
|
||||
while true; do
|
||||
local ps_out
|
||||
ps_out=$(ps -o pid=,rss= -p "$pid_csv" 2>/dev/null || true)
|
||||
|
||||
local live_count sum_rss
|
||||
if [[ -z "$ps_out" ]]; then
|
||||
live_count=0
|
||||
sum_rss=0
|
||||
else
|
||||
read -r live_count sum_rss <<<"$(echo "$ps_out" | awk '{s += $2; c += 1} END { print c + 0, s + 0 }')"
|
||||
fi
|
||||
|
||||
local elapsed=$(( $(date +%s) - start_ts ))
|
||||
echo "$elapsed,$sum_rss,$live_count" >>"$samples_csv"
|
||||
|
||||
if [[ "$sum_rss" -gt "$peak_sum" ]]; then
|
||||
peak_sum="$sum_rss"
|
||||
vm_stat >"$point_dir/vm_stat-peak.txt" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ "$live_count" -eq 0 ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "$peak_sum" >"$point_dir/.peak_sum_rss_kib"
|
||||
}
|
||||
|
||||
run_sweep_point() {
|
||||
local n="$1"
|
||||
local do_footprint="$2"
|
||||
local point_dir="$OUT_DIR/n$n"
|
||||
mkdir -p "$point_dir"
|
||||
|
||||
log "spawning $n instance(s) of scenario '$SCENARIO' (hold=${HOLD_SECS}s, stagger=${STAGGER_MS}ms)"
|
||||
|
||||
local -a pids=()
|
||||
local i
|
||||
for ((i = 1; i <= n; i++)); do
|
||||
local out_file="$point_dir/proc-$i.json"
|
||||
local log_file="$point_dir/proc-$i.log"
|
||||
|
||||
env "OPENHUMAN_PROFILE_HOLD_SECS=$HOLD_SECS" "$BIN" "$SCENARIO" \
|
||||
>"$out_file" 2>"$log_file" &
|
||||
pids+=("$!")
|
||||
|
||||
if [[ "$i" -lt "$n" ]]; then
|
||||
sleep_ms "$STAGGER_MS"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$do_footprint" -eq 1 ]]; then
|
||||
capture_footprint_sample "$point_dir" "${pids[@]}"
|
||||
fi
|
||||
|
||||
sample_while_holding "$point_dir" "${pids[@]}"
|
||||
|
||||
local ok=0 failed=0
|
||||
local pid_index=0
|
||||
for pid in "${pids[@]}"; do
|
||||
pid_index=$((pid_index + 1))
|
||||
local out_file="$point_dir/proc-$pid_index.json"
|
||||
local status=0
|
||||
wait "$pid" || status=$?
|
||||
|
||||
if [[ "$status" -eq 0 && -s "$out_file" ]] && jq empty "$out_file" >/dev/null 2>&1; then
|
||||
ok=$((ok + 1))
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
log "WARN: instance $pid_index (pid $pid, n=$n) failed (exit=$status)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$ok" >"$point_dir/.ok_count"
|
||||
echo "$failed" >"$point_dir/.failed_count"
|
||||
log "n=$n done: ok=$ok failed=$failed"
|
||||
}
|
||||
|
||||
# --- aggregate ----------------------------------------------------------------
|
||||
|
||||
kib_to_mib() {
|
||||
local kib="$1"
|
||||
if [[ "$kib" == "null" || -z "$kib" ]]; then
|
||||
echo "n/a"
|
||||
return
|
||||
fi
|
||||
jq -n --argjson kib "$kib" '($kib / 1024 * 100 | round) / 100'
|
||||
}
|
||||
|
||||
num_or_na() {
|
||||
local v="$1"
|
||||
[[ "$v" == "null" || -z "$v" ]] && echo "n/a" || echo "$v"
|
||||
}
|
||||
|
||||
aggregate_point() {
|
||||
local n="$1"
|
||||
local point_dir="$OUT_DIR/n$n"
|
||||
|
||||
local ok failed peak_sum_rss_kib
|
||||
ok=$(cat "$point_dir/.ok_count" 2>/dev/null || echo 0)
|
||||
failed=$(cat "$point_dir/.failed_count" 2>/dev/null || echo 0)
|
||||
peak_sum_rss_kib=$(cat "$point_dir/.peak_sum_rss_kib" 2>/dev/null || echo 0)
|
||||
|
||||
local -a valid_files=()
|
||||
local f
|
||||
for f in "$point_dir"/proc-*.json; do
|
||||
[[ -e "$f" ]] || continue
|
||||
if jq empty "$f" >/dev/null 2>&1; then
|
||||
valid_files+=("$f")
|
||||
fi
|
||||
done
|
||||
|
||||
local settled_rss_median pss_max pss_sum_kib
|
||||
if [[ "${#valid_files[@]}" -gt 0 ]]; then
|
||||
settled_rss_median=$(jq -s '
|
||||
[ .[] | .settled.rss_kib | select(. != null) ] as $vals |
|
||||
($vals | sort) as $sorted |
|
||||
($sorted | length) as $n |
|
||||
(if $n == 0 then null else $sorted[(($n - 1) / 2 | floor)] end)
|
||||
' "${valid_files[@]}")
|
||||
pss_max=$(jq -s '[ .[] | .settled.pss_kib // 0 ] | max' "${valid_files[@]}")
|
||||
pss_sum_kib=$(jq -s '[ .[] | .settled.pss_kib // 0 ] | add' "${valid_files[@]}")
|
||||
else
|
||||
settled_rss_median=null
|
||||
pss_max=0
|
||||
pss_sum_kib=0
|
||||
fi
|
||||
|
||||
local mean_sum_rss_kib_per_instance
|
||||
if [[ "$ok" -gt 0 ]]; then
|
||||
mean_sum_rss_kib_per_instance=$(jq -n --argjson total "$peak_sum_rss_kib" --argjson ok "$ok" '$total / $ok')
|
||||
else
|
||||
mean_sum_rss_kib_per_instance=null
|
||||
fi
|
||||
|
||||
local pss_available
|
||||
pss_available=$(jq -n --argjson v "$pss_max" 'if $v > 0 then true else false end')
|
||||
|
||||
jq -n \
|
||||
--argjson instances "$n" \
|
||||
--argjson launched "$n" \
|
||||
--argjson ok "$ok" \
|
||||
--argjson failed "$failed" \
|
||||
--argjson peak_sum_rss_kib "$peak_sum_rss_kib" \
|
||||
--argjson mean_sum_rss_kib_per_instance "$mean_sum_rss_kib_per_instance" \
|
||||
--argjson settled_rss_kib_median "$settled_rss_median" \
|
||||
--argjson pss_available "$pss_available" \
|
||||
--argjson pss_sum_kib "$pss_sum_kib" \
|
||||
'{
|
||||
instances: $instances,
|
||||
launched: $launched,
|
||||
ok: $ok,
|
||||
failed: $failed,
|
||||
peak_sum_rss_kib: $peak_sum_rss_kib,
|
||||
mean_sum_rss_kib_per_instance: $mean_sum_rss_kib_per_instance,
|
||||
settled_rss_kib_median: $settled_rss_kib_median,
|
||||
pss_available: $pss_available,
|
||||
pss_sum_kib: (if $pss_available then $pss_sum_kib else null end)
|
||||
}'
|
||||
}
|
||||
|
||||
write_summary() {
|
||||
local summary_json="$OUT_DIR/summary.json"
|
||||
local summary_md="$OUT_DIR/summary.md"
|
||||
|
||||
jq -s \
|
||||
--argjson hold_secs "$HOLD_SECS" \
|
||||
--arg scenario "$SCENARIO" \
|
||||
--argjson stagger_ms "$STAGGER_MS" \
|
||||
--arg build "$([[ "$SLIM" -eq 1 ]] && echo "slim" || echo "default")" \
|
||||
'{
|
||||
generated_at: (now | todate),
|
||||
build: $build,
|
||||
config: { scenario: $scenario, hold_secs: $hold_secs, stagger_ms: $stagger_ms },
|
||||
sweep: .
|
||||
}' \
|
||||
"$OUT_DIR"/*.point.json >"$summary_json"
|
||||
|
||||
local any_fail=0
|
||||
local last_point_file=""
|
||||
|
||||
{
|
||||
echo "# Multi-instance (many-processes) benchmark summary"
|
||||
echo
|
||||
echo "Build: \`$([[ "$SLIM" -eq 1 ]] && echo "slim" || echo "default")\` "
|
||||
echo "Scenario: \`${SCENARIO}\`, hold: ${HOLD_SECS}s, stagger: ${STAGGER_MS}ms "
|
||||
echo "Generated: $(date)"
|
||||
echo
|
||||
echo "This measures the **many-processes** deployment model: N independent"
|
||||
echo "\`library-profile\` processes, each a live held instance, as opposed to"
|
||||
echo "\`library-fleet.sh\`'s **one-process** model (N agents inside a single"
|
||||
echo "process). **Correctness note:** summed RSS below double-counts shared"
|
||||
echo "clean pages — all instances share one binary's resident executable text"
|
||||
echo "and any shared library mappings — so sum-RSS is an **upper bound** on"
|
||||
echo "real physical memory use, not the true cost. On Linux, summed PSS"
|
||||
echo "(proportional set size, from each instance's \`settled.pss_kib\`) divides"
|
||||
echo "shared pages across the processes that share them and is the honest"
|
||||
echo "number; it is surfaced below whenever the field is nonzero. On macOS"
|
||||
echo "there is no PSS equivalent, so only sum-RSS is available and the table"
|
||||
echo "says so explicitly."
|
||||
echo
|
||||
echo "| N | ok/launched | median settled RSS/instance (MiB) | mean sum-RSS/instance (MiB) | peak aggregate sum-RSS (MiB) | summed PSS (MiB) |"
|
||||
echo "| ---: | :---: | ---: | ---: | ---: | ---: |"
|
||||
|
||||
local pf
|
||||
for pf in "$OUT_DIR"/*.point.json; do
|
||||
[[ -e "$pf" ]] || continue
|
||||
last_point_file="$pf"
|
||||
local n ok launched settled_rss mean_sum peak_sum pss_available pss_sum pss_cell
|
||||
n=$(jq -r '.instances' "$pf")
|
||||
ok=$(jq -r '.ok' "$pf")
|
||||
launched=$(jq -r '.launched' "$pf")
|
||||
settled_rss=$(kib_to_mib "$(jq -r '.settled_rss_kib_median' "$pf")")
|
||||
mean_sum=$(kib_to_mib "$(jq -r '.mean_sum_rss_kib_per_instance' "$pf")")
|
||||
peak_sum=$(kib_to_mib "$(jq -r '.peak_sum_rss_kib' "$pf")")
|
||||
pss_available=$(jq -r '.pss_available' "$pf")
|
||||
if [[ "$pss_available" == "true" ]]; then
|
||||
pss_sum=$(kib_to_mib "$(jq -r '.pss_sum_kib' "$pf")")
|
||||
pss_cell="$pss_sum"
|
||||
else
|
||||
pss_cell="n/a (macOS)"
|
||||
fi
|
||||
|
||||
if [[ "$ok" != "$launched" ]]; then
|
||||
any_fail=1
|
||||
fi
|
||||
|
||||
echo "| $n | $ok/$launched | $settled_rss | $mean_sum | $peak_sum | $pss_cell |"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "## Verdict (2 GB box extrapolation, estimate)"
|
||||
echo
|
||||
|
||||
if [[ -n "$last_point_file" ]]; then
|
||||
local pss_available per_instance_mib per_instance_label fits_estimate
|
||||
pss_available=$(jq -r '.pss_available' "$last_point_file")
|
||||
if [[ "$pss_available" == "true" ]]; then
|
||||
per_instance_mib=$(kib_to_mib "$(jq -r '(.pss_sum_kib / .ok)' "$last_point_file")")
|
||||
per_instance_label="summed PSS/instance (honest, shared pages divided across sharers)"
|
||||
else
|
||||
per_instance_mib=$(kib_to_mib "$(jq -r '.mean_sum_rss_kib_per_instance' "$last_point_file")")
|
||||
per_instance_label="mean sum-RSS/instance (macOS, no PSS — this OVERSTATES true cost by double-counting shared pages)"
|
||||
fi
|
||||
|
||||
if [[ "$per_instance_mib" != "n/a" ]]; then
|
||||
fits_estimate=$(jq -n --argjson budget "$BUDGET_MIB" --argjson per "$per_instance_mib" '($budget / $per) | floor')
|
||||
echo "Using the largest swept N's ${per_instance_label}: **${per_instance_mib} MiB/instance**."
|
||||
echo
|
||||
echo "\`instances_that_fit ~= ${BUDGET_MIB} / ${per_instance_mib} ~= ${fits_estimate}\` — a rough"
|
||||
echo "**estimate**, not a measured limit. It assumes every additional instance"
|
||||
echo "costs the same as the ones already measured (no host-level contention,"
|
||||
echo "no cgroup memory limit enforced here)."
|
||||
if [[ "$pss_available" == "true" ]]; then
|
||||
echo "PSS was available (Linux), so this divides shared pages across the"
|
||||
echo "processes that share them rather than double-counting them — still an"
|
||||
echo "estimate, but not skewed in a known direction the way the macOS"
|
||||
echo "sum-RSS fallback is."
|
||||
else
|
||||
echo "No PSS was available (macOS), so this uses sum-RSS as a stand-in,"
|
||||
echo "which double-counts shared pages (executable text, shared library"
|
||||
echo "mappings) across instances — treat this estimate as"
|
||||
echo "conservative-in-the-wrong-direction (fewer instances would actually fit"
|
||||
echo "than the sum-RSS math implies is safe, since real physical use is lower"
|
||||
echo "than sum-RSS thanks to shared pages, but this is also unverified against"
|
||||
echo "an actual memory-limited box)."
|
||||
fi
|
||||
else
|
||||
echo "No successful instances at the largest swept N — cannot extrapolate."
|
||||
any_fail=1
|
||||
fi
|
||||
else
|
||||
echo "No sweep points completed — nothing to extrapolate."
|
||||
any_fail=1
|
||||
fi
|
||||
} >"$summary_md"
|
||||
|
||||
if [[ "$any_fail" -eq 1 ]]; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
main() {
|
||||
IFS=',' read -r -a instance_list <<<"$INSTANCES"
|
||||
|
||||
local n
|
||||
for n in "${instance_list[@]}"; do
|
||||
check_cap "$n"
|
||||
done
|
||||
|
||||
build_binaries
|
||||
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "ERROR: $BIN not found or not executable. Build it or drop --skip-build." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
probe_binary
|
||||
|
||||
local first_n="${instance_list[0]}"
|
||||
for n in "${instance_list[@]}"; do
|
||||
local do_footprint=0
|
||||
[[ "$n" == "$first_n" ]] && do_footprint=1
|
||||
run_sweep_point "$n" "$do_footprint"
|
||||
done
|
||||
|
||||
for n in "${instance_list[@]}"; do
|
||||
aggregate_point "$n" >"$OUT_DIR/n$n.point.json"
|
||||
done
|
||||
|
||||
local gate_status=0
|
||||
write_summary || gate_status=1
|
||||
|
||||
log "results: $OUT_DIR"
|
||||
cat "$OUT_DIR/summary.md"
|
||||
|
||||
if [[ "$GATE" -eq 1 && "$gate_status" -ne 0 ]]; then
|
||||
log "GATE FAILED: at least one instance failed to complete cleanly"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,495 @@
|
||||
//! Shared profiling harness: hermetic fixture, RSS sampling, and the
|
||||
//! `measure()` wrapper that every scenario runs its workload through.
|
||||
//!
|
||||
//! All diagnostics go to **stderr** (stdout must stay pure JSON) using the
|
||||
//! stable `[library-profile]` prefix.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use openhuman_core::openhuman::config::Config;
|
||||
use openhuman_core::openhuman::proc_metrics::{self, ProcSample, TreeSample};
|
||||
use serde::Serialize;
|
||||
|
||||
/// One sampled point inside a measured workload. `delta_kib` is the RSS change
|
||||
/// versus the *previous* checkpoint (or the baseline for the first one).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Checkpoint {
|
||||
pub label: String,
|
||||
pub at_ms: u128,
|
||||
pub rss_kib: u64,
|
||||
pub delta_kib: i64,
|
||||
}
|
||||
|
||||
/// Turn wall-latency percentiles (milliseconds) collected under overlapping
|
||||
/// load by the `fleet` scenario.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub struct TurnLatency {
|
||||
pub p50: u128,
|
||||
pub p95: u128,
|
||||
pub p99: u128,
|
||||
pub max: u128,
|
||||
}
|
||||
|
||||
/// Per-descendant RSS entry in a [`TreeReport`].
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TreeChild {
|
||||
pub name: String,
|
||||
pub rss_kib: u64,
|
||||
}
|
||||
|
||||
/// Process-*tree* RSS reporting: this process plus every descendant
|
||||
/// (interpreter children such as `node` / `python` a skill run spawns). Folds a
|
||||
/// [`TreeSample`] down to the reported shape. `tree_rss_kib` counts self + all
|
||||
/// descendants, so it exceeds `settled.rss_kib` whenever a child was live at
|
||||
/// sample time.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TreeReport {
|
||||
pub tree_rss_kib: u64,
|
||||
pub child_count: usize,
|
||||
pub children: Vec<TreeChild>,
|
||||
}
|
||||
|
||||
impl TreeReport {
|
||||
pub fn from_sample(sample: &TreeSample) -> Self {
|
||||
Self {
|
||||
tree_rss_kib: sample.tree_rss_kib,
|
||||
child_count: sample.children.len(),
|
||||
children: sample
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| TreeChild {
|
||||
name: child.name.clone(),
|
||||
rss_kib: child.rss_kib,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fleet capacity budget math (purely informational — scripts turn `fits` into
|
||||
/// pass/fail). `projected_rss_mib_at_target = settled_base + marginal * target`.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub struct FleetBudget {
|
||||
pub target_agents: u64,
|
||||
pub ram_budget_mib: u64,
|
||||
pub projected_rss_mib_at_target: f64,
|
||||
pub fits: bool,
|
||||
}
|
||||
|
||||
/// Pinned JSON output contract (schema_version = 2). New optional fields are
|
||||
/// skipped when absent so the two original scenarios stay byte-identical.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProfileResult {
|
||||
pub schema_version: u32,
|
||||
pub scenario: &'static str,
|
||||
pub workload_units: usize,
|
||||
pub duration_ms: u128,
|
||||
pub baseline: ProcSample,
|
||||
pub settled: ProcSample,
|
||||
pub peak_rss_kib: u64,
|
||||
pub retained_delta_kib: i64,
|
||||
pub peak_delta_kib: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub turns: Option<usize>,
|
||||
/// (`fleet`) requested live-agent count.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agents: Option<usize>,
|
||||
/// (`fleet`) agents actually constructed (may be < `agents` on fd/OOM).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agents_built: Option<usize>,
|
||||
/// (`fleet`) marginal RSS cost per agent (`baseline → constructed`), KiB.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub marginal_rss_kib_per_agent: Option<f64>,
|
||||
/// (`fleet`) user+system CPU delta over the 10 s idle window, ms.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub idle_cpu_ms: Option<u64>,
|
||||
/// (`fleet`) turn wall-latency percentiles under overlapping load.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub turn_latency_ms: Option<TurnLatency>,
|
||||
/// (`fleet`) capacity budget projection.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub budget: Option<FleetBudget>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checkpoints: Option<Vec<Checkpoint>>,
|
||||
/// (`subagent-storm`) K parallel researcher subagents fanned out in the turn.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subagents: Option<usize>,
|
||||
/// (`skill-run`) process-tree RSS including interpreter child processes.
|
||||
/// Reports the richest tree seen — the peak-during-workload sample when a
|
||||
/// short-lived child (e.g. `node`) has already exited by settle time.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tree: Option<TreeReport>,
|
||||
/// Present (and `true`) only when the dhat heap profiler is active, since
|
||||
/// RSS/time numbers are perturbed by dhat's global allocator.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dhat: Option<bool>,
|
||||
}
|
||||
|
||||
/// Restores (or removes) an environment variable on drop.
|
||||
pub struct EnvGuard {
|
||||
key: &'static str,
|
||||
old: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
pub fn set(key: &'static str, value: &str) -> Self {
|
||||
let old = std::env::var(key).ok();
|
||||
std::env::set_var(key, value);
|
||||
Self { key, old }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.old {
|
||||
Some(value) => std::env::set_var(self.key, value),
|
||||
None => std::env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hermetic config rooted in a throwaway temp workspace. Local inference,
|
||||
/// Python, spaCy, and embeddings are all disabled so runs are offline.
|
||||
pub struct Fixture {
|
||||
pub config: Config,
|
||||
_workspace_env: EnvGuard,
|
||||
_keyring_env: EnvGuard,
|
||||
_action_env: EnvGuard,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
pub fn fixture() -> Result<Fixture> {
|
||||
let tmp = tempfile::tempdir().context("create profile workspace")?;
|
||||
let root = tmp.path();
|
||||
let workspace = root.join("workspace");
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
// A real, writable action sandbox so acting tools (e.g. `node_exec`, which
|
||||
// spawns `node` in the action dir) have a valid cwd. Harmless for scenarios
|
||||
// that never act.
|
||||
let action_dir = root.join("action");
|
||||
std::fs::create_dir_all(&action_dir)?;
|
||||
|
||||
let mut config_toml = r#"api_url = "http://127.0.0.1:9"
|
||||
default_model = "profile-mock"
|
||||
default_temperature = 0.0
|
||||
chat_onboarding_completed = true
|
||||
|
||||
[secrets]
|
||||
encrypt = false
|
||||
|
||||
[local_ai]
|
||||
enabled = false
|
||||
runtime_enabled = false
|
||||
|
||||
[runtime_python]
|
||||
enabled = false
|
||||
|
||||
[memory_tree]
|
||||
spacy_enabled = false
|
||||
"#
|
||||
.to_string();
|
||||
if std::env::var_os("OPENHUMAN_PROFILE_DISABLE_MEMORY_WRITES").is_some() {
|
||||
config_toml.push_str(
|
||||
r#"
|
||||
[memory]
|
||||
auto_save = false
|
||||
|
||||
[learning]
|
||||
episodic_capture_enabled = false
|
||||
"#,
|
||||
);
|
||||
}
|
||||
// `skill-run` executes real acting tools (`node_exec`); those need the Full
|
||||
// autonomy tier so the write-class gate does not park the turn on approval.
|
||||
// Config::load_or_init inside the detached workflow run re-reads this file,
|
||||
// so the tier must live in config.toml (not just the in-memory Config).
|
||||
if std::env::var_os("OPENHUMAN_PROFILE_FULL_AUTONOMY").is_some() {
|
||||
config_toml.push_str(
|
||||
r#"
|
||||
[autonomy]
|
||||
level = "full"
|
||||
"#,
|
||||
);
|
||||
}
|
||||
std::fs::write(root.join("config.toml"), &config_toml)?;
|
||||
|
||||
let workspace_env = EnvGuard::set("OPENHUMAN_WORKSPACE", &root.to_string_lossy());
|
||||
let keyring_env = EnvGuard::set("OPENHUMAN_KEYRING_BACKEND", "file");
|
||||
let action_env = EnvGuard::set("OPENHUMAN_ACTION_DIR", &action_dir.to_string_lossy());
|
||||
let mut config: Config = toml::from_str(&config_toml)?;
|
||||
config.workspace_dir = workspace;
|
||||
config.action_dir = action_dir;
|
||||
config.memory_tree.embedding_endpoint = None;
|
||||
config.memory_tree.embedding_model = None;
|
||||
config.memory_tree.embedding_strict = false;
|
||||
|
||||
Ok(Fixture {
|
||||
config,
|
||||
_workspace_env: workspace_env,
|
||||
_keyring_env: keyring_env,
|
||||
_action_env: action_env,
|
||||
_tmp: tmp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Background task that polls `proc_metrics::sample_self()` every 5 ms and
|
||||
/// keeps the running peak RSS.
|
||||
pub struct PeakSampler {
|
||||
peak: Arc<AtomicU64>,
|
||||
stop: Arc<AtomicU64>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl PeakSampler {
|
||||
pub fn start(initial_kib: u64) -> Self {
|
||||
let peak = Arc::new(AtomicU64::new(initial_kib));
|
||||
let stop = Arc::new(AtomicU64::new(0));
|
||||
let task_peak = Arc::clone(&peak);
|
||||
let task_stop = Arc::clone(&stop);
|
||||
let task = tokio::spawn(async move {
|
||||
while task_stop.load(Ordering::Relaxed) == 0 {
|
||||
if let Ok(sample) = proc_metrics::sample_self() {
|
||||
task_peak.fetch_max(sample.rss_kib, Ordering::Relaxed);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
});
|
||||
Self { peak, stop, task }
|
||||
}
|
||||
|
||||
pub async fn stop(self) -> u64 {
|
||||
self.stop.store(1, Ordering::Relaxed);
|
||||
let _ = self.task.await;
|
||||
self.peak.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task that polls `proc_metrics::sample_tree()` and keeps the
|
||||
/// [`TreeSample`] with the highest `tree_rss_kib` seen — capturing the moment a
|
||||
/// short-lived interpreter child (e.g. a `node -e` step) is resident, which a
|
||||
/// settle-time-only sample would miss because the child has already exited.
|
||||
pub struct TreePeakSampler {
|
||||
best: Arc<Mutex<Option<TreeSample>>>,
|
||||
stop: Arc<AtomicU64>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TreePeakSampler {
|
||||
pub fn start() -> Self {
|
||||
let best: Arc<Mutex<Option<TreeSample>>> = Arc::new(Mutex::new(None));
|
||||
let stop = Arc::new(AtomicU64::new(0));
|
||||
let task_best = Arc::clone(&best);
|
||||
let task_stop = Arc::clone(&stop);
|
||||
let task = tokio::spawn(async move {
|
||||
while task_stop.load(Ordering::Relaxed) == 0 {
|
||||
if let Ok(sample) = proc_metrics::sample_tree() {
|
||||
let mut guard = task_best.lock().expect("tree peak lock");
|
||||
let replace = guard
|
||||
.as_ref()
|
||||
.map(|prev| sample.tree_rss_kib > prev.tree_rss_kib)
|
||||
.unwrap_or(true);
|
||||
if replace {
|
||||
*guard = Some(sample);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(15)).await;
|
||||
}
|
||||
});
|
||||
Self { best, stop, task }
|
||||
}
|
||||
|
||||
pub async fn stop(self) -> Option<TreeSample> {
|
||||
self.stop.store(1, Ordering::Relaxed);
|
||||
let _ = self.task.await;
|
||||
self.best.lock().expect("tree peak lock").clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects per-phase / per-turn checkpoints inside a measured workload.
|
||||
/// Cloneable (shares one buffer) so it can be handed to closures freely.
|
||||
#[derive(Clone)]
|
||||
pub struct Recorder {
|
||||
inner: Arc<Mutex<RecorderState>>,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
struct RecorderState {
|
||||
checkpoints: Vec<Checkpoint>,
|
||||
last_rss_kib: u64,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
fn new(baseline_kib: u64, started: Instant) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(RecorderState {
|
||||
checkpoints: Vec::new(),
|
||||
last_rss_kib: baseline_kib,
|
||||
})),
|
||||
started,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample RSS now and append a labelled checkpoint whose `delta_kib` is
|
||||
/// relative to the previous checkpoint (baseline for the first).
|
||||
pub fn checkpoint(&self, label: impl Into<String>) -> Result<()> {
|
||||
let label = label.into();
|
||||
let sample = proc_metrics::sample_self()?;
|
||||
let at_ms = self.started.elapsed().as_millis();
|
||||
let mut state = self.inner.lock().expect("recorder lock");
|
||||
let delta_kib = sample.rss_kib as i64 - state.last_rss_kib as i64;
|
||||
state.last_rss_kib = sample.rss_kib;
|
||||
eprintln!(
|
||||
"[library-profile] checkpoint label={label} at_ms={at_ms} rss_kib={} delta_kib={delta_kib}",
|
||||
sample.rss_kib
|
||||
);
|
||||
state.checkpoints.push(Checkpoint {
|
||||
label,
|
||||
at_ms,
|
||||
rss_kib: sample.rss_kib,
|
||||
delta_kib,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn take(self) -> Vec<Checkpoint> {
|
||||
self.inner
|
||||
.lock()
|
||||
.expect("recorder lock")
|
||||
.checkpoints
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `workload` between a settled baseline and a settled post-run sample,
|
||||
/// tracking peak RSS throughout. `turns` and any checkpoints pushed via the
|
||||
/// `Recorder` are folded into the result (both `None`/omitted when unused).
|
||||
pub async fn measure<F, Fut>(
|
||||
scenario: &'static str,
|
||||
workload_units: usize,
|
||||
turns: Option<usize>,
|
||||
workload: F,
|
||||
) -> Result<ProfileResult>
|
||||
where
|
||||
F: FnOnce(Recorder) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
measure_impl(scenario, workload_units, turns, false, workload).await
|
||||
}
|
||||
|
||||
/// Like [`measure`], but also samples the process **tree** (self + descendant
|
||||
/// interpreter processes). A [`TreePeakSampler`] runs alongside the RSS peak
|
||||
/// sampler, and the settle-time tree is captured too; the reported `tree` is
|
||||
/// whichever of the two has the higher `tree_rss_kib`, so a `node` child that
|
||||
/// exits before settle is still attributed. Used by `skill-run`.
|
||||
pub async fn measure_with_tree<F, Fut>(
|
||||
scenario: &'static str,
|
||||
workload_units: usize,
|
||||
turns: Option<usize>,
|
||||
workload: F,
|
||||
) -> Result<ProfileResult>
|
||||
where
|
||||
F: FnOnce(Recorder) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
measure_impl(scenario, workload_units, turns, true, workload).await
|
||||
}
|
||||
|
||||
async fn measure_impl<F, Fut>(
|
||||
scenario: &'static str,
|
||||
workload_units: usize,
|
||||
turns: Option<usize>,
|
||||
sample_tree: bool,
|
||||
workload: F,
|
||||
) -> Result<ProfileResult>
|
||||
where
|
||||
F: FnOnce(Recorder) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
let baseline = proc_metrics::sample_self()?;
|
||||
if let Some(seconds) = std::env::var("OPENHUMAN_PROFILE_HOLD_BEFORE_SECS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|seconds| *seconds > 0)
|
||||
{
|
||||
eprintln!(
|
||||
"[library-profile] pid={} holding at baseline for {seconds}s",
|
||||
std::process::id()
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(seconds)).await;
|
||||
}
|
||||
let sampler = PeakSampler::start(baseline.rss_kib);
|
||||
let tree_sampler = if sample_tree {
|
||||
Some(TreePeakSampler::start())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let started = Instant::now();
|
||||
let recorder = Recorder::new(baseline.rss_kib, started);
|
||||
eprintln!("[library-profile] scenario={scenario} workload starting");
|
||||
workload(recorder.clone()).await?;
|
||||
let duration_ms = started.elapsed().as_millis();
|
||||
eprintln!("[library-profile] scenario={scenario} workload done duration_ms={duration_ms}");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let settled = proc_metrics::sample_self()?;
|
||||
let peak_rss_kib = sampler.stop().await.max(settled.rss_kib);
|
||||
|
||||
// Fold the process-tree samples (peak-during-workload + settle-time) into a
|
||||
// single report: whichever has the higher tree RSS wins, so a short-lived
|
||||
// interpreter child that already exited by settle is still attributed.
|
||||
let tree = if let Some(tree_sampler) = tree_sampler {
|
||||
let peak_tree = tree_sampler.stop().await;
|
||||
let settle_tree = proc_metrics::sample_tree().ok();
|
||||
let best = match (peak_tree, settle_tree) {
|
||||
(Some(peak), Some(settle)) => {
|
||||
if peak.tree_rss_kib >= settle.tree_rss_kib {
|
||||
Some(peak)
|
||||
} else {
|
||||
Some(settle)
|
||||
}
|
||||
}
|
||||
(peak, settle) => peak.or(settle),
|
||||
};
|
||||
if let Some(best) = best.as_ref() {
|
||||
eprintln!(
|
||||
"[library-profile] scenario={scenario} tree_rss_kib={} child_count={}",
|
||||
best.tree_rss_kib,
|
||||
best.children.len()
|
||||
);
|
||||
}
|
||||
best.map(|sample| TreeReport::from_sample(&sample))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let checkpoints = recorder.take();
|
||||
let checkpoints = if checkpoints.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(checkpoints)
|
||||
};
|
||||
Ok(ProfileResult {
|
||||
schema_version: 2,
|
||||
scenario,
|
||||
workload_units,
|
||||
duration_ms,
|
||||
baseline,
|
||||
settled,
|
||||
peak_rss_kib,
|
||||
retained_delta_kib: settled.rss_kib as i64 - baseline.rss_kib as i64,
|
||||
peak_delta_kib: peak_rss_kib.saturating_sub(baseline.rss_kib),
|
||||
turns,
|
||||
agents: None,
|
||||
agents_built: None,
|
||||
marginal_rss_kib_per_agent: None,
|
||||
idle_cpu_ms: None,
|
||||
turn_latency_ms: None,
|
||||
budget: None,
|
||||
checkpoints,
|
||||
subagents: None,
|
||||
tree,
|
||||
dhat: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Hermetic, Rust-only library profiling workloads.
|
||||
//!
|
||||
//! This binary never enters shipped builds (it requires the default-off
|
||||
//! `rss-bench` feature). It measures production code paths in fresh processes
|
||||
//! with network inference replaced by a deterministic provider.
|
||||
//!
|
||||
//! Scenarios (`library-profile <scenario>`):
|
||||
//! - `memory-ingest` — ingest 100 chat messages, drain the memory queue.
|
||||
//! - `subagents` — one orchestrator turn spawning two real researchers.
|
||||
//! - `agent-turn` — a single cold agent turn (minimal library unit).
|
||||
//! - `long-agent` — N warmed sequential turns with a per-turn checkpoint series.
|
||||
//! - `workflow` — a real flows trigger->transform->agent graph, end to end.
|
||||
//! - `subconscious` — one promoted subconscious turn WITHOUT delegation.
|
||||
//! - `cold-phases` — per-phase checkpoints of the cold bootstrap in one region.
|
||||
//! - `fleet` — N live agents: marginal RSS, idle CPU, fd/thread growth, turn latency.
|
||||
//! - `skill-run` — a skill step executing on a real `node` child: process-tree RSS.
|
||||
//! - `subagent-storm`— K parallel researcher subagents in one instance: marginal RSS per subagent.
|
||||
//!
|
||||
//! stdout is ALWAYS a single pretty JSON object (the pinned schema in
|
||||
//! `harness::ProfileResult`); every diagnostic goes to stderr with the stable
|
||||
//! `[library-profile]` prefix.
|
||||
//!
|
||||
//! With the `rss-bench-dhat` feature, dhat's global allocator + profiler are
|
||||
//! active: RSS/time numbers are perturbed, the result carries `"dhat": true`,
|
||||
//! and a `dhat-<scenario>.json` heap profile is written under
|
||||
//! `target/profile/rust-library/` (override via `OPENHUMAN_PROFILE_DHAT_OUT`).
|
||||
|
||||
mod harness;
|
||||
mod mock;
|
||||
mod scenarios;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use harness::ProfileResult;
|
||||
|
||||
#[cfg(feature = "rss-bench-dhat")]
|
||||
#[global_allocator]
|
||||
static ALLOC: dhat::Alloc = dhat::Alloc;
|
||||
|
||||
/// Builds the dhat profiler (feature-gated), writing to the requested path.
|
||||
/// Kept alive by the caller until after the JSON result is printed.
|
||||
#[cfg(feature = "rss-bench-dhat")]
|
||||
fn start_dhat(scenario: &str) -> Result<dhat::Profiler> {
|
||||
let out = std::env::var("OPENHUMAN_PROFILE_DHAT_OUT")
|
||||
.unwrap_or_else(|_| format!("target/profile/rust-library/dhat-{scenario}.json"));
|
||||
if let Some(parent) = std::path::Path::new(&out).parent() {
|
||||
std::fs::create_dir_all(parent).context("create dhat output directory")?;
|
||||
}
|
||||
eprintln!("[library-profile] dhat active — heap profile -> {out}");
|
||||
Ok(dhat::Profiler::builder().file_name(&out).build())
|
||||
}
|
||||
|
||||
async fn dispatch(scenario: &str) -> Result<ProfileResult> {
|
||||
match scenario {
|
||||
"memory-ingest" => scenarios::memory_ingest::run().await,
|
||||
"subagents" => scenarios::subagents::run().await,
|
||||
"agent-turn" => scenarios::agent_turn::run().await,
|
||||
"long-agent" => scenarios::long_agent::run().await,
|
||||
"workflow" => scenarios::workflow::run().await,
|
||||
"subconscious" => scenarios::subconscious::run().await,
|
||||
"cold-phases" => scenarios::cold_phases::run().await,
|
||||
"fleet" => scenarios::fleet::run().await,
|
||||
"skill-run" => scenarios::skill_run::run().await,
|
||||
"subagent-storm" => scenarios::subagent_storm::run().await,
|
||||
other => anyhow::bail!("unknown scenario: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the tokio runtime. When `OPENHUMAN_PROFILE_WORKER_THREADS` is set the
|
||||
/// multi-thread runtime is built manually with that worker count (set to `2` to
|
||||
/// simulate the 2 vCPU box); otherwise the standard multi-thread default runs.
|
||||
fn build_runtime() -> Result<tokio::runtime::Runtime> {
|
||||
let mut builder = tokio::runtime::Builder::new_multi_thread();
|
||||
builder.enable_all();
|
||||
if let Some(workers) = std::env::var("OPENHUMAN_PROFILE_WORKER_THREADS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|n| *n > 0)
|
||||
{
|
||||
eprintln!("[library-profile] tokio worker_threads={workers}");
|
||||
builder.worker_threads(workers);
|
||||
}
|
||||
builder.build().context("build tokio runtime")
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Parse args BEFORE building the runtime so `OPENHUMAN_PROFILE_WORKER_THREADS`
|
||||
// can size the worker pool (the `fleet` scenario simulates the 2 vCPU box).
|
||||
let scenario = std::env::args().nth(1).context(
|
||||
"usage: library-profile \
|
||||
<memory-ingest|subagents|agent-turn|long-agent|workflow|subconscious|cold-phases|fleet|\
|
||||
skill-run|subagent-storm>",
|
||||
)?;
|
||||
|
||||
// Profiler must outlive the whole run + the JSON print so its Drop writes
|
||||
// the complete heap profile last.
|
||||
#[cfg(feature = "rss-bench-dhat")]
|
||||
let _dhat = start_dhat(&scenario)?;
|
||||
|
||||
eprintln!(
|
||||
"[library-profile] pid={} scenario={scenario} start",
|
||||
std::process::id()
|
||||
);
|
||||
|
||||
let runtime = build_runtime()?;
|
||||
runtime.block_on(async move {
|
||||
#[cfg_attr(not(feature = "rss-bench-dhat"), allow(unused_mut))]
|
||||
let mut result = dispatch(&scenario).await?;
|
||||
|
||||
#[cfg(feature = "rss-bench-dhat")]
|
||||
{
|
||||
result.dhat = Some(true);
|
||||
}
|
||||
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
|
||||
if let Some(seconds) = std::env::var("OPENHUMAN_PROFILE_HOLD_SECS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|seconds| *seconds > 0)
|
||||
{
|
||||
eprintln!(
|
||||
"[library-profile] pid={} holding for {seconds}s",
|
||||
std::process::id()
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(seconds)).await;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
//! Deterministic offline `Provider` mocks installed via
|
||||
//! `test_provider_override` (honoured only under the `rss-bench` feature).
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use openhuman_core::openhuman::inference::provider::traits::{
|
||||
ChatRequest, ChatResponse, ProviderCapabilities, ToolCall,
|
||||
};
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
|
||||
/// A plain `ChatResponse` carrying only text (no tool calls).
|
||||
pub fn response(text: &str) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some(text.into()),
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records every prompt it sees so scenarios can assert what ran.
|
||||
fn record(prompts: &Mutex<Vec<String>>, joined: &str) {
|
||||
prompts
|
||||
.lock()
|
||||
.expect("mock prompt lock")
|
||||
.push(joined.into());
|
||||
}
|
||||
|
||||
/// Read `key` as a `u64`, falling back to `default` when unset/unparsable.
|
||||
fn env_u64(key: &str, default: u64) -> u64 {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Stable per-researcher marker embedded in a delegated subagent's prompt.
|
||||
/// Zero-padded so `..._001` is never a substring of `..._011` — the storm mock
|
||||
/// routes by exact marker containment across K up to 32.
|
||||
pub fn subagent_marker(index: usize) -> String {
|
||||
format!("LIB_PROFILE_SUBAGENT_{index:03}")
|
||||
}
|
||||
|
||||
/// The finding text a delegated researcher returns for `index`. The merge turn
|
||||
/// is detected by all K of these being present in the conversation.
|
||||
pub fn finding_text(index: usize) -> String {
|
||||
format!("Finding {index:03}: researcher {index} reports healthy.")
|
||||
}
|
||||
|
||||
/// Text the orchestrator returns once it has merged every researcher finding;
|
||||
/// its arrival in the parent (subconscious) conversation ends the storm turn.
|
||||
pub const MERGE_SENTINEL: &str = "STORM_MERGE_COMPLETE";
|
||||
|
||||
/// Shared, dependency-free latency sampler driven by the standard env knobs
|
||||
/// (`OPENHUMAN_PROFILE_MOCK_LATENCY_MS` mean, `OPENHUMAN_PROFILE_MOCK_JITTER_MS`
|
||||
/// jitter, default `mean / 4`). Reused by both [`LatencyMock`] and
|
||||
/// [`SubagentMock`] so a delegated subagent turn can carry realistic latency.
|
||||
pub struct LatencyKnobs {
|
||||
mean_ms: u64,
|
||||
jitter_ms: u64,
|
||||
counter: AtomicU64,
|
||||
}
|
||||
|
||||
impl LatencyKnobs {
|
||||
pub fn from_env() -> Self {
|
||||
let mean_ms = env_u64("OPENHUMAN_PROFILE_MOCK_LATENCY_MS", 0);
|
||||
let jitter_ms = env_u64("OPENHUMAN_PROFILE_MOCK_JITTER_MS", mean_ms / 4);
|
||||
eprintln!("[library-profile] LatencyKnobs mean_ms={mean_ms} jitter_ms={jitter_ms}");
|
||||
Self {
|
||||
mean_ms,
|
||||
jitter_ms,
|
||||
counter: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample `mean ± jitter` (clamped at zero) via a seeded xorshift step. The
|
||||
/// seed advances per call so successive turns get distinct latencies.
|
||||
pub fn sample_ms(&self) -> u64 {
|
||||
if self.mean_ms == 0 && self.jitter_ms == 0 {
|
||||
return 0;
|
||||
}
|
||||
let seed = self.counter.fetch_add(1, Ordering::Relaxed).wrapping_add(1);
|
||||
// xorshift64 — deterministic, dependency-free pseudo-randomness.
|
||||
let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
let span = self.jitter_ms.saturating_mul(2).saturating_add(1);
|
||||
let delta = (x % span) as i64 - self.jitter_ms as i64;
|
||||
(self.mean_ms as i64 + delta).max(0) as u64
|
||||
}
|
||||
|
||||
/// Sleep a sampled latency and return the ms slept (0 when disabled).
|
||||
pub async fn sleep_sampled(&self) -> u64 {
|
||||
let ms = self.sample_ms();
|
||||
if ms > 0 {
|
||||
tokio::time::sleep(Duration::from_millis(ms)).await;
|
||||
}
|
||||
ms
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestration mock: the first (orchestrator) turn emits a
|
||||
/// `spawn_parallel_agents` tool call fanning out to **K** researchers; each
|
||||
/// researcher turn returns its finding; the final merge turn returns plain
|
||||
/// text once all K findings are present.
|
||||
///
|
||||
/// `width` = K parallel researchers. `new()` keeps the original two-researcher
|
||||
/// shape (K = 2, no injected latency, driven from the subconscious which has no
|
||||
/// `spawn_parallel_agents` — the tool call is rejected and its markers echo
|
||||
/// back, which is all the `subagents` scenario asserts). `with_width(k)` drives
|
||||
/// the **orchestrator directly** and scripts the full delegation chain, so it
|
||||
/// must first hand off via `delegate_orchestrator`-free direct fan-out — this is
|
||||
/// the `subagent-storm` fuzz-width path.
|
||||
pub struct SubagentMock {
|
||||
pub prompts: Mutex<Vec<String>>,
|
||||
/// Actual wall-time (ms) of each *researcher* chat call, for percentiles.
|
||||
pub researcher_latencies_ms: Mutex<Vec<u128>>,
|
||||
width: usize,
|
||||
latency: LatencyKnobs,
|
||||
/// `true` for `with_width` (orchestrator-driven storm): route the full
|
||||
/// agent-aware chain. `false` for `new` (subconscious-driven `subagents`):
|
||||
/// the original simple routing that emits the fan-out directly.
|
||||
orchestrator_driven: bool,
|
||||
/// Increments per fan-out so successive `spawn_parallel_agents` calls carry
|
||||
/// distinct task prompts.
|
||||
spawn_nonce: AtomicU64,
|
||||
}
|
||||
|
||||
impl SubagentMock {
|
||||
/// Two researchers, no injected latency (the original `subagents` shape).
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
prompts: Mutex::new(Vec::new()),
|
||||
researcher_latencies_ms: Mutex::new(Vec::new()),
|
||||
width: 2,
|
||||
latency: LatencyKnobs {
|
||||
mean_ms: 0,
|
||||
jitter_ms: 0,
|
||||
counter: AtomicU64::new(0),
|
||||
},
|
||||
orchestrator_driven: false,
|
||||
spawn_nonce: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// K researchers with per-researcher latency drawn from the env knobs, driven
|
||||
/// directly against the orchestrator agent (full agent-aware chain).
|
||||
pub fn with_width(width: usize) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
prompts: Mutex::new(Vec::new()),
|
||||
researcher_latencies_ms: Mutex::new(Vec::new()),
|
||||
width: width.max(1),
|
||||
latency: LatencyKnobs::from_env(),
|
||||
orchestrator_driven: true,
|
||||
spawn_nonce: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Which researcher (1-based) this prompt is for, if any. A researcher
|
||||
/// prompt embeds exactly one `subagent_marker`.
|
||||
fn researcher_index(&self, joined: &str) -> Option<usize> {
|
||||
(1..=self.width).find(|&i| joined.contains(&subagent_marker(i)))
|
||||
}
|
||||
|
||||
/// True once every researcher's finding is present — the merge turn.
|
||||
fn is_merge(&self, joined: &str) -> bool {
|
||||
(1..=self.width).all(|i| joined.contains(&finding_text(i)))
|
||||
}
|
||||
|
||||
/// True when this call is a real researcher worker turn: exactly one task
|
||||
/// marker is present and the executing agent is neither the orchestrator nor
|
||||
/// the subconscious (their Tool Policy Boundary headers name them, and their
|
||||
/// merge/echo turns also carry every marker). The researcher agent's system
|
||||
/// prompt names it `Researcher`, so it matches neither header string.
|
||||
fn is_researcher_turn(&self, joined: &str) -> bool {
|
||||
self.researcher_index(joined).is_some()
|
||||
&& !joined.contains("Agent: orchestrator")
|
||||
&& !joined.contains("Agent: subconscious")
|
||||
}
|
||||
|
||||
/// Build the fan-out tool call delegating to K parallel researchers. Only
|
||||
/// valid on an **orchestrator** turn — the subconscious has no
|
||||
/// `spawn_parallel_agents` tool, so we `delegate_orchestrator` there first.
|
||||
fn spawn_call(&self) -> ChatResponse {
|
||||
let nonce = self.spawn_nonce.fetch_add(1, Ordering::Relaxed);
|
||||
let tasks: Vec<serde_json::Value> = (1..=self.width)
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"agent_id": "researcher",
|
||||
// The nonce keeps each fan-out's tasks byte-distinct so the
|
||||
// parallel-graph result cache can't short-circuit a re-spawn.
|
||||
"prompt": format!("{} [spawn {nonce}]: inspect subsystem {i}", subagent_marker(i)),
|
||||
"ownership": format!("scope: subsystem-{i}-spawn-{nonce}")
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ChatResponse {
|
||||
text: Some(format!("Delegating to {} researchers.", self.width)),
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "profile-parallel-call".into(),
|
||||
name: "spawn_parallel_agents".into(),
|
||||
arguments: serde_json::json!({ "tasks": tasks }).to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The subconscious's first turn: hand the task to the orchestrator (which
|
||||
/// owns `spawn_parallel_agents` and allows the `researcher` subagent).
|
||||
fn delegate_orchestrator_call(&self) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some("Delegating to the orchestrator for a parallel research fan-out.".into()),
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "profile-delegate-orchestrator".into(),
|
||||
name: "delegate_orchestrator".into(),
|
||||
arguments: serde_json::json!({
|
||||
"prompt": "Research every subsystem in parallel and merge the findings."
|
||||
})
|
||||
.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify the turn by the *executing agent* (from the Tool Policy Boundary
|
||||
/// header) and script the real delegation chain:
|
||||
/// subconscious → `delegate_orchestrator` → orchestrator →
|
||||
/// `spawn_parallel_agents(K)` → K researchers → orchestrator merge →
|
||||
/// subconscious final. No latency/recording here — the async `chat` wrappers
|
||||
/// handle sleeping + latency capture around this.
|
||||
fn reply(&self, joined: &str) -> ChatResponse {
|
||||
if self.orchestrator_driven {
|
||||
return self.reply_orchestrator_driven(joined);
|
||||
}
|
||||
// Original `subagents` routing (subconscious-driven): merge once both
|
||||
// findings are present, answer a researcher's marker with its finding,
|
||||
// else emit the fan-out directly. The subconscious rejects the unknown
|
||||
// `spawn_parallel_agents`, echoing the markers back — which is all the
|
||||
// `subagents` scenario asserts.
|
||||
if self.is_merge(joined) {
|
||||
return response("Merged all researcher findings.");
|
||||
}
|
||||
if let Some(i) = self.researcher_index(joined) {
|
||||
return response(&finding_text(i));
|
||||
}
|
||||
self.spawn_call()
|
||||
}
|
||||
|
||||
/// Agent-aware routing for the orchestrator-driven storm.
|
||||
fn reply_orchestrator_driven(&self, joined: &str) -> ChatResponse {
|
||||
// Researcher worker: return its finding.
|
||||
if self.is_researcher_turn(joined) {
|
||||
let i = self
|
||||
.researcher_index(joined)
|
||||
.expect("researcher turn has a marker");
|
||||
return response(&finding_text(i));
|
||||
}
|
||||
// Orchestrator: fan out, then merge once every finding is back.
|
||||
if joined.contains("Agent: orchestrator") {
|
||||
if self.is_merge(joined) {
|
||||
return response(MERGE_SENTINEL);
|
||||
}
|
||||
return self.spawn_call();
|
||||
}
|
||||
// Parent (subconscious / any other): finish once the orchestrator's
|
||||
// merged result has flowed back; otherwise delegate to the orchestrator.
|
||||
if joined.contains(MERGE_SENTINEL) {
|
||||
return response("Storm complete: merged every researcher's finding.");
|
||||
}
|
||||
self.delegate_orchestrator_call()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for SubagentMock {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
let joined = format!("{}\n{message}", system_prompt.unwrap_or(""));
|
||||
Ok(self.dispatch(&joined).await.text.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
let joined = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| format!("{}: {}", message.role, message.content))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(self.dispatch(&joined).await)
|
||||
}
|
||||
}
|
||||
|
||||
impl SubagentMock {
|
||||
/// Record the prompt, sleep a sampled latency for *researcher* calls (and
|
||||
/// capture their wall time), then return the classified response.
|
||||
async fn dispatch(&self, joined: &str) -> ChatResponse {
|
||||
record(&self.prompts, joined);
|
||||
let is_researcher = self.is_researcher_turn(joined);
|
||||
let started = std::time::Instant::now();
|
||||
if is_researcher {
|
||||
self.latency.sleep_sampled().await;
|
||||
}
|
||||
let resp = self.reply(joined);
|
||||
if is_researcher {
|
||||
self.researcher_latencies_ms
|
||||
.lock()
|
||||
.expect("mock latency lock")
|
||||
.push(started.elapsed().as_millis());
|
||||
}
|
||||
resp
|
||||
}
|
||||
}
|
||||
|
||||
/// Latency-configurable text-only mock used by the `fleet` scenario. Before
|
||||
/// returning its fixed answer it sleeps a sampled latency: a mean from
|
||||
/// `OPENHUMAN_PROFILE_MOCK_LATENCY_MS` (default `0` = no sleep) with jitter
|
||||
/// `± OPENHUMAN_PROFILE_MOCK_JITTER_MS` (default `mean / 4`). Per-call jitter is
|
||||
/// derived from a seeded xorshift counter — deterministic and dependency-free
|
||||
/// (no `rand` crate).
|
||||
pub struct LatencyMock {
|
||||
text: String,
|
||||
latency: LatencyKnobs,
|
||||
pub prompts: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl LatencyMock {
|
||||
/// Build from the standard env knobs.
|
||||
pub fn from_env(text: impl Into<String>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
text: text.into(),
|
||||
latency: LatencyKnobs::from_env(),
|
||||
prompts: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for LatencyMock {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
self.latency.sleep_sampled().await;
|
||||
record(
|
||||
&self.prompts,
|
||||
&format!("{}\n{message}", system_prompt.unwrap_or("")),
|
||||
);
|
||||
Ok(self.text.clone())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
self.latency.sleep_sampled().await;
|
||||
let joined = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| format!("{}: {}", message.role, message.content))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
record(&self.prompts, &joined);
|
||||
Ok(response(&self.text))
|
||||
}
|
||||
}
|
||||
|
||||
/// Text-only mock: always returns a fixed direct answer, never a tool call.
|
||||
/// Used by the single-turn / workflow scenarios that must NOT delegate.
|
||||
pub struct PlainTextMock {
|
||||
text: String,
|
||||
pub prompts: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl PlainTextMock {
|
||||
pub fn new(text: impl Into<String>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
text: text.into(),
|
||||
prompts: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for PlainTextMock {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
record(
|
||||
&self.prompts,
|
||||
&format!("{}\n{message}", system_prompt.unwrap_or("")),
|
||||
);
|
||||
Ok(self.text.clone())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
let joined = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| format!("{}: {}", message.role, message.content))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
record(&self.prompts, &joined);
|
||||
Ok(response(&self.text))
|
||||
}
|
||||
}
|
||||
|
||||
/// The stdout marker the profiling `node` step prints. Its presence in the run
|
||||
/// conversation proves the real interpreter child executed (not merely that a
|
||||
/// tool call was emitted).
|
||||
pub const NODE_MARKER: &str = "PROFILE_NODE_RESULT";
|
||||
|
||||
/// Skill-run mock: the first turn emits a `node_exec` tool call running a short,
|
||||
/// real JavaScript computation (which spawns a genuine `node` child process);
|
||||
/// once its printed JSON (carrying [`NODE_MARKER`]) rides back into the
|
||||
/// conversation, the mock returns a plain final answer so the agent turn
|
||||
/// completes. This scripts exactly the tool call the code-executor specialist
|
||||
/// needs to spawn the language runtime whose cost we measure.
|
||||
pub struct SkillRunMock {
|
||||
code: String,
|
||||
node_call_emitted: AtomicU64,
|
||||
node_output_seen: AtomicU64,
|
||||
pub prompts: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl SkillRunMock {
|
||||
pub fn new() -> Arc<Self> {
|
||||
// A real computation, a live allocation, and a ~1.2s busy-wait so the
|
||||
// child stays resident long enough for the tree sampler (15 ms poll) to
|
||||
// catch it. The JSON it prints carries NODE_MARKER.
|
||||
let code = format!(
|
||||
"const start = Date.now();\n\
|
||||
const buf = [];\n\
|
||||
let sum = 0;\n\
|
||||
for (let i = 0; i < 500000; i++) {{ buf.push(i % 97); sum += i; }}\n\
|
||||
while (Date.now() - start < 1200) {{ sum += buf.length; }}\n\
|
||||
console.log(JSON.stringify({{ marker: '{NODE_MARKER}', sum, kept: buf.length }}));\n"
|
||||
);
|
||||
Arc::new(Self {
|
||||
code,
|
||||
node_call_emitted: AtomicU64::new(0),
|
||||
node_output_seen: AtomicU64::new(0),
|
||||
prompts: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// True once the `node_exec` tool call has been emitted.
|
||||
pub fn node_call_emitted(&self) -> bool {
|
||||
self.node_call_emitted.load(Ordering::Relaxed) > 0
|
||||
}
|
||||
|
||||
/// True once the node child's printed output flowed back into the turn —
|
||||
/// i.e. the interpreter child actually ran and printed.
|
||||
pub fn node_output_seen(&self) -> bool {
|
||||
self.node_output_seen.load(Ordering::Relaxed) > 0
|
||||
}
|
||||
|
||||
fn reply(&self, joined: &str) -> ChatResponse {
|
||||
record(&self.prompts, joined);
|
||||
if joined.contains(NODE_MARKER) {
|
||||
self.node_output_seen.store(1, Ordering::Relaxed);
|
||||
return response(
|
||||
"Skill complete: the Node.js step computed the value and it checks out.",
|
||||
);
|
||||
}
|
||||
self.node_call_emitted.store(1, Ordering::Relaxed);
|
||||
ChatResponse {
|
||||
text: Some("Running the JavaScript computation step.".into()),
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "profile-node-call".into(),
|
||||
name: "node_exec".into(),
|
||||
arguments: serde_json::json!({ "inline_code": self.code }).to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for SkillRunMock {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok(self
|
||||
.reply(&format!("{}\n{message}", system_prompt.unwrap_or("")))
|
||||
.text
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
let joined = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| format!("{}: {}", message.role, message.content))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(self.reply(&joined))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! `agent-turn`: the minimal "embed OpenHuman as a library" unit — a single
|
||||
//! cold agent turn built directly from config, with a plain-text mock provider
|
||||
//! (no tool calls, no delegation).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
use crate::mock::PlainTextMock;
|
||||
|
||||
pub async fn run() -> Result<ProfileResult> {
|
||||
let fixture = fixture()?;
|
||||
let _ = init_global(256);
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let mock = PlainTextMock::new("The Phoenix migration is healthy and on track.");
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
eprintln!("[library-profile] agent-turn: registries ready, mock installed");
|
||||
|
||||
measure("agent-turn", 1, None, |_rec| async {
|
||||
let mut agent = Agent::from_config_for_agent(&fixture.config, "subconscious")?;
|
||||
let reply = agent
|
||||
.run_single("Give me a one-line status on the Phoenix migration.")
|
||||
.await?;
|
||||
anyhow::ensure!(!reply.trim().is_empty(), "empty agent reply");
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! `cold-phases`: sequential per-phase checkpoints of the cold bootstrap, all
|
||||
//! inside one measured region. Each phase is sampled right after it completes
|
||||
//! so the JSON `checkpoints` series attributes the cold-start cost per phase.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use openhuman_core::openhuman::memory_store::MemoryClient;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
use crate::mock::PlainTextMock;
|
||||
|
||||
/// config, event-bus, agent-registry, detectors, memory-store, agent-build,
|
||||
/// first-turn, warm-turn, teardown.
|
||||
const PHASE_COUNT: usize = 9;
|
||||
|
||||
pub async fn run() -> Result<ProfileResult> {
|
||||
measure("cold-phases", PHASE_COUNT, None, |rec| async move {
|
||||
// a. config — hermetic fixture parse (see deviation note in the report:
|
||||
// kept as fixture parsing rather than `Config::load_or_init` to
|
||||
// guarantee we never touch the real ~/.openhuman).
|
||||
let fixture = fixture()?;
|
||||
rec.checkpoint("config-parse")?;
|
||||
|
||||
// b. event-bus (plus agent-handler registration so turns can run).
|
||||
let _ = init_global(256);
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
rec.checkpoint("event-bus")?;
|
||||
|
||||
// c. agent-registry.
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
rec.checkpoint("agent-registry")?;
|
||||
|
||||
// d. detectors — force the lazy PII + prompt-injection statics.
|
||||
let _ = openhuman_core::openhuman::security::pii::scan("");
|
||||
let _ = openhuman_core::openhuman::prompt_injection::scan_tool_definition("x", "");
|
||||
rec.checkpoint("detectors")?;
|
||||
|
||||
// e. memory-store — build and hold a unified-memory client until teardown.
|
||||
let mem = MemoryClient::from_workspace_dir(fixture.config.workspace_dir.clone())
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
rec.checkpoint("memory-store")?;
|
||||
|
||||
// Provider mock for the two turns below (not itself a phase).
|
||||
let mock = PlainTextMock::new("Phoenix migration is healthy and on track.");
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
|
||||
// f. agent-build.
|
||||
let mut agent = Agent::from_config_for_agent(&fixture.config, "subconscious")?;
|
||||
rec.checkpoint("agent-build")?;
|
||||
|
||||
// g. first-turn (cold).
|
||||
let first = agent
|
||||
.run_single("Give me a one-line status on the Phoenix migration.")
|
||||
.await?;
|
||||
anyhow::ensure!(!first.trim().is_empty(), "empty first-turn reply");
|
||||
rec.checkpoint("first-turn")?;
|
||||
|
||||
// h. warm-turn (second, same agent).
|
||||
let warm = agent.run_single("Any change since the last check?").await?;
|
||||
anyhow::ensure!(!warm.trim().is_empty(), "empty warm-turn reply");
|
||||
rec.checkpoint("warm-turn")?;
|
||||
|
||||
// i. teardown — drop the agent + memory client, settle, sample.
|
||||
drop(agent);
|
||||
drop(mem);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
rec.checkpoint("teardown")?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! `fleet`: can OpenHuman host 100–1000 live agents on a 2 GB / 2 vCPU box?
|
||||
//!
|
||||
//! Answers four questions in one process: (1) marginal RSS per live agent
|
||||
//! (`baseline → constructed`), (2) idle CPU of parked agents (CPU delta over a
|
||||
//! 10 s do-nothing window), (3) thread + fd growth vs N (rides along in
|
||||
//! `ProcSample`), and (4) turn latency percentiles under overlapping load.
|
||||
//!
|
||||
//! Env knobs:
|
||||
//! - `OPENHUMAN_PROFILE_AGENTS` (default 100) — live agents to construct.
|
||||
//! - `OPENHUMAN_PROFILE_TURNS` (default 3) — turns per agent under load.
|
||||
//! - `OPENHUMAN_PROFILE_MOCK_LATENCY_MS` / `_JITTER_MS` — mock reply latency.
|
||||
//! - `OPENHUMAN_PROFILE_TARGET_AGENTS` (default 1000) — budget projection target.
|
||||
//! - `OPENHUMAN_PROFILE_RAM_BUDGET_MIB` (default 2048) — budget ceiling.
|
||||
//!
|
||||
//! `OPENHUMAN_PROFILE_WORKER_THREADS` is honoured in `main` (runtime built
|
||||
//! manually) rather than here.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use openhuman_core::openhuman::proc_metrics;
|
||||
|
||||
use crate::harness::{fixture, measure, FleetBudget, ProfileResult, Recorder, TurnLatency};
|
||||
use crate::mock::LatencyMock;
|
||||
|
||||
const DEFAULT_AGENTS: usize = 100;
|
||||
const DEFAULT_TURNS: usize = 3;
|
||||
const DEFAULT_TARGET_AGENTS: u64 = 1000;
|
||||
const DEFAULT_RAM_BUDGET_MIB: u64 = 2048;
|
||||
const IDLE_WINDOW: Duration = Duration::from_secs(10);
|
||||
|
||||
fn env_usize(key: &str, default: usize) -> usize {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.filter(|n| *n > 0)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn env_u64(key: &str, default: u64) -> u64 {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.filter(|n| *n > 0)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Raise `RLIMIT_NOFILE` toward its hard cap so N agents (each opening SQLite
|
||||
/// etc.) don't exhaust the default macOS 256 soft limit. Logs old/new to stderr.
|
||||
fn raise_fd_limit() {
|
||||
use std::mem::MaybeUninit;
|
||||
let mut lim = MaybeUninit::<libc::rlimit>::uninit();
|
||||
// SAFETY: `getrlimit` initialises `lim` on success.
|
||||
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, lim.as_mut_ptr()) } != 0 {
|
||||
eprintln!("[library-profile] fleet: getrlimit(RLIMIT_NOFILE) failed");
|
||||
return;
|
||||
}
|
||||
// SAFETY: initialised by the successful `getrlimit`.
|
||||
let mut lim = unsafe { lim.assume_init() };
|
||||
let old_soft = lim.rlim_cur;
|
||||
lim.rlim_cur = lim.rlim_max;
|
||||
// SAFETY: raising the soft limit to the existing hard limit is always valid.
|
||||
let rc = unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &lim) };
|
||||
eprintln!(
|
||||
"[library-profile] fleet: RLIMIT_NOFILE soft {old_soft} -> {} (hard {}) setrlimit_rc={rc}",
|
||||
lim.rlim_cur, lim.rlim_max
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared metrics captured inside the measured closure and read back after.
|
||||
#[derive(Default)]
|
||||
struct FleetMetrics {
|
||||
agents_built: usize,
|
||||
baseline_rss_kib: u64,
|
||||
constructed_rss_kib: u64,
|
||||
idle_cpu_ms: u64,
|
||||
latency_ms: Vec<u128>,
|
||||
}
|
||||
|
||||
/// Percentile (nearest-rank) of an already-sorted slice. `p` in `[0,100]`.
|
||||
fn percentile(sorted: &[u128], p: u128) -> u128 {
|
||||
if sorted.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let rank = ((p * sorted.len() as u128) + 99) / 100; // ceil(p% * n)
|
||||
let idx = rank.saturating_sub(1).min(sorted.len() as u128 - 1) as usize;
|
||||
sorted[idx]
|
||||
}
|
||||
|
||||
fn latency_summary(mut samples: Vec<u128>) -> Option<TurnLatency> {
|
||||
if samples.is_empty() {
|
||||
return None;
|
||||
}
|
||||
samples.sort_unstable();
|
||||
Some(TurnLatency {
|
||||
p50: percentile(&samples, 50),
|
||||
p95: percentile(&samples, 95),
|
||||
p99: percentile(&samples, 99),
|
||||
max: *samples.last().unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build agents sequentially, checkpointing the marginal-RSS curve. On a
|
||||
/// mid-construction failure, records `construction-failed-<count>` and returns
|
||||
/// what was built rather than crashing.
|
||||
fn build_agents(
|
||||
config: &openhuman_core::openhuman::config::Config,
|
||||
n: usize,
|
||||
rec: &Recorder,
|
||||
) -> Result<Vec<Agent>> {
|
||||
let stride = (n / 10).max(1);
|
||||
let mut agents = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
match Agent::from_config_for_agent(config, "subconscious") {
|
||||
Ok(agent) => agents.push(agent),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"[library-profile] fleet: construction failed at agent {} — {err}",
|
||||
i + 1
|
||||
);
|
||||
rec.checkpoint(format!("construction-failed-{}", agents.len()))?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let built = i + 1;
|
||||
if built % stride == 0 || built == n {
|
||||
rec.checkpoint(format!("built-{built}"))?;
|
||||
}
|
||||
}
|
||||
Ok(agents)
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<ProfileResult> {
|
||||
let agents_requested = env_usize("OPENHUMAN_PROFILE_AGENTS", DEFAULT_AGENTS);
|
||||
let turns = env_usize("OPENHUMAN_PROFILE_TURNS", DEFAULT_TURNS);
|
||||
let target_agents = env_u64("OPENHUMAN_PROFILE_TARGET_AGENTS", DEFAULT_TARGET_AGENTS);
|
||||
let ram_budget_mib = env_u64("OPENHUMAN_PROFILE_RAM_BUDGET_MIB", DEFAULT_RAM_BUDGET_MIB);
|
||||
|
||||
raise_fd_limit();
|
||||
|
||||
let fixture = fixture()?;
|
||||
let _ = init_global(256);
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let mock = LatencyMock::from_env("Fleet agent: nothing needs your attention.");
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
eprintln!(
|
||||
"[library-profile] fleet: agents={agents_requested} turns={turns} \
|
||||
target={target_agents} budget_mib={ram_budget_mib}"
|
||||
);
|
||||
|
||||
let metrics = Arc::new(Mutex::new(FleetMetrics::default()));
|
||||
let config = fixture.config.clone();
|
||||
let metrics_for_workload = Arc::clone(&metrics);
|
||||
|
||||
let mut result = measure(
|
||||
"fleet",
|
||||
agents_requested,
|
||||
Some(turns),
|
||||
move |rec| async move {
|
||||
rec.checkpoint("baseline")?;
|
||||
let baseline_rss = proc_metrics::sample_self()?.rss_kib;
|
||||
|
||||
// (b) construct N agents sequentially, curve visible via checkpoints.
|
||||
let mut agents = build_agents(&config, agents_requested, &rec)?;
|
||||
let agents_built = agents.len();
|
||||
rec.checkpoint("constructed")?;
|
||||
let constructed_rss = proc_metrics::sample_self()?.rss_kib;
|
||||
|
||||
{
|
||||
let mut m = metrics_for_workload.lock().expect("metrics lock");
|
||||
m.agents_built = agents_built;
|
||||
m.baseline_rss_kib = baseline_rss;
|
||||
m.constructed_rss_kib = constructed_rss;
|
||||
}
|
||||
eprintln!(
|
||||
"[library-profile] fleet: built {agents_built}/{agents_requested} agents; \
|
||||
baseline_rss={baseline_rss} constructed_rss={constructed_rss}"
|
||||
);
|
||||
|
||||
// (d) idle phase — park all agents, measure CPU drift over 10 s.
|
||||
rec.checkpoint("idle-start")?;
|
||||
let idle_start = proc_metrics::sample_self()?;
|
||||
tokio::time::sleep(IDLE_WINDOW).await;
|
||||
rec.checkpoint("idle-end")?;
|
||||
let idle_end = proc_metrics::sample_self()?;
|
||||
let idle_cpu_ms = (idle_end.cpu_user_ms + idle_end.cpu_system_ms)
|
||||
.saturating_sub(idle_start.cpu_user_ms + idle_start.cpu_system_ms);
|
||||
metrics_for_workload
|
||||
.lock()
|
||||
.expect("metrics lock")
|
||||
.idle_cpu_ms = idle_cpu_ms;
|
||||
eprintln!("[library-profile] fleet: idle CPU over 10s = {idle_cpu_ms} ms");
|
||||
|
||||
// (e) load phase — TURNS turns per agent, one task each, staggered.
|
||||
let mut handles = Vec::with_capacity(agents.len());
|
||||
for (idx, mut agent) in agents.drain(..).enumerate() {
|
||||
let stagger = Duration::from_millis(((idx as u64) * 10).min(2000));
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::time::sleep(stagger).await;
|
||||
let mut latencies = Vec::with_capacity(turns);
|
||||
for _ in 0..turns {
|
||||
let started = Instant::now();
|
||||
let reply = agent.run_single("Give me a one-line status update.").await;
|
||||
let elapsed = started.elapsed().as_millis();
|
||||
match reply {
|
||||
Ok(text) if !text.trim().is_empty() => latencies.push(elapsed),
|
||||
Ok(_) => eprintln!("[library-profile] fleet: empty reply agent={idx}"),
|
||||
Err(err) => {
|
||||
eprintln!("[library-profile] fleet: turn error agent={idx} — {err}")
|
||||
}
|
||||
}
|
||||
}
|
||||
latencies
|
||||
}));
|
||||
}
|
||||
|
||||
let mut all_latencies = Vec::new();
|
||||
for handle in handles {
|
||||
match handle.await {
|
||||
Ok(mut latencies) => all_latencies.append(&mut latencies),
|
||||
Err(err) => eprintln!("[library-profile] fleet: task join error — {err}"),
|
||||
}
|
||||
}
|
||||
metrics_for_workload
|
||||
.lock()
|
||||
.expect("metrics lock")
|
||||
.latency_ms = all_latencies;
|
||||
rec.checkpoint("load-done")?;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Fold fleet-specific metrics into the pinned result.
|
||||
let metrics = Arc::try_unwrap(metrics)
|
||||
.map(|m| m.into_inner().expect("metrics lock"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let agents_built = metrics.agents_built;
|
||||
let marginal = if agents_built > 0 {
|
||||
Some(
|
||||
(metrics.constructed_rss_kib as f64 - metrics.baseline_rss_kib as f64)
|
||||
/ agents_built as f64,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// (4) budget projection: settled base + marginal * target.
|
||||
let base_mib = metrics.baseline_rss_kib as f64 / 1024.0;
|
||||
let marginal_mib = marginal.unwrap_or(0.0) / 1024.0;
|
||||
let projected = base_mib + marginal_mib * target_agents as f64;
|
||||
let budget = FleetBudget {
|
||||
target_agents,
|
||||
ram_budget_mib,
|
||||
projected_rss_mib_at_target: projected,
|
||||
fits: projected <= ram_budget_mib as f64,
|
||||
};
|
||||
|
||||
result.agents = Some(agents_requested);
|
||||
result.agents_built = Some(agents_built);
|
||||
result.marginal_rss_kib_per_agent = marginal;
|
||||
result.idle_cpu_ms = Some(metrics.idle_cpu_ms);
|
||||
result.turn_latency_ms = latency_summary(metrics.latency_ms);
|
||||
result.budget = Some(budget);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn percentiles_nearest_rank() {
|
||||
let mut v: Vec<u128> = (1..=100).collect();
|
||||
v.sort_unstable();
|
||||
assert_eq!(percentile(&v, 50), 50);
|
||||
assert_eq!(percentile(&v, 95), 95);
|
||||
assert_eq!(percentile(&v, 99), 99);
|
||||
assert_eq!(percentile(&v, 100), 100);
|
||||
assert_eq!(percentile(&[], 50), 0);
|
||||
assert_eq!(percentile(&[7], 99), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latency_summary_none_when_empty() {
|
||||
assert!(latency_summary(Vec::new()).is_none());
|
||||
let s = latency_summary(vec![10, 20, 30]).unwrap();
|
||||
assert_eq!(s.max, 30);
|
||||
assert_eq!(s.p50, 20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! `long-agent`: steady-state many-turn loop on ONE warmed agent — models a
|
||||
//! long-running opencompany agent. Builds the agent and runs one warm-up turn
|
||||
//! BEFORE the measured region, then runs N sequential turns inside it, pushing
|
||||
//! a per-turn checkpoint so the plateau/leak curve is visible.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
use crate::mock::PlainTextMock;
|
||||
|
||||
const DEFAULT_TURNS: usize = 25;
|
||||
|
||||
const PROMPTS: &[&str] = &[
|
||||
"Summarise today's Phoenix migration standup in one line.",
|
||||
"What is the current staging p99 latency and error rate?",
|
||||
"Who owns the rollback runbook and on-call coordination?",
|
||||
"When does the phoenix_v2_enabled flag ramp, and what gates it?",
|
||||
"Draft a one-sentence status update for the billing-ledger team.",
|
||||
];
|
||||
|
||||
pub async fn run() -> Result<ProfileResult> {
|
||||
let turns = std::env::var("OPENHUMAN_PROFILE_TURNS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|n| *n > 0)
|
||||
.unwrap_or(DEFAULT_TURNS);
|
||||
|
||||
let fixture = fixture()?;
|
||||
let _ = init_global(256);
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let mock = PlainTextMock::new("Phoenix migration is healthy; no action needed.");
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
|
||||
let mut agent = Agent::from_config_for_agent(&fixture.config, "subconscious")?;
|
||||
eprintln!("[library-profile] long-agent: warming agent with one pre-measure turn");
|
||||
let warm = agent.run_single("Warm-up: confirm you are ready.").await?;
|
||||
anyhow::ensure!(!warm.trim().is_empty(), "empty warm-up reply");
|
||||
|
||||
measure("long-agent", turns, Some(turns), move |rec| async move {
|
||||
for i in 0..turns {
|
||||
let prompt = PROMPTS[i % PROMPTS.len()];
|
||||
let reply = agent.run_single(prompt).await?;
|
||||
anyhow::ensure!(!reply.trim().is_empty(), "empty reply on turn {i}");
|
||||
rec.checkpoint(format!("turn-{i}"))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! `memory-ingest`: canonicalise and ingest 100 chat messages, then drain the
|
||||
//! real extraction/admission/tree queue.
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::memory::ingest_pipeline::ingest_chat;
|
||||
use openhuman_core::openhuman::memory_queue::drain_until_idle;
|
||||
use openhuman_core::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
|
||||
const INGEST_MESSAGE_COUNT: usize = 100;
|
||||
|
||||
fn ingestion_batch() -> ChatBatch {
|
||||
let messages = (0..INGEST_MESSAGE_COUNT)
|
||||
.map(|index| ChatMessage {
|
||||
author: if index % 2 == 0 { "alice" } else { "bob" }.into(),
|
||||
timestamp: Utc
|
||||
.timestamp_millis_opt(1_700_000_000_000 + index as i64 * 60_000)
|
||||
.single()
|
||||
.expect("valid profile timestamp"),
|
||||
text: format!(
|
||||
"Phoenix migration update {index}: staging p99 is 12ms and error rate is 0.001%. \
|
||||
Alice owns the rollback runbook, Bob owns on-call coordination, and the \
|
||||
phoenix_v2_enabled flag ramps Friday after billing-ledger verification."
|
||||
),
|
||||
source_ref: Some(format!("profile://message/{index}")),
|
||||
})
|
||||
.collect();
|
||||
ChatBatch {
|
||||
platform: "profile".into(),
|
||||
channel_label: "library-benchmark".into(),
|
||||
messages,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<ProfileResult> {
|
||||
let fixture = fixture()?;
|
||||
let _ = init_global(256);
|
||||
eprintln!("[library-profile] memory-ingest: fixture + event bus ready");
|
||||
measure("memory-ingest", INGEST_MESSAGE_COUNT, None, |_rec| async {
|
||||
let result = ingest_chat(
|
||||
&fixture.config,
|
||||
"profile:chat:100",
|
||||
"profile-user",
|
||||
vec!["profile".into()],
|
||||
ingestion_batch(),
|
||||
)
|
||||
.await?;
|
||||
anyhow::ensure!(result.chunks_written > 0, "ingestion wrote no chunks");
|
||||
drain_until_idle(&fixture.config).await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! One module per profiling scenario. Each exposes a single
|
||||
//! `run() -> Result<ProfileResult>` entry point dispatched from `main`.
|
||||
|
||||
pub mod agent_turn;
|
||||
pub mod cold_phases;
|
||||
pub mod fleet;
|
||||
pub mod long_agent;
|
||||
pub mod memory_ingest;
|
||||
pub mod skill_run;
|
||||
pub mod subagent_storm;
|
||||
pub mod subagents;
|
||||
pub mod subconscious;
|
||||
pub mod workflow;
|
||||
@@ -0,0 +1,149 @@
|
||||
//! `skill-run`: the true, process-*tree* cost of a skill step that executes on
|
||||
//! a real language runtime — the interpreter child process included.
|
||||
//!
|
||||
//! ## What it actually runs
|
||||
//!
|
||||
//! A skill run's orchestrator (`spawn_workflow_run_background` → the
|
||||
//! `orchestrator` agent) deliberately owns **no** `node_exec` tool: the
|
||||
//! chat-tier orchestrator never executes code itself, it delegates every code
|
||||
//! step to the `code_executor` specialist (the only builtin whose allow-list
|
||||
//! carries `node_exec` / `npm_exec`). So the agent that genuinely spawns the
|
||||
//! language runtime *is* `code_executor`. This scenario drives that specialist
|
||||
//! directly — one turn, one scripted `node_exec` call — which is the real
|
||||
//! node-executing path, not a bare `std::process` spawn. Measuring the
|
||||
//! orchestrator→specialist delegation on top would add in-process agent cost
|
||||
//! without changing the runtime-child cost this scenario exists to capture;
|
||||
//! the compromise is documented here on purpose.
|
||||
//!
|
||||
//! The mock ([`SkillRunMock`]) emits a `node_exec` call whose inline JavaScript
|
||||
//! does real work, allocates, and busy-waits ~1.2 s so the child stays resident
|
||||
//! long enough for the harness tree sampler (15 ms poll) to attribute it, then
|
||||
//! prints JSON carrying [`NODE_MARKER`]. When that output rides back into the
|
||||
//! turn the mock returns a plain final answer and the turn completes.
|
||||
//!
|
||||
//! ## No interpreter download
|
||||
//!
|
||||
//! `node.prefer_system = true` (the default) means a host `node` whose **major**
|
||||
//! matches the configured target is reused rather than downloaded. This
|
||||
//! scenario **requires** a system `node` and bails with a clear stderr error +
|
||||
//! nonzero exit if none is on `PATH` — it must never pull a runtime.
|
||||
//!
|
||||
//! The measured cost lands in `result.tree` (`tree_rss_kib`, `child_count`,
|
||||
//! per-child RSS) — captured at the workload peak, since the `node` child has
|
||||
//! already exited by settle time.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use openhuman_core::openhuman::security::AutonomyLevel;
|
||||
|
||||
use crate::harness::{fixture, measure_with_tree, EnvGuard, ProfileResult};
|
||||
use crate::mock::SkillRunMock;
|
||||
|
||||
/// The specialist agent that owns `node_exec` and spawns the runtime child.
|
||||
const CODE_AGENT: &str = "code_executor";
|
||||
|
||||
/// Probe for a usable system `node`. Returns its version on success; on failure
|
||||
/// prints a clear `[library-profile]` stderr error and returns `Err` (which
|
||||
/// propagates to a nonzero process exit). We must NOT download an interpreter.
|
||||
fn require_system_node() -> Result<String> {
|
||||
match std::process::Command::new("node").arg("--version").output() {
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
eprintln!("[library-profile] skill-run: using system node {version}");
|
||||
Ok(version)
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
eprintln!(
|
||||
"[library-profile] skill-run: `node --version` failed (status {:?}): {stderr}",
|
||||
output.status.code()
|
||||
);
|
||||
anyhow::bail!("skill-run requires a working system `node`, but `node --version` failed")
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"[library-profile] skill-run: `node` not found on PATH: {err}. Install Node.js — \
|
||||
the profiler will NOT download an interpreter."
|
||||
);
|
||||
anyhow::bail!("skill-run requires a system `node` on PATH; none found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<ProfileResult> {
|
||||
// Hard requirement: a system node must be present (no download).
|
||||
require_system_node()?;
|
||||
|
||||
let mut fixture = fixture()?;
|
||||
// `node_exec` is a Write-class acting tool. Full autonomy keeps the gate
|
||||
// from parking the turn on approval; the gate is also opted out explicitly.
|
||||
fixture.config.autonomy.level = AutonomyLevel::Full;
|
||||
let _approval_env = EnvGuard::set("OPENHUMAN_APPROVAL_GATE", "0");
|
||||
|
||||
let _ = init_global(256);
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
|
||||
let mock = SkillRunMock::new();
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
eprintln!(
|
||||
"[library-profile] skill-run: registries ready, node_exec mock installed \
|
||||
(agent={CODE_AGENT})"
|
||||
);
|
||||
|
||||
let mock_for_workload = mock.clone();
|
||||
let config = fixture.config.clone();
|
||||
let mut result = measure_with_tree("skill-run", 1, None, move |rec| async move {
|
||||
rec.checkpoint("turn-start")?;
|
||||
let mut agent = Agent::from_config_for_agent(&config, CODE_AGENT)?;
|
||||
let reply = agent
|
||||
.run_single(
|
||||
"Run a short JavaScript computation with node_exec and report the JSON it prints.",
|
||||
)
|
||||
.await?;
|
||||
rec.checkpoint("turn-done")?;
|
||||
anyhow::ensure!(!reply.trim().is_empty(), "empty code_executor reply");
|
||||
anyhow::ensure!(
|
||||
mock_for_workload.node_call_emitted(),
|
||||
"the node_exec tool call was never emitted"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
mock_for_workload.node_output_seen(),
|
||||
"the node child's output never flowed back — the interpreter child did not run/print"
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
match result.tree.as_ref() {
|
||||
Some(tree) if tree.child_count >= 1 => {
|
||||
eprintln!(
|
||||
"[library-profile] skill-run: captured tree_rss_kib={} child_count={} \
|
||||
children={:?}",
|
||||
tree.tree_rss_kib, tree.child_count, tree.children
|
||||
);
|
||||
}
|
||||
Some(tree) => {
|
||||
eprintln!(
|
||||
"[library-profile] skill-run: WARNING tree captured but no child was resident at \
|
||||
peak (tree_rss_kib={}). The node child may have been too short-lived; \
|
||||
the busy-wait should have kept it alive.",
|
||||
tree.tree_rss_kib
|
||||
);
|
||||
}
|
||||
None => {
|
||||
eprintln!("[library-profile] skill-run: WARNING no process-tree sample captured");
|
||||
}
|
||||
}
|
||||
|
||||
// Fold in scenario-visible fields (schema stays additive).
|
||||
result.workload_units = 1;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! `subagent-storm`: fuzz the *width* of delegation inside ONE core instance.
|
||||
//!
|
||||
//! One orchestrator turn fans out to **K** parallel researcher subagents (all
|
||||
//! in-process tokio tasks, not child processes) via `spawn_parallel_agents`.
|
||||
//! K comes from `OPENHUMAN_PROFILE_SUBAGENTS` (default 8; tested up to 32), and
|
||||
//! each researcher carries per-subagent mock latency drawn from the shared
|
||||
//! `OPENHUMAN_PROFILE_MOCK_LATENCY_MS` / `_JITTER_MS` knobs.
|
||||
//!
|
||||
//! ## Measurement shape (and a hard constraint we hit)
|
||||
//!
|
||||
//! The intended shape was: prewarm one width-K fan-out, then measure a second on
|
||||
//! the same warm process so the delta is attributable to the K children rather
|
||||
//! than cold bootstrap. That is **not achievable** here: the parallel-spawn
|
||||
//! machinery is effectively one-shot per process. Once a fan-out's run ledger is
|
||||
//! finalized, a second `spawn_parallel_agents` returns an empty result and the
|
||||
//! orchestrator just re-calls the tool without re-running the workers. Worse,
|
||||
//! merely *constructing* an orchestrator/researcher agent beforehand perturbs
|
||||
//! the fan-out the same way. The only shape that reliably executes all K real
|
||||
//! researcher subagents is a single fan-out as the process's first agent
|
||||
//! activity.
|
||||
//!
|
||||
//! So this scenario measures exactly that: one cold width-K fan-out. `retained_delta_kib`
|
||||
//! therefore includes the shared per-process bootstrap (~20–30 MiB every
|
||||
//! scenario pays once) amortized across K, so a single run's
|
||||
//! `marginal_rss_kib_per_agent = retained_delta_kib / K` is an **upper bound**,
|
||||
//! not the true marginal. Read the true marginal by comparing runs: the fixed
|
||||
//! bootstrap amortizes, so `(retained(K₂) - retained(K₁)) / (K₂ - K₁)` across
|
||||
//! two widths (e.g. K=8 vs K=32) isolates the genuine per-additional-subagent
|
||||
//! cost. `peak_delta_kib` additionally captures the K-concurrent peak.
|
||||
//!
|
||||
//! Reported fields: `subagents = K`, `marginal_rss_kib_per_agent` (retained/K,
|
||||
//! upper bound), `checkpoints` (baseline → storm-turn-done), and
|
||||
//! `turn_latency_ms` (percentiles across the K researcher child executions).
|
||||
//! The workload asserts all K researcher subagents actually executed.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult, TurnLatency};
|
||||
use crate::mock::{subagent_marker, SubagentMock};
|
||||
|
||||
const DEFAULT_SUBAGENTS: usize = 8;
|
||||
|
||||
/// The orchestrator's top-level task. The mock ignores the wording and always
|
||||
/// fans out to K researchers.
|
||||
const STORM_PROMPT: &str = "Research every subsystem in parallel and merge the findings.";
|
||||
|
||||
/// Positive identity anchor for a researcher *worker* turn — its own system
|
||||
/// prompt names it. Distinguishes a real worker from the orchestrator turns that
|
||||
/// also echo every task marker in the fan-out tool call / result.
|
||||
const RESEARCHER_IDENTITY: &str = "You are the **Researcher** agent";
|
||||
|
||||
fn env_usize(key: &str, default: usize) -> usize {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|n| *n > 0)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Nearest-rank percentile of an already-sorted slice. `p` in `[0, 100]`.
|
||||
fn percentile(sorted: &[u128], p: u128) -> u128 {
|
||||
if sorted.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let rank = ((p * sorted.len() as u128) + 99) / 100; // ceil(p% * n)
|
||||
let idx = rank.saturating_sub(1).min(sorted.len() as u128 - 1) as usize;
|
||||
sorted[idx]
|
||||
}
|
||||
|
||||
fn latency_summary(mut samples: Vec<u128>) -> Option<TurnLatency> {
|
||||
if samples.is_empty() {
|
||||
return None;
|
||||
}
|
||||
samples.sort_unstable();
|
||||
Some(TurnLatency {
|
||||
p50: percentile(&samples, 50),
|
||||
p95: percentile(&samples, 95),
|
||||
p99: percentile(&samples, 99),
|
||||
max: *samples.last().unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<ProfileResult> {
|
||||
let width = env_usize("OPENHUMAN_PROFILE_SUBAGENTS", DEFAULT_SUBAGENTS);
|
||||
|
||||
let mut fixture = fixture()?;
|
||||
// `spawn_parallel_agents` rejects a fan-out wider than the orchestrator's
|
||||
// `max_parallel_tools` (default 4). Raise it to K so the full width actually
|
||||
// spawns instead of erroring back to a re-spawn loop.
|
||||
fixture.config.agent.max_parallel_tools = width.max(4);
|
||||
let _ = init_global(256);
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
|
||||
let mock = SubagentMock::with_width(width);
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
eprintln!("[library-profile] subagent-storm: width={width} — single cold width-K fan-out");
|
||||
|
||||
// We drive the `orchestrator` agent directly: it owns `spawn_parallel_agents`
|
||||
// and allows the `researcher` subagent (the chat-tier subconscious has
|
||||
// neither, and would reject the fan-out). One orchestrator turn fans out to
|
||||
// K real researcher subagents via the parallel graph. This fan-out MUST be
|
||||
// the process's first agent activity — see the module docs for why prewarming
|
||||
// is not possible here.
|
||||
let config = fixture.config.clone();
|
||||
let mock_for_workload = mock.clone();
|
||||
let mut result = measure("subagent-storm", width, None, move |rec| async move {
|
||||
rec.checkpoint("baseline")?;
|
||||
let mut agent = Agent::from_config_for_agent(&config, "orchestrator")?;
|
||||
let reply = agent.run_single(STORM_PROMPT).await?;
|
||||
rec.checkpoint("storm-turn-done")?;
|
||||
anyhow::ensure!(!reply.trim().is_empty(), "empty storm-turn response");
|
||||
// Every one of the K researcher subagents must have actually executed as
|
||||
// its own worker turn: for each i there must be a prompt that carries the
|
||||
// researcher identity anchor AND that researcher's task marker — not
|
||||
// merely an orchestrator turn echoing every marker in the fan-out call.
|
||||
let prompts = mock_for_workload.prompts.lock().expect("mock prompt lock");
|
||||
for i in 1..=width {
|
||||
anyhow::ensure!(
|
||||
prompts
|
||||
.iter()
|
||||
.any(|p| p.contains(RESEARCHER_IDENTITY) && p.contains(&subagent_marker(i))),
|
||||
"researcher subagent {i}/{width} never executed as its own worker turn"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Marginal per subagent = retained / K. An UPPER BOUND: `retained_delta_kib`
|
||||
// still carries the one-time per-process bootstrap (see module docs), so the
|
||||
// true marginal is the cross-width delta `(retained(K₂)-retained(K₁))/(K₂-K₁)`.
|
||||
let marginal = if width > 0 {
|
||||
Some(result.retained_delta_kib as f64 / width as f64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let latencies = mock
|
||||
.researcher_latencies_ms
|
||||
.lock()
|
||||
.expect("mock latency lock")
|
||||
.clone();
|
||||
eprintln!(
|
||||
"[library-profile] subagent-storm: width={width} retained_delta_kib={} \
|
||||
marginal_rss_kib_per_agent={:?} researcher_executions={}",
|
||||
result.retained_delta_kib,
|
||||
marginal,
|
||||
latencies.len()
|
||||
);
|
||||
|
||||
result.subagents = Some(width);
|
||||
result.marginal_rss_kib_per_agent = marginal;
|
||||
result.turn_latency_ms = latency_summary(latencies);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn percentiles_nearest_rank() {
|
||||
let v: Vec<u128> = (1..=100).collect();
|
||||
assert_eq!(percentile(&v, 50), 50);
|
||||
assert_eq!(percentile(&v, 95), 95);
|
||||
assert_eq!(percentile(&v, 99), 99);
|
||||
assert_eq!(percentile(&v, 100), 100);
|
||||
assert_eq!(percentile(&[], 50), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latency_summary_none_when_empty() {
|
||||
assert!(latency_summary(Vec::new()).is_none());
|
||||
let s = latency_summary(vec![10, 20, 30]).unwrap();
|
||||
assert_eq!(s.max, 30);
|
||||
assert_eq!(s.p50, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_usize_falls_back_on_zero_or_unset() {
|
||||
assert_eq!(env_usize("OPENHUMAN_PROFILE_STORM_UNSET_XYZ", 8), 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! `subagents`: run one real orchestrator chat turn that spawns two real
|
||||
//! researcher subagents through the parallel-delegation tool.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::config::schema::SubconsciousMode;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use openhuman_core::openhuman::subconscious::LongLivedSession;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
use crate::mock::{subagent_marker, SubagentMock};
|
||||
|
||||
pub async fn run() -> Result<ProfileResult> {
|
||||
let fixture = fixture()?;
|
||||
let _ = init_global(256);
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let mock = SubagentMock::new();
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
if std::env::var_os("OPENHUMAN_PROFILE_PREWARM_SUBAGENTS").is_some() {
|
||||
eprintln!("[library-profile] subagents: prewarming one full turn");
|
||||
let warmup = LongLivedSession::with_thread(
|
||||
fixture.config.workspace_dir.clone(),
|
||||
SubconsciousMode::Aggressive,
|
||||
"profile:warmup".into(),
|
||||
);
|
||||
let outcome = warmup
|
||||
.process_promoted("Please research the Phoenix migration.", false)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
anyhow::ensure!(!outcome.response.is_empty(), "empty warmup response");
|
||||
mock.prompts.lock().expect("mock prompt lock").clear();
|
||||
}
|
||||
let session = LongLivedSession::with_thread(
|
||||
fixture.config.workspace_dir.clone(),
|
||||
SubconsciousMode::Aggressive,
|
||||
"profile:orchestrator".into(),
|
||||
);
|
||||
measure("subagents", 2, None, |_rec| async {
|
||||
let outcome = session
|
||||
.process_promoted("Please research the Phoenix migration.", false)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
anyhow::ensure!(!outcome.response.is_empty(), "empty orchestrator response");
|
||||
let prompts = mock.prompts.lock().expect("mock prompt lock");
|
||||
anyhow::ensure!(prompts.iter().any(|p| p.contains(&subagent_marker(1))));
|
||||
anyhow::ensure!(prompts.iter().any(|p| p.contains(&subagent_marker(2))));
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! `subconscious`: one promoted subconscious turn WITHOUT delegation. Same
|
||||
//! `LongLivedSession` path as `subagents`, but the mock returns a direct text
|
||||
//! response (no `spawn_parallel_agents` tool call). Complements `subagents`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::config::schema::SubconsciousMode;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use openhuman_core::openhuman::subconscious::LongLivedSession;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
use crate::mock::PlainTextMock;
|
||||
|
||||
pub async fn run() -> Result<ProfileResult> {
|
||||
let fixture = fixture()?;
|
||||
let _ = init_global(256);
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let mock = PlainTextMock::new("Phoenix migration is on track; nothing needs your attention.");
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
|
||||
if std::env::var_os("OPENHUMAN_PROFILE_PREWARM_SUBAGENTS").is_some() {
|
||||
eprintln!("[library-profile] subconscious: prewarming one full turn");
|
||||
let warmup = LongLivedSession::with_thread(
|
||||
fixture.config.workspace_dir.clone(),
|
||||
SubconsciousMode::Aggressive,
|
||||
"profile:warmup".into(),
|
||||
);
|
||||
let outcome = warmup
|
||||
.process_promoted("Please review the Phoenix migration.", false)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
anyhow::ensure!(!outcome.response.is_empty(), "empty warmup response");
|
||||
mock.prompts.lock().expect("mock prompt lock").clear();
|
||||
}
|
||||
|
||||
let session = LongLivedSession::with_thread(
|
||||
fixture.config.workspace_dir.clone(),
|
||||
SubconsciousMode::Aggressive,
|
||||
"profile:subconscious".into(),
|
||||
);
|
||||
measure("subconscious", 1, None, |_rec| async {
|
||||
let outcome = session
|
||||
.process_promoted("Please review the Phoenix migration.", false)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
anyhow::ensure!(!outcome.response.is_empty(), "empty subconscious response");
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `workflow`: run a real flows-domain workflow end to end. A
|
||||
//! trigger -> transform -> agent graph is created OUTSIDE the measured region
|
||||
//! (recorded as a checkpoint), then `flows_run` is measured as the workload.
|
||||
//! The agent node's LLM routes through the plain-text mock provider.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::flows::ops::{flows_create, flows_run};
|
||||
use openhuman_core::openhuman::flows::FlowRunTrigger;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
use crate::mock::PlainTextMock;
|
||||
|
||||
pub async fn run() -> Result<ProfileResult> {
|
||||
let fixture = fixture()?;
|
||||
let _ = init_global(256);
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let mock = PlainTextMock::new("Phoenix migration status: healthy, ramp on Friday.");
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
|
||||
let graph = json!({
|
||||
"name": "profile-workflow",
|
||||
"nodes": [
|
||||
{ "id": "t", "kind": "trigger", "name": "Trigger" },
|
||||
{ "id": "prep", "kind": "transform", "name": "Prep",
|
||||
"config": { "set": { "topic": "Phoenix migration" } } },
|
||||
{ "id": "summarize", "kind": "agent", "name": "Summarize",
|
||||
"config": { "agent_ref": "researcher",
|
||||
"prompt": "Summarise the Phoenix migration status in one line." } }
|
||||
],
|
||||
"edges": [
|
||||
{ "from_node": "t", "to_node": "prep" },
|
||||
{ "from_node": "prep", "to_node": "summarize" }
|
||||
]
|
||||
});
|
||||
|
||||
eprintln!("[library-profile] workflow: creating flow (outside measured region)");
|
||||
let flow = flows_create(&fixture.config, "profile-workflow".into(), graph, false)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.value;
|
||||
let flow_id = flow.id.clone();
|
||||
|
||||
measure("workflow", 1, None, move |_rec| async move {
|
||||
let outcome = flows_run(
|
||||
&fixture.config,
|
||||
&flow_id,
|
||||
json!({ "topic": "Phoenix migration" }),
|
||||
FlowRunTrigger::Rpc,
|
||||
)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let output = outcome.value.get("output");
|
||||
anyhow::ensure!(
|
||||
output.is_some() && !output.unwrap().is_null(),
|
||||
"workflow run produced no output: {}",
|
||||
outcome.value
|
||||
);
|
||||
anyhow::ensure!(
|
||||
outcome.value.get("note").is_none(),
|
||||
"workflow with an actionable agent node unexpectedly reported nothing-to-run: {}",
|
||||
outcome.value
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -111,6 +111,16 @@ pub fn render_datetime(ctx: &PromptContext<'_>) -> Result<String> {
|
||||
/// session. The static grounding *rule* that tells the model to read this
|
||||
/// line lives in [`DateTimeSection`] / [`render_datetime`].
|
||||
pub fn current_datetime_line() -> String {
|
||||
#[cfg(feature = "rss-bench")]
|
||||
if std::env::var_os("OPENHUMAN_PROFILE_FORCE_UTC").is_some() {
|
||||
let now = chrono::Utc::now();
|
||||
return format!(
|
||||
"Current Date & Time: {} UTC (UTC, UTC+00:00), {}",
|
||||
now.format("%Y-%m-%d %H:%M:%S"),
|
||||
now.format("%A"),
|
||||
);
|
||||
}
|
||||
|
||||
// When the host resolves an IANA zone, stamp local time + that zone. When
|
||||
// it can't (CI, stripped containers), fall back to true UTC — formatting
|
||||
// `Utc::now()` so the time, offset, and zone label all agree rather than
|
||||
|
||||
@@ -513,9 +513,9 @@ pub(crate) fn resolve_byok_fallback_provider_string(config: &Config) -> Option<S
|
||||
///
|
||||
/// Because it is global, tests that install an override MUST run serially
|
||||
/// and clear it via the returned guard. Inert in production: the check below
|
||||
/// is gated on `cfg(test)` or the off-by-default `e2e-test-support` feature,
|
||||
/// is gated on `cfg(test)` or an off-by-default test/profiling feature,
|
||||
/// so the override is never consulted in shipped builds.
|
||||
#[cfg(any(test, feature = "e2e-test-support"))]
|
||||
#[cfg(any(test, feature = "e2e-test-support", feature = "rss-bench"))]
|
||||
pub mod test_provider_override {
|
||||
use super::Provider;
|
||||
use crate::openhuman::inference::provider::traits::{
|
||||
@@ -710,7 +710,7 @@ pub fn create_chat_provider(
|
||||
// Test-only: a scripted mock provider injected by an e2e test wins over
|
||||
// anything config-derived. Gated on cfg(test) / the off-by-default
|
||||
// `e2e-test-support` feature; never consulted in shipped builds.
|
||||
#[cfg(any(test, feature = "e2e-test-support"))]
|
||||
#[cfg(any(test, feature = "e2e-test-support", feature = "rss-bench"))]
|
||||
if let Some(p) = test_provider_override::current() {
|
||||
return Ok((
|
||||
Box::new(test_provider_override::ProviderHandle(p)),
|
||||
@@ -984,11 +984,11 @@ pub fn create_chat_model_with_model_id(
|
||||
// override is installed. Temperature rides the per-call `ModelRequest` on the
|
||||
// crate path (the managed model is reused across prompts of differing temp).
|
||||
let test_override_active = {
|
||||
#[cfg(any(test, feature = "e2e-test-support"))]
|
||||
#[cfg(any(test, feature = "e2e-test-support", feature = "rss-bench"))]
|
||||
{
|
||||
test_provider_override::current().is_some()
|
||||
}
|
||||
#[cfg(not(any(test, feature = "e2e-test-support")))]
|
||||
#[cfg(not(any(test, feature = "e2e-test-support", feature = "rss-bench")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
@@ -1058,11 +1058,11 @@ pub fn create_chat_model_from_string_with_model_id(
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<(Arc<dyn ChatModel<()>>, String)> {
|
||||
let test_override_active = {
|
||||
#[cfg(any(test, feature = "e2e-test-support"))]
|
||||
#[cfg(any(test, feature = "e2e-test-support", feature = "rss-bench"))]
|
||||
{
|
||||
test_provider_override::current().is_some()
|
||||
}
|
||||
#[cfg(not(any(test, feature = "e2e-test-support")))]
|
||||
#[cfg(not(any(test, feature = "e2e-test-support", feature = "rss-bench")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
@@ -1477,11 +1477,11 @@ pub(crate) fn create_turn_chat_model_with_native_tools(
|
||||
native_tool_calling: bool,
|
||||
) -> anyhow::Result<Arc<dyn ChatModel<()>>> {
|
||||
let test_override_active = {
|
||||
#[cfg(any(test, feature = "e2e-test-support"))]
|
||||
#[cfg(any(test, feature = "e2e-test-support", feature = "rss-bench"))]
|
||||
{
|
||||
test_provider_override::current().is_some()
|
||||
}
|
||||
#[cfg(not(any(test, feature = "e2e-test-support")))]
|
||||
#[cfg(not(any(test, feature = "e2e-test-support", feature = "rss-bench")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
@@ -1559,11 +1559,11 @@ pub(crate) fn create_turn_chat_model_from_string_with_native_tools(
|
||||
native_tool_calling: bool,
|
||||
) -> anyhow::Result<Arc<dyn ChatModel<()>>> {
|
||||
let test_override_active = {
|
||||
#[cfg(any(test, feature = "e2e-test-support"))]
|
||||
#[cfg(any(test, feature = "e2e-test-support", feature = "rss-bench"))]
|
||||
{
|
||||
test_provider_override::current().is_some()
|
||||
}
|
||||
#[cfg(not(any(test, feature = "e2e-test-support")))]
|
||||
#[cfg(not(any(test, feature = "e2e-test-support", feature = "rss-bench")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
|
||||
@@ -172,8 +172,8 @@ pub fn create_routed_provider_with_options(
|
||||
) -> anyhow::Result<Box<dyn Provider>> {
|
||||
// Test-only: a mock provider injected by an e2e test wins over any
|
||||
// config-derived routing (covers the triage remote arm). Gated on
|
||||
// cfg(test) / the off-by-default `e2e-test-support` feature.
|
||||
#[cfg(any(test, feature = "e2e-test-support"))]
|
||||
// cfg(test) / the off-by-default test and profiling features.
|
||||
#[cfg(any(test, feature = "e2e-test-support", feature = "rss-bench"))]
|
||||
if let Some(p) = super::super::factory::test_provider_override::current() {
|
||||
return Ok(Box::new(
|
||||
super::super::factory::test_provider_override::ProviderHandle(p),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Process memory sampling from Linux `/proc`.
|
||||
//! Cross-platform process memory sampling.
|
||||
//!
|
||||
//! Reads the current process's resident-memory breakdown from
|
||||
//! `/proc/self/smaps_rollup` + `/proc/self/status` and aggregates repeated
|
||||
@@ -6,15 +6,19 @@
|
||||
//! `rss-bench` benchmark harness (#5046), which measures the steady-state RSS
|
||||
//! of an embedded `openhuman_core` agent roster against the 20–30 MiB budget,
|
||||
//! but [`sample_self`] is a general capability: any caller wanting this
|
||||
//! process's RSS / PSS / private-page / peak-RSS figures on Linux can use it.
|
||||
//! process's RSS / peak-RSS figures can use it. Linux additionally reports PSS
|
||||
//! and private-page breakdowns; macOS leaves those Linux-only fields at zero.
|
||||
//!
|
||||
//! The parsers ([`parse_status`], [`parse_smaps_rollup`]) are OS-agnostic and
|
||||
//! take `&str`, so they are unit-tested without a live `/proc`. [`sample_self`]
|
||||
//! is Linux-only and returns a structured error elsewhere — it never fabricates
|
||||
//! a reading (a macOS local run fails loudly rather than emitting garbage).
|
||||
//! supports Linux and macOS and returns a structured error elsewhere — it never
|
||||
//! fabricates a reading.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod tree;
|
||||
pub use tree::{sample_tree, ChildSample, TreeSample};
|
||||
|
||||
/// Product budget for the embedded roster, in KiB (#5046). Target the agent
|
||||
/// roster should land under.
|
||||
pub const RSS_BUDGET_KIB: u64 = 20 * 1024;
|
||||
@@ -39,6 +43,20 @@ pub struct ProcSample {
|
||||
pub threads: u64,
|
||||
/// On-disk size of the running executable, in bytes.
|
||||
pub binary_size_bytes: u64,
|
||||
/// Cumulative user-mode CPU time, in milliseconds. Linux: `/proc/self/stat`
|
||||
/// `utime` (ticks → ms). macOS: `proc_pid_rusage` `ri_user_time` (ns → ms).
|
||||
/// Defaulted so pre-existing JSON consumers keep deserializing.
|
||||
#[serde(default)]
|
||||
pub cpu_user_ms: u64,
|
||||
/// Cumulative system-mode CPU time, in milliseconds. Linux: `/proc/self/stat`
|
||||
/// `stime`. macOS: `proc_pid_rusage` `ri_system_time`.
|
||||
#[serde(default)]
|
||||
pub cpu_system_ms: u64,
|
||||
/// Open file-descriptor count. Linux: entries in `/proc/self/fd`. macOS:
|
||||
/// `proc_pidinfo(PROC_PIDLISTFDS)` buffer size / `proc_fdinfo` size. `None`
|
||||
/// when the platform lookup is unavailable rather than a misleading zero.
|
||||
#[serde(default)]
|
||||
pub open_fds: Option<u64>,
|
||||
}
|
||||
|
||||
/// Fields extracted from `/proc/<pid>/status`.
|
||||
@@ -100,6 +118,49 @@ pub fn parse_smaps_rollup(contents: &str) -> SmapsRollupFields {
|
||||
fields
|
||||
}
|
||||
|
||||
/// User/system CPU jiffies parsed from `/proc/<pid>/stat`.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct StatCpuFields {
|
||||
pub utime_ticks: u64,
|
||||
pub stime_ticks: u64,
|
||||
}
|
||||
|
||||
/// Parse `utime` (field 14) and `stime` (field 15) out of `/proc/<pid>/stat`.
|
||||
/// The `comm` field (2) is parenthesised and may itself contain spaces or
|
||||
/// parens, so we split on the **last** `')'` and index the remaining
|
||||
/// whitespace-separated fields: after `)`, token 0 is `state`, so `utime` is
|
||||
/// token 11 and `stime` token 12. Missing/short input yields zero.
|
||||
pub fn parse_proc_stat_cpu(contents: &str) -> StatCpuFields {
|
||||
let Some(after) = contents.rsplit_once(')').map(|(_, tail)| tail) else {
|
||||
return StatCpuFields::default();
|
||||
};
|
||||
let fields: Vec<&str> = after.split_whitespace().collect();
|
||||
// token 0 == state; utime == token 11, stime == token 12.
|
||||
let utime_ticks = fields.get(11).and_then(|t| t.parse().ok()).unwrap_or(0);
|
||||
let stime_ticks = fields.get(12).and_then(|t| t.parse().ok()).unwrap_or(0);
|
||||
StatCpuFields {
|
||||
utime_ticks,
|
||||
stime_ticks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert CPU clock ticks to milliseconds given `CLK_TCK` (ticks per second).
|
||||
/// A zero or negative tick rate yields zero rather than dividing by zero.
|
||||
pub fn cpu_ticks_to_ms(ticks: u64, clk_tck: i64) -> u64 {
|
||||
if clk_tck <= 0 {
|
||||
return 0;
|
||||
}
|
||||
ticks.saturating_mul(1000) / clk_tck as u64
|
||||
}
|
||||
|
||||
/// Count entries in `/proc/<pid>/fd` (excluding `.`/`..`, which `read_dir`
|
||||
/// already omits). `None` when the directory can't be read.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn count_open_fds() -> Option<u64> {
|
||||
let entries = std::fs::read_dir("/proc/self/fd").ok()?;
|
||||
Some(entries.filter(|e| e.is_ok()).count() as u64)
|
||||
}
|
||||
|
||||
/// Sample this process's resident memory. Linux-only.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn sample_self() -> anyhow::Result<ProcSample> {
|
||||
@@ -109,6 +170,10 @@ pub fn sample_self() -> anyhow::Result<ProcSample> {
|
||||
.context("read /proc/self/smaps_rollup")?;
|
||||
let status = parse_status(&status);
|
||||
let smaps = parse_smaps_rollup(&smaps);
|
||||
let stat = std::fs::read_to_string("/proc/self/stat").unwrap_or_default();
|
||||
let cpu = parse_proc_stat_cpu(&stat);
|
||||
// SAFETY: `sysconf` is a pure lookup; `_SC_CLK_TCK` is a valid selector.
|
||||
let clk_tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
|
||||
let binary_size_bytes = std::env::current_exe()
|
||||
.and_then(std::fs::metadata)
|
||||
.map(|meta| meta.len())
|
||||
@@ -121,15 +186,103 @@ pub fn sample_self() -> anyhow::Result<ProcSample> {
|
||||
vm_hwm_kib: status.vm_hwm_kib,
|
||||
threads: status.threads,
|
||||
binary_size_bytes,
|
||||
cpu_user_ms: cpu_ticks_to_ms(cpu.utime_ticks, clk_tck),
|
||||
cpu_system_ms: cpu_ticks_to_ms(cpu.stime_ticks, clk_tck),
|
||||
open_fds: count_open_fds(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Sample this process's resident memory. Non-Linux stub — fails loudly rather
|
||||
/// than fabricating a reading.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
/// Sample this process's resident memory on macOS via `proc_pid_rusage`.
|
||||
///
|
||||
/// `ri_resident_size` and `ru_maxrss` are bytes on Darwin. PSS and private-page
|
||||
/// accounting have no direct macOS equivalent, so those Linux-specific fields
|
||||
/// remain zero rather than being populated with a misleading substitute.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn sample_self() -> anyhow::Result<ProcSample> {
|
||||
use std::mem::{size_of, MaybeUninit};
|
||||
|
||||
let pid = std::process::id() as libc::c_int;
|
||||
let mut usage = MaybeUninit::<libc::rusage_info_v2>::uninit();
|
||||
// SAFETY: `usage` points to writable storage of the exact structure size
|
||||
// required by `RUSAGE_INFO_V2`; the kernel initializes it on success.
|
||||
let result = unsafe {
|
||||
libc::proc_pid_rusage(
|
||||
pid,
|
||||
libc::RUSAGE_INFO_V2,
|
||||
usage.as_mut_ptr().cast::<libc::rusage_info_t>(),
|
||||
)
|
||||
};
|
||||
if result != 0 {
|
||||
return Err(std::io::Error::last_os_error()).map_err(Into::into);
|
||||
}
|
||||
// SAFETY: `proc_pid_rusage` returned success and initialized `usage`.
|
||||
let usage = unsafe { usage.assume_init() };
|
||||
|
||||
let mut peak = MaybeUninit::<libc::rusage>::uninit();
|
||||
// SAFETY: `peak` is valid writable storage for `getrusage`.
|
||||
let peak_result = unsafe { libc::getrusage(libc::RUSAGE_SELF, peak.as_mut_ptr()) };
|
||||
let vm_hwm_kib = if peak_result == 0 {
|
||||
// SAFETY: `getrusage` returned success and initialized `peak`.
|
||||
(unsafe { peak.assume_init() }.ru_maxrss as u64) / 1024
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut task = MaybeUninit::<libc::proc_taskinfo>::uninit();
|
||||
// SAFETY: `task` points to writable storage and its size is passed to
|
||||
// `proc_pidinfo`, which initializes it when the returned byte count matches.
|
||||
let task_bytes = unsafe {
|
||||
libc::proc_pidinfo(
|
||||
pid,
|
||||
libc::PROC_PIDTASKINFO,
|
||||
0,
|
||||
task.as_mut_ptr().cast(),
|
||||
size_of::<libc::proc_taskinfo>() as libc::c_int,
|
||||
)
|
||||
};
|
||||
let threads = if task_bytes == size_of::<libc::proc_taskinfo>() as libc::c_int {
|
||||
// SAFETY: the kernel returned the full structure size.
|
||||
unsafe { task.assume_init() }.pti_threadnum.max(0) as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// `proc_pidinfo(PROC_PIDLISTFDS, .., NULL, 0)` returns the byte size of the
|
||||
// fd table; dividing by `proc_fdinfo` size gives the descriptor count.
|
||||
// SAFETY: passing a null buffer with zero length is the documented
|
||||
// size-probe form of `proc_pidinfo`.
|
||||
let fd_bytes =
|
||||
unsafe { libc::proc_pidinfo(pid, libc::PROC_PIDLISTFDS, 0, std::ptr::null_mut(), 0) };
|
||||
let open_fds = if fd_bytes > 0 {
|
||||
Some(fd_bytes as u64 / size_of::<libc::proc_fdinfo>() as u64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let binary_size_bytes = std::env::current_exe()
|
||||
.and_then(std::fs::metadata)
|
||||
.map(|meta| meta.len())
|
||||
.unwrap_or(0);
|
||||
Ok(ProcSample {
|
||||
rss_kib: usage.ri_resident_size / 1024,
|
||||
pss_kib: 0,
|
||||
private_clean_kib: 0,
|
||||
private_dirty_kib: 0,
|
||||
vm_hwm_kib,
|
||||
threads,
|
||||
binary_size_bytes,
|
||||
// `ri_user_time` / `ri_system_time` are nanoseconds on Darwin.
|
||||
cpu_user_ms: usage.ri_user_time / 1_000_000,
|
||||
cpu_system_ms: usage.ri_system_time / 1_000_000,
|
||||
open_fds,
|
||||
})
|
||||
}
|
||||
|
||||
/// Unsupported-platform stub — fails loudly rather than fabricating a reading.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
pub fn sample_self() -> anyhow::Result<ProcSample> {
|
||||
anyhow::bail!(
|
||||
"proc_metrics::sample_self requires Linux /proc/self/smaps_rollup + status (this is a {} build)",
|
||||
"proc_metrics::sample_self supports Linux and macOS (this is a {} build)",
|
||||
std::env::consts::OS
|
||||
)
|
||||
}
|
||||
@@ -340,9 +493,41 @@ mod tests {
|
||||
vm_hwm_kib: hwm,
|
||||
threads,
|
||||
binary_size_bytes: 1024,
|
||||
cpu_user_ms: 0,
|
||||
cpu_system_ms: 0,
|
||||
open_fds: None,
|
||||
}
|
||||
}
|
||||
|
||||
// A realistic `/proc/self/stat` line whose `comm` field embeds spaces and a
|
||||
// close-paren, to prove the last-`)` split is robust.
|
||||
const SAMPLE_STAT: &str = "1234 (weird ) name) R 1 1234 1234 0 -1 4194304 500 0 0 0 \
|
||||
420 137 0 0 20 0 8 0 99999 123456789 512 18446744073709551615";
|
||||
|
||||
#[test]
|
||||
fn parse_proc_stat_cpu_extracts_utime_stime() {
|
||||
let f = parse_proc_stat_cpu(SAMPLE_STAT);
|
||||
assert_eq!(f.utime_ticks, 420);
|
||||
assert_eq!(f.stime_ticks, 137);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_proc_stat_cpu_short_input_stays_zero() {
|
||||
assert_eq!(
|
||||
parse_proc_stat_cpu("1234 (x) R 1"),
|
||||
StatCpuFields::default()
|
||||
);
|
||||
assert_eq!(parse_proc_stat_cpu(""), StatCpuFields::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_ticks_to_ms_converts_and_guards_zero_rate() {
|
||||
// 420 ticks at 100 Hz == 4200 ms.
|
||||
assert_eq!(cpu_ticks_to_ms(420, 100), 4200);
|
||||
assert_eq!(cpu_ticks_to_ms(1000, 0), 0);
|
||||
assert_eq!(cpu_ticks_to_ms(1000, -1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_handles_odd_and_even() {
|
||||
assert_eq!(median_u64(&[]), 0);
|
||||
@@ -438,9 +623,22 @@ mod tests {
|
||||
assert_eq!(report.per_agent_increment_kib(), None);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
#[test]
|
||||
fn sample_self_is_linux_only() {
|
||||
fn sample_self_rejects_unsupported_platform() {
|
||||
assert!(sample_self().is_err());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn sample_self_reports_macos_resident_memory() {
|
||||
let sample = sample_self().expect("macOS process metrics");
|
||||
assert!(sample.rss_kib > 0);
|
||||
assert!(sample.vm_hwm_kib >= sample.rss_kib);
|
||||
assert!(sample.threads > 0);
|
||||
assert!(sample.binary_size_bytes > 0);
|
||||
assert_eq!(sample.pss_kib, 0);
|
||||
// A live process has consumed at least some CPU and holds open fds.
|
||||
assert!(sample.open_fds.map(|n| n > 0).unwrap_or(false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
//! Process-*tree* resident memory sampling.
|
||||
//!
|
||||
//! Where [`super::sample_self`] measures only the current process, this module
|
||||
//! measures the process *and all of its descendants* — the interpreter child
|
||||
//! processes (`node`, `python`, …) that a skill run or shell tool spawns. The
|
||||
//! true resource cost of "run this skill" includes those children, which never
|
||||
//! show up in a self-only RSS reading.
|
||||
//!
|
||||
//! [`sample_tree`] returns a [`TreeSample`]: this process's own
|
||||
//! [`ProcSample`](super::ProcSample), a flat list of descendant
|
||||
//! [`ChildSample`]s (pid + name + RSS), and `tree_rss_kib` (self + every
|
||||
//! descendant). Per-child RSS lookups that fail (a child that raced away, or a
|
||||
//! permission error) are skipped with a `[proc_metrics]` stderr note rather
|
||||
//! than aborting the whole sample.
|
||||
//!
|
||||
//! - **Linux** walks `/proc/*/stat` to recover each pid's `ppid`, chains those
|
||||
//! into a descendant set, and reads RSS from `/proc/<pid>/status` (`VmRSS`).
|
||||
//! - **macOS** enumerates descendants via `proc_listchildpids` (recursively),
|
||||
//! names them via `proc_pidinfo(PROC_PIDTBSDINFO)`, and reads RSS via
|
||||
//! `proc_pid_rusage` (`ri_resident_size`).
|
||||
//!
|
||||
//! The pure graph walk ([`collect_descendants`]) and the Linux `/proc/<pid>/stat`
|
||||
//! parser ([`parse_stat_comm_ppid`]) are OS-agnostic and unit-tested without a
|
||||
//! live `/proc`.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ProcSample;
|
||||
|
||||
/// One descendant process in a [`TreeSample`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChildSample {
|
||||
/// Process id of the descendant.
|
||||
pub pid: i32,
|
||||
/// Executable / accounting name (`node`, `python3`, …). May be empty when
|
||||
/// the platform lookup fails.
|
||||
pub name: String,
|
||||
/// Resident set size of this descendant, in KiB.
|
||||
pub rss_kib: u64,
|
||||
}
|
||||
|
||||
/// A process-tree resident-memory sample: this process plus every descendant.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TreeSample {
|
||||
/// This process's own self sample.
|
||||
pub self_sample: ProcSample,
|
||||
/// Every descendant process found at sample time.
|
||||
pub children: Vec<ChildSample>,
|
||||
/// Self RSS + the RSS of every descendant, in KiB.
|
||||
pub tree_rss_kib: u64,
|
||||
}
|
||||
|
||||
impl TreeSample {
|
||||
/// Number of descendant processes captured.
|
||||
pub fn child_count(&self) -> usize {
|
||||
self.children.len()
|
||||
}
|
||||
|
||||
/// Sum `self.rss` + every child's RSS into `tree_rss_kib`.
|
||||
fn assemble(self_sample: ProcSample, children: Vec<ChildSample>) -> Self {
|
||||
let tree_rss_kib =
|
||||
self_sample.rss_kib + children.iter().map(|child| child.rss_kib).sum::<u64>();
|
||||
Self {
|
||||
self_sample,
|
||||
children,
|
||||
tree_rss_kib,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect every transitive descendant of `root` given a `pid -> ppid` map.
|
||||
///
|
||||
/// OS-agnostic and pure so it can be unit-tested without a live process table.
|
||||
/// Cycles (which a live table should never contain, but a racy snapshot might)
|
||||
/// are broken by a visited set; `root` itself is never included.
|
||||
pub fn collect_descendants(root: i32, ppid_of: &HashMap<i32, i32>) -> Vec<i32> {
|
||||
let mut children_of: HashMap<i32, Vec<i32>> = HashMap::new();
|
||||
for (&pid, &ppid) in ppid_of {
|
||||
children_of.entry(ppid).or_default().push(pid);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut stack = vec![root];
|
||||
while let Some(parent) = stack.pop() {
|
||||
if let Some(kids) = children_of.get(&parent) {
|
||||
for &kid in kids {
|
||||
if kid != root && seen.insert(kid) {
|
||||
out.push(kid);
|
||||
stack.push(kid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the `comm` (name) and `ppid` out of a `/proc/<pid>/stat` line.
|
||||
///
|
||||
/// `stat` is `pid (comm) state ppid …`; `comm` is parenthesised and may itself
|
||||
/// contain spaces and parens, so we take everything between the first `(` and
|
||||
/// the **last** `)`. After that `)`, whitespace token 0 is `state` and token 1
|
||||
/// is `ppid`. Missing / malformed input yields `None`.
|
||||
pub fn parse_stat_comm_ppid(contents: &str) -> Option<(String, i32)> {
|
||||
let open = contents.find('(')?;
|
||||
let close = contents.rfind(')')?;
|
||||
if close <= open {
|
||||
return None;
|
||||
}
|
||||
let comm = contents[open + 1..close].to_string();
|
||||
let after = &contents[close + 1..];
|
||||
let fields: Vec<&str> = after.split_whitespace().collect();
|
||||
// token 0 == state, token 1 == ppid.
|
||||
let ppid = fields.get(1)?.parse::<i32>().ok()?;
|
||||
Some((comm, ppid))
|
||||
}
|
||||
|
||||
/// Sample this process and every descendant. Linux implementation.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn sample_tree() -> anyhow::Result<TreeSample> {
|
||||
use anyhow::Context;
|
||||
|
||||
let self_pid = std::process::id() as i32;
|
||||
let self_sample = super::sample_self().context("sample self for tree")?;
|
||||
|
||||
// Build the full pid -> ppid map and a pid -> name map from /proc/*/stat.
|
||||
let mut ppid_of: HashMap<i32, i32> = HashMap::new();
|
||||
let mut name_of: HashMap<i32, String> = HashMap::new();
|
||||
let entries = std::fs::read_dir("/proc").context("read /proc")?;
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let Some(name) = file_name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(pid) = name.parse::<i32>() else {
|
||||
continue; // non-pid entry (self, cpuinfo, …)
|
||||
};
|
||||
let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||
Ok(contents) => contents,
|
||||
Err(_) => continue, // process exited between readdir and read
|
||||
};
|
||||
if let Some((comm, ppid)) = parse_stat_comm_ppid(&stat) {
|
||||
ppid_of.insert(pid, ppid);
|
||||
name_of.insert(pid, comm);
|
||||
}
|
||||
}
|
||||
|
||||
let descendants = collect_descendants(self_pid, &ppid_of);
|
||||
let mut children = Vec::with_capacity(descendants.len());
|
||||
for pid in descendants {
|
||||
let status = match std::fs::read_to_string(format!("/proc/{pid}/status")) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"[proc_metrics] tree: skipping child pid={pid}: status read failed: {err}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let rss_kib = super::parse_status(&status).vm_rss_kib;
|
||||
children.push(ChildSample {
|
||||
pid,
|
||||
name: name_of.get(&pid).cloned().unwrap_or_default(),
|
||||
rss_kib,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(TreeSample::assemble(self_sample, children))
|
||||
}
|
||||
|
||||
/// Sample this process and every descendant. macOS implementation.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn sample_tree() -> anyhow::Result<TreeSample> {
|
||||
let self_pid = std::process::id() as i32;
|
||||
let self_sample = super::sample_self()?;
|
||||
|
||||
let descendants = macos::descendants(self_pid);
|
||||
let mut children = Vec::with_capacity(descendants.len());
|
||||
for pid in descendants {
|
||||
match macos::child_rss_kib(pid) {
|
||||
Some(rss_kib) => children.push(ChildSample {
|
||||
pid,
|
||||
name: macos::proc_name(pid),
|
||||
rss_kib,
|
||||
}),
|
||||
None => {
|
||||
eprintln!(
|
||||
"[proc_metrics] tree: skipping child pid={pid}: proc_pid_rusage unavailable (exited or permission denied)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TreeSample::assemble(self_sample, children))
|
||||
}
|
||||
|
||||
/// Unsupported-platform stub — fails loudly rather than fabricating a reading.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
pub fn sample_tree() -> anyhow::Result<TreeSample> {
|
||||
anyhow::bail!(
|
||||
"proc_metrics::sample_tree supports Linux and macOS (this is a {} build)",
|
||||
std::env::consts::OS
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use std::collections::HashSet;
|
||||
use std::mem::{size_of, MaybeUninit};
|
||||
|
||||
/// Direct children of `ppid` via `proc_listchildpids`. Empty on any error.
|
||||
///
|
||||
/// Note the Darwin ABI quirk: `proc_listchildpids` returns the **number of
|
||||
/// pids** written (not a byte count, unlike `proc_listpids`), and fills the
|
||||
/// buffer with that many `pid_t`. It also doesn't reliably support the NULL
|
||||
/// size-probe form, so we allocate a real buffer up front and grow it if the
|
||||
/// kernel filled it completely (the list may have been truncated).
|
||||
fn child_pids(ppid: i32) -> Vec<i32> {
|
||||
let mut cap = 256usize;
|
||||
loop {
|
||||
let mut buf = vec![0 as libc::pid_t; cap];
|
||||
let byte_cap = (buf.len() * size_of::<libc::pid_t>()) as libc::c_int;
|
||||
// SAFETY: `buf` is `cap` writable pid_t slots; `byte_cap` matches its size.
|
||||
let written = unsafe {
|
||||
libc::proc_listchildpids(ppid, buf.as_mut_ptr().cast::<libc::c_void>(), byte_cap)
|
||||
};
|
||||
if written <= 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let count = written as usize;
|
||||
// Buffer filled to the brim ⇒ the list may be truncated; grow + retry.
|
||||
if count >= cap && cap < 65_536 {
|
||||
cap *= 4;
|
||||
continue;
|
||||
}
|
||||
buf.truncate(count.min(buf.len()));
|
||||
return buf.into_iter().filter(|&pid| pid > 0).collect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Every transitive descendant of `root`, walked breadth-first through
|
||||
/// `proc_listchildpids`. A visited set breaks any cycle a racy snapshot
|
||||
/// might present.
|
||||
pub(super) fn descendants(root: i32) -> Vec<i32> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut stack = child_pids(root);
|
||||
while let Some(pid) = stack.pop() {
|
||||
if pid == root || !seen.insert(pid) {
|
||||
continue;
|
||||
}
|
||||
out.push(pid);
|
||||
stack.extend(child_pids(pid));
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// Resident set size of `pid` in KiB via `proc_pid_rusage`, or `None` when
|
||||
/// the call fails (process exited, or permission denied).
|
||||
pub(super) fn child_rss_kib(pid: i32) -> Option<u64> {
|
||||
let mut usage = MaybeUninit::<libc::rusage_info_v2>::uninit();
|
||||
// SAFETY: `usage` is writable storage of the exact size for
|
||||
// `RUSAGE_INFO_V2`; the kernel initializes it on success (return 0).
|
||||
let rc = unsafe {
|
||||
libc::proc_pid_rusage(
|
||||
pid,
|
||||
libc::RUSAGE_INFO_V2,
|
||||
usage.as_mut_ptr().cast::<libc::rusage_info_t>(),
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `proc_pid_rusage` returned success and initialized `usage`.
|
||||
let usage = unsafe { usage.assume_init() };
|
||||
Some(usage.ri_resident_size / 1024)
|
||||
}
|
||||
|
||||
/// Executable / accounting name of `pid` via `proc_pidinfo(PROC_PIDTBSDINFO)`.
|
||||
/// Prefers the longer `pbi_name`, falling back to `pbi_comm`; empty string
|
||||
/// when the lookup fails.
|
||||
pub(super) fn proc_name(pid: i32) -> String {
|
||||
let mut info = MaybeUninit::<libc::proc_bsdinfo>::uninit();
|
||||
let size = size_of::<libc::proc_bsdinfo>() as libc::c_int;
|
||||
// SAFETY: `info` is writable storage of the exact structure size passed
|
||||
// to `proc_pidinfo`, which initializes it when the returned count matches.
|
||||
let filled = unsafe {
|
||||
libc::proc_pidinfo(
|
||||
pid,
|
||||
libc::PROC_PIDTBSDINFO,
|
||||
0,
|
||||
info.as_mut_ptr().cast::<libc::c_void>(),
|
||||
size,
|
||||
)
|
||||
};
|
||||
if filled != size {
|
||||
return String::new();
|
||||
}
|
||||
// SAFETY: the kernel returned the full structure size.
|
||||
let info = unsafe { info.assume_init() };
|
||||
let name = cstr_field(&info.pbi_name);
|
||||
if name.is_empty() {
|
||||
cstr_field(&info.pbi_comm)
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a fixed-size, NUL-padded `c_char` array into a `String` up to the
|
||||
/// first NUL, lossily decoding any non-UTF-8 bytes.
|
||||
fn cstr_field(raw: &[libc::c_char]) -> String {
|
||||
let bytes: Vec<u8> = raw
|
||||
.iter()
|
||||
.take_while(|&&c| c != 0)
|
||||
.map(|&c| c as u8)
|
||||
.collect();
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn collect_descendants_walks_transitive_chain() {
|
||||
// 1 -> 2 -> 4, 1 -> 3; 5 is unrelated (parent 99).
|
||||
let map: HashMap<i32, i32> = [(2, 1), (3, 1), (4, 2), (5, 99)].into_iter().collect();
|
||||
let mut got = collect_descendants(1, &map);
|
||||
got.sort_unstable();
|
||||
assert_eq!(got, vec![2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_descendants_excludes_root_and_unrelated() {
|
||||
let map: HashMap<i32, i32> = [(2, 1), (3, 2)].into_iter().collect();
|
||||
assert_eq!(collect_descendants(2, &map), vec![3]);
|
||||
assert!(collect_descendants(42, &map).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_descendants_survives_a_cycle() {
|
||||
// Degenerate self-parent + mutual cycle must terminate, not hang.
|
||||
let map: HashMap<i32, i32> = [(2, 1), (1, 2)].into_iter().collect();
|
||||
let got = collect_descendants(1, &map);
|
||||
assert_eq!(got, vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stat_extracts_comm_and_ppid() {
|
||||
// pid (comm) state ppid pgrp …
|
||||
let line = "4321 (node) R 4300 4321 4300 0 -1 4194304 100 0 0 0 5 2 0 0 20 0 11 0";
|
||||
let (comm, ppid) = parse_stat_comm_ppid(line).unwrap();
|
||||
assert_eq!(comm, "node");
|
||||
assert_eq!(ppid, 4300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stat_handles_comm_with_spaces_and_parens() {
|
||||
let line = "7 (weird ) proc) S 3 7 3 0 -1 0 0 0 0 0 1 1 0 0 20 0 2 0";
|
||||
let (comm, ppid) = parse_stat_comm_ppid(line).unwrap();
|
||||
assert_eq!(comm, "weird ) proc");
|
||||
assert_eq!(ppid, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stat_rejects_short_or_malformed() {
|
||||
assert!(parse_stat_comm_ppid("").is_none());
|
||||
assert!(parse_stat_comm_ppid("123 no-parens here").is_none());
|
||||
assert!(parse_stat_comm_ppid("1 (x) R").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_sums_self_plus_children() {
|
||||
let self_sample = ProcSample {
|
||||
rss_kib: 1000,
|
||||
pss_kib: 0,
|
||||
private_clean_kib: 0,
|
||||
private_dirty_kib: 0,
|
||||
vm_hwm_kib: 0,
|
||||
threads: 1,
|
||||
binary_size_bytes: 0,
|
||||
cpu_user_ms: 0,
|
||||
cpu_system_ms: 0,
|
||||
open_fds: None,
|
||||
};
|
||||
let children = vec![
|
||||
ChildSample {
|
||||
pid: 2,
|
||||
name: "node".into(),
|
||||
rss_kib: 400,
|
||||
},
|
||||
ChildSample {
|
||||
pid: 3,
|
||||
name: "python3".into(),
|
||||
rss_kib: 250,
|
||||
},
|
||||
];
|
||||
let tree = TreeSample::assemble(self_sample, children);
|
||||
assert_eq!(tree.tree_rss_kib, 1650);
|
||||
assert_eq!(tree.child_count(), 2);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn sample_tree_reports_self_on_macos() {
|
||||
// A leaf test process has no children, but tree_rss must equal self RSS
|
||||
// and the self sample must be populated.
|
||||
let tree = sample_tree().expect("macOS tree sample");
|
||||
assert!(tree.self_sample.rss_kib > 0);
|
||||
assert_eq!(
|
||||
tree.tree_rss_kib,
|
||||
tree.self_sample.rss_kib + tree.children.iter().map(|c| c.rss_kib).sum::<u64>()
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
Submodule vendor/tinycortex updated: 9a0603afbe...daaaf6ba5f
Reference in New Issue
Block a user