Skip to content

Commit cfab0ac

Browse files
committed
perf(harness): measure first hot calls of fresh bytecode functions
1 parent be88579 commit cfab0ac

8 files changed

Lines changed: 482 additions & 18 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
;;; first-hot-loop.el --- first calls through hot loops -*- lexical-binding: t; -*-
2+
(require 'bytecomp)
3+
(require 'json)
4+
5+
;; One operation is a fresh function's first call. Byte compilation and GC are
6+
;; setup; interpreter heat-up, OSR compilation, execution and result collection
7+
;; are timed together. No function is called during preparation.
8+
(defconst neomacs-perf-first-hot-loop--inner-iterations 65536)
9+
10+
(defvar neomacs-perf-first-hot-loop--profile-gate-process nil)
11+
(defvar neomacs-perf-first-hot-loop--profile-gate-response "")
12+
13+
(defun neomacs-perf-first-hot-loop--profile-gate-filter (_process output)
14+
(setq neomacs-perf-first-hot-loop--profile-gate-response
15+
(concat neomacs-perf-first-hot-loop--profile-gate-response output)))
16+
17+
(defun neomacs-perf-first-hot-loop--profile-gate-connect ()
18+
(let* ((port-text (getenv "NEOMACS_PERF_GATE_PORT"))
19+
(port (and port-text (string-to-number port-text))))
20+
(when (and port-text (not (> port 0)))
21+
(error "invalid first-hot-loop profile gate port %S" port-text))
22+
(when (and port-text
23+
(not (process-live-p
24+
neomacs-perf-first-hot-loop--profile-gate-process)))
25+
(setq neomacs-perf-first-hot-loop--profile-gate-process
26+
(make-network-process
27+
:name "neomacs-perf-first-hot-loop-gate"
28+
:family 'ipv4
29+
:host "127.0.0.1"
30+
:service port
31+
:coding 'binary
32+
:noquery t
33+
:filter #'neomacs-perf-first-hot-loop--profile-gate-filter)))
34+
neomacs-perf-first-hot-loop--profile-gate-process))
35+
36+
(defun neomacs-perf-first-hot-loop--sampling-command (command)
37+
(let ((process (neomacs-perf-first-hot-loop--profile-gate-connect)))
38+
(when process
39+
(setq neomacs-perf-first-hot-loop--profile-gate-response "")
40+
(process-send-string process (concat command "\n"))
41+
(let ((deadline (+ (float-time) 30.0)))
42+
(while (and
43+
(not (and
44+
(> (length
45+
neomacs-perf-first-hot-loop--profile-gate-response)
46+
0)
47+
(= (aref
48+
neomacs-perf-first-hot-loop--profile-gate-response
49+
(1- (length
50+
neomacs-perf-first-hot-loop--profile-gate-response)))
51+
?\n)))
52+
(< (float-time) deadline))
53+
(unless (process-live-p process)
54+
(error "first-hot-loop profile gate disconnected during %s" command))
55+
(accept-process-output process 0.05))
56+
(unless (equal neomacs-perf-first-hot-loop--profile-gate-response
57+
"ack\n")
58+
(error "first-hot-loop profile gate rejected %s: %S"
59+
command
60+
neomacs-perf-first-hot-loop--profile-gate-response))))))
61+
62+
(defun neomacs-perf-first-hot-loop--close-profile-gate ()
63+
(when (processp neomacs-perf-first-hot-loop--profile-gate-process)
64+
(delete-process neomacs-perf-first-hot-loop--profile-gate-process)
65+
(setq neomacs-perf-first-hot-loop--profile-gate-process nil)))
66+
67+
(defun neomacs-perf-first-hot-loop--run ()
68+
(let* ((scenario (getenv "NEOMACS_PERF_WORKLOAD"))
69+
(iterations (string-to-number (or (getenv "NEOMACS_PERF_ITERATIONS") "0")))
70+
(functions nil) (results nil) (index 0) (compiled t)
71+
(prepared 0) (completed 0) (elapsed-us 0) (wall-us 0)
72+
(status "error") (error-message nil) (exit-code 2))
73+
(condition-case err
74+
(progn
75+
(unless (and (> iterations 0) (equal scenario "first-hot-loop"))
76+
(error "Invalid first-hot-loop input"))
77+
(while (< index iterations)
78+
;; Fresh compiler outputs and distinct constants prevent accidental
79+
;; reuse of one function's native cache for the entire sample.
80+
(let ((function
81+
(byte-compile
82+
`(lambda (n)
83+
(let ((held ,index) (i 0) (sum 0))
84+
(while (< i n)
85+
(setq sum (+ sum i) i (1+ i)))
86+
(list i sum held))))))
87+
(unless (byte-code-function-p function)
88+
(setq compiled nil)
89+
(error "First-hot-loop function %d is not bytecode" index))
90+
(push function functions))
91+
(setq index (1+ index)))
92+
(setq functions (nreverse functions) prepared (length functions))
93+
(garbage-collect)
94+
(neomacs-perf-first-hot-loop--sampling-command "enable")
95+
(unwind-protect
96+
(let ((cpu-start (car (current-cpu-time)))
97+
(wall-start (float-time)))
98+
(dolist (function functions)
99+
(push (funcall function neomacs-perf-first-hot-loop--inner-iterations)
100+
results))
101+
(setq elapsed-us (- (car (current-cpu-time)) cpu-start)
102+
wall-us (round (* 1000000 (- (float-time) wall-start)))))
103+
(neomacs-perf-first-hot-loop--sampling-command "disable"))
104+
;; Keep every result for independent host validation after timing.
105+
(setq results (nreverse results) completed (length results)
106+
status "ok" exit-code 0))
107+
(error (setq error-message (error-message-string err))))
108+
(neomacs-perf-first-hot-loop--close-profile-gate)
109+
(with-temp-file (getenv "NEOMACS_PERF_RESULT")
110+
(insert
111+
(json-serialize
112+
`((schema_version . 1) (scenario . ,scenario) (status . ,status)
113+
(iterations . ,iterations)
114+
(inner_iterations . ,neomacs-perf-first-hot-loop--inner-iterations)
115+
(prepared_functions . ,prepared)
116+
(bytecode_compiled . ,(if compiled t :json-false))
117+
(completed_operations . ,completed)
118+
(results . ,(vconcat (mapcar #'vconcat results)))
119+
(elapsed_us . ,elapsed-us) (elapsed_wall_us . ,wall-us)
120+
(error . ,error-message))
121+
:false-object :json-false :null-object nil)))
122+
(write-region "done\n" nil (getenv "SENTINEL") nil 'silent)
123+
(kill-emacs exit-code)))
124+
125+
(if noninteractive
126+
(neomacs-perf-first-hot-loop--run)
127+
(run-at-time 0 nil #'neomacs-perf-first-hot-loop--run))
128+
129+
;;; first-hot-loop.el ends here

‎crates/neomacs-perf/src/catalog.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ pub enum ScenarioId {
3838
/// Rare-call tier-entry diagnostics, excluded from the whole-editor suite.
3939
LexicalLoop,
4040
DynamicBindingLoop,
41+
/// First call of fresh functions, including heat-up and OSR compilation.
42+
FirstHotLoop,
4143
EditingSimulation,
4244
Startup,
4345
SustainedEditing,
@@ -150,6 +152,7 @@ impl ScenarioId {
150152
Self::BytecodeCallLoop => "bytecode-call-loop",
151153
Self::LexicalLoop => "lexical-loop",
152154
Self::DynamicBindingLoop => "dynamic-binding-loop",
155+
Self::FirstHotLoop => "first-hot-loop",
153156
Self::EditingSimulation => "editing-simulation",
154157
Self::Startup => "startup",
155158
Self::SustainedEditing => "sustained-editing",
@@ -206,6 +209,7 @@ impl FromStr for ScenarioId {
206209
"bytecode-call-loop" => Ok(Self::BytecodeCallLoop),
207210
"lexical-loop" => Ok(Self::LexicalLoop),
208211
"dynamic-binding-loop" => Ok(Self::DynamicBindingLoop),
212+
"first-hot-loop" => Ok(Self::FirstHotLoop),
209213
"editing-simulation" => Ok(Self::EditingSimulation),
210214
"startup" => Ok(Self::Startup),
211215
"sustained-editing" => Ok(Self::SustainedEditing),
@@ -526,6 +530,14 @@ const SCENARIOS: &[ScenarioSpec] = &[
526530
primary_metric: MetricName::PerOperationWallTime,
527531
cross_editor_parity_metrics: &[],
528532
},
533+
ScenarioSpec {
534+
id: ScenarioId::FirstHotLoop,
535+
description: "First call of fresh bytecode functions, each looping 65,536 times under the editor default tier policy",
536+
default_frontend: Frontend::Batch,
537+
default_iterations: NonZeroU32::new(100).expect("non-zero scenario default"),
538+
primary_metric: MetricName::PerOperationWallTime,
539+
cross_editor_parity_metrics: &[],
540+
},
529541
];
530542

531543
pub fn scenarios() -> &'static [ScenarioSpec] {
@@ -544,6 +556,7 @@ pub const fn scenario(id: ScenarioId) -> &'static ScenarioSpec {
544556
ScenarioId::BytecodeCallLoop => &SCENARIOS[2],
545557
ScenarioId::LexicalLoop => &SCENARIOS[27],
546558
ScenarioId::DynamicBindingLoop => &SCENARIOS[28],
559+
ScenarioId::FirstHotLoop => &SCENARIOS[29],
547560
ScenarioId::EditingSimulation => &SCENARIOS[3],
548561
ScenarioId::Startup => &SCENARIOS[4],
549562
ScenarioId::SustainedEditing => &SCENARIOS[5],

‎crates/neomacs-perf/src/catalog_test.rs‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use super::{CrossEditorParityMetric, Frontend, MetricName, ScenarioId, scenario,
77
#[test]
88
fn catalog_exposes_the_rust_lsp_typing_workload_as_a_typed_scenario() {
99
let scenarios = scenarios();
10-
assert_eq!(scenarios.len(), 29);
10+
assert_eq!(scenarios.len(), 30);
1111

1212
// The heavy row exists so the light one keeps its baseline: same workload,
1313
// a whole-file diagnostic set instead of four on adjacent lines.
@@ -300,12 +300,23 @@ fn bounded_search_diagnostics_are_portable_and_stay_out_of_the_editor_score() {
300300

301301
#[test]
302302
fn rare_call_loop_diagnostics_stay_out_of_editor_score() {
303-
for id in [ScenarioId::LexicalLoop, ScenarioId::DynamicBindingLoop] {
303+
for id in [
304+
ScenarioId::LexicalLoop,
305+
ScenarioId::DynamicBindingLoop,
306+
ScenarioId::FirstHotLoop,
307+
] {
304308
assert_eq!(ScenarioId::from_str(id.as_str()), Ok(id));
305309
let spec = scenario(id);
306310
assert_eq!(spec.id, id);
307311
assert_eq!(spec.default_frontend, Frontend::Batch);
308-
assert_eq!(spec.default_iterations.get(), 1_000_000);
312+
assert_eq!(
313+
spec.default_iterations.get(),
314+
if id == ScenarioId::FirstHotLoop {
315+
100
316+
} else {
317+
1_000_000
318+
}
319+
);
309320
assert_eq!(spec.primary_metric, MetricName::PerOperationWallTime);
310321
assert!(
311322
!crate::suite::SuiteId::Standard

‎crates/neomacs-perf/src/harness.rs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ impl PerfHarness {
514514
| ScenarioId::BoundedSearchNoEdit => {
515515
scenarios::bounded_search::prepare(&self.workspace_root, request, run_directory)
516516
}
517-
ScenarioId::LexicalLoop | ScenarioId::DynamicBindingLoop => {
517+
ScenarioId::LexicalLoop | ScenarioId::DynamicBindingLoop | ScenarioId::FirstHotLoop => {
518518
scenarios::vm_loop::prepare(&self.workspace_root, request, run_directory)
519519
}
520520
ScenarioId::BytecodeCallLoop => {
@@ -1582,6 +1582,7 @@ enum ScenarioResult {
15821582
MxTabCompletion(scenarios::mx_tab::MxTabCompletionResult),
15831583
BytecodeCallLoop(scenarios::bytecode::BytecodeCallLoopResult),
15841584
VmLoop(scenarios::vm_loop::VmLoopResult),
1585+
FirstHotLoop(scenarios::vm_loop::first_entry::FirstHotLoopResult),
15851586
BoundedSearch(scenarios::bounded_search::BoundedSearchResult),
15861587
ElispBenchmarks(scenarios::elisp_benchmarks::ElispBenchmarksResult),
15871588
EditorWorkload(scenarios::editor_workload::EditorWorkloadResult),
@@ -1604,6 +1605,7 @@ impl ScenarioResult {
16041605
Self::MxTabCompletion(result) => result.elapsed_us,
16051606
Self::BytecodeCallLoop(result) => result.elapsed_us,
16061607
Self::VmLoop(result) => result.elapsed_us(),
1608+
Self::FirstHotLoop(result) => result.elapsed_us(),
16071609
Self::BoundedSearch(result) => result.elapsed_us(),
16081610
Self::ElispBenchmarks(result) => result.elapsed_us,
16091611
Self::EditorWorkload(result) => result.elapsed_us,
@@ -1630,6 +1632,7 @@ fn parse_scenario_result(
16301632
| ScenarioId::BoundedSearchNoEdit => {
16311633
serde_json::from_str(raw).map(ScenarioResult::BoundedSearch)
16321634
}
1635+
ScenarioId::FirstHotLoop => serde_json::from_str(raw).map(ScenarioResult::FirstHotLoop),
16331636
ScenarioId::LexicalLoop | ScenarioId::DynamicBindingLoop => {
16341637
serde_json::from_str(raw).map(ScenarioResult::VmLoop)
16351638
}
@@ -1853,6 +1856,9 @@ fn result_verdict(
18531856
process_wall_us: u128,
18541857
) -> RunVerdict {
18551858
let mismatches = match result {
1859+
ScenarioResult::FirstHotLoop(result) => {
1860+
scenarios::vm_loop::first_entry::validate(request, result)
1861+
}
18561862
ScenarioResult::VmLoop(result) => {
18571863
scenarios::vm_loop::validate_vm_loop_result(request, result)
18581864
}
@@ -1907,6 +1913,9 @@ where
19071913

19081914
fn valid_measurements(result: &ScenarioResult, wall_elapsed_us: u128) -> Vec<Measurement> {
19091915
match result {
1916+
ScenarioResult::FirstHotLoop(result) => {
1917+
scenarios::vm_loop::first_entry::measurements(result, wall_elapsed_us)
1918+
}
19101919
ScenarioResult::VmLoop(result) => {
19111920
scenarios::vm_loop::valid_vm_loop_measurements(result, wall_elapsed_us)
19121921
}

‎crates/neomacs-perf/src/harness/scenarios/editor_workload.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,8 @@ pub(crate) fn validate_editor_workload_result(
665665
| ScenarioId::MxTabCompletion
666666
| ScenarioId::BytecodeCallLoop
667667
| ScenarioId::LexicalLoop
668-
| ScenarioId::DynamicBindingLoop => {
668+
| ScenarioId::DynamicBindingLoop
669+
| ScenarioId::FirstHotLoop => {
669670
unreachable!("dedicated scenario results do not use the editor workload validator")
670671
}
671672
}

‎crates/neomacs-perf/src/harness/scenarios/vm_loop.rs‎

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
//! Rarely called, long-running bytecode loops under the editor's normal tier policy.
22
//! The paired scenarios differ by a live special binding across the hot loop.
3+
//! First-entry diagnostics share preparation but validate every fresh function.
4+
5+
pub(crate) mod first_entry;
36

47
use std::collections::BTreeMap;
58
use std::fs;
@@ -23,14 +26,19 @@ pub(crate) fn prepare(
2326
) -> Result<PreparedScenario, String> {
2427
let sandbox = MelpaSandbox::new(&format!("perf-{}", request.scenario))?;
2528
let editor = collect_editor_provenance(request.editor(), &sandbox)?;
26-
let fixture_source = workspace_root.join("crates/neomacs-perf/fixtures/vm-loop.el");
29+
let workload_source = if request.scenario == ScenarioId::FirstHotLoop {
30+
"crates/neomacs-perf/fixtures/first-hot-loop.el"
31+
} else {
32+
"crates/neomacs-perf/fixtures/vm-loop.el"
33+
};
34+
let fixture_source = workspace_root.join(workload_source);
2735
if !fixture_source.is_file() {
2836
return Err(format!(
2937
"missing committed performance fixture {}",
3038
fixture_source.display()
3139
));
3240
}
33-
let fixture = run_directory.join("vm-loop.el");
41+
let fixture = run_directory.join(fixture_source.file_name().expect("fixture filename"));
3442
fs::copy(&fixture_source, &fixture).map_err(|error| {
3543
format!(
3644
"failed to copy performance fixture {} to {}: {error}",
@@ -42,7 +50,7 @@ pub(crate) fn prepare(
4250
let provenance_manifest = VmLoopInputProvenanceManifest {
4351
editor,
4452
host: collect_host_provenance(request.machine_policy()),
45-
workload_source: "crates/neomacs-perf/fixtures/vm-loop.el",
53+
workload_source,
4654
workload_source_sha256: sha256_file(&fixture_source)?,
4755
execution_policy: "editor-default-with-recorded-overrides",
4856
environment_policy: "closed-v1",
@@ -226,23 +234,36 @@ pub(crate) fn valid_vm_loop_measurements(
226234
wall_elapsed_us: u128,
227235
) -> Vec<Measurement> {
228236
let r = &result.wire;
237+
loop_measurements(
238+
r.iterations,
239+
r.completed_operations,
240+
r.elapsed_us,
241+
r.elapsed_wall_us,
242+
wall_elapsed_us,
243+
)
244+
}
245+
246+
fn loop_measurements(
247+
iterations: u32,
248+
completed: u32,
249+
cpu_us: u64,
250+
wall_us: u64,
251+
process_us: u128,
252+
) -> Vec<Measurement> {
229253
[
230-
(MetricName::ProcessWallTime, wall_elapsed_us as f64),
231-
(MetricName::WorkloadCpuTime, r.elapsed_us as f64),
232-
(MetricName::WorkloadWallTime, r.elapsed_wall_us as f64),
254+
(MetricName::ProcessWallTime, process_us as f64),
255+
(MetricName::WorkloadCpuTime, cpu_us as f64),
256+
(MetricName::WorkloadWallTime, wall_us as f64),
233257
(
234258
MetricName::PerOperationCpuTime,
235-
r.elapsed_us as f64 / f64::from(r.iterations),
259+
cpu_us as f64 / f64::from(iterations),
236260
),
237261
(
238262
MetricName::PerOperationWallTime,
239-
r.elapsed_wall_us as f64 / f64::from(r.iterations),
240-
),
241-
(
242-
MetricName::OperationCount,
243-
f64::from(r.completed_operations),
263+
wall_us as f64 / f64::from(iterations),
244264
),
245-
(MetricName::Iterations, f64::from(r.iterations)),
265+
(MetricName::OperationCount, f64::from(completed)),
266+
(MetricName::Iterations, f64::from(iterations)),
246267
]
247268
.into_iter()
248269
.map(|(name, value)| Measurement {

0 commit comments

Comments
 (0)