Files
moli/scripts/benchmark-ax-native.py
T
ldm0 6e70b3f1cf perf(accessibility): keep uncommon AX fields out of line
Group ignored reasons, values and nonempty properties behind one optional box. Use nonzero backend IDs and serialize fixed role/name kinds on demand, reducing the common AX node from 296 to 96 bytes while preserving payload shape and field order.

Add reproducible CLI and native construction benchmarks. Generated 40000-row text dumps use 382.6 to 341.5 MiB peak RSS; 200000-row text dumps use 1753.5 to 1612.8 MiB. Record the roughly 5.5 percent native construction cost for dense ignored nodes alongside the memory gains.

Validation: cargo fmt --all; workspace clippy with all targets/features and warnings denied; cargo nextest run --no-fail-fast (19890 passed, 17 skipped). All 188 formal benchmark samples match their corresponding before/after outputs.
2026-10-11 17:37:40 +08:00

180 lines
9.4 KiB
Python

#!/usr/bin/env python3
"""Benchmark typed AX and CDP payload construction using the same generated DOM.
Build both revisions with `cargo build --release -p moli`, then pass their
separate target/release/deps directories. The standalone Rust harness uses the
system allocator and synthetic visible styles; the CLI benchmark measures the
complete browser pipeline with its default allocator and real layout.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import statistics
import subprocess
import time
NATIVE_SOURCE = r"""use std::{io, time::Instant};
use moli_dom::{NodeId, native::{DomHost, NativeDom}, accessibility::{AccessibilityStyle, AccessibilityStyleSource, AccessibilityInput, AccessibilityFrameState, AccessibilityRequest, accessibility_nodes_for_document, accessibility_payloads_for_document}};
struct Styles;
impl AccessibilityStyleSource for Styles {
fn element_style(&mut self, _: NodeId) -> Option<AccessibilityStyle> {
Some(AccessibilityStyle { display_none: false, visibility_visible: true, hides_contents: false, block_level: false })
}
}
fn element(doc: &mut DomHost, parent: NodeId, tag: &str) -> NodeId {
let node = doc.create_element(tag);
assert!(doc.append_child(parent, node));
node
}
fn text(doc: &mut DomHost, parent: NodeId, data: &str) {
let node = doc.create_text_node(data);
assert!(doc.append_child(parent, node));
}
fn main() {
let args: Vec<_> = std::env::args().collect();
let case = args[1].as_str();
let path = args[2].as_str();
let count: usize = args[3].parse().unwrap();
let mut doc = DomHost::from_dom(NativeDom::new_html(url::Url::parse("https://example.test/").unwrap()));
let root = doc.document_node_id();
let host = element(&mut doc, root, if case == "markers" { "ul" } else { "main" });
for index in 0..count {
match case {
"rows" => {
let row = element(&mut doc, host, "div");
let link = element(&mut doc, row, "a");
doc.set_attribute(link, "href", &format!("/item/{index}"));
text(&mut doc, link, &format!("Item {index}"));
let span = element(&mut doc, row, "span");
text(&mut doc, span, " description");
}
"values" => {
let input = element(&mut doc, host, "input");
doc.set_attribute(input, "aria-label", "Text input");
doc.set_attribute(input, "value", "Input value");
}
"ignored" => {
let div = element(&mut doc, host, "div");
doc.set_attribute(div, "aria-hidden", "true");
let button = element(&mut doc, div, "button");
text(&mut doc, button, "Ignored action");
}
"markers" => {
let item = element(&mut doc, host, "li");
text(&mut doc, item, "List item");
}
_ => panic!("unknown case"),
}
}
let mut styles = Styles;
let mut input = AccessibilityInput::new(&mut styles, AccessibilityFrameState::Active);
let mut backend_id = |node: NodeId| Some(u32::try_from(node.index() + 1).unwrap());
let started = Instant::now();
match path {
"typed" => {
let nodes = accessibility_nodes_for_document(&doc, &mut input, root, AccessibilityRequest::Tree { max_depth: None }, &mut backend_id).unwrap();
eprintln!("{{\"nodes\":{},\"build_seconds\":{}}}", nodes.len(), started.elapsed().as_secs_f64());
serde_json::to_writer(io::stdout().lock(), &nodes).unwrap();
}
"protocol" => {
let nodes = accessibility_payloads_for_document(&doc, &mut input, root, AccessibilityRequest::Tree { max_depth: None }, &mut backend_id).unwrap();
eprintln!("{{\"nodes\":{},\"build_seconds\":{}}}", nodes.len(), started.elapsed().as_secs_f64());
serde_json::to_writer(io::stdout().lock(), &nodes).unwrap();
}
_ => panic!("unknown path"),
}
}
"""
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--before-deps', type=Path, required=True)
parser.add_argument('--after-deps', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--count', type=int, default=20000)
parser.add_argument('--repeats', type=int, default=5)
args = parser.parse_args()
if platform.system() != 'Linux':
parser.error('peak RSS reporting currently requires Linux')
if args.count < 1 or args.repeats < 1:
parser.error('--count and --repeats must be positive')
root = args.output.resolve()
root.mkdir(parents=True, exist_ok=True)
source = root / 'native-storage.rs'
source.write_text(NATIVE_SOURCE)
manifest = {'count': args.count, 'repeats': args.repeats, 'libraries': {}}
for label, deps in [('before', args.before_deps.resolve()), ('after', args.after_deps.resolve())]:
command = ['rustc', '--edition=2024', '--crate-name', 'native_ax_storage', str(source),
'-L', f'dependency={deps}', '-C', 'panic=abort', '-C', 'opt-level=3',
'-C', 'codegen-units=1', '-D', 'warnings', '-o', str(root/f'native-{label}')]
manifest['libraries'][label] = {}
for crate in ('moli_dom', 'serde_json', 'url'):
candidates = list(deps.glob(f'lib{crate}-*.rlib'))
if not candidates:
parser.error(f'missing {crate} release library in {deps}')
rlib = max(candidates, key=lambda path: path.stat().st_mtime)
with rlib.open('rb') as library:
digest = hashlib.file_digest(library, 'sha256').hexdigest()
manifest['libraries'][label][crate] = {'path': str(rlib), 'sha256': digest}
command += ['--extern', f'{crate}={rlib}']
subprocess.run(command, check=True)
(root/'manifest.json').write_text(json.dumps(manifest, indent=2)+'\n')
records = []
for label in ('before', 'after'):
subprocess.run(['ax-storage', 'rows', 'typed', '100'],
executable=str(root/f'native-{label}'),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
with (root/'native-results.jsonl').open('w') as log:
for case in ('rows', 'values', 'ignored', 'markers'):
for path in ('typed', 'protocol'):
hashes = set()
for repeat in range(args.repeats):
order = ('before','after') if repeat % 2 == 0 else ('after','before')
for label in order:
dest = root/f'native-{case}-{path}-{repeat}-{label}.out'
meta = dest.with_suffix('.meta')
started = time.monotonic()
with dest.open('wb') as out, meta.open('wb') as err:
process = subprocess.Popen(['ax-storage',case,path,str(args.count)],
executable=str(root/f'native-{label}'),
stdout=out,stderr=err)
_, status, usage = os.wait4(process.pid,0)
process.returncode = os.waitstatus_to_exitcode(status)
if process.returncode:
raise RuntimeError(meta.read_text())
wall = time.monotonic()-started
with dest.open('rb') as out:
digest = hashlib.file_digest(out,'sha256').hexdigest()
hashes.add(digest)
record = dict(case=case,path=path,repeat=repeat,binary=label,
maxrss_bytes=usage.ru_maxrss*1024,wall_seconds=wall,
sha256=digest,**json.loads(meta.read_text()))
records.append(record)
line=json.dumps(record)
log.write(line+'\n')
log.flush()
print(line,flush=True)
assert len(hashes)==1,(case,path,hashes)
lines=['# Native AX construction benchmark','',
'Same generated DOM and style source; Rust system allocator; no V8 or real layout.',
f'Median of {args.repeats} alternating runs. Build time isolates the AX API call; peak RSS covers the process including DOM construction and streaming serialization.','',
'| Case | Path | Nodes | RSS before MiB | RSS after MiB | Build before | Build after | Build change |',
'|---|---|---:|---:|---:|---:|---:|---:|']
for case,path in dict.fromkeys((r['case'],r['path']) for r in records):
data={label:[r for r in records if (r['case'],r['path'],r['binary'])==(case,path,label)] for label in ('before','after')}
rss={label:statistics.median(r['maxrss_bytes']/2**20 for r in rows) for label,rows in data.items()}
build={label:statistics.median(r['build_seconds'] for r in rows) for label,rows in data.items()}
lines.append(f"| {case} | {path} | {data['before'][0]['nodes']} | {rss['before']:.1f} | {rss['after']:.1f} | {build['before']:.4f}s | {build['after']:.4f}s | {(build['after']/build['before']-1)*100:+.1f}% |")
lines += ['', 'Before/after output SHA-256 matches for every case and path.', '']
report='\n'.join(lines)
(root/'native-report.md').write_text(report)
print(report)
if __name__ == '__main__':
main()