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.
This commit is contained in:
ldm0
2026-10-11 17:37:40 +08:00
committed by Donough Liu
parent 1404fa344b
commit 6e70b3f1cf
5 changed files with 752 additions and 132 deletions
+234 -25
View File
@@ -1,4 +1,5 @@
use std::fmt;
use std::num::NonZeroU32;
use serde::{Serialize, Serializer, ser::SerializeMap};
use serde_json::Value;
@@ -6,8 +7,8 @@ use serde_json::Value;
/// Keep references numeric until a protocol or dump actually serializes them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AccessibilityNodeId {
Dom(u32),
ListMarker(u32),
Dom(NonZeroU32),
ListMarker(NonZeroU32),
}
impl fmt::Display for AccessibilityNodeId {
@@ -37,15 +38,79 @@ pub struct AccessibilityValue<T> {
#[derive(Debug)]
pub struct AccessibilityNode {
pub node_id: AccessibilityNodeId,
pub backend_node_id: Option<u32>,
pub backend_node_id: Option<NonZeroU32>,
pub ignored: bool,
pub ignored_reasons: Option<Value>,
pub role: AccessibilityValue<&'static str>,
pub name: Option<AccessibilityValue<String>>,
pub value: Option<Value>,
pub properties: Option<Vec<Value>>,
pub role: &'static str,
pub name: Option<String>,
pub parent_id: Option<AccessibilityNodeId>,
pub child_ids: Option<Vec<AccessibilityNodeId>>,
properties_present: bool,
rare_data: Option<Box<AccessibilityNodeRareData>>,
}
/// Common nodes have neither a value nor an ignored reason, and most have no
/// properties. Keep their empty JSON containers out of the common record.
#[derive(Debug, Default)]
struct AccessibilityNodeRareData {
ignored_reasons: Option<Value>,
value: Option<Value>,
properties: Vec<Value>,
}
impl AccessibilityNode {
/// An ordinary AX node includes an empty properties array even when it
/// needs no rare allocation. Synthetic nodes can omit it via set_rare_data.
pub fn new(node_id: AccessibilityNodeId, role: &'static str) -> Self {
Self {
node_id,
backend_node_id: match node_id {
AccessibilityNodeId::Dom(id) => Some(id),
AccessibilityNodeId::ListMarker(_) => None,
},
ignored: false,
role,
name: None,
parent_id: None,
child_ids: None,
properties_present: true,
rare_data: None,
}
}
pub fn set_rare_data(
&mut self,
ignored_reasons: Option<Value>,
value: Option<Value>,
properties: Option<Vec<Value>>,
) {
self.properties_present = properties.is_some();
let properties = properties.unwrap_or_default();
self.rare_data = if ignored_reasons.is_none() && value.is_none() && properties.is_empty() {
None
} else {
Some(Box::new(AccessibilityNodeRareData {
ignored_reasons,
value,
properties,
}))
};
}
pub fn ignored_reasons(&self) -> Option<&Value> {
self.rare_data.as_deref()?.ignored_reasons.as_ref()
}
pub fn value(&self) -> Option<&Value> {
self.rare_data.as_deref()?.value.as_ref()
}
pub fn properties(&self) -> Option<&[Value]> {
self.properties_present.then(|| {
self.rare_data
.as_deref()
.map_or(&[][..], |data| data.properties.as_slice())
})
}
}
impl Serialize for AccessibilityNode {
@@ -58,27 +123,37 @@ impl Serialize for AccessibilityNode {
payload.serialize_entry("ignored", &self.ignored)?;
// DOM nodes without an AX object have a smaller synthetic payload,
// whose role historically precedes its ignored reason.
if self.properties.is_none() {
payload.serialize_entry("role", &self.role)?;
let role = AccessibilityValue {
kind: "role",
value: self.role,
};
if !self.properties_present {
payload.serialize_entry("role", &role)?;
}
if let Some(reasons) = &self.ignored_reasons {
if let Some(reasons) = self.ignored_reasons() {
payload.serialize_entry("ignoredReasons", reasons)?;
}
if self.properties.is_some() {
payload.serialize_entry("role", &self.role)?;
if self.properties_present {
payload.serialize_entry("role", &role)?;
}
if let Some(name) = &self.name {
payload.serialize_entry("name", name)?;
payload.serialize_entry(
"name",
&AccessibilityValue {
kind: "computedString",
value: name,
},
)?;
}
if let Some(value) = &self.value {
if let Some(value) = self.value() {
payload.serialize_entry("value", value)?;
}
if let Some(properties) = &self.properties {
if let Some(properties) = self.properties() {
payload.serialize_entry("properties", properties)?;
}
// Joining a child frame appends parentId to its existing root payload.
let mut parent_id = self.parent_id;
if self.role.value != "RootWebArea"
if self.role != "RootWebArea"
&& let Some(parent) = parent_id.take()
{
payload.serialize_entry("parentId", &parent)?;
@@ -98,18 +173,22 @@ impl From<AccessibilityNode> for Value {
let mut payload = serde_json::Map::new();
payload.insert("nodeId".to_owned(), Value::String(node.node_id.to_string()));
if let Some(id) = node.backend_node_id {
payload.insert("backendDOMNodeId".to_owned(), id.into());
payload.insert("backendDOMNodeId".to_owned(), id.get().into());
}
payload.insert("ignored".to_owned(), node.ignored.into());
let mut ignored_reasons = node.ignored_reasons;
if node.properties.is_some()
let AccessibilityNodeRareData {
mut ignored_reasons,
value,
properties,
} = node.rare_data.map(|data| *data).unwrap_or_default();
if node.properties_present
&& let Some(reasons) = ignored_reasons.take()
{
payload.insert("ignoredReasons".to_owned(), reasons);
}
payload.insert(
"role".to_owned(),
serde_json::json!({"type":node.role.kind, "value":node.role.value}),
serde_json::json!({"type":"role", "value":node.role}),
);
if let Some(reasons) = ignored_reasons {
payload.insert("ignoredReasons".to_owned(), reasons);
@@ -117,17 +196,17 @@ impl From<AccessibilityNode> for Value {
if let Some(name) = node.name {
payload.insert(
"name".to_owned(),
serde_json::json!({"type":name.kind, "value":name.value}),
serde_json::json!({"type":"computedString", "value":name}),
);
}
if let Some(value) = node.value {
if let Some(value) = value {
payload.insert("value".to_owned(), value);
}
if let Some(properties) = node.properties {
if node.properties_present {
payload.insert("properties".to_owned(), Value::Array(properties));
}
let mut parent_id = node.parent_id;
if node.role.value != "RootWebArea"
if node.role != "RootWebArea"
&& let Some(parent) = parent_id.take()
{
payload.insert("parentId".to_owned(), Value::String(parent.to_string()));
@@ -149,3 +228,133 @@ impl From<AccessibilityNode> for Value {
Self::Object(payload)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn id(value: u32) -> NonZeroU32 {
NonZeroU32::new(value).expect("nonzero fixture ID")
}
#[test]
#[cfg(target_pointer_width = "64")]
fn common_ax_node_layout_stays_compact() {
assert_eq!(size_of::<Option<NonZeroU32>>(), size_of::<u32>());
assert_eq!(size_of::<AccessibilityNodeId>(), 8);
assert_eq!(size_of::<Option<AccessibilityNodeId>>(), 8);
assert_eq!(size_of::<Option<Box<AccessibilityNodeRareData>>>(), 8);
assert!(
size_of::<AccessibilityNode>() <= 96,
"AccessibilityNode grew to {} bytes",
size_of::<AccessibilityNode>()
);
}
#[test]
fn empty_properties_do_not_allocate_rare_data() {
let mut node = AccessibilityNode::new(AccessibilityNodeId::Dom(id(7)), "generic");
node.set_rare_data(None, None, Some(Vec::new()));
assert!(node.rare_data.is_none());
assert_eq!(node.properties(), Some(&[][..]));
assert_eq!(
serde_json::to_string(&node).expect("AX JSON"),
r#"{"nodeId":"AX-7","backendDOMNodeId":7,"ignored":false,"role":{"type":"role","value":"generic"},"properties":[]}"#
);
node.set_rare_data(None, Some(json!({"type":"string", "value":"text"})), None);
assert!(node.rare_data.is_some());
assert!(node.properties().is_none());
node.set_rare_data(None, None, None);
assert!(node.rare_data.is_none());
assert!(node.properties().is_none());
}
#[test]
fn cold_fields_preserve_protocol_values_and_field_order() {
for properties in [
None,
Some(Vec::new()),
Some(vec![
json!({"name":"focusable", "value":{"type":"boolean", "value":true}}),
]),
] {
let mut node = AccessibilityNode::new(AccessibilityNodeId::Dom(id(7)), "textbox");
node.ignored = true;
node.name = Some("A name".to_owned());
node.parent_id = Some(AccessibilityNodeId::Dom(id(1)));
node.child_ids = Some(Vec::new());
let reasons = json!([{"name":"notRendered", "value":{"type":"boolean", "value":true}}]);
let value = json!({"type":"string", "value":"A value"});
node.set_rare_data(
Some(reasons.clone()),
Some(value.clone()),
properties.clone(),
);
let serialized = serde_json::to_string(&node).expect("AX JSON");
let mut expected = json!({
"nodeId":"AX-7", "backendDOMNodeId":7, "ignored":true,
"ignoredReasons":reasons, "role":{"type":"role", "value":"textbox"},
"name":{"type":"computedString", "value":"A name"}, "value":value,
"parentId":"AX-1", "childIds":[],
});
if let Some(properties) = &properties {
expected["properties"] = json!(properties);
}
assert_eq!(
serde_json::from_str::<Value>(&serialized).expect("AX JSON"),
expected
);
assert_eq!(Value::from(node), expected);
let role_position = serialized.find("\"role\"").expect("role");
let reason_position = serialized.find("\"ignoredReasons\"").expect("reasons");
assert_eq!(role_position < reason_position, properties.is_none());
assert!(serialized.find("\"parentId\"") < serialized.find("\"childIds\""));
}
}
#[test]
fn list_markers_and_child_frame_roots_keep_their_distinct_payloads() {
let mut marker =
AccessibilityNode::new(AccessibilityNodeId::ListMarker(id(7)), "ListMarker");
marker.name = Some("• ".to_owned());
marker.parent_id = Some(AccessibilityNodeId::Dom(id(7)));
marker.child_ids = Some(Vec::new());
let expected = json!({
"nodeId":"AX-LM-7", "ignored":false,
"role":{"type":"role", "value":"ListMarker"},
"name":{"type":"computedString", "value":"• "},
"properties":[], "parentId":"AX-7", "childIds":[],
});
assert!(marker.rare_data.is_none());
assert_eq!(
serde_json::to_value(&marker).expect("marker JSON"),
expected
);
assert_eq!(Value::from(marker), expected);
let mut root = AccessibilityNode::new(AccessibilityNodeId::Dom(id(8)), "RootWebArea");
root.parent_id = Some(AccessibilityNodeId::Dom(id(7)));
root.child_ids = Some(vec![AccessibilityNodeId::Dom(id(9))]);
let serialized = serde_json::to_string(&root).expect("root JSON");
assert!(serialized.find("\"childIds\"") < serialized.find("\"parentId\""));
assert_eq!(
serde_json::from_str::<Value>(&serialized).expect("root JSON"),
Value::from(root)
);
}
#[test]
fn ids_preserve_the_full_nonzero_u32_range_and_marker_namespace() {
for value in [1, 2_000_000_000, u32::MAX] {
let dom = AccessibilityNodeId::Dom(id(value));
let marker = AccessibilityNodeId::ListMarker(id(value));
assert_ne!(dom, marker);
assert_eq!(dom.to_string(), format!("AX-{value}"));
assert_eq!(marker.to_string(), format!("AX-LM-{value}"));
}
}
}
+36 -60
View File
@@ -4,11 +4,12 @@ use crate::{
};
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::num::NonZeroU32;
use super::ax_projection::{AxIgnoredReason, AxTreeProjection};
use super::ax_properties::{ax_name, ax_properties, ax_value};
use super::ax_roles::{ax_role, ordered_list_item_index};
use super::{AccessibilityInput, AccessibilityNode, AccessibilityNodeId, AccessibilityValue};
use super::{AccessibilityInput, AccessibilityNode, AccessibilityNodeId};
/// The semantic traversal requested by one synchronous AX command.
#[derive(Clone, Copy, Debug)]
@@ -189,24 +190,15 @@ fn ax_dom_node_without_object_payload(
node_id: NodeId,
backend_node_id_for_node: &mut dyn FnMut(NodeId) -> Option<u32>,
) -> Option<AccessibilityNode> {
let backend_node_id = backend_node_id_for_node(node_id)?;
Some(AccessibilityNode {
node_id: AccessibilityNodeId::Dom(backend_node_id),
backend_node_id: Some(backend_node_id),
ignored: true,
role: AccessibilityValue {
kind: "role",
value: "none",
},
ignored_reasons: Some(
json!([{"name": "notRendered", "value": {"type": "boolean", "value": true}}]),
),
name: None,
value: None,
properties: None,
parent_id: None,
child_ids: None,
})
let backend_node_id = NonZeroU32::new(backend_node_id_for_node(node_id)?)?;
let mut node = AccessibilityNode::new(AccessibilityNodeId::Dom(backend_node_id), "none");
node.ignored = true;
node.set_rare_data(
Some(json!([{"name": "notRendered", "value": {"type": "boolean", "value": true}}])),
None,
None,
);
Some(node)
}
fn add_ax_children<T: From<AccessibilityNode>>(
@@ -259,7 +251,7 @@ fn ax_node_payload(
let document = projection.document;
let node = document.node(node_id)?;
let projected = projection.node(node_id).ok()??;
let backend_node_id = backend_node_id_for_node(node_id)?;
let backend_node_id = NonZeroU32::new(backend_node_id_for_node(node_id)?)?;
let ignored = projected.ignored_reason.is_some();
let ignored_reasons = projected.ignored_reason.map(|reason| {
ax_ignored_reasons_payload(document, node_id, reason, backend_node_id_for_node)
@@ -280,7 +272,9 @@ fn ax_node_payload(
ax_properties(document, node_id, node)
};
let parent_id = match projected.parent {
Some(parent) => Some(AccessibilityNodeId::Dom(backend_node_id_for_node(parent)?)),
Some(parent) => Some(AccessibilityNodeId::Dom(NonZeroU32::new(
backend_node_id_for_node(parent)?,
)?)),
None => None,
};
let children = projection.children(node_id).ok()?;
@@ -290,31 +284,23 @@ fn ax_node_payload(
let mut child_ids = Vec::with_capacity(children.len());
for child_id in children {
if projection.is_rendered(child_id).ok()? {
child_ids.push(AccessibilityNodeId::Dom(backend_node_id_for_node(
child_id,
child_ids.push(AccessibilityNodeId::Dom(NonZeroU32::new(
backend_node_id_for_node(child_id)?,
)?));
}
}
Some(child_ids)
};
Some(AccessibilityNode {
node_id: AccessibilityNodeId::Dom(backend_node_id),
backend_node_id: Some(backend_node_id),
ignored,
ignored_reasons,
role: AccessibilityValue {
kind: "role",
value: if ignored { "none" } else { ax_role(node) },
},
name: (!name.is_empty()).then_some(AccessibilityValue {
kind: "computedString",
value: name,
}),
value,
properties: Some(properties),
parent_id,
child_ids,
})
let mut payload = AccessibilityNode::new(
AccessibilityNodeId::Dom(backend_node_id),
if ignored { "none" } else { ax_role(node) },
);
payload.ignored = ignored;
payload.name = (!name.is_empty()).then_some(name);
payload.parent_id = parent_id;
payload.child_ids = child_ids;
payload.set_rare_data(ignored_reasons, value, Some(properties));
Some(payload)
}
fn ax_ignored_reasons_payload(
@@ -392,23 +378,13 @@ fn ax_list_marker_payload(
_ => return None,
};
let parent_backend_node_id = backend_node_id_for_node(node_id)?;
Some(AccessibilityNode {
node_id: AccessibilityNodeId::ListMarker(parent_backend_node_id),
backend_node_id: None,
ignored: false,
ignored_reasons: None,
role: AccessibilityValue {
kind: "role",
value: "ListMarker",
},
name: Some(AccessibilityValue {
kind: "computedString",
value: marker_text,
}),
value: None,
properties: Some(Vec::new()),
parent_id: Some(AccessibilityNodeId::Dom(parent_backend_node_id)),
child_ids: Some(Vec::new()),
})
let parent_backend_node_id = NonZeroU32::new(backend_node_id_for_node(node_id)?)?;
let mut payload = AccessibilityNode::new(
AccessibilityNodeId::ListMarker(parent_backend_node_id),
"ListMarker",
);
payload.name = Some(marker_text);
payload.parent_id = Some(AccessibilityNodeId::Dom(parent_backend_node_id));
payload.child_ids = Some(Vec::new());
Some(payload)
}
+28 -47
View File
@@ -58,10 +58,10 @@ fn attach_child_frame_accessibility_tree(
let Some(child_root) = child_payloads.first_mut() else {
return;
};
let Some(owner) = payloads
.iter_mut()
.find(|node| node.backend_node_id == Some(owner_backend_node_id))
else {
let Some(owner) = payloads.iter_mut().find(|node| {
node.backend_node_id
.is_some_and(|id| id.get() == owner_backend_node_id)
}) else {
return;
};
child_root.parent_id = Some(owner.node_id);
@@ -88,23 +88,20 @@ fn render_payloads_text(payloads: &[AccessibilityNode]) -> String {
out.push_str(" ");
}
out.push_str("- ");
out.push_str(node.role.value);
out.push_str(node.role);
if let Some(name) = &node.name
&& !name.value.is_empty()
&& !name.is_empty()
{
out.push_str(": ");
out.push_str(&name.value);
out.push_str(name);
}
if let Some(value) = node
.value
.as_ref()
.and_then(|value| value["value"].as_str())
if let Some(value) = node.value().and_then(|value| value["value"].as_str())
&& !value.is_empty()
{
out.push_str(" = ");
out.push_str(value);
}
if let Some(backend) = node.backend_node_id.filter(|id| *id != 0) {
if let Some(backend) = node.backend_node_id {
write!(&mut out, " [backendNodeId={backend}]").expect("writing to a String");
}
out.push('\n');
@@ -122,8 +119,14 @@ fn render_payloads_text(payloads: &[AccessibilityNode]) -> String {
#[cfg(test)]
mod tests {
use std::num::NonZeroU32;
use super::*;
fn dom_id(id: u32) -> AccessibilityNodeId {
AccessibilityNodeId::Dom(NonZeroU32::new(id).expect("fixture backend ID"))
}
fn frame(frame_id: &str, child_frames: Vec<ChildFrameTreeSnapshot>) -> ChildFrameTreeSnapshot {
ChildFrameTreeSnapshot {
frame_id: frame_id.to_owned(),
@@ -157,27 +160,10 @@ mod tests {
parent: Option<u32>,
children: &[u32],
) -> AccessibilityNode {
AccessibilityNode {
node_id: AccessibilityNodeId::Dom(id),
backend_node_id: Some(id),
ignored: false,
ignored_reasons: None,
role: moli_core::page::AccessibilityValue {
kind: "role",
value: role,
},
name: None,
value: None,
properties: Some(Vec::new()),
parent_id: parent.map(AccessibilityNodeId::Dom),
child_ids: Some(
children
.iter()
.copied()
.map(AccessibilityNodeId::Dom)
.collect(),
),
}
let mut node = AccessibilityNode::new(dom_id(id), role);
node.parent_id = parent.map(dom_id);
node.child_ids = Some(children.iter().copied().map(dom_id).collect());
node
}
#[test]
@@ -187,19 +173,13 @@ mod tests {
node(2, "Iframe", Some(1), &[]),
];
let mut child_button = node(4, "button", Some(3), &[]);
child_button.name = Some(moli_core::page::AccessibilityValue {
kind: "computedString",
value: "Child action".to_owned(),
});
child_button.name = Some("Child action".to_owned());
let child_payloads = vec![node(3, "RootWebArea", None, &[4]), child_button];
attach_child_frame_accessibility_tree(&mut payloads, 2, child_payloads);
assert_eq!(
payloads[1].child_ids,
Some(vec![AccessibilityNodeId::Dom(3)])
);
assert_eq!(payloads[2].parent_id, Some(AccessibilityNodeId::Dom(2)));
assert_eq!(payloads[1].child_ids, Some(vec![dom_id(3)]));
assert_eq!(payloads[2].parent_id, Some(dom_id(2)));
assert!(render_payloads_text(&payloads).contains("button: Child action"));
}
@@ -232,11 +212,12 @@ mod tests {
));
}
let mut last = node(DEPTH + 1, "textbox", Some(1), &[]);
last.name = Some(moli_core::page::AccessibilityValue {
kind: "computedString",
value: "Name".to_owned(),
});
last.value = Some(serde_json::json!({"type":"string", "value":"value"}));
last.name = Some("Name".to_owned());
last.set_rare_data(
None,
Some(serde_json::json!({"type":"string", "value":"value"})),
Some(Vec::new()),
);
nodes.push(last);
let text = render_payloads_text(&nodes);
assert_eq!(text.lines().count(), (DEPTH + 1) as usize);
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Compare semantic dumps with alternating before/after release binaries.
Uses generated local fixtures, Linux wait4 peak RSS, three measured repetitions
by default, and SHA-256 checks for every output. Run without concurrent builds.
Example:
python3 scripts/benchmark-ax-compact.py --before /path/before/moli \
--after /path/after/moli --output target/ax-compact-benchmark/results
"""
import argparse
import functools
import hashlib
import html
import http.server
import json
import multiprocessing
import os
from pathlib import Path
import platform
import statistics
import subprocess
import threading
import time
def fixtures(root):
root.mkdir(parents=True, exist_ok=True)
def page(name, body):
(root / f"{name}.html").write_text(
f"<!doctype html><html><head><title>{name}</title></head>"
f"<body>{body}</body></html>", encoding="utf-8"
)
for count in (500, 40000, 200000):
page(f"rows-{count}", "".join(
f'<div class="row"><a href="/item/{i}">Item {i}</a>'
f'<span> description {i}</span></div>' for i in range(count)
))
page("table-10000", '<table><caption>Results</caption><thead><tr>'
'<th>Item</th><th>Count</th><th>Status</th></tr></thead><tbody>' + "".join(
f'<tr><th scope="row">Item {i}</th><td>{i}</td><td>Ready</td></tr>'
for i in range(10000)
) + '</tbody></table>')
page("forms-300", "".join(
f'<label for="t{i}">Name {i}</label><input id="t{i}" value="Value {i}">'
f'<textarea aria-label="Notes {i}">Notes {i}</textarea>'
f'<select aria-label="Choice {i}"><option>First</option>'
'<option selected>Second</option></select>'
f'<input type="checkbox" aria-label="Enabled {i}" checked>'
f'<input type="range" aria-label="Amount {i}" value="42">'
for i in range(300)
))
page("ignored-10000", "".join(
f'<div {attribute}><button>Ignored {i}</button><span>Text {i}</span></div>'
for i in range(10000)
for attribute in [('aria-hidden="true"', 'inert',
'style="visibility:hidden"')[i % 3]]
) + '<p>Visible sibling</p>')
page("markers-10000", '<ul>' + "".join(
f'<li>Bullet {i}</li>' for i in range(5000)
) + '</ul><ol start="9">' + "".join(
f'<li>Number {i}</li>' for i in range(5000)
) + '</ol>')
child = '<h1>Frame heading</h1>' + "".join(
f'<p>Frame row {i}</p>' for i in range(200)
) + '<iframe srcdoc="&lt;button&gt;Nested action&lt;/button&gt;"></iframe>'
page("frames", '<h1>Main heading</h1>' + "".join(
f'<iframe title="Frame {i}" srcdoc="{html.escape(child, quote=True)}"></iframe>'
for i in range(4)
))
page("mixed", '<ul><li>Bullet</li></ul><ol start="9"><li>Number</li></ol>'
'<input aria-label="Text" value="a value"><button disabled>Disabled</button>'
'<div aria-hidden="true"><button>ARIA hidden</button></div>'
'<div inert>Inert</div><div style="visibility:hidden">Invisible</div>'
'<div hidden>Boolean hidden</div><div hidden="until-found">Until found</div>'
'<div style="content-visibility:hidden">Locked</div><div id="host"></div>'
'<iframe srcdoc="&lt;h1&gt;Child heading&lt;/h1&gt;"></iframe>'
'<script>host.attachShadow({mode:"open"}).innerHTML="<button>Shadow</button>"</script>')
def run(binary, url, dump, frames, destination, timeout, env):
args = ["moli", "fetch", "--timeout", "120000", "--dump", dump]
if frames:
args.append("--with-frames")
args.append(url)
started = time.monotonic()
with destination.open("wb") as out, destination.with_suffix(".stderr").open("wb") as err:
process = subprocess.Popen(args, executable=binary, stdout=out, stderr=err, env=env)
timed_out = threading.Event()
def terminate():
timed_out.set()
process.kill()
watchdog = threading.Timer(timeout, terminate)
watchdog.daemon = True
watchdog.start()
try:
_, status, usage = os.wait4(process.pid, 0)
process.returncode = os.waitstatus_to_exitcode(status)
finally:
watchdog.cancel()
elapsed = time.monotonic() - started
if timed_out.is_set():
raise TimeoutError(f"{binary}: {url} {dump}")
if process.returncode:
raise RuntimeError(destination.with_suffix(".stderr").read_text())
with destination.open("rb") as output:
digest = hashlib.file_digest(output, "sha256").hexdigest()
return {"maxrss_bytes": usage.ru_maxrss * 1024, "wall_seconds": elapsed,
"output_bytes": destination.stat().st_size, "sha256": digest}
def distribution(path):
with path.open() as file:
nodes = json.load(file)
return {
"nodes": len(nodes),
"ignored_reasons": sum("ignoredReasons" in node for node in nodes),
"values": sum("value" in node for node in nodes),
"nonempty_properties": sum(bool(node.get("properties")) for node in nodes),
"rare_payloads": sum("ignoredReasons" in node or "value" in node
or bool(node.get("properties")) for node in nodes),
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--before", type=Path, required=True)
parser.add_argument("--after", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--repeats", type=int, default=3)
parser.add_argument("--timeout", type=float, default=180)
parser.add_argument("--cases", nargs="+")
parser.add_argument("--resume", action="store_true",
help="reuse complete samples after verifying binary hashes")
args = parser.parse_args()
if platform.system() != "Linux":
parser.error("peak RSS reporting currently requires Linux")
if args.repeats < 1:
parser.error("--repeats must be positive")
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
root = output / "fixtures"
# A large fixture's temporary strings must not inflate the RSS inherited
# by subsequently forked benchmark processes.
generator = multiprocessing.get_context("spawn").Process(target=fixtures, args=(root,))
generator.start()
generator.join()
if generator.exitcode:
raise RuntimeError("fixture generation failed")
binaries = {"before": str(args.before.resolve()), "after": str(args.after.resolve())}
manifest = {"binaries": {}, "platform": platform.platform(), "repeats": args.repeats}
for label, binary in binaries.items():
with open(binary, "rb") as file:
manifest["binaries"][label] = {
"path": binary, "sha256": hashlib.file_digest(file, "sha256").hexdigest()
}
if args.resume:
previous_manifest = json.loads((output / "manifest.json").read_text())
if previous_manifest != manifest:
parser.error("resume requires the same binaries, platform and repeat count")
(output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
env = {key: value for key, value in os.environ.items()
if key.lower() not in ("http_proxy", "https_proxy", "all_proxy")}
class Handler(http.server.SimpleHTTPRequestHandler):
def log_message(self, *unused):
pass
server = http.server.ThreadingHTTPServer(
("127.0.0.1", 0), functools.partial(Handler, directory=str(root))
)
threading.Thread(target=server.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{server.server_port}"
cases = ["rows-500", "rows-40000", "table-10000", "forms-300",
"ignored-10000", "markers-10000", "frames", "mixed", "rows-200000"]
if args.cases:
unknown = set(args.cases) - set(cases)
if unknown:
parser.error(f"unknown cases: {sorted(unknown)}")
cases = args.cases
records = []
completed = set()
if args.resume:
previous = [json.loads(line) for line in (output / "results.jsonl").read_text().splitlines()]
expected_trials = {(label, repeat) for label in binaries for repeat in range(args.repeats)}
for case, dump in dict.fromkeys((row["case"], row["dump"]) for row in previous):
group = [row for row in previous if (row["case"], row["dump"]) == (case, dump)]
trials = {(row["binary"], row["repeat"]) for row in group}
if trials == expected_trials and len(group) == len(expected_trials):
if len({row["sha256"] for row in group}) != 1:
raise AssertionError(f"existing output mismatch: {case} {dump}")
completed.add((case, dump))
records = [row for row in previous if (row["case"], row["dump"]) in completed]
try:
# Both binaries receive the same argv[0], URL, environment and cwd.
for label, binary in binaries.items():
run(binary, base + "/rows-500.html", "semantic_tree_text", False,
output / f"warmup-{label}.out", args.timeout, env)
with (output / "results.jsonl").open("w") as log:
for record in records:
log.write(json.dumps(record) + "\n")
log.flush()
for case in cases:
dumps = ("semantic_tree_text",) if case == "rows-200000" else (
"semantic_tree_text", "semantic_tree"
)
if case == "rows-40000":
dumps += ("markdown",)
for dump in dumps:
if (case, dump) in completed:
print(f"Reusing complete samples: {case} {dump}", flush=True)
continue
hashes = set()
for repeat in range(args.repeats):
order = ("before", "after") if repeat % 2 == 0 else ("after", "before")
for label in order:
destination = output / f"{case}-{dump}-{repeat}-{label}.out"
record = {"case": case, "dump": dump, "repeat": repeat, "binary": label,
**run(binaries[label], base + f"/{case}.html", dump,
case in ("frames", "mixed"), destination, args.timeout, env)}
hashes.add(record["sha256"])
records.append(record)
line = json.dumps(record)
log.write(line + "\n")
log.flush()
print(line, flush=True)
if len(hashes) != 1:
raise AssertionError(f"output mismatch: {case} {dump}")
finally:
server.shutdown()
server.server_close()
lines = ["# AX compact benchmark", "",
"Generated local fixtures; alternating runs; median of "
f"{args.repeats} runs; RSS in MiB. Before is the existing typed AX implementation.", "",
"| Case | Dump | RSS before | RSS after | RSS change | Time before | Time after | Time change |",
"|---|---|---:|---:|---:|---:|---:|---:|"]
for case, dump in dict.fromkeys((row["case"], row["dump"]) for row in records):
stats = {}
for label in binaries:
rows = [row for row in records if (row["case"], row["dump"], row["binary"]) == (case, dump, label)]
stats[label] = {key: statistics.median(row[key] for row in rows)
for key in ("maxrss_bytes", "wall_seconds")}
before, after = stats["before"], stats["after"]
rss_change = (after["maxrss_bytes"] / before["maxrss_bytes"] - 1) * 100
wall_change = (after["wall_seconds"] / before["wall_seconds"] - 1) * 100
lines.append(f"| {case} | {dump} | {before['maxrss_bytes']/2**20:.1f} | "
f"{after['maxrss_bytes']/2**20:.1f} | {rss_change:+.1f}% | "
f"{before['wall_seconds']:.3f}s | {after['wall_seconds']:.3f}s | {wall_change:+.1f}% |")
lines += ["", "Every output is byte-for-byte identical across binaries and repetitions.", "",
"Small-page timings include process startup and are sensitive to noise.", "",
"| Case | AX nodes | Ignored reasons | Values | Nonempty properties | Nodes with rare payload |",
"|---|---:|---:|---:|---:|---:|"]
distributions = {}
for case in cases:
path = output / f"{case}-semantic_tree-0-after.out"
if not path.exists():
continue
stats = distributions[case] = distribution(path)
lines.append(f"| {case} | {stats['nodes']} | {stats['ignored_reasons']} | "
f"{stats['values']} | {stats['nonempty_properties']} | "
f"{stats['rare_payloads'] / max(stats['nodes'], 1):.1%} |")
(output / "node-distribution.json").write_text(json.dumps(distributions, indent=2) + "\n")
lines += ["", "Raw samples and binary hashes: `results.jsonl`, `manifest.json`.", ""]
report = "\n".join(lines)
(output / "report.md").write_text(report)
print(report)
if __name__ == "__main__":
main()
+179
View File
@@ -0,0 +1,179 @@
#!/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()