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
+71 -44

No files matched your search

+41 -18
View File
@@ -814,6 +814,10 @@ class Element extends Node {
super(nid);
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 localName() {
// tagName is an op call and the tag never changes, so cache the lowercased
@@ -956,18 +960,21 @@ class Element extends Node {
get attributes() {
const el = this;
const names = _domParse("attribute_names", el._nid) || [];
const list = names.map((name) => ({
name,
localName: name,
value: el.getAttribute(name) ?? "",
namespaceURI: null,
prefix: null,
specified: true,
ownerElement: el,
nodeName: name,
nodeValue: el.getAttribute(name) ?? "",
nodeType: 2,
}));
const list = names.map((name) => {
const v = el.getAttribute(name) ?? "";
return {
name,
localName: name,
value: v,
namespaceURI: null,
prefix: null,
specified: true,
ownerElement: el,
nodeName: name,
nodeValue: v,
nodeType: 2,
};
});
list.length = names.length;
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; };
@@ -1573,11 +1580,13 @@ class Element extends Node {
this.dispatchEvent(new Event('reset', { bubbles: true }));
}
get dataset() {
if (this._dataset) return this._dataset;
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()); },
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 offsetHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
@@ -2500,18 +2509,32 @@ globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
globalThis.__fetchInterceptEnabled = false;
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) {
const clean = String(b64 || '').replace(/[\r\n\s]/g, '');
if (!clean) return new Uint8Array();
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const T = _B64_DECODE_TABLE;
const padding = clean.endsWith('==') ? 2 : (clean.endsWith('=') ? 1 : 0);
const bytes = new Uint8Array((clean.length * 3 >> 2) - padding);
let out = 0;
for (let i = 0; i < clean.length; i += 4) {
const a = alphabet.indexOf(clean[i]);
const b = alphabet.indexOf(clean[i + 1]);
const c = clean[i + 2] === '=' ? 0 : alphabet.indexOf(clean[i + 2]);
const d = clean[i + 3] === '=' ? 0 : alphabet.indexOf(clean[i + 3]);
// charCodeAt avoids the per-char substring alloc; T[code] replaces the
// O(64) indexOf scan. Out-of-range (NaN or code >= 128) folds to -1, and
// `=== 61` is `=== '='`, so results match the old code exactly.
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;
if (out < bytes.length) bytes[out++] = (n >> 16) & 0xff;
if (out < bytes.length) bytes[out++] = (n >> 8) & 0xff;