perf: DOM read fast paths (constant nodeType, borrowed op reads, base64 table) (#259)

* DOM read fast paths: constant nodeType, fewer ops and allocations

- Element.nodeType is now a constant getter instead of inheriting Node's
  dynamic op-backed getter. nodeType is invariant (1) for element wrappers
  (wrappers are only built for element nodes and node ids are never freed
  and reused), so this drops one JS/Rust op per read on a pervasive path.
  Micro-bench: ~600k nodeType reads go from ~380ms to ~5ms.
- attributes getter reads each attribute once instead of twice.
- dataset Proxy is cached per element instead of reallocated on every
  access, which also makes el.dataset === el.dataset (spec-correct).
- stylesheet <link> scan borrows the node (with_node) instead of deep
  cloning it and compares rel via eq_ignore_ascii_case, dropping a clone
  and a lowercased String alloc per link per navigation.

No regression: obstacle course 33/33, WPT dom/nodes Node- subset identical
to baseline (3551/4163 subtests), targeted DOM probe identical.

* DOM op reads: borrow the node instead of deep-cloning it

The scalar read arms in op_dom (node_type, node_name, tag_name,
get_attribute, attribute_names, parent/first/last/next/prev lookups,
has_child_nodes, pi_target, doctype_name, doctype_public_id) and the
node_root / node_ancestors / node_child_index walks called get_node,
which clones the whole Node (for elements a deep clone of the attribute
Vec and its Strings) just to read one field. Switch them to with_node,
which borrows under the RefCell. element_children and the
document_element / document_doctype child scans stay on get_node on
purpose (they iterate, so the borrow story is left untouched).

Output is identical. Traversal/attribute micro-bench ~25-30% faster with
fewer allocations per op. No regression: obstacle course 33/33, WPT
dom/nodes identical to baseline (7057/10620 across 314 files).

* base64 decode: reverse-lookup table instead of indexOf scan

_base64ToUint8Array decodes every fetch/XHR response body. It ran an
O(64) alphabet.indexOf plus a one-char substring alloc per character.
Build a charCode->6-bit reverse table once at module load and index it by
charCodeAt, folding out-of-range (NaN or code >= 128) to -1 so the
bitmath stays byte-identical to the old indexOf path on valid and
malformed input alike.

~20-25% faster decoding a 2MB body; output byte-identical (differential
test across valid, unpadded, '='-position, >127, non-alphabet and
truncated inputs matches the old code exactly). No regression: obstacle
course 33/33.
This commit is contained in:
SGavrl authored and GitHub committed 2026-06-07 11:39:18 +02:00
1 parent 24cf7cad56
commit 5135e3cb4d
3 files changed
+59 -32

No files matched your search

+7 -2
View File
@@ -929,13 +929,18 @@ impl Page {
.unwrap_or_default() .unwrap_or_default()
.iter() .iter()
.filter_map(|&nid| { .filter_map(|&nid| {
let node = dom.get_node(nid)?; // Borrow the node instead of deep-cloning it; rel keywords are
// ASCII so eq_ignore_ascii_case matches to_lowercase() exactly
// without allocating a lowercased String.
dom.with_node(nid, |node| {
let rel = node.get_attribute("rel")?; let rel = node.get_attribute("rel")?;
if rel.to_lowercase() != "stylesheet" { if !rel.eq_ignore_ascii_case("stylesheet") {
return None; return None;
} }
node.get_attribute("href").map(|s| s.to_string()) node.get_attribute("href").map(|s| s.to_string())
}) })
.flatten()
})
.collect(); .collect();
let mut css_fetch_urls: Vec<String> = Vec::new(); let mut css_fetch_urls: Vec<String> = Vec::new();
+33 -10
View File
@@ -814,6 +814,10 @@ class Element extends Node {
super(nid); super(nid);
this._style = _styleProxy(new CSSStyleDeclaration()); this._style = _styleProxy(new CSSStyleDeclaration());
} }
// Element wrappers always back a nodeType-1 node (_wrap/_wrapEl only build an
// Element for element nodes, and node ids are never freed-and-reused), so this
// is constant. Overrides Node's dynamic getter to drop one op per nodeType read.
get nodeType() { return 1; }
get tagName() { return _domParse("tag_name", this._nid) || ""; } get tagName() { return _domParse("tag_name", this._nid) || ""; }
get localName() { get localName() {
// tagName is an op call and the tag never changes, so cache the lowercased // tagName is an op call and the tag never changes, so cache the lowercased
@@ -956,18 +960,21 @@ class Element extends Node {
get attributes() { get attributes() {
const el = this; const el = this;
const names = _domParse("attribute_names", el._nid) || []; const names = _domParse("attribute_names", el._nid) || [];
const list = names.map((name) => ({ const list = names.map((name) => {
const v = el.getAttribute(name) ?? "";
return {
name, name,
localName: name, localName: name,
value: el.getAttribute(name) ?? "", value: v,
namespaceURI: null, namespaceURI: null,
prefix: null, prefix: null,
specified: true, specified: true,
ownerElement: el, ownerElement: el,
nodeName: name, nodeName: name,
nodeValue: el.getAttribute(name) ?? "", nodeValue: v,
nodeType: 2, nodeType: 2,
})); };
});
list.length = names.length; list.length = names.length;
list.getNamedItem = (n) => names.includes(n) ? list[names.indexOf(n)] : null; list.getNamedItem = (n) => names.includes(n) ? list[names.indexOf(n)] : null;
list.setNamedItem = (a) => { if (a && a.name) el.setAttribute(a.name, a.value); return a; }; list.setNamedItem = (a) => { if (a && a.name) el.setAttribute(a.name, a.value); return a; };
@@ -1573,11 +1580,13 @@ class Element extends Node {
this.dispatchEvent(new Event('reset', { bubbles: true })); this.dispatchEvent(new Event('reset', { bubbles: true }));
} }
get dataset() { get dataset() {
if (this._dataset) return this._dataset;
const el = this; const el = this;
return new Proxy({}, { this._dataset = new Proxy({}, {
get(_, k) { if(typeof k!=="string")return undefined; return el.getAttribute("data-"+k.replace(/([A-Z])/g,"-$1").toLowerCase()); }, get(_, k) { if(typeof k!=="string")return undefined; return el.getAttribute("data-"+k.replace(/([A-Z])/g,"-$1").toLowerCase()); },
set(_, k, v) { el.setAttribute("data-"+k.replace(/([A-Z])/g,"-$1").toLowerCase(), v); return true; }, set(_, k, v) { el.setAttribute("data-"+k.replace(/([A-Z])/g,"-$1").toLowerCase(), v); return true; },
}); });
return this._dataset;
} }
get offsetWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; } get offsetWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; }
get offsetHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; } get offsetHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
@@ -2500,18 +2509,32 @@ globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
globalThis.__fetchInterceptEnabled = false; globalThis.__fetchInterceptEnabled = false;
globalThis.__fetchInterceptCallback = null; // Set by CDP to handle paused requests globalThis.__fetchInterceptCallback = null; // Set by CDP to handle paused requests
// charCode -> 6-bit value reverse table for base64 decode. -1 for any byte not
// in the standard alphabet, which mirrors String.indexOf's miss exactly, so the
// bitmath below stays byte-identical to the old indexOf path including on
// malformed input. Built once at module load.
const _B64_DECODE_TABLE = (function () {
const t = new Int16Array(128).fill(-1);
const a = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
for (let i = 0; i < 64; i++) t[a.charCodeAt(i)] = i;
return t;
})();
function _base64ToUint8Array(b64) { function _base64ToUint8Array(b64) {
const clean = String(b64 || '').replace(/[\r\n\s]/g, ''); const clean = String(b64 || '').replace(/[\r\n\s]/g, '');
if (!clean) return new Uint8Array(); if (!clean) return new Uint8Array();
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const T = _B64_DECODE_TABLE;
const padding = clean.endsWith('==') ? 2 : (clean.endsWith('=') ? 1 : 0); const padding = clean.endsWith('==') ? 2 : (clean.endsWith('=') ? 1 : 0);
const bytes = new Uint8Array((clean.length * 3 >> 2) - padding); const bytes = new Uint8Array((clean.length * 3 >> 2) - padding);
let out = 0; let out = 0;
for (let i = 0; i < clean.length; i += 4) { for (let i = 0; i < clean.length; i += 4) {
const a = alphabet.indexOf(clean[i]); // charCodeAt avoids the per-char substring alloc; T[code] replaces the
const b = alphabet.indexOf(clean[i + 1]); // O(64) indexOf scan. Out-of-range (NaN or code >= 128) folds to -1, and
const c = clean[i + 2] === '=' ? 0 : alphabet.indexOf(clean[i + 2]); // `=== 61` is `=== '='`, so results match the old code exactly.
const d = clean[i + 3] === '=' ? 0 : alphabet.indexOf(clean[i + 3]); const ca = clean.charCodeAt(i); const a = ca < 128 ? T[ca] : -1;
const cb = clean.charCodeAt(i + 1); const b = cb < 128 ? T[cb] : -1;
const cc = clean.charCodeAt(i + 2); const c = cc === 61 ? 0 : (cc < 128 ? T[cc] : -1);
const cd = clean.charCodeAt(i + 3); const d = cd === 61 ? 0 : (cd < 128 ? T[cd] : -1);
const n = (a << 18) | (b << 12) | (c << 6) | d; const n = (a << 18) | (b << 12) | (c << 6) | d;
if (out < bytes.length) bytes[out++] = (n >> 16) & 0xff; if (out < bytes.length) bytes[out++] = (n >> 16) & 0xff;
if (out < bytes.length) bytes[out++] = (n >> 8) & 0xff; if (out < bytes.length) bytes[out++] = (n >> 8) & 0xff;
+19 -20
View File
@@ -160,14 +160,14 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
} }
"node_type" => { "node_type" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
dom.get_node(NodeId::new(nid)).map(|n| match &n.data { dom.with_node(NodeId::new(nid), |n| match &n.data {
NodeData::Document => "9", NodeData::Element { .. } => "1", NodeData::Text { .. } => "3", NodeData::Document => "9", NodeData::Element { .. } => "1", NodeData::Text { .. } => "3",
NodeData::Comment { .. } => "8", NodeData::Doctype { .. } => "10", NodeData::ProcessingInstruction { .. } => "7", NodeData::Comment { .. } => "8", NodeData::Doctype { .. } => "10", NodeData::ProcessingInstruction { .. } => "7",
}).unwrap_or("0").into() }).unwrap_or("0").into()
} }
"node_name" => { "node_name" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
let name: String = dom.get_node(NodeId::new(nid)).map(|n| match &n.data { let name: String = dom.with_node(NodeId::new(nid), |n| match &n.data {
NodeData::Document => "#document".to_string(), NodeData::Element { name, .. } => name.local.as_ref().to_ascii_uppercase(), NodeData::Document => "#document".to_string(), NodeData::Element { name, .. } => name.local.as_ref().to_ascii_uppercase(),
NodeData::Text { .. } => "#text".to_string(), NodeData::Comment { .. } => "#comment".to_string(), NodeData::Text { .. } => "#text".to_string(), NodeData::Comment { .. } => "#comment".to_string(),
NodeData::Doctype { name, .. } => name.clone(), NodeData::ProcessingInstruction { target, .. } => target.clone(), NodeData::Doctype { name, .. } => name.clone(), NodeData::ProcessingInstruction { target, .. } => target.clone(),
@@ -180,11 +180,11 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
} }
"parent_node" | "first_child" | "last_child" | "next_sibling" | "prev_sibling" => { "parent_node" | "first_child" | "last_child" | "next_sibling" | "prev_sibling" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
dom.get_node(NodeId::new(nid)).and_then(|n| match cmd.as_str() { dom.with_node(NodeId::new(nid), |n| match cmd.as_str() {
"parent_node" => n.parent, "first_child" => n.first_child, "parent_node" => n.parent, "first_child" => n.first_child,
"last_child" => n.last_child, "next_sibling" => n.next_sibling, "last_child" => n.last_child, "next_sibling" => n.next_sibling,
"prev_sibling" => n.prev_sibling, _ => None, "prev_sibling" => n.prev_sibling, _ => None,
}).map(|id| id.index().to_string()).unwrap_or("-1".into()) }).flatten().map(|id| id.index().to_string()).unwrap_or("-1".into())
} }
"child_nodes" => { "child_nodes" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
@@ -193,19 +193,18 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
} }
"tag_name" => { "tag_name" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
let name = dom.get_node(NodeId::new(nid)).and_then(|n| n.as_element().map(|name| name.local.as_ref().to_ascii_uppercase())).unwrap_or_default(); let name = dom.with_node(NodeId::new(nid), |n| n.as_element().map(|name| name.local.as_ref().to_ascii_uppercase())).flatten().unwrap_or_default();
serde_json::to_string(&name).unwrap_or("\"\"".into()) serde_json::to_string(&name).unwrap_or("\"\"".into())
} }
"get_attribute" => { "get_attribute" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
let val = dom.get_node(NodeId::new(nid)).and_then(|n| n.get_attribute(&arg2).map(|s| s.to_string())); let val = dom.with_node(NodeId::new(nid), |n| n.get_attribute(&arg2).map(|s| s.to_string())).flatten();
serde_json::to_string(&val).unwrap_or("null".into()) serde_json::to_string(&val).unwrap_or("null".into())
} }
"attribute_names" => { "attribute_names" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
let names: Vec<String> = dom let names: Vec<String> = dom
.get_node(NodeId::new(nid)) .with_node(NodeId::new(nid), |n| {
.map(|n| {
n.attrs() n.attrs()
.map(|a| a.iter().map(|x| x.name.local.as_ref().to_string()).collect()) .map(|a| a.iter().map(|x| x.name.local.as_ref().to_string()).collect())
.unwrap_or_default() .unwrap_or_default()
@@ -218,7 +217,7 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
let node_id = NodeId::new(nid); let node_id = NodeId::new(nid);
if let Some((name, value)) = arg2.split_once('\0') { if let Some((name, value)) = arg2.split_once('\0') {
if name == "id" { if name == "id" {
let old_id = dom.get_node(node_id).and_then(|n| n.get_attribute("id").map(|s| s.to_string())); let old_id = dom.with_node(node_id, |n| n.get_attribute("id").map(|s| s.to_string())).flatten();
dom.with_node_mut(node_id, |n| n.set_attribute(name, value.to_string())); dom.with_node_mut(node_id, |n| n.set_attribute(name, value.to_string()));
dom.update_id_index(node_id, old_id.as_deref(), Some(value)); dom.update_id_index(node_id, old_id.as_deref(), Some(value));
} else { } else {
@@ -321,26 +320,26 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
} }
"pi_target" => { "pi_target" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
let val = dom.get_node(NodeId::new(nid)).and_then(|n| match &n.data { let val = dom.with_node(NodeId::new(nid), |n| match &n.data {
NodeData::ProcessingInstruction { target, .. } => Some(target.clone()), NodeData::ProcessingInstruction { target, .. } => Some(target.clone()),
_ => None, _ => None,
}).unwrap_or_default(); }).flatten().unwrap_or_default();
serde_json::to_string(&val).unwrap_or("\"\"".into()) serde_json::to_string(&val).unwrap_or("\"\"".into())
} }
"doctype_name" => { "doctype_name" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
let val = dom.get_node(NodeId::new(nid)).and_then(|n| match &n.data { let val = dom.with_node(NodeId::new(nid), |n| match &n.data {
NodeData::Doctype { name, .. } => Some(name.clone()), NodeData::Doctype { name, .. } => Some(name.clone()),
_ => None, _ => None,
}).unwrap_or_default(); }).flatten().unwrap_or_default();
serde_json::to_string(&val).unwrap_or("\"\"".into()) serde_json::to_string(&val).unwrap_or("\"\"".into())
} }
"doctype_public_id" => { "doctype_public_id" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
let val = dom.get_node(NodeId::new(nid)).and_then(|n| match &n.data { let val = dom.with_node(NodeId::new(nid), |n| match &n.data {
NodeData::Doctype { public_id, .. } => Some(public_id.clone()), NodeData::Doctype { public_id, .. } => Some(public_id.clone()),
_ => None, _ => None,
}).unwrap_or_default(); }).flatten().unwrap_or_default();
serde_json::to_string(&val).unwrap_or("\"\"".into()) serde_json::to_string(&val).unwrap_or("\"\"".into())
} }
"element_children" => { "element_children" => {
@@ -352,7 +351,7 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
} }
"has_child_nodes" => { "has_child_nodes" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
dom.get_node(NodeId::new(nid)).map(|n| n.first_child.is_some()).unwrap_or(false).to_string() dom.with_node(NodeId::new(nid), |n| n.first_child.is_some()).unwrap_or(false).to_string()
} }
"contains" => { "contains" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
@@ -377,7 +376,7 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
// walk of parentNode ops from JS. // walk of parentNode ops from JS.
"node_root" => { "node_root" => {
let mut cur = NodeId::new(arg1.parse::<u32>().unwrap_or(0)); let mut cur = NodeId::new(arg1.parse::<u32>().unwrap_or(0));
while let Some(p) = dom.get_node(cur).and_then(|x| x.parent) { while let Some(p) = dom.with_node(cur, |x| x.parent).flatten() {
cur = p; cur = p;
} }
cur.index().to_string() cur.index().to_string()
@@ -389,10 +388,10 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
/// Index of `n` among its parent's children (0-based). /// Index of `n` among its parent's children (0-based).
fn node_child_index(dom: &DomTree, n: NodeId) -> usize { fn node_child_index(dom: &DomTree, n: NodeId) -> usize {
let mut i = 0usize; let mut i = 0usize;
let mut cur = dom.get_node(n).and_then(|x| x.prev_sibling); let mut cur = dom.with_node(n, |x| x.prev_sibling).flatten();
while let Some(p) = cur { while let Some(p) = cur {
i += 1; i += 1;
cur = dom.get_node(p).and_then(|x| x.prev_sibling); cur = dom.with_node(p, |x| x.prev_sibling).flatten();
} }
i i
} }
@@ -401,7 +400,7 @@ fn node_child_index(dom: &DomTree, n: NodeId) -> usize {
fn node_ancestors_root_first(dom: &DomTree, n: NodeId) -> Vec<NodeId> { fn node_ancestors_root_first(dom: &DomTree, n: NodeId) -> Vec<NodeId> {
let mut v = vec![n]; let mut v = vec![n];
let mut cur = n; let mut cur = n;
while let Some(p) = dom.get_node(cur).and_then(|x| x.parent) { while let Some(p) = dom.with_node(cur, |x| x.parent).flatten() {
v.push(p); v.push(p);
cur = p; cur = p;
} }