Skip to content

Commit ce6f861

Browse files
UnbreakableMJclaude
andcommitted
feat(bench): M6-3 part 1 — TSC boot-phase instrumentation + parser
Lands the code half of M6-3 so the remaining bare-metal leg (cold-boot timing on real hardware) is a one-shot capture + parse rather than "write instrumentation from scratch first." UEFI side (zamak-uefi/src/main.rs): - `mark_phase(name)` helper — reads `rdtsc` via the existing `zamak_core::arch::x86::rdtsc()` wrapper and emits a single `ZAMAK_PHASE=<name> tsc=<u64>` line through `info!`, which uefi_services already routes to COM1 and the UEFI console. - `emit_tsc_freq()` — CPUID leaf 0x16 (Intel Skylake+ / AMD Zen+) reports TSC base MHz in EAX; emit `ZAMAK_TSC_MHZ=<n>` or `unknown` when CPUID reports a smaller max leaf or zero freq (QEMU → `unknown`, bare metal with modern CPU → e.g. 2400). - Six checkpoints wired into `main`: `uefi_entry` (after `uefi_services::init`), `config_parsed` (after `config::parse`), `menu_finished` (after the TUI loop), `kernel_loaded` (after `read_file` of the kernel), `requests_fulfilled` (after `fulfill_requests` on the Limine path / `load_linux_kernel` on the Linux path), and `pre_exit_boot_services` (immediately before `ExitBootServices`). All instrumentation is pure observation — zero boot-semantic change. - `cpuid(leaf)` helper wraps the `cpuid` instruction with manual `rbx` save/restore (LLVM reserves it on x86-64 PIC) and clears ECX to sub-leaf 0. Non-`x86_64` UEFI builds get no-op stubs for both `mark_phase` and `emit_tsc_freq`. Host side (zamak-cli): - New `commands/bench.rs` with sub-verb `parse-serial [--tsc-mhz <mhz>] [<path>]`. Scans the log for `ZAMAK_PHASE=` and `ZAMAK_TSC_MHZ=` tokens anywhere on a line (so the uefi logger's `[ INFO]: file@line:` prefix is irrelevant), builds an SFRS envelope whose `data.phases[]` has `{phase, tsc, delta_cycles[, delta_ns]}`, and emits `tsc_mhz` at the envelope top level when a frequency is known (explicit `--tsc-mhz` wins over log-reported). - Wired into `main.rs` sub-command dispatch, `print_help`, and the `schema::COMMANDS` table so `zamak describe` / `zamak schema bench` self-document the new command's parameters, output shape, and examples. - 7 unit tests covering in-order phase parsing, explicit override, log-reported freq, missing freq (no `delta_ns` + no `tsc_mhz` field), `ZAMAK_TSC_MHZ=unknown` ignored, malformed phase lines skipped, empty input → empty array. - All 20 existing zamak-cli conformance tests still pass; clippy `-D warnings` clean; `cargo fmt` clean. End-to-end sanity check: captured serial from the existing `linux-bzimage` suite, ran the parser with `--tsc-mhz 2400`, got a clean JSON envelope with six monotonically-increasing TSC timestamps and reasonable deltas (menu_finished dominates at ~113 ms on the 0-timeout TUI; everything else is <15 ms). TODO.md: M6-3 stays `[~]` (hardware leg outstanding), but the description now points to the parse-serial command so whoever runs the hardware capture knows exactly how to produce a comparable baseline against Limine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 42d2505 commit ce6f861

7 files changed

Lines changed: 480 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@ All dates use ISO 8601 format (YYYY-MM-DD).
1212

1313
## [Unreleased]
1414

15+
### Added
16+
17+
- **M6-3 part 1 — boot-phase TSC instrumentation**. `zamak-uefi`
18+
now emits `ZAMAK_PHASE=<name> tsc=<u64>` lines on COM1 at six
19+
checkpoints (`uefi_entry`, `config_parsed`, `menu_finished`,
20+
`kernel_loaded`, `requests_fulfilled`, `pre_exit_boot_services`)
21+
plus one `ZAMAK_TSC_MHZ=<n>` discovered via CPUID 0x16
22+
(`unknown` when unavailable, as in QEMU). New
23+
`zamak-cli bench parse-serial [--tsc-mhz <mhz>] [<path>]`
24+
sub-command ingests a captured UEFI serial log and emits an
25+
SFRS envelope whose `data.phases[]` carries `{phase, tsc,
26+
delta_cycles[, delta_ns]}` per checkpoint. With this, the
27+
bare-metal perf leg of M6-3 is a one-shot: capture
28+
`-serial` on real hardware, feed through the parser, compare
29+
against the same run on Limine. Part 2 (actual hardware run)
30+
remains the user's responsibility. 7 new unit tests cover
31+
format matrix (explicit vs log-reported `tsc_mhz`,
32+
malformed/missing lines, empty input).
33+
1534
## [0.8.4] - 2026-04-24
1635

1736
Banks M2-12. No other functional change since v0.8.3.

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ SPDX-FileCopyrightText: 2026 Mohamed Hammad
198198
|---|--------|------|
199199
| M6-1 | `[~]` | LoongArch64 UEFI boot path — `arch::loongarch64::paging::PageTableBuilder` (4-level PGDH, MAT-encoded cache policy, PLV/NX encoding) + `paging::loongarch64::build` + `handoff::jump_to_kernel` (CRMD clear IE, PGDH, STLB flush) all implemented and compile for `loongarch64-unknown-none`; 5 new paging tests pass. Full `cargo check --target loongarch64-unknown-uefi` blocked on rustc upstream — the target does not yet exist (`uefi-services` uses the `efiapi` ABI which is unsupported on `loongarch64-unknown-none`) |
200200
| M6-2 | `[✓]` | `BOOTLOONGARCH64.EFI``release.yml` matrix entry for `loongarch64-unknown-none` executed by `v0.7.0` tag push on 2026-04-21 |
201-
| M6-3 | `[~]` | Performance tuning — LTO + `codegen-units = 1` + `panic = abort` in release profile; CI `size-gate` enforces ≤120% size target; cold-boot timing baseline needs real hardware to validate |
201+
| M6-3 | `[~]` | Performance tuning — LTO + `codegen-units = 1` + `panic = abort` in release profile; CI `size-gate` enforces ≤120% size target. **Part 1 (instrumentation) landed**: `zamak-uefi` emits `ZAMAK_PHASE=<name> tsc=<u64>` + `ZAMAK_TSC_MHZ=<n>` markers at 6 boot phases (uefi_entry / config_parsed / menu_finished / kernel_loaded / requests_fulfilled / pre_exit_boot_services) via `rdtsc()`; new `zamak-cli bench parse-serial [--tsc-mhz <n>] [<path>]` sub-command ingests a captured UEFI serial log and emits an SFRS envelope with `{tsc_mhz, phases:[{phase, tsc, delta_cycles, delta_ns}]}`. Hardware leg (Part 2) still needed: boot a USB stick (`target/esp.img` or any assembled image works as-is) on a physical x86-64 UEFI machine with COM1 captured, feed the log through `zamak bench parse-serial`, and compare Δ-ns against Limine v10.x on the same hardware. |
202202
| M6-4 | `[✓]` | Full rustdoc — zero warnings on `cargo doc --no-deps` for zamak-core / theme / proto / cli / macros |
203203

204204
---

zamak-cli/src/commands/bench.rs

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// SPDX-FileCopyrightText: 2026 Mohamed Hammad
3+
4+
//! `zamak bench parse-serial` — host-side consumer for the
5+
//! TSC-based boot-phase instrumentation `zamak-uefi` emits to
6+
//! COM1 (M6-3 part 1).
7+
//!
8+
//! Reads a UEFI serial log (file path or stdin) and emits an
9+
//! SFRS-canonical JSON envelope whose `data` payload is:
10+
//!
11+
//! ```json
12+
//! {
13+
//! "tsc_mhz": 2400,
14+
//! "phases": [
15+
//! {"phase": "uefi_entry", "tsc": 1000000, "delta_cycles": 0},
16+
//! {"phase": "config_parsed", "tsc": 1100000, "delta_cycles": 100000,
17+
//! "delta_ns": 41666.67},
18+
//! ...
19+
//! ]
20+
//! }
21+
//! ```
22+
//!
23+
//! `delta_ns` is emitted only when a TSC frequency is known —
24+
//! either the log contained a `ZAMAK_TSC_MHZ=<n>` line or the
25+
//! caller passed `--tsc-mhz <n>` on the CLI (explicit wins over
26+
//! log-reported).
27+
28+
// Rust guideline compliant 2026-03-30
29+
30+
use std::fs;
31+
use std::io::{self, Read as _};
32+
33+
use crate::error::CliError;
34+
use crate::json::{obj, Value};
35+
36+
/// Entry point wired into `main.rs`' sub-command match.
37+
pub fn run(args: &[String]) -> Result<Value, CliError> {
38+
let sub = args
39+
.first()
40+
.ok_or_else(|| CliError::usage("bench: missing sub-verb (expected 'parse-serial')"))?;
41+
match sub.as_str() {
42+
"parse-serial" => run_parse_serial(&args[1..]),
43+
other => Err(CliError::usage(format!(
44+
"bench: unknown sub-verb '{other}' (expected 'parse-serial')"
45+
))),
46+
}
47+
}
48+
49+
/// `zamak bench parse-serial [--tsc-mhz <n>] [<path>]`.
50+
fn run_parse_serial(args: &[String]) -> Result<Value, CliError> {
51+
let mut tsc_mhz_override: Option<u64> = None;
52+
let mut path: Option<String> = None;
53+
let mut i = 0;
54+
while i < args.len() {
55+
match args[i].as_str() {
56+
"--tsc-mhz" => {
57+
let v = args
58+
.get(i + 1)
59+
.ok_or_else(|| CliError::usage("--tsc-mhz requires a value (MHz integer)"))?;
60+
let n: u64 = v
61+
.parse()
62+
.map_err(|_| CliError::usage(format!("--tsc-mhz: invalid MHz value '{v}'")))?;
63+
if n == 0 {
64+
return Err(CliError::usage("--tsc-mhz must be > 0"));
65+
}
66+
tsc_mhz_override = Some(n);
67+
i += 2;
68+
}
69+
"--help" | "-h" => {
70+
return Err(CliError::usage(
71+
"usage: zamak bench parse-serial [--tsc-mhz <mhz>] [<path>]",
72+
));
73+
}
74+
arg if arg.starts_with("--") => {
75+
return Err(CliError::usage(format!(
76+
"bench parse-serial: unknown flag '{arg}'"
77+
)));
78+
}
79+
_ => {
80+
if path.is_some() {
81+
return Err(CliError::usage(
82+
"bench parse-serial: only one positional path allowed",
83+
));
84+
}
85+
path = Some(args[i].clone());
86+
i += 1;
87+
}
88+
}
89+
}
90+
91+
let input = match path {
92+
Some(p) => fs::read_to_string(&p).map_err(|e| {
93+
CliError::new(
94+
crate::error::ErrorCode::NotFound,
95+
format!("bench parse-serial: cannot read '{p}': {e}"),
96+
)
97+
})?,
98+
None => {
99+
let mut buf = String::new();
100+
io::stdin().read_to_string(&mut buf).map_err(|e| {
101+
CliError::new(
102+
crate::error::ErrorCode::General,
103+
format!("bench parse-serial: stdin read failed: {e}"),
104+
)
105+
})?;
106+
buf
107+
}
108+
};
109+
110+
Ok(parse_serial_to_value(&input, tsc_mhz_override))
111+
}
112+
113+
/// Pure string-in → Value-out. Exposed for unit tests.
114+
pub(crate) fn parse_serial_to_value(log: &str, tsc_mhz_override: Option<u64>) -> Value {
115+
let mut phases: Vec<(String, u64)> = Vec::new();
116+
let mut tsc_mhz_from_log: Option<u64> = None;
117+
for line in log.lines() {
118+
if let Some((name, tsc)) = parse_phase_line(line) {
119+
phases.push((name, tsc));
120+
} else if let Some(mhz) = parse_tsc_mhz_line(line) {
121+
tsc_mhz_from_log = Some(mhz);
122+
}
123+
}
124+
125+
let tsc_mhz_used = tsc_mhz_override.or(tsc_mhz_from_log);
126+
127+
let mut phase_values: Vec<Value> = Vec::with_capacity(phases.len());
128+
let mut prev: Option<u64> = None;
129+
for (name, tsc) in &phases {
130+
let delta_cycles = prev.map_or(0u64, |p| tsc.saturating_sub(p));
131+
let mut entry = obj([
132+
("phase", Value::str(name)),
133+
("tsc", Value::UInt(*tsc)),
134+
("delta_cycles", Value::UInt(delta_cycles)),
135+
]);
136+
if let Some(mhz) = tsc_mhz_used {
137+
// cycles × (1 / MHz) = cycles × (1 / (cycles/μs)) = μs
138+
// multiply by 1000 for ns.
139+
let ns = (delta_cycles as f64) * 1000.0 / (mhz as f64);
140+
entry.insert("delta_ns", Value::Float(ns));
141+
}
142+
phase_values.push(entry);
143+
prev = Some(*tsc);
144+
}
145+
146+
let mut out = obj([("phases", Value::Array(phase_values))]);
147+
if let Some(mhz) = tsc_mhz_used {
148+
out.insert("tsc_mhz", Value::UInt(mhz));
149+
}
150+
out
151+
}
152+
153+
/// Finds `ZAMAK_PHASE=<name> tsc=<u64>` anywhere in the line.
154+
/// Accepts log-framed lines (e.g. the `[ INFO]: file@line:` prefix
155+
/// that `uefi_services`' logger adds).
156+
fn parse_phase_line(line: &str) -> Option<(String, u64)> {
157+
let after_tag = line.split_once("ZAMAK_PHASE=")?.1;
158+
// Phase name goes up to the next whitespace.
159+
let (name, rest) = after_tag.split_once(char::is_whitespace)?;
160+
let tsc_part = rest.split_once("tsc=")?.1;
161+
// TSC digits: parse until whitespace / end.
162+
let digits: String = tsc_part
163+
.chars()
164+
.take_while(|c| c.is_ascii_digit())
165+
.collect();
166+
let tsc: u64 = digits.parse().ok()?;
167+
Some((name.to_string(), tsc))
168+
}
169+
170+
/// Finds `ZAMAK_TSC_MHZ=<n>` anywhere in the line (ignores
171+
/// `ZAMAK_TSC_MHZ=unknown`).
172+
fn parse_tsc_mhz_line(line: &str) -> Option<u64> {
173+
let rest = line.split_once("ZAMAK_TSC_MHZ=")?.1;
174+
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
175+
if digits.is_empty() {
176+
return None;
177+
}
178+
digits.parse().ok()
179+
}
180+
181+
#[cfg(test)]
182+
mod tests {
183+
use super::*;
184+
185+
const SAMPLE_LOG: &str = "\
186+
[ INFO]: zamak-uefi/src/main.rs@565: ZAMAK_TSC_MHZ=2400
187+
[ INFO]: zamak-uefi/src/main.rs@542: ZAMAK_PHASE=uefi_entry tsc=1000000
188+
[ INFO]: Zamak starting up (0.8.4)...
189+
[ INFO]: zamak-uefi/src/main.rs@542: ZAMAK_PHASE=config_parsed tsc=1120000
190+
[ INFO]: zamak-uefi/src/main.rs@542: ZAMAK_PHASE=pre_exit_boot_services tsc=1240000
191+
";
192+
193+
fn phase_at(v: &Value, idx: usize) -> &Vec<(String, Value)> {
194+
let Value::Object(root) = v else {
195+
panic!("expected object")
196+
};
197+
let phases = &root.iter().find(|(k, _)| k == "phases").unwrap().1;
198+
let Value::Array(items) = phases else {
199+
panic!("expected array")
200+
};
201+
let Value::Object(entry) = &items[idx] else {
202+
panic!("expected object")
203+
};
204+
entry
205+
}
206+
207+
fn field<'a>(entry: &'a [(String, Value)], k: &str) -> Option<&'a Value> {
208+
entry.iter().find(|(key, _)| key == k).map(|(_, v)| v)
209+
}
210+
211+
#[test]
212+
fn parses_phases_in_order() {
213+
let v = parse_serial_to_value(SAMPLE_LOG, None);
214+
let p0 = phase_at(&v, 0);
215+
assert!(matches!(field(p0, "phase"), Some(Value::Str(s)) if s == "uefi_entry"));
216+
assert!(matches!(field(p0, "tsc"), Some(Value::UInt(1000000))));
217+
assert!(matches!(field(p0, "delta_cycles"), Some(Value::UInt(0))));
218+
219+
let p1 = phase_at(&v, 1);
220+
assert!(matches!(field(p1, "phase"), Some(Value::Str(s)) if s == "config_parsed"));
221+
assert!(matches!(
222+
field(p1, "delta_cycles"),
223+
Some(Value::UInt(120000))
224+
));
225+
}
226+
227+
#[test]
228+
fn log_tsc_mhz_enables_delta_ns() {
229+
let v = parse_serial_to_value(SAMPLE_LOG, None);
230+
let p1 = phase_at(&v, 1);
231+
let Some(Value::Float(ns)) = field(p1, "delta_ns") else {
232+
panic!("expected delta_ns float");
233+
};
234+
// 120000 cycles at 2400 MHz = 50_000 ns exactly.
235+
assert!((ns - 50000.0).abs() < 0.01);
236+
}
237+
238+
#[test]
239+
fn explicit_tsc_mhz_overrides_log() {
240+
let v = parse_serial_to_value(SAMPLE_LOG, Some(1200));
241+
let p1 = phase_at(&v, 1);
242+
let Some(Value::Float(ns)) = field(p1, "delta_ns") else {
243+
panic!("expected delta_ns float");
244+
};
245+
// 120000 / 1200 MHz = 100 μs = 100_000 ns.
246+
assert!((ns - 100000.0).abs() < 0.01);
247+
}
248+
249+
#[test]
250+
fn missing_tsc_mhz_omits_delta_ns() {
251+
let log = "\
252+
[ INFO]: ZAMAK_PHASE=uefi_entry tsc=10
253+
[ INFO]: ZAMAK_PHASE=config_parsed tsc=20
254+
";
255+
let v = parse_serial_to_value(log, None);
256+
let p1 = phase_at(&v, 1);
257+
assert!(field(p1, "delta_ns").is_none());
258+
assert!(matches!(field(p1, "delta_cycles"), Some(Value::UInt(10))));
259+
// Envelope must NOT advertise a tsc_mhz if we don't know one.
260+
let Value::Object(root) = &v else { panic!() };
261+
assert!(!root.iter().any(|(k, _)| k == "tsc_mhz"));
262+
}
263+
264+
#[test]
265+
fn unknown_tsc_mhz_line_is_ignored() {
266+
let log = "\
267+
[ INFO]: ZAMAK_TSC_MHZ=unknown
268+
[ INFO]: ZAMAK_PHASE=uefi_entry tsc=10
269+
[ INFO]: ZAMAK_PHASE=config_parsed tsc=20
270+
";
271+
let v = parse_serial_to_value(log, None);
272+
let Value::Object(root) = &v else { panic!() };
273+
// No tsc_mhz field, no delta_ns on phases.
274+
assert!(!root.iter().any(|(k, _)| k == "tsc_mhz"));
275+
assert!(field(phase_at(&v, 1), "delta_ns").is_none());
276+
}
277+
278+
#[test]
279+
fn empty_input_produces_empty_phases() {
280+
let v = parse_serial_to_value("", Some(2400));
281+
let Value::Object(root) = &v else { panic!() };
282+
// tsc_mhz still present because explicit.
283+
assert!(matches!(
284+
root.iter().find(|(k, _)| k == "tsc_mhz").map(|(_, v)| v),
285+
Some(Value::UInt(2400)),
286+
));
287+
let phases = &root.iter().find(|(k, _)| k == "phases").unwrap().1;
288+
assert!(matches!(phases, Value::Array(a) if a.is_empty()));
289+
}
290+
291+
#[test]
292+
fn malformed_phase_line_is_skipped() {
293+
let log = "\
294+
[ INFO]: ZAMAK_PHASE=good tsc=10
295+
[ INFO]: ZAMAK_PHASE=bad tsc=not-a-number
296+
[ INFO]: ZAMAK_PHASE=other_good tsc=30
297+
";
298+
let v = parse_serial_to_value(log, None);
299+
let Value::Object(root) = &v else { panic!() };
300+
let phases = &root.iter().find(|(k, _)| k == "phases").unwrap().1;
301+
let Value::Array(items) = phases else {
302+
panic!()
303+
};
304+
assert_eq!(items.len(), 2);
305+
assert!(matches!(
306+
field(phase_at(&v, 0), "phase"),
307+
Some(Value::Str(s)) if s == "good",
308+
));
309+
assert!(matches!(
310+
field(phase_at(&v, 1), "phase"),
311+
Some(Value::Str(s)) if s == "other_good",
312+
));
313+
}
314+
}

zamak-cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//! envelope, plus any side effects (writes, prints) that its
77
//! contract requires.
88
9+
pub mod bench;
910
pub mod completions;
1011
pub mod describe;
1112
pub mod enroll_config;

zamak-cli/src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ fn run(argv: &[String]) -> Outcome {
122122
"describe" => run_data_cmd("describe", &policy, cmdline.clone(), || {
123123
commands::describe::run(&sub_args)
124124
}),
125+
"bench" => run_data_cmd("bench", &policy, cmdline.clone(), || {
126+
commands::bench::run(&sub_args)
127+
}),
125128
"completions" => match commands::completions::run(&sub_args) {
126129
Ok(()) => Ok(0),
127130
Err(e) => Err((e, policy, cmdline)),
@@ -252,6 +255,7 @@ fn print_help() {
252255
println!(" schema [<cmd>] Emit JSON Schema (Draft 2020-12)");
253256
println!(" describe Emit capability manifest");
254257
println!(" completions <sh> Emit shell completion script (bash|zsh|fish|nushell)");
258+
println!(" bench <sub> Boot-phase benchmarking (parse-serial)");
255259
println!(" help Show this message");
256260
println!();
257261
println!("Global flags:");

0 commit comments

Comments
 (0)