From c88cbb06bb1af6c3e1a5726178fdeec99dd7f0ea Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Fri, 25 Sep 2026 22:52:55 -0700 Subject: [PATCH 01/10] Allow `in` inside brackets of a for-statement initializer A for head's initializer is parsed with the grammar's [~In] so that `in` can start a for-in, but every bracketed production inside it -- parentheses, array and object literals, arguments, member brackets, template substitutions, function bodies -- takes [+In] again. The parser cleared `allow_in` for the whole initializer, so `for (var a = ('k' in o); ...)` and `for (var f = function () { return k in o; }; ...)` were syntax errors. The parser now records each token's bracket depth and withholds `in` only at the depth the initializer started at; an unbracketed arrow body still inherits the restriction. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-parser/src/cursor.rs | 42 ++++++++++++++++++++++ crates/qjs-parser/src/expression.rs | 5 +-- crates/qjs-parser/src/expression/binary.rs | 2 +- crates/qjs-parser/src/expression/unary.rs | 2 +- crates/qjs-parser/src/lib.rs | 9 +++++ crates/qjs-runtime/src/tests/statements.rs | 26 ++++++++++++++ 6 files changed, 82 insertions(+), 4 deletions(-) diff --git a/crates/qjs-parser/src/cursor.rs b/crates/qjs-parser/src/cursor.rs index 6d39f6833..1c4008d8a 100644 --- a/crates/qjs-parser/src/cursor.rs +++ b/crates/qjs-parser/src/cursor.rs @@ -5,6 +5,7 @@ use crate::{ParseError, Parser}; impl Parser { pub(crate) fn new(tokens: Vec, source: String, source_is_wtf16: bool) -> Self { let closers = matching_closers(&tokens); + let bracket_depths = bracket_depths(&tokens); Self { source, source_is_wtf16, @@ -14,6 +15,8 @@ impl Parser { goal: crate::Goal::Script, strict: false, allow_in: true, + no_in_depth: 0, + bracket_depths, in_method: false, in_derived_constructor: false, in_field_initializer: false, @@ -30,6 +33,19 @@ impl Parser { } } + /// Whether `in` is a relational operator at the cursor (`no_in_depth`). + pub(crate) fn in_allowed(&self) -> bool { + self.allow_in + || self + .bracket_depths + .get(self.cursor) + .is_some_and(|&depth| depth > self.no_in_depth) + } + + pub(crate) fn bracket_depth(&self) -> u32 { + self.bracket_depths.get(self.cursor).copied().unwrap_or(0) + } + pub(crate) fn at(&self, kind: &TokenKind) -> bool { // Every precedence level asks this about each of its operators for // every operand, and nearly every answer is no: comparing the variant @@ -97,6 +113,32 @@ impl Parser { pub(crate) const NO_CLOSER: u32 = u32::MAX; /// The index of the token closing each bracket token, found in one pass. +/// The number of brackets (and template substitutions) open at each token; +/// a closing token counts at the depth it returns to. +fn bracket_depths(tokens: &[Token]) -> Vec { + let mut depth = 0_u32; + tokens + .iter() + .map(|token| match token.kind { + TokenKind::LeftParen + | TokenKind::LeftBracket + | TokenKind::LeftBrace + | TokenKind::TemplateHead(_) => { + depth += 1; + depth - 1 + } + TokenKind::RightParen + | TokenKind::RightBracket + | TokenKind::RightBrace + | TokenKind::TemplateTail(_) => { + depth = depth.saturating_sub(1); + depth + } + _ => depth, + }) + .collect() +} + fn matching_closers(tokens: &[Token]) -> Vec { let mut closers = vec![NO_CLOSER; tokens.len()]; let mut open: Vec = Vec::new(); diff --git a/crates/qjs-parser/src/expression.rs b/crates/qjs-parser/src/expression.rs index 941ec46b0..74e012a82 100644 --- a/crates/qjs-parser/src/expression.rs +++ b/crates/qjs-parser/src/expression.rs @@ -40,10 +40,11 @@ impl Parser { } pub(crate) fn expression_no_in(&mut self) -> Result { - let previous = self.allow_in; + let previous = (self.allow_in, self.no_in_depth); self.allow_in = false; + self.no_in_depth = self.bracket_depth(); let result = self.expression(); - self.allow_in = previous; + (self.allow_in, self.no_in_depth) = previous; result } diff --git a/crates/qjs-parser/src/expression/binary.rs b/crates/qjs-parser/src/expression/binary.rs index 11e089ccc..42b288fbb 100644 --- a/crates/qjs-parser/src/expression/binary.rs +++ b/crates/qjs-parser/src/expression/binary.rs @@ -89,7 +89,7 @@ impl Parser { } fn comparison(&mut self) -> Result { - if !self.allow_in { + if !self.in_allowed() { return self.binary_left_assoc( Self::shift, &[ diff --git a/crates/qjs-parser/src/expression/unary.rs b/crates/qjs-parser/src/expression/unary.rs index cc34075a7..70d7e55c7 100644 --- a/crates/qjs-parser/src/expression/unary.rs +++ b/crates/qjs-parser/src/expression/unary.rs @@ -167,7 +167,7 @@ impl Parser { /// name in any other expression position is a syntax error. fn private_in_expression(&mut self, name: String, span: Span) -> Result { self.advance(); - if !self.allow_in || !self.at(&TokenKind::In) { + if !self.in_allowed() || !self.at(&TokenKind::In) { return Err(ParseError { message: format!( "private name `#{name}` is only valid on the left of `in` or as a member \ diff --git a/crates/qjs-parser/src/lib.rs b/crates/qjs-parser/src/lib.rs index 88523275a..a60b3bb8d 100644 --- a/crates/qjs-parser/src/lib.rs +++ b/crates/qjs-parser/src/lib.rs @@ -227,7 +227,16 @@ struct Parser { /// top-level `import`/`export` declarations. goal: Goal, strict: bool, + /// The grammar's `[In]` parameter: cleared for a `for` head's + /// initializer (`expression_no_in`), where `in` would start a for-in. allow_in: bool, + /// The bracket depth `allow_in` was cleared at. Every bracketed + /// production inside it -- parentheses, array and object literals, + /// arguments, member brackets, template substitutions, function bodies -- + /// takes `[+In]` again, so `in` is only withheld at this depth. + no_in_depth: u32, + /// Each token's bracket nesting depth (`bracket_depths`). + bracket_depths: Vec, /// Whether `super.prop`/`super[expr]` member access is currently allowed, /// i.e. the parser is inside a method or accessor body (or an arrow nested /// in one). diff --git a/crates/qjs-runtime/src/tests/statements.rs b/crates/qjs-runtime/src/tests/statements.rs index 214e5c996..200d625ba 100644 --- a/crates/qjs-runtime/src/tests/statements.rs +++ b/crates/qjs-runtime/src/tests/statements.rs @@ -1587,3 +1587,29 @@ fn for_in_over_a_primitive_enumerates_its_wrapper() { Ok(Value::String("0,1,extra||n|||0,1".to_owned().into())) ); } + +/// A `for` head's initializer withholds `in` (it would start a for-in) only +/// at its own nesting level: inside parentheses, brackets, braces, call +/// arguments, template substitutions and function bodies `in` is the +/// operator again, while an unbracketed arrow body inherits the restriction. +#[test] +fn for_initializers_allow_in_inside_brackets() { + assert_eq!( + eval( + "var o = { k: 1 }; var r = []; + function f(x) { return x; } + for (var a = ('k' in o), b = ['k' in o], c = f('k' in o), + d = function () { return 'k' in o; }, e = `${'k' in o}`, + g = { v: 'k' in o }, j = 0; j < 1; j++) { + r.push(a, b[0], c, d(), e, g.v); + } + for (var x = 0 in o) r.push(x); + r.join();" + ), + Ok(Value::String( + "true,true,true,true,true,true,k".to_owned().into() + )) + ); + assert!(eval("var o = {}; for (var f = x => 'k' in o; false;) {}").is_err()); + assert!(eval("var o = {}; for (var i = 'k' in o; false;) {}").is_err()); +} From 838c82048f0a347b786b2ca48f181be6ef3c6a78 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Fri, 25 Sep 2026 22:53:43 -0700 Subject: [PATCH 02/10] Parse binary operators by precedence climbing Each operand of a binary expression descended through ten one-function-per-level parsers (`||` down to `*`), each returning the expression by value and scanning its own operator list, before the one level that owned the operator saw it. One loop now takes an operand once and binds each operator by its precedence, building the same left-associative trees. Parsing imaging-darkroom (a 1.8 MB literal) takes 21% fewer cycles; crypto-md5's parse 12% fewer. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-parser/src/expression/binary.rs | 177 +++++++------------- crates/qjs-runtime/src/tests/expressions.rs | 38 +++++ 2 files changed, 99 insertions(+), 116 deletions(-) diff --git a/crates/qjs-parser/src/expression/binary.rs b/crates/qjs-parser/src/expression/binary.rs index 42b288fbb..a76a214ab 100644 --- a/crates/qjs-parser/src/expression/binary.rs +++ b/crates/qjs-parser/src/expression/binary.rs @@ -3,6 +3,13 @@ use qjs_lexer::TokenKind; use crate::{ParseError, Parser}; +/// Binary precedence levels, loosest first; the levels between these are +/// `&&`, `|`, `^`, `&` after `||`, and `+ -`, `* / %` after the shifts. +const LOGICAL_OR: u8 = 1; +const EQUALITY: u8 = 6; +const RELATIONAL: u8 = 7; +const SHIFT: u8 = 8; + impl Parser { pub(crate) fn nullish_coalescing(&mut self) -> Result { let mut left_start = self.cursor; @@ -45,111 +52,69 @@ impl Parser { } fn logical_or(&mut self) -> Result { - self.binary_left_assoc( - Self::logical_and, - &[(TokenKind::PipePipe, BinaryOp::LogicalOr)], - ) - } - - fn logical_and(&mut self) -> Result { - self.binary_left_assoc( - Self::bitwise_or, - &[(TokenKind::AmpersandAmpersand, BinaryOp::LogicalAnd)], - ) - } - - fn bitwise_or(&mut self) -> Result { - self.binary_left_assoc(Self::bitwise_xor, &[(TokenKind::Pipe, BinaryOp::BitwiseOr)]) - } - - fn bitwise_xor(&mut self) -> Result { - self.binary_left_assoc( - Self::bitwise_and, - &[(TokenKind::Caret, BinaryOp::BitwiseXor)], - ) - } - - fn bitwise_and(&mut self) -> Result { - self.binary_left_assoc( - Self::equality, - &[(TokenKind::Ampersand, BinaryOp::BitwiseAnd)], - ) - } - - fn equality(&mut self) -> Result { - self.binary_left_assoc( - Self::comparison, - &[ - (TokenKind::EqualEqual, BinaryOp::Eq), - (TokenKind::EqualEqualEqual, BinaryOp::StrictEq), - (TokenKind::BangEqual, BinaryOp::Ne), - (TokenKind::BangEqualEqual, BinaryOp::StrictNe), - ], - ) - } - - fn comparison(&mut self) -> Result { - if !self.in_allowed() { - return self.binary_left_assoc( - Self::shift, - &[ - (TokenKind::Less, BinaryOp::Lt), - (TokenKind::LessEqual, BinaryOp::Le), - (TokenKind::Greater, BinaryOp::Gt), - (TokenKind::GreaterEqual, BinaryOp::Ge), - (TokenKind::Instanceof, BinaryOp::Instanceof), - ], - ); - } - self.binary_left_assoc( - Self::shift, - &[ - (TokenKind::Less, BinaryOp::Lt), - (TokenKind::LessEqual, BinaryOp::Le), - (TokenKind::Greater, BinaryOp::Gt), - (TokenKind::GreaterEqual, BinaryOp::Ge), - (TokenKind::In, BinaryOp::In), - (TokenKind::Instanceof, BinaryOp::Instanceof), - ], - ) + self.binary_at_least(LOGICAL_OR) } /// Parses a `ShiftExpression`, used as the right operand of a `#x in obj` /// ergonomic brand check. pub(crate) fn shift_expression(&mut self) -> Result { - self.shift() + self.binary_at_least(SHIFT) } - fn shift(&mut self) -> Result { - self.binary_left_assoc( - Self::additive, - &[ - (TokenKind::LessLess, BinaryOp::Shl), - (TokenKind::GreaterGreater, BinaryOp::Shr), - (TokenKind::GreaterGreaterGreater, BinaryOp::UShr), - ], - ) - } - - fn additive(&mut self) -> Result { - self.binary_left_assoc( - Self::multiplicative, - &[ - (TokenKind::Plus, BinaryOp::Add), - (TokenKind::Minus, BinaryOp::Sub), - ], - ) + /// The left-associative binary levels from `||` down to `*`, by + /// precedence climbing: an operand is parsed once and each operator + /// binds by its level. The grammar's one-function-per-level descent + /// built the same trees but passed every operand up through all ten + /// levels, each returning the expression by value and testing its own + /// operator list -- most of parsing a large script. + fn binary_at_least(&mut self, minimum: u8) -> Result { + let mut expr = self.exponentiation()?; + while let Some((op, precedence)) = self.binary_operator() + && precedence >= minimum + { + self.cursor += 1; + let right = self.binary_at_least(precedence + 1)?; + let span = Span::new(expr.span().start, right.span().end); + expr = Expr::Binary { + left: Box::new(expr), + op, + right: Box::new(right), + span, + }; + } + Ok(expr) } - fn multiplicative(&mut self) -> Result { - self.binary_left_assoc( - Self::exponentiation, - &[ - (TokenKind::Star, BinaryOp::Mul), - (TokenKind::Slash, BinaryOp::Div), - (TokenKind::Percent, BinaryOp::Rem), - ], - ) + /// The binary operator at the cursor and its precedence; `in` only + /// where the grammar's `[In]` parameter allows it. + fn binary_operator(&self) -> Option<(BinaryOp, u8)> { + let operator = match self.peek()?.kind { + TokenKind::PipePipe => (BinaryOp::LogicalOr, LOGICAL_OR), + TokenKind::AmpersandAmpersand => (BinaryOp::LogicalAnd, LOGICAL_OR + 1), + TokenKind::Pipe => (BinaryOp::BitwiseOr, LOGICAL_OR + 2), + TokenKind::Caret => (BinaryOp::BitwiseXor, LOGICAL_OR + 3), + TokenKind::Ampersand => (BinaryOp::BitwiseAnd, LOGICAL_OR + 4), + TokenKind::EqualEqual => (BinaryOp::Eq, EQUALITY), + TokenKind::EqualEqualEqual => (BinaryOp::StrictEq, EQUALITY), + TokenKind::BangEqual => (BinaryOp::Ne, EQUALITY), + TokenKind::BangEqualEqual => (BinaryOp::StrictNe, EQUALITY), + TokenKind::Less => (BinaryOp::Lt, RELATIONAL), + TokenKind::LessEqual => (BinaryOp::Le, RELATIONAL), + TokenKind::Greater => (BinaryOp::Gt, RELATIONAL), + TokenKind::GreaterEqual => (BinaryOp::Ge, RELATIONAL), + TokenKind::Instanceof => (BinaryOp::Instanceof, RELATIONAL), + TokenKind::In if self.in_allowed() => (BinaryOp::In, RELATIONAL), + TokenKind::LessLess => (BinaryOp::Shl, SHIFT), + TokenKind::GreaterGreater => (BinaryOp::Shr, SHIFT), + TokenKind::GreaterGreaterGreater => (BinaryOp::UShr, SHIFT), + TokenKind::Plus => (BinaryOp::Add, SHIFT + 1), + TokenKind::Minus => (BinaryOp::Sub, SHIFT + 1), + TokenKind::Star => (BinaryOp::Mul, SHIFT + 2), + TokenKind::Slash => (BinaryOp::Div, SHIFT + 2), + TokenKind::Percent => (BinaryOp::Rem, SHIFT + 2), + _ => return None, + }; + Some(operator) } fn exponentiation(&mut self) -> Result { @@ -197,26 +162,6 @@ impl Parser { }) } - fn binary_left_assoc( - &mut self, - next: fn(&mut Self) -> Result, - operators: &[(TokenKind, BinaryOp)], - ) -> Result { - let mut expr = next(self)?; - while let Some((kind, op)) = operators.iter().find(|(kind, _)| self.at(kind)) { - self.expect(kind)?; - let right = next(self)?; - let span = Span::new(expr.span().start, right.span().end); - expr = Expr::Binary { - left: Box::new(expr), - op: *op, - right: Box::new(right), - span, - }; - } - Ok(expr) - } - fn expression_range_is_parenthesized(&self, start: usize, end: usize) -> bool { let Some(first) = self.tokens.get(start) else { return false; diff --git a/crates/qjs-runtime/src/tests/expressions.rs b/crates/qjs-runtime/src/tests/expressions.rs index 764659661..734e2f214 100644 --- a/crates/qjs-runtime/src/tests/expressions.rs +++ b/crates/qjs-runtime/src/tests/expressions.rs @@ -1534,3 +1534,41 @@ fn instanceof_respects_a_custom_has_instance() { Ok(Value::String("true:true".to_owned().into())) ); } + +/// Every binary precedence level against its neighbours and left +/// associativity within a level, parsed by precedence climbing. Expected +/// values from V8. +#[test] +fn binary_operators_bind_by_precedence_and_associate_left() { + let source = r#"var a = 5, b = 3, c = 12, d = 1, e = 2; +var o = { k: 1 }; +var r = []; +r.push(a + b * c - d / e % 3); +r.push(a - b - c - d); +r.push(c / b / e); +r.push(a << d + e >> 1 >>> 0); +r.push(a < b == c > d); +r.push(a | b ^ c & d); +r.push(a & b | c ^ d); +r.push(a || b && 0 || c); +r.push(0 && a || b); +r.push(a == 5 === true != false); +r.push('k' in o && !('z' in o)); +r.push(o instanceof Object == true); +r.push(2 ** 3 ** 2); +r.push(-(2 ** 2) + 3 * 2 ** 2); +r.push(a + b + '' + a + b); +r.push(null ?? (0 || 7)); +for (var i = ('k' in o) ? 1 : 0, j = 0; j < 1; j++) r.push(i); +for (var q in { x: 1 }) r.push(q); +r.push(a > b ? c < d ? 1 : 2 : 3); +r.join(',');"#; + assert_eq!( + eval(source), + Ok(Value::String( + "40.5,-11,2,20,false,7,13,5,3,true,true,true,512,8,853,7,1,x,2" + .to_owned() + .into() + )) + ); +} From fd5ec9ed4e76e6ac36e6e06ed95d6dbd7c2063a6 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Fri, 25 Sep 2026 23:03:51 -0700 Subject: [PATCH 03/10] Hash the compiler's scope tables with the name hasher Every identifier the compiler resolves looks its name up in the lexical scope maps and the local-slot map, which used the standard SipHash. They now use the engine's `NameMap`/`NameSet` (FxHash), like the runtime's name tables. Parsing and compiling cdjs takes about 10% fewer cycles. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-runtime/src/bytecode/compiler.rs | 14 +++++++------- .../qjs-runtime/src/bytecode/compiler_lexical.rs | 11 ++++++----- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/qjs-runtime/src/bytecode/compiler.rs b/crates/qjs-runtime/src/bytecode/compiler.rs index cb5eb7da2..427e0766a 100644 --- a/crates/qjs-runtime/src/bytecode/compiler.rs +++ b/crates/qjs-runtime/src/bytecode/compiler.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use crate::value::name_hash::{NameMap, NameSet}; use qjs_ast::{ForInLeft, ForInit, FunctionParams, Script, Stmt, VarKind}; @@ -24,8 +24,8 @@ pub(super) struct Compiler { pub(super) highest_jump_target: usize, pub(super) constants: Vec, pub(super) locals: Vec, - pub(super) local_slots: HashMap, - pub(super) lexical_scopes: Vec>, + pub(super) local_slots: NameMap, + pub(super) lexical_scopes: Vec>, pub(super) code: Vec, loop_stack: Vec, pending_labels: Vec, @@ -40,7 +40,7 @@ pub(super) struct Compiler { /// Names of `var`/function declarations hoisted at global script scope. /// They live in the realm (and on `globalThis`), not frame slots, so /// closures, direct eval, and promise jobs all observe one binding. - pub(super) global_hoisted: std::collections::HashSet, + pub(super) global_hoisted: NameSet, annex_b_blocked_function_names: Vec>, /// Count of `with` scopes currently open around the code being compiled. /// Break/continue/return that leave one or more of them emit `Op::ExitWith` @@ -124,8 +124,8 @@ impl Default for Compiler { highest_jump_target: 0, constants: Vec::new(), locals: Vec::new(), - local_slots: HashMap::new(), - lexical_scopes: vec![HashMap::new()], + local_slots: NameMap::default(), + lexical_scopes: vec![NameMap::default()], code: Vec::new(), loop_stack: Vec::new(), pending_labels: Vec::new(), @@ -134,7 +134,7 @@ impl Default for Compiler { strict: false, global_scope: true, direct_eval_source: false, - global_hoisted: std::collections::HashSet::new(), + global_hoisted: NameSet::default(), annex_b_blocked_function_names: Vec::new(), with_depth: 0, with_base_depth: 0, diff --git a/crates/qjs-runtime/src/bytecode/compiler_lexical.rs b/crates/qjs-runtime/src/bytecode/compiler_lexical.rs index 41fa0eba6..ad89ba91c 100644 --- a/crates/qjs-runtime/src/bytecode/compiler_lexical.rs +++ b/crates/qjs-runtime/src/bytecode/compiler_lexical.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use qjs_ast::{BindingPattern, ForInLeft, ForInit, FunctionParams, Stmt, SwitchCase, VarKind}; use crate::RuntimeError; @@ -139,7 +137,8 @@ impl Compiler { &mut self, compile: impl FnOnce(&mut Self) -> Result, ) -> Result { - self.lexical_scopes.push(HashMap::new()); + self.lexical_scopes + .push(crate::value::name_hash::NameMap::default()); let result = compile(self); self.lexical_scopes .pop() @@ -238,13 +237,15 @@ impl Compiler { } } - fn current_lexical_scope(&self) -> &HashMap { + fn current_lexical_scope(&self) -> &crate::value::name_hash::NameMap { self.lexical_scopes .last() .expect("compiler should always have a lexical scope") } - fn current_lexical_scope_mut(&mut self) -> &mut HashMap { + fn current_lexical_scope_mut( + &mut self, + ) -> &mut crate::value::name_hash::NameMap { self.lexical_scopes .last_mut() .expect("compiler should always have a lexical scope") From d62448b015e7967b4cb4ba60fe2918677dc1f605 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Fri, 25 Sep 2026 23:04:33 -0700 Subject: [PATCH 04/10] Copy canonical text straight through string_from_utf8_scalars Converting host UTF-8 to the engine's representation pushed every scalar through the surrogate-escape check, and function source text for `Function.prototype.toString` is converted this way for every function a script declares, nested ones included. Only a scalar at or above U+10000 -- a UTF-8 sequence led by 0xF0 or above -- can fall in the sentinel range, so text without one is copied as it is. cdjs's parse and compile run 9% fewer instructions. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-runtime/src/string.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/qjs-runtime/src/string.rs b/crates/qjs-runtime/src/string.rs index c5edf9049..88951f28d 100644 --- a/crates/qjs-runtime/src/string.rs +++ b/crates/qjs-runtime/src/string.rs @@ -224,6 +224,13 @@ pub(crate) fn string_from_code_units(code_units: &[u16]) -> String { } pub(crate) fn string_from_utf8_scalars(value: &str) -> String { + // Only a scalar at or above U+10000 -- four UTF-8 bytes, led by 0xF0 or + // above -- can fall in the surrogate-escape sentinel range; text without + // one is already canonical. Function source text is converted this way + // for every function a script declares, nested ones included. + if value.bytes().all(|byte| byte < 0xF0) { + return value.to_owned(); + } let mut result = String::with_capacity(value.len()); for character in value.chars() { push_code_point(&mut result, character as u32); From aef759c1cfaa368d0f102e2c8e48814eba1bdd0a Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Fri, 25 Sep 2026 23:04:40 -0700 Subject: [PATCH 05/10] Collect bytecode binding names as borrowed strings Finishing each compiled body gathers the names it and its nested closures read and write, cloning every name (and every nested closure's whole cached list) into a BTreeSet, once per store instruction. The sets now borrow the names and the lists are cloned once, at the end. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-runtime/src/bytecode/ir.rs | 108 +++++++++++--------- crates/qjs-runtime/src/bytecode/ir_names.rs | 89 ++++++++-------- 2 files changed, 107 insertions(+), 90 deletions(-) diff --git a/crates/qjs-runtime/src/bytecode/ir.rs b/crates/qjs-runtime/src/bytecode/ir.rs index e3901ceab..67d6af11c 100644 --- a/crates/qjs-runtime/src/bytecode/ir.rs +++ b/crates/qjs-runtime/src/bytecode/ir.rs @@ -1010,30 +1010,19 @@ impl Bytecode { &self.global_names } - pub(crate) fn referenced_global_names(&self) -> Vec { - let mut names = BTreeSet::new(); - for name in &self.global_names { - names.insert(name.clone()); - } - for local in &self.locals { - if !local.sloppy_global_fallback && !local.from_env { - names.remove(&local.name); - } - } - names.into_iter().collect() - } - - pub(crate) fn closure_referenced_global_names(&self) -> Vec { - self.cached_closure_referenced_global_names.clone() + pub(super) fn closure_referenced_global_names_ref(&self) -> &[String] { + &self.cached_closure_referenced_global_names } fn compute_closure_referenced_global_names(&self) -> Vec { - let mut names = BTreeSet::new(); - for name in self.referenced_global_names() { - names.insert(name); + let mut names: BTreeSet<&str> = self.global_names.iter().map(String::as_str).collect(); + for local in &self.locals { + if !local.sloppy_global_fallback && !local.from_env { + names.remove(local.name.as_str()); + } } super::ir_names::collect_nested_global_names_from_ops(&self.code, &mut names); - names.into_iter().collect() + names.into_iter().map(str::to_owned).collect() } pub(crate) fn written_binding_names(&self) -> Vec { @@ -1055,23 +1044,24 @@ impl Bytecode { collect_written_binding_names_from_ops(self, &self.code, &mut names); for local in &self.locals { if !local.sloppy_global_fallback && !local.from_env { - names.remove(&local.name); + names.remove(local.name.as_str()); } } - names.into_iter().collect() + names.into_iter().map(str::to_owned).collect() } - pub(crate) fn closure_written_binding_names(&self) -> Vec { - self.cached_closure_written_binding_names.clone() + pub(super) fn closure_written_binding_names_ref(&self) -> &[String] { + &self.cached_closure_written_binding_names } fn compute_closure_written_binding_names(&self) -> Vec { - let mut names = BTreeSet::new(); - for name in self.written_binding_names() { - names.insert(name); - } + let mut names: BTreeSet<&str> = self + .cached_written_binding_names + .iter() + .map(String::as_str) + .collect(); super::ir_names::collect_nested_written_binding_names_from_ops(&self.code, &mut names); - names.into_iter().collect() + names.into_iter().map(str::to_owned).collect() } pub(crate) fn global_lexical_names(&self) -> &[String] { @@ -1503,7 +1493,8 @@ impl Bytecode { fn compute_writes_binding_set(&self) -> crate::value::name_hash::NameSet { let mut direct = BTreeSet::new(); collect_written_binding_names_from_ops(self, &self.code, &mut direct); - let mut set: crate::value::name_hash::NameSet = direct.into_iter().collect(); + let mut set: crate::value::name_hash::NameSet = + direct.into_iter().map(str::to_owned).collect(); for op in &self.code { match op { Op::NewFunction { bytecode, .. } => { @@ -1610,10 +1601,10 @@ fn op_touches_local_slot(op: &Op, slot: usize) -> bool { } } -fn collect_written_binding_names_from_ops( - bytecode: &Bytecode, - code: &[Op], - names: &mut BTreeSet, +fn collect_written_binding_names_from_ops<'a>( + bytecode: &'a Bytecode, + code: &'a [Op], + names: &mut BTreeSet<&'a str>, ) { for op in code { match op { @@ -1627,7 +1618,7 @@ fn collect_written_binding_names_from_ops( | Op::StoreResolvedIdentWith { name, slot: None, .. } => { - names.insert(name.clone()); + names.insert(name.as_str()); } Op::StoreLocal(slot) | Op::AssignLocal(slot) @@ -1643,7 +1634,7 @@ fn collect_written_binding_names_from_ops( .get(*slot) .filter(|local| !local.compiler_temporary) { - names.insert(local.name.clone()); + names.insert(local.name.as_str()); } } _ => {} @@ -1654,10 +1645,10 @@ fn collect_written_binding_names_from_ops( fn collect_global_names(code: &[Op]) -> Vec { let mut names = BTreeSet::new(); collect_global_names_from_ops(code, &mut names); - names.into_iter().collect() + names.into_iter().map(str::to_owned).collect() } -fn collect_global_names_from_ops(code: &[Op], names: &mut BTreeSet) { +fn collect_global_names_from_ops<'a>(code: &'a [Op], names: &mut BTreeSet<&'a str>) { for op in code { match op { Op::LoadGlobal(name) @@ -1665,10 +1656,10 @@ fn collect_global_names_from_ops(code: &[Op], names: &mut BTreeSet) { | Op::StoreGlobalSloppy { name, .. } | Op::AppendStringLiteralGlobal { name, .. } | Op::TypeofGlobal(name) => { - names.insert(name.clone()); + names.insert(name.as_str()); } Op::StoreLocalOrGlobalSloppy { name, .. } => { - names.insert(name.clone()); + names.insert(name.as_str()); } Op::LoadIdentWith { name, slot: None, .. @@ -1686,10 +1677,10 @@ fn collect_global_names_from_ops(code: &[Op], names: &mut BTreeSet) { name, slot: None, .. } | Op::TypeofIdentWith { name, slot: None } => { - names.insert(name.clone()); + names.insert(name.as_str()); } Op::NewFunction { bytecode, .. } => { - names.extend(bytecode.global_names().iter().cloned()); + names.extend(bytecode.global_names().iter().map(String::as_str)); } Op::NewClass { definition } => { names.extend( @@ -1698,39 +1689,50 @@ fn collect_global_names_from_ops(code: &[Op], names: &mut BTreeSet) { .bytecode .global_names() .iter() - .cloned(), + .map(String::as_str), ); for key in &definition.computed_keys { if let ClassComputedKeyDef::Deferred { bytecode, .. } = key { - names.extend(bytecode.global_names().iter().cloned()); + names.extend(bytecode.global_names().iter().map(String::as_str)); } } for element in &definition.elements { match element { ClassElementDef::Method(method) => { - names.extend(method.bytecode.global_names().iter().cloned()); + names.extend(method.bytecode.global_names().iter().map(String::as_str)); } ClassElementDef::Field(field) => { if let Some(initializer) = &field.initializer { - names.extend(initializer.bytecode.global_names().iter().cloned()); + names.extend( + initializer + .bytecode + .global_names() + .iter() + .map(String::as_str), + ); } } ClassElementDef::Private(element) => match element { ClassPrivateElementDef::Field { initializer, .. } => { if let Some(initializer) = initializer { names.extend( - initializer.bytecode.global_names().iter().cloned(), + initializer + .bytecode + .global_names() + .iter() + .map(String::as_str), ); } } ClassPrivateElementDef::Method { def, .. } | ClassPrivateElementDef::Getter { def, .. } | ClassPrivateElementDef::Setter { def, .. } => { - names.extend(def.bytecode.global_names().iter().cloned()); + names + .extend(def.bytecode.global_names().iter().map(String::as_str)); } }, ClassElementDef::StaticBlock(block) => { - names.extend(block.bytecode.global_names().iter().cloned()); + names.extend(block.bytecode.global_names().iter().map(String::as_str)); } } } @@ -1738,13 +1740,19 @@ fn collect_global_names_from_ops(code: &[Op], names: &mut BTreeSet) { match element { ClassPrivateElementDef::Field { initializer, .. } => { if let Some(initializer) = initializer { - names.extend(initializer.bytecode.global_names().iter().cloned()); + names.extend( + initializer + .bytecode + .global_names() + .iter() + .map(String::as_str), + ); } } ClassPrivateElementDef::Method { def, .. } | ClassPrivateElementDef::Getter { def, .. } | ClassPrivateElementDef::Setter { def, .. } => { - names.extend(def.bytecode.global_names().iter().cloned()); + names.extend(def.bytecode.global_names().iter().map(String::as_str)); } } } diff --git a/crates/qjs-runtime/src/bytecode/ir_names.rs b/crates/qjs-runtime/src/bytecode/ir_names.rs index 93a044ab3..18168711f 100644 --- a/crates/qjs-runtime/src/bytecode/ir_names.rs +++ b/crates/qjs-runtime/src/bytecode/ir_names.rs @@ -2,21 +2,24 @@ use std::collections::BTreeSet; use super::ir::{ClassElementDef, ClassPrivateElementDef, Op}; -pub(super) fn collect_nested_global_names_from_ops(code: &[Op], names: &mut BTreeSet) { +pub(super) fn collect_nested_global_names_from_ops<'a>( + code: &'a [Op], + names: &mut BTreeSet<&'a str>, +) { for op in code { match op { Op::NewFunction { bytecode, .. } => { - for name in bytecode.closure_referenced_global_names() { - names.insert(name); + for name in bytecode.closure_referenced_global_names_ref() { + names.insert(name.as_str()); } } Op::NewClass { definition } => { for name in definition .constructor .bytecode - .closure_referenced_global_names() + .closure_referenced_global_names_ref() { - names.insert(name); + names.insert(name.as_str()); } for element in &definition.elements { collect_class_element_global_names(element, names); @@ -27,24 +30,24 @@ pub(super) fn collect_nested_global_names_from_ops(code: &[Op], names: &mut BTre } } -pub(super) fn collect_nested_written_binding_names_from_ops( - code: &[Op], - names: &mut BTreeSet, +pub(super) fn collect_nested_written_binding_names_from_ops<'a>( + code: &'a [Op], + names: &mut BTreeSet<&'a str>, ) { for op in code { match op { Op::NewFunction { bytecode, .. } => { - for name in bytecode.closure_written_binding_names() { - names.insert(name); + for name in bytecode.closure_written_binding_names_ref() { + names.insert(name.as_str()); } } Op::NewClass { definition } => { for name in definition .constructor .bytecode - .closure_written_binding_names() + .closure_written_binding_names_ref() { - names.insert(name); + names.insert(name.as_str()); } for element in &definition.elements { collect_class_element_written_names(element, names); @@ -55,91 +58,97 @@ pub(super) fn collect_nested_written_binding_names_from_ops( } } -fn collect_class_element_global_names(element: &ClassElementDef, names: &mut BTreeSet) { +fn collect_class_element_global_names<'a>( + element: &'a ClassElementDef, + names: &mut BTreeSet<&'a str>, +) { match element { ClassElementDef::Method(def) => { - for name in def.bytecode.closure_referenced_global_names() { - names.insert(name); + for name in def.bytecode.closure_referenced_global_names_ref() { + names.insert(name.as_str()); } } ClassElementDef::Field(def) => { if let Some(initializer) = &def.initializer { - for name in initializer.bytecode.closure_referenced_global_names() { - names.insert(name); + for name in initializer.bytecode.closure_referenced_global_names_ref() { + names.insert(name.as_str()); } } } ClassElementDef::Private(def) => collect_private_class_element_global_names(def, names), ClassElementDef::StaticBlock(def) => { - for name in def.bytecode.closure_referenced_global_names() { - names.insert(name); + for name in def.bytecode.closure_referenced_global_names_ref() { + names.insert(name.as_str()); } } } } -fn collect_private_class_element_global_names( - element: &ClassPrivateElementDef, - names: &mut BTreeSet, +fn collect_private_class_element_global_names<'a>( + element: &'a ClassPrivateElementDef, + names: &mut BTreeSet<&'a str>, ) { match element { ClassPrivateElementDef::Field { initializer, .. } => { if let Some(initializer) = initializer { - for name in initializer.bytecode.closure_referenced_global_names() { - names.insert(name); + for name in initializer.bytecode.closure_referenced_global_names_ref() { + names.insert(name.as_str()); } } } ClassPrivateElementDef::Method { def, .. } | ClassPrivateElementDef::Getter { def, .. } | ClassPrivateElementDef::Setter { def, .. } => { - for name in def.bytecode.closure_referenced_global_names() { - names.insert(name); + for name in def.bytecode.closure_referenced_global_names_ref() { + names.insert(name.as_str()); } } } } -fn collect_class_element_written_names(element: &ClassElementDef, names: &mut BTreeSet) { +fn collect_class_element_written_names<'a>( + element: &'a ClassElementDef, + names: &mut BTreeSet<&'a str>, +) { match element { ClassElementDef::Method(def) => { - for name in def.bytecode.closure_written_binding_names() { - names.insert(name); + for name in def.bytecode.closure_written_binding_names_ref() { + names.insert(name.as_str()); } } ClassElementDef::Field(def) => { if let Some(initializer) = &def.initializer { - for name in initializer.bytecode.closure_written_binding_names() { - names.insert(name); + for name in initializer.bytecode.closure_written_binding_names_ref() { + names.insert(name.as_str()); } } } ClassElementDef::Private(def) => collect_private_class_element_written_names(def, names), ClassElementDef::StaticBlock(def) => { - for name in def.bytecode.closure_written_binding_names() { - names.insert(name); + for name in def.bytecode.closure_written_binding_names_ref() { + names.insert(name.as_str()); } } } } -fn collect_private_class_element_written_names( - element: &ClassPrivateElementDef, - names: &mut BTreeSet, +fn collect_private_class_element_written_names<'a>( + element: &'a ClassPrivateElementDef, + names: &mut BTreeSet<&'a str>, ) { match element { ClassPrivateElementDef::Field { initializer, .. } => { if let Some(initializer) = initializer { - for name in initializer.bytecode.closure_written_binding_names() { - names.insert(name); + for name in initializer.bytecode.closure_written_binding_names_ref() { + names.insert(name.as_str()); } } } ClassPrivateElementDef::Method { def, .. } | ClassPrivateElementDef::Getter { def, .. } | ClassPrivateElementDef::Setter { def, .. } => { - for name in def.bytecode.closure_written_binding_names() { - names.insert(name); + for name in def.bytecode.closure_written_binding_names_ref() { + names.insert(name.as_str()); } } } From d162f3e1fd071e8251d3907f649d5689bdec68e4 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Fri, 25 Sep 2026 23:11:44 -0700 Subject: [PATCH 06/10] Keep Math's two-argument evaluation out of the typed executor Whether LLVM inlined vm_numeric_leaf::math_binary into the typed-loop executor flipped with unrelated edits (here, front-end changes in other modules): inlined, it grew the executor by 176 bytes and re-rolled its code, and access-nsieve ran 4% and heterogeneous_property_read 5% more cycles at identical instructions. It is now out of line, as it was, and the order file is re-pinned. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-cli/hot-functions.order | 27 ++++++++++--------- .../src/bytecode/vm_numeric_leaf.rs | 6 +++++ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/crates/qjs-cli/hot-functions.order b/crates/qjs-cli/hot-functions.order index d62cc83c4..3ca0ee4e7 100644 --- a/crates/qjs-cli/hot-functions.order +++ b/crates/qjs-cli/hot-functions.order @@ -11,7 +11,7 @@ # budget 0x700 __RNvMNtNtNtCs9nYd1Hk1rek_11qjs_runtime5value6object10slot_readsNtB4_9ObjectRef22own_data_property_read # budget 0xa00 __RNvMs6_NtNtCs9nYd1Hk1rek_11qjs_runtime5value6objectNtB5_9ObjectRef32write_existing_own_data_property # budget 0x300 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16boxed_truthiness -# pinned: typed-loop executor at 0x0, interpreter's at 0xc40 mod 4 KiB (python3 -m tools.benchmark.layout_pin), head 38 +# pinned: typed-loop executor at 0x0, interpreter's at 0xc40 mod 4 KiB (python3 -m tools.benchmark.layout_pin), head 39 __RNvMsz_NtNtNtCs1OjIl8oxbrv_5alloc11collections5btree3mapINtB5_8IntoIteryNtNtNtCscUtGwbhD4WH_5gimli4read6abbrev12AbbreviationE10dying_nextCsg55jX0GwzBC_3std __RNvXs7_NtNtCsg55jX0GwzBC_3std2io5errorNtB5_5ErrorNtNtCsl8K0bEFm1U0_4core3fmt7Display3fmt __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute3runNtNtB6_2vm2VmEB8_ @@ -21,33 +21,34 @@ __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute3runNtNtNtNt __RNvMs_NtNtNtCsg55jX0GwzBC_3std2io8buffered9bufwriterINtB4_9BufWriterNtNtB8_5stdio9StdoutRawE9flush_bufBa_ __RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterNtNtB7_5stdio10StderrLockENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute18try_run_typed_loopNtNtNtNtB6_10compact_fn4wide10loop_frame13WideLoopFrameEB8_ -__RNvXs2_NtNtCsg55jX0GwzBC_3std4hash6randomNtB5_13DefaultHasherNtNtCsl8K0bEFm1U0_4core4hash6Hasher5write +__RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterINtNtCs1OjIl8oxbrv_5alloc3vec3VechEENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ +__RNvYNtNtNtNtCsg55jX0GwzBC_3std3sys5stdio4unix6StderrNtNtBa_2io5Write9write_allBa_ __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16get_named_object -__RNvMs8_NtCsg55jX0GwzBC_3std4pathNtB5_10Components7as_path +__RNvNtNtCsg55jX0GwzBC_3std3sys2fs8read_dir +__RNvXs7_NtNtCsg55jX0GwzBC_3std2io5stdioNtB5_9StdinLockNtB7_7BufRead9read_line __RNvMs4_NtNtCs9nYd1Hk1rek_11qjs_runtime5value5arrayNtB5_8ArrayRef24direct_dense_index_value __RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcINtNtNtCscUtGwbhD4WH_5gimli4read5dwarf5DwarfINtNtBL_12endian_slice11EndianSliceNtNtBN_9endianity12LittleEndianEEE9drop_slowCsg55jX0GwzBC_3std -__RNvXs7_NtNtCsg55jX0GwzBC_3std2io5stdioNtB5_9StdinLockNtB7_7BufRead9read_line +__RNvNtNtCsg55jX0GwzBC_3std2io5stdio7__eprint __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute9call_leaf -__RNvXs_NvNtCsg55jX0GwzBC_3std9panicking13panic_handlerNtB4_19FormatStringPayloadNtNtCsl8K0bEFm1U0_4core5panic12PanicPayload3get -__RNvMs_NtNtNtCsg55jX0GwzBC_3std2io8buffered9bufwriterINtB4_9BufWriterNtNtB8_5stdio9StdoutRawE14write_all_coldBa_ +__RNvYINtNtNtCscUtGwbhD4WH_5gimli4read12endian_slice11EndianSliceNtNtB9_9endianity12LittleEndianENtNtB7_6reader6Reader17read_sized_offsetCsg55jX0GwzBC_3std __RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode15vm_numeric_leaf21try_eval_numeric_leaf __RNvXNvNtCsg55jX0GwzBC_3std2io17default_write_fmtINtB2_7AdapterNtNtNtNtB6_3sys5stdio4unix6StderrENtNtCsl8K0bEFm1U0_4core3fmt5Write9write_strB6_ __RNvMs3_NtNtCs9nYd1Hk1rek_11qjs_runtime5value5arrayNtB5_9ArrayData21has_property_at_index -__RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterINtNtCs1OjIl8oxbrv_5alloc3vec3VechEENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ -__RNvNtNtCsg55jX0GwzBC_3std2io5stdio7__eprint -__RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode15vm_numeric_leaf20direct_number_binary __RNvXso_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StderrNtB7_5Write9write_fmt -__RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute22ordinary_data_property __RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcNtNtNtNtCsg55jX0GwzBC_3std3sys2fs4unix12InnerReadDirE9drop_slowBO_ -__RNvXs1g_NtCsl8K0bEFm1U0_4core3fmtRINtNtCs1OjIl8oxbrv_5alloc3vec3VechENtB6_5Debug3fmtCsg55jX0GwzBC_3std +__RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode15vm_numeric_leaf20direct_number_binary +__RNvXse_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StdoutNtB7_5Write9write_fmt +__RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute22ordinary_data_property +__RNvXs_NvNtCsg55jX0GwzBC_3std9panicking13panic_handlerNtB4_19FormatStringPayloadNtNtCsl8K0bEFm1U0_4core5panic12PanicPayload3get +__RNvMs_NtNtNtCsg55jX0GwzBC_3std2io8buffered9bufwriterINtB4_9BufWriterNtNtB8_5stdio9StdoutRawE14write_all_coldBa_ __RNvMNtNtNtCs9nYd1Hk1rek_11qjs_runtime5value6object10slot_readsNtB4_9ObjectRef22own_data_property_read __RNvMs1_NtNtNtNtCsg55jX0GwzBC_3std3sys4sync6rwlock5queueNtB5_6RwLock21read_unlock_contended __RNvMs1_NtNtNtNtCsg55jX0GwzBC_3std3sys4sync6rwlock5queueNtB5_6RwLock16unlock_contended __RNvMs6_NtNtCs9nYd1Hk1rek_11qjs_runtime5value6objectNtB5_9ObjectRef32write_existing_own_data_property __RNvNtNtNtCsg55jX0GwzBC_3std12backtrace_rs9symbolize5gimli4mmap __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16boxed_truthiness -__RNvXse_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StdoutNtB7_5Write9write_fmt -__RNvYNtNtNtNtCsg55jX0GwzBC_3std3sys5stdio4unix6StderrNtNtBa_2io5Write9write_allBa_ +__RNvNtNtNtNtCsg55jX0GwzBC_3std3sys2io5error4unix17decode_error_kind +__RNvXNvNtCsg55jX0GwzBC_3std2io17default_write_fmtINtB2_7AdapterINtNtB4_6cursor6CursorQShEENtNtCsl8K0bEFm1U0_4core3fmt5Write9write_strB6_ __RNvXs1_NtCs9nYd1Hk1rek_11qjs_runtime5valueNtB5_5ValueNtNtCsl8K0bEFm1U0_4core5clone5Clone5clone __RINvNtCsl8K0bEFm1U0_4core3ptr13drop_in_placeNtNtCs9nYd1Hk1rek_11qjs_runtime5value5ValueEBK_ __RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8function4call25call_direct_leaf_function diff --git a/crates/qjs-runtime/src/bytecode/vm_numeric_leaf.rs b/crates/qjs-runtime/src/bytecode/vm_numeric_leaf.rs index 3ff2f3690..5f25229d5 100644 --- a/crates/qjs-runtime/src/bytecode/vm_numeric_leaf.rs +++ b/crates/qjs-runtime/src/bytecode/vm_numeric_leaf.rs @@ -669,6 +669,12 @@ pub(super) fn math_unary(native: NativeFunction, argument: f64) -> Option { /// A `Math` function of two arguments whose entire effect is a floating-point /// computation. `max`/`min` follow the spec's NaN and signed-zero rules rather /// than Rust's, which disagree on both. +/// +/// Out of line: whether LLVM inlined it into the typed-loop executor flipped +/// with unrelated edits elsewhere in the crate, and the inlined copy grew +/// the executor by 176 bytes and re-rolled its code (access-nsieve +4%, +/// heterogeneous_property_read +5% cycles at equal instructions). +#[inline(never)] pub(super) fn math_binary(native: NativeFunction, left: f64, right: f64) -> Option { let value = match native { NativeFunction::MathPow => crate::operations::number_exponentiate(left, right), From 761ef1325c91672e28823af8348ed62762c24806 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Fri, 25 Sep 2026 23:21:08 -0700 Subject: [PATCH 07/10] Pin boxed_equality and get_named and move the executor to 0x200 Both are hot typed-executor callees (ai-astar's equality and named reads) that floated in the unordered tail; they now own slots after the other callees. With every callee fixed, a scan of the executor's offset put 0x200 best across the canaries (string_key_map_churn 0.98, heterogeneous_property_read 1.00, imaging-desaturate 0.97 against the 0x0 placement's 1.00, 1.06, 0.97). Byte-identical binaries at different paths still measure ai-astar 1-2% apart, which bounds what a scan can resolve there. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-cli/hot-functions.order | 21 +++++++++++++-------- tools/benchmark/layout_pin.py | 4 +++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/qjs-cli/hot-functions.order b/crates/qjs-cli/hot-functions.order index 3ca0ee4e7..24d7903e2 100644 --- a/crates/qjs-cli/hot-functions.order +++ b/crates/qjs-cli/hot-functions.order @@ -11,12 +11,15 @@ # budget 0x700 __RNvMNtNtNtCs9nYd1Hk1rek_11qjs_runtime5value6object10slot_readsNtB4_9ObjectRef22own_data_property_read # budget 0xa00 __RNvMs6_NtNtCs9nYd1Hk1rek_11qjs_runtime5value6objectNtB5_9ObjectRef32write_existing_own_data_property # budget 0x300 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16boxed_truthiness -# pinned: typed-loop executor at 0x0, interpreter's at 0xc40 mod 4 KiB (python3 -m tools.benchmark.layout_pin), head 39 +# budget 0x300 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute14boxed_equality +# budget 0x500 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute9get_named +# pinned: typed-loop executor at 0x200, interpreter's at 0xc40 mod 4 KiB (python3 -m tools.benchmark.layout_pin), head 44 __RNvMsz_NtNtNtCs1OjIl8oxbrv_5alloc11collections5btree3mapINtB5_8IntoIteryNtNtNtCscUtGwbhD4WH_5gimli4read6abbrev12AbbreviationE10dying_nextCsg55jX0GwzBC_3std __RNvXs7_NtNtCsg55jX0GwzBC_3std2io5errorNtB5_5ErrorNtNtCsl8K0bEFm1U0_4core3fmt7Display3fmt __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute3runNtNtB6_2vm2VmEB8_ -__RNvNvNtCsg55jX0GwzBC_3std2fs4read5inner __RNvNtNtNtCsg55jX0GwzBC_3std3sys3env4unix13env_read_lock +__RNvMs8_NtCsg55jX0GwzBC_3std4pathNtB5_10Components7as_path +__RNvMs0_NtNtCscUtGwbhD4WH_5gimli4read5dwarfINtB5_5DwarfINtNtB7_12endian_slice11EndianSliceNtNtB9_9endianity12LittleEndianEE11attr_stringCsg55jX0GwzBC_3std __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute3runNtNtNtNtB6_10compact_fn4wide10loop_frame13WideLoopFrameEB8_ __RNvMs_NtNtNtCsg55jX0GwzBC_3std2io8buffered9bufwriterINtB4_9BufWriterNtNtB8_5stdio9StdoutRawE9flush_bufBa_ __RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterNtNtB7_5stdio10StderrLockENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ @@ -24,18 +27,17 @@ __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute18try_run_ty __RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterINtNtCs1OjIl8oxbrv_5alloc3vec3VechEENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ __RNvYNtNtNtNtCsg55jX0GwzBC_3std3sys5stdio4unix6StderrNtNtBa_2io5Write9write_allBa_ __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16get_named_object -__RNvNtNtCsg55jX0GwzBC_3std3sys2fs8read_dir -__RNvXs7_NtNtCsg55jX0GwzBC_3std2io5stdioNtB5_9StdinLockNtB7_7BufRead9read_line +__RNvNvNtCsg55jX0GwzBC_3std2fs4read5inner __RNvMs4_NtNtCs9nYd1Hk1rek_11qjs_runtime5value5arrayNtB5_8ArrayRef24direct_dense_index_value __RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcINtNtNtCscUtGwbhD4WH_5gimli4read5dwarf5DwarfINtNtBL_12endian_slice11EndianSliceNtNtBN_9endianity12LittleEndianEEE9drop_slowCsg55jX0GwzBC_3std -__RNvNtNtCsg55jX0GwzBC_3std2io5stdio7__eprint +__RNvXs7_NtNtCsg55jX0GwzBC_3std2io5stdioNtB5_9StdinLockNtB7_7BufRead9read_line __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute9call_leaf __RNvYINtNtNtCscUtGwbhD4WH_5gimli4read12endian_slice11EndianSliceNtNtB9_9endianity12LittleEndianENtNtB7_6reader6Reader17read_sized_offsetCsg55jX0GwzBC_3std __RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode15vm_numeric_leaf21try_eval_numeric_leaf __RNvXNvNtCsg55jX0GwzBC_3std2io17default_write_fmtINtB2_7AdapterNtNtNtNtB6_3sys5stdio4unix6StderrENtNtCsl8K0bEFm1U0_4core3fmt5Write9write_strB6_ __RNvMs3_NtNtCs9nYd1Hk1rek_11qjs_runtime5value5arrayNtB5_9ArrayData21has_property_at_index __RNvXso_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StderrNtB7_5Write9write_fmt -__RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcNtNtNtNtCsg55jX0GwzBC_3std3sys2fs4unix12InnerReadDirE9drop_slowBO_ +__RNvNtNtCsg55jX0GwzBC_3std2io5stdio7__eprint __RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode15vm_numeric_leaf20direct_number_binary __RNvXse_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StdoutNtB7_5Write9write_fmt __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute22ordinary_data_property @@ -49,6 +51,11 @@ __RNvNtNtNtCsg55jX0GwzBC_3std12backtrace_rs9symbolize5gimli4mmap __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16boxed_truthiness __RNvNtNtNtNtCsg55jX0GwzBC_3std3sys2io5error4unix17decode_error_kind __RNvXNvNtCsg55jX0GwzBC_3std2io17default_write_fmtINtB2_7AdapterINtNtB4_6cursor6CursorQShEENtNtCsl8K0bEFm1U0_4core3fmt5Write9write_strB6_ +__RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute14boxed_equality +__RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcNtNtNtNtCsg55jX0GwzBC_3std3sys2fs4unix12InnerReadDirE9drop_slowBO_ +__RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterNtNtNtNtB9_3sys5stdio4unix6StderrENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ +__RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute9get_named +__RNvNtNtCsg55jX0GwzBC_3std3sys2fs8read_dir __RNvXs1_NtCs9nYd1Hk1rek_11qjs_runtime5valueNtB5_5ValueNtNtCsl8K0bEFm1U0_4core5clone5Clone5clone __RINvNtCsl8K0bEFm1U0_4core3ptr13drop_in_placeNtNtCs9nYd1Hk1rek_11qjs_runtime5value5ValueEBK_ __RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8function4call25call_direct_leaf_function @@ -99,7 +106,6 @@ __RINvMs4_NtNtCs9nYd1Hk1rek_11qjs_runtime5value5arrayNtB6_8ArrayRef28with_dense_ __RINvMs4_NtNtCs9nYd1Hk1rek_11qjs_runtime5value5arrayNtB6_8ArrayRef37with_distinct_dense_writable_elementsNtNtNtNtNtBa_8bytecode24vm_numeric_mutation_loop5dense6nested13RegionOutcomeNCNvMs0_B1G_NtB1G_15NestedDensePlan7try_runs0_0EBa_ __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10compact_fn8property14get_prop_named __RNvNtNtNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode24vm_numeric_mutation_loop5dense6legacy11typed_array7try_run -__RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute9get_named __RNCNvMs7_NtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode24vm_numeric_mutation_loop5denseNtB7_16DynamicDensePlan24try_run_hole_tail_append0Bd_ __RINvMs4_NtNtCs9nYd1Hk1rek_11qjs_runtime5value5arrayNtB6_8ArrayRef37with_distinct_dense_writable_elementsNtNtNtNtBa_8bytecode24vm_numeric_mutation_loop5dense17DynamicProgramRunNCNvMs7_B1G_NtB1G_16DynamicDensePlan7try_runs6_0EBa_ __RNvMs5_NtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode2vmNtB5_2Vm16construct_callee @@ -150,7 +156,6 @@ __RNvNtNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode24vm_numeric_mutation_loop5dens __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10compact_fn8property14set_prop_named __RNvMs4_NtNtCs9nYd1Hk1rek_11qjs_runtime5value6objectNtB5_15PropertyStorage6insert __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute23fill_hole_or_grow_valueNtNtNtNtB6_10compact_fn4wide10loop_frame13WideLoopFrameEB8_ -__RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute14boxed_equality __RNvMs4_NtCs1OjIl8oxbrv_5alloc7raw_vecNtB5_11RawVecInner11finish_growCs9nYd1Hk1rek_11qjs_runtime __RNCNvMs_NtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode8compilerNtB6_8Compiler24compile_into_with_hoists0Ba_ __RNvMs_NtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode8compilerNtB4_8Compiler12compile_stmt diff --git a/tools/benchmark/layout_pin.py b/tools/benchmark/layout_pin.py index 1421af119..43582afd5 100644 --- a/tools/benchmark/layout_pin.py +++ b/tools/benchmark/layout_pin.py @@ -39,7 +39,7 @@ ROOT = Path(__file__).resolve().parents[2] DEFAULT_ORDER = ROOT / "crates/qjs-cli/hot-functions.order" -DEFAULT_OFFSET = 0x0 +DEFAULT_OFFSET = 0x200 # The same executor instantiated for the interpreter's own loops -- a # script's top-level `for` -- is pinned too, ahead of its wide twin: # it is most of access-fannkuch and math-partial-sums, and floating in the @@ -69,6 +69,8 @@ "slot_readsNtB4_9ObjectRef22own_data_property_read", "ObjectRef32write_existing_own_data_property", "typed_loop7execute16boxed_truthiness", + "typed_loop7execute14boxed_equality", + "typed_loop7execute9get_named", ) # After the budgeted callees: defined once per codegen unit, in a number of # copies that changes with unrelated code, so nothing pinned may follow them. From 3babb930f9bb383f165fbdbc20d17b61229a52a8 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Fri, 25 Sep 2026 23:25:46 -0700 Subject: [PATCH 08/10] Record the executor re-scan and the binary-path noise floor Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/performance-knowledge.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/performance-knowledge.md b/docs/performance-knowledge.md index 5cd468d37..8ab8e3b24 100644 --- a/docs/performance-knowledge.md +++ b/docs/performance-knowledge.md @@ -37,6 +37,10 @@ The procedure (queue, plan, decision) is in harness, from the same directory, with the same build recipe, interleaved on one host. The same script has measured differently from different filesystem locations; never compare numbers taken along different paths. + The same holds for binaries: two byte-identical `qjs` copies at different + paths measured ai-astar 1-2% apart, consistently (2026-09-26). Warm each + binary once before an A/B too -- a freshly built one runs its first + process slower. - **Keep samples long enough.** Short runs are dominated by startup, timer resolution and scheduling. Raise iterations until each sample clears the harness minimum instead of adding repetitions of a too-short sample. @@ -133,7 +137,11 @@ The procedure (queue, plan, decision) is in (`# budget` lines) and change only when a function outgrows its slot. The per-codegen-unit copies go last. Re-run `layout_pin` after every code change (fillers are sized from the binary it reads), and add a hot - executor callee to `CALLEES` instead of letting it float. + executor callee to `CALLEES` instead of letting it float. With the + callees fixed, the executor's offset was re-scanned to 0x200. Check the + executor's own size in `nm` between builds as well: a codegen-unit shift + inlined `math_binary` into it (+176 bytes, access-nsieve +4% at equal + instructions), now kept out of line. - **Know each case's codegen noise band before blaming a change.** The functions that hold a dispatch loop are re-compiled differently by edits anywhere in the crate (an inlined thread-local access, a helper's inline From 04e0e7aa92042cce968c4467c95593221785b1f0 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 00:43:22 -0700 Subject: [PATCH 09/10] Add front-end cost, bundle export and executor-scan helpers Three diagnostics this session's work kept rebuilding by hand in /tmp: `tools.benchmark.front_end` measures lexing, parsing and compiling of each external bundle (wrapped in a function never called, process start subtracted) against QuickJS-NG or another build, with each case's share of its whole run; `tools.benchmark.bundles` writes the corpus bundles to target/bundles for profiling; `scripts/layout-scan.sh` pins the typed-loop executor at each offset, relinks to a fixed point and screens the layout canaries. None touches the hashed measurement protocol. docs/performance-workflow.md lists them with the existing counter screen, which is the whole-corpus A/B to use instead of ad-hoc loops. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 4 + docs/performance-workflow.md | 28 +++++ scripts/layout-scan.sh | 88 ++++++++++++++++ tools/benchmark/bundles.py | 51 +++++++++ tools/benchmark/front_end.py | 131 ++++++++++++++++++++++++ tools/benchmark/tests/test_front_end.py | 34 ++++++ 6 files changed, 336 insertions(+) create mode 100755 scripts/layout-scan.sh create mode 100644 tools/benchmark/bundles.py create mode 100644 tools/benchmark/front_end.py create mode 100644 tools/benchmark/tests/test_front_end.py diff --git a/AGENTS.md b/AGENTS.md index f82b73c32..84dd5d198 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,10 @@ performance loop below is the contract. - Burndown recorder: `./scripts/test262-burndown.sh --report | --entry ` - Performance inner loop (counter screen, symbol sizes, optional trace): `./scripts/perf-loop.sh --plan tasks/performance-units/.json` +- Diagnostic A/B, front-end cost, executor placement: + `python3 -m tools.benchmark.screen --candidate B --base A --case external`, + `python3 -m tools.benchmark.front_end --binary B --reference `, + `./scripts/layout-scan.sh --offsets 0x0,0x200` (see docs/performance-workflow.md) - Formal local comparison (feeds queue/decide): `./scripts/perf-compare.sh --base --output-dir target/comparison/` - Source size report: `./scripts/source-size-report.sh [limit] [--vendor]` diff --git a/docs/performance-workflow.md b/docs/performance-workflow.md index c21438567..6d19f71de 100644 --- a/docs/performance-workflow.md +++ b/docs/performance-workflow.md @@ -211,6 +211,34 @@ For multiple receipts, place their directories under one profile root and use relative paths such as `case-a/receipt.json` in the plan. `check-unit` checks only plan structure; `validate-unit` checks actual profile artifacts too. +## Diagnostic helpers + +Tools for finding and checking work between formal runs; none of them +produces decision evidence. + +- **Whole-corpus A/B.** `python3 -m tools.benchmark.screen --candidate B + --base A --case external --pairs 3` alternates the two executables over + every external bundle and reports instruction and cycle ratios. Prefer it + to ad-hoc loops: it validates output, holds the measurement lock, and + waits for builds. An instruction ratio near 1.0 with a cycles ratio that + moves is layout, not the change (see performance-knowledge.md). +- **Bundles on disk.** `python3 -m tools.benchmark.bundles` writes each + case to `target/bundles/--.js` exactly as a run executes it, + for `sample` profiles and trace runs. +- **Front-end cost.** `python3 -m tools.benchmark.front_end --binary B + --reference third_party/quickjs-ng/build/qjs` measures lexing, parsing and + compiling alone (each bundle wrapped in a function never called, process + start subtracted) against QuickJS-NG, with each case's share of its whole + run; `--base A` compares two builds instead. +- **Executor placement.** `scripts/layout-scan.sh --offsets 0x0,0x200,0x400` + pins the typed-loop executor at each offset, relinks to a fixed point and + screens the layout canaries; run it after editing the executor or a + pinned callee (`tools/benchmark/layout_pin.py`). +- **Typed-loop programs.** On a `perf-counters` build, `QJS_TL_TRACE=4` + prints each program as it runs (after register packing, hoisting and + copy forwarding) and `QJS_TL_NO_FORWARD=1` disables forwarding, so one + binary can A/B it (docs/benchmarking.md). + ## Acceptance after implementation Measure the candidate against the same base executable represented by the diff --git a/scripts/layout-scan.sh b/scripts/layout-scan.sh new file mode 100755 index 000000000..fdf96193e --- /dev/null +++ b/scripts/layout-scan.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Scan the typed-loop executor's pinned offset (tools/benchmark/layout_pin.py). +# +# For each offset: pin the executor there, relink until the order file is a +# fixed point, keep the binary as target/layout-scan/qjs-, and screen +# it against a base with tools.benchmark.screen on the layout canaries. The +# order file is restored afterwards; adopt an offset by setting +# DEFAULT_OFFSET in layout_pin.py and re-pinning. Diagnostic only: run it +# after editing the executor or its pinned callees, on a quiet host. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/scripts/lib.sh" +export PYTHONDONTWRITEBYTECODE=1 +export PYTHONPATH="$ROOT_DIR${PYTHONPATH:+:$PYTHONPATH}" + +usage() { + cat <<'EOF' +usage: scripts/layout-scan.sh --offsets [--base ] [--case ]... [--pairs ] + + --offsets executor offsets modulo 4 KiB, e.g. 0x0,0x200,0x400 + --base executable to compare against (default: a copy of the current + target/release/qjs, taken before the first relink) + --case tools.benchmark.screen case spec; repeatable (default: the six + sentinels, ai-astar, imaging-desaturate, access-nsieve, + access-nbody -- the cases the executor's placement has moved) + --pairs screen pairs per case (default 2) +EOF +} + +offsets="" +base="" +pairs=2 +cases=() +while [ $# -gt 0 ]; do + case "$1" in + --offsets) offsets="${2:?}"; shift 2 ;; + --base) base="${2:?}"; shift 2 ;; + --case) cases+=("${2:?}"); shift 2 ;; + --pairs) pairs="${2:?}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "error: unknown argument $1" >&2; usage >&2; exit 2 ;; + esac +done +if [ -z "$offsets" ]; then + usage >&2 + exit 2 +fi +if [ ${#cases[@]} -eq 0 ]; then + cases=(sentinel external/kraken-1.1/ai-astar external/kraken-1.1/imaging-desaturate + external/sunspider-1.0/access-nsieve external/sunspider-1.0/access-nbody) +fi + +cargo_bin="$(qjs_resolve_cargo)" || { echo "error: cargo not found" >&2; exit 1; } +cd "$ROOT_DIR" +order=crates/qjs-cli/hot-functions.order +scan_dir=target/layout-scan +mkdir -p "$scan_dir" +"$cargo_bin" build --release -q -p qjs-cli +if [ -z "$base" ]; then + base="$scan_dir/qjs-base" + cp target/release/qjs "$base" +fi +saved="$(mktemp "${TMPDIR:-/tmp}/qjs-order.XXXXXX")" +cp "$order" "$saved" +trap 'cp "$saved" "$order"; rm -f "$saved"' EXIT + +case_args=() +for spec in "${cases[@]}"; do + case_args+=(--case "$spec") +done + +IFS=',' read -r -a offset_list <<< "$offsets" +for offset in "${offset_list[@]}"; do + cp "$saved" "$order" + # Fillers are sized from the binary they are computed on, so pin, relink + # and pin again until the order file stops changing. + for _ in 1 2 3; do + before="$(shasum "$order")" + python3 -m tools.benchmark.layout_pin --binary target/release/qjs --offset "$offset" >/dev/null + "$cargo_bin" build --release -q -p qjs-cli + [ "$(shasum "$order")" = "$before" ] && break + done + cp target/release/qjs "$scan_dir/qjs-$offset" + echo "== executor at $offset: $scan_dir/qjs-$offset" >&2 + python3 -m tools.benchmark.screen --candidate "$scan_dir/qjs-$offset" --base "$base" \ + --pairs "$pairs" "${case_args[@]}" +done diff --git a/tools/benchmark/bundles.py b/tools/benchmark/bundles.py new file mode 100644 index 000000000..837f71b6c --- /dev/null +++ b/tools/benchmark/bundles.py @@ -0,0 +1,51 @@ +"""Write every external case's bundle to a directory, exactly as a run +executes it, as `--.js` -- for `sample` profiles, trace runs and +other diagnostic work. Diagnostic only; formal runs build their own. + +Usage: + python3 -m tools.benchmark.bundles [--output-dir target/bundles] +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Sequence + +from .external_preview import ExternalPreviewError, _bundle_source, fetch_corpora, load_manifest +from .screen import DEFAULT_CACHE, EXTERNAL_MANIFEST + + +def write_bundles(manifest_path: Path, cache_root: Path, output_dir: Path) -> int: + manifest = load_manifest(manifest_path) + fetch_corpora(manifest, cache_root) + output_dir.mkdir(parents=True, exist_ok=True) + written = 0 + for suite in manifest.suites: + for case in suite.cases: + bundle = output_dir / f"{suite.id}--{case.id}.js" + bundle.write_text(_bundle_source(suite, case, cache_root.resolve()), encoding="utf-8") + written += 1 + return written + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python3 -m tools.benchmark.bundles", + description=__doc__.split("\n\n")[0]) + parser.add_argument("--output-dir", type=Path, default=Path("target/bundles")) + parser.add_argument("--cache-root", type=Path, default=DEFAULT_CACHE) + parser.add_argument("--manifest", type=Path, default=EXTERNAL_MANIFEST) + args = parser.parse_args(argv) + try: + written = write_bundles(args.manifest, args.cache_root, args.output_dir) + except (ExternalPreviewError, ValueError, OSError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + print(json.dumps({"bundles": written, "output_dir": str(args.output_dir)})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/benchmark/front_end.py b/tools/benchmark/front_end.py new file mode 100644 index 000000000..eda7a266f --- /dev/null +++ b/tools/benchmark/front_end.py @@ -0,0 +1,131 @@ +"""Parse-and-compile cost of the external corpus, against a reference engine. + +Every external case runs its whole process, so its time includes lexing, +parsing and compiling the bundle, which this engine did 2-3x slower than +QuickJS-NG (2026-09-26; 1-8% of many cases' totals). This measures that +front end alone: each bundle is wrapped in a function that is never called, +so the engine parses and compiles all of it and runs nothing, and the cost +of an empty script (process start) is subtracted. Hardware counters, as in +`tools.benchmark.screen`; diagnostic only, never decision evidence. + +Usage: + python3 -m tools.benchmark.front_end --binary B [--reference NG] [--base A] + [--case external// ...] [--pairs N] + +With `--reference` the table gives the engine's front-end cost over the +reference's; with `--base` (another build of this engine), candidate over +base. The `share` column is the front-end excess over the reference as a +fraction of the case's whole run on `--binary`: what a front end as fast as +the reference would save. +""" + +from __future__ import annotations + +import argparse +import math +import statistics +import sys +import tempfile +from pathlib import Path +from typing import Sequence + +from .external_preview import ExternalPreviewError +from .measure_lock import measurement_lock +from .screen import ( + DEFAULT_CACHE, + CaseSpec, + CounterTool, + ScreenError, + counter_tool, + resolve_cases, + sample, +) + +EMPTY_SCRIPT = "1;\n" + + +def _flags(binary: Path, reference: Path | None) -> list[str]: + # QuickJS-NG treats a file as a module unless told otherwise. + return ["--script"] if reference is not None and binary == reference else ["--raw"] + + +def _cost(tool: CounterTool, binary: Path, flags: list[str], script: Path, pairs: int, + timeout: float) -> tuple[float, float]: + """Median instructions and cycles of running `script`.""" + taken = [sample(tool, [str(binary), *flags, str(script)], timeout) for _ in range(pairs)] + return (statistics.median(s.instructions for s in taken), + statistics.median(s.cycles for s in taken)) + + +def _wrap(bundle: Path, work: Path) -> Path: + wrapped = work / f"front-end--{bundle.name}" + source = bundle.read_text(encoding="utf-8") + wrapped.write_text("function __qjsFrontEndOnly__() {\n" + source + "\n}\n1;\n", encoding="utf-8") + return wrapped + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python3 -m tools.benchmark.front_end", + description=__doc__.split("\n\n")[0]) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--reference", type=Path, + help="a reference engine (QuickJS-NG, run with --script)") + parser.add_argument("--base", type=Path, help="another build of this engine") + parser.add_argument("--case", action="append", default=[], + help="external//; repeatable (default: every external case)") + parser.add_argument("--pairs", type=int, default=3) + parser.add_argument("--timeout", type=float, default=120.0) + parser.add_argument("--cache-root", type=Path, default=DEFAULT_CACHE) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + if (args.reference is None) == (args.base is None): + print("error: give exactly one of --reference and --base", file=sys.stderr) + return 2 + binary = args.binary.resolve() + other = (args.reference or args.base).resolve() + reference = other if args.reference is not None else None + try: + tool = counter_tool() + with measurement_lock("front-end"), tempfile.TemporaryDirectory(prefix="qjs-front-end-") as work: + work_dir = Path(work) + cases: list[CaseSpec] = resolve_cases(args.case or ["external"], args.cache_root, work_dir) + empty = work_dir / "empty.js" + empty.write_text(EMPTY_SCRIPT, encoding="utf-8") + start = {role: _cost(tool, exe, _flags(exe, reference), empty, args.pairs, args.timeout) + for role, exe in (("binary", binary), ("other", other))} + rows = [] + for case in cases: + if case.kind != "whole": + continue + wrapped = _wrap(case.workload, work_dir) + front = {} + for role, exe in (("binary", binary), ("other", other)): + instructions, cycles = _cost(tool, exe, _flags(exe, reference), wrapped, + args.pairs, args.timeout) + front[role] = (instructions - start[role][0], cycles - start[role][1]) + _, whole_cycles = _cost(tool, binary, ["--raw"], case.workload, 1, args.timeout) + rows.append((case.id, front, whole_cycles)) + except (ScreenError, ExternalPreviewError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + label = "reference" if reference is not None else "base" + print(f"{'case':56s} {'Mcyc':>8s} {label + ' Mcyc':>15s} {'ratio':>6s} {'share':>6s}") + logs = [] + for case_id, front, whole_cycles in rows: + mine, theirs = front["binary"][1], front["other"][1] + ratio = mine / theirs if theirs > 0 and mine > 0 else math.nan + share = (mine - theirs) / whole_cycles if whole_cycles > 0 else math.nan + if ratio == ratio: + logs.append(math.log(ratio)) + print(f"{case_id:56s} {mine / 1e6:8.1f} {theirs / 1e6:15.1f} {ratio:6.2f} {share:6.1%}") + if logs: + print(f"geometric mean front-end cycles ratio: {math.exp(sum(logs) / len(logs)):.3f} " + f"over {len(logs)} cases (diagnostic only)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/benchmark/tests/test_front_end.py b/tools/benchmark/tests/test_front_end.py new file mode 100644 index 000000000..5cd219c82 --- /dev/null +++ b/tools/benchmark/tests/test_front_end.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from tools.benchmark import front_end + + +class FrontEndTests(unittest.TestCase): + def test_wrap_puts_the_bundle_in_a_function_that_is_never_called(self): + with tempfile.TemporaryDirectory() as work: + bundle = Path(work) / "suite--case.js" + bundle.write_text("var x = 1;\nprint(x);\n", encoding="utf-8") + wrapped = front_end._wrap(bundle, Path(work)).read_text(encoding="utf-8") + self.assertTrue(wrapped.startswith("function __qjsFrontEndOnly__() {\n")) + self.assertIn("var x = 1;\nprint(x);\n", wrapped) + self.assertTrue(wrapped.endswith("\n}\n1;\n")) + + def test_only_the_reference_runs_as_a_script(self): + engine, reference = Path("/bin/qjs"), Path("/bin/ng") + self.assertEqual(front_end._flags(engine, reference), ["--raw"]) + self.assertEqual(front_end._flags(reference, reference), ["--script"]) + self.assertEqual(front_end._flags(engine, None), ["--raw"]) + + def test_exactly_one_comparison_is_required(self): + self.assertEqual(front_end.main(["--binary", "/bin/qjs"]), 2) + self.assertEqual( + front_end.main(["--binary", "/bin/qjs", "--reference", "/a", "--base", "/b"]), 2 + ) + + +if __name__ == "__main__": + unittest.main() From c5d730822b1beb48fefd403869d2b65f9c0dc896 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 00:45:17 -0700 Subject: [PATCH 10/10] Warm each engine before measuring front-end cost A binary's first process after a build or a pause runs slower, which inflated the empty-script baseline past small front ends (crypto-md5 read negative). One run is now discarded, and the least of the pairs estimates each cost. Also records the perf23 units and stack run in T033. Co-Authored-By: Claude Opus 5.5 (1M context) --- tasks/T033-wide-tier-interpreter-exits.md | 17 +++++++++++++++++ tools/benchmark/front_end.py | 13 ++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/tasks/T033-wide-tier-interpreter-exits.md b/tasks/T033-wide-tier-interpreter-exits.md index 09128fa91..fb7d396af 100644 --- a/tasks/T033-wide-tier-interpreter-exits.md +++ b/tasks/T033-wide-tier-interpreter-exits.md @@ -342,6 +342,23 @@ Plan and evidence: `tasks/performance-units/wide-tier-interpreter-exits.json` 0.88, fannkuch 0.92. Liveness counts a site's entries only where an operation can stop. Differential fuzz (600 random loops with type changes mid-loop) matches the pass-off build and V8. +- perf23 units (c88cbb06..04e0e7aa): the front end measured 2-3x + QuickJS-NG (`tools.benchmark.front_end`; 1-8% of many cases' totals): + binary operators by precedence climbing (imaging-darkroom's parse -21% + cycles), the compiler's scope tables on the name hasher (cdjs parse + -10%), canonical UTF-8 copied straight through (cdjs -9% instructions), + borrowed name sets in bytecode finalization. A parser bug found on the + way: `in` inside brackets of a for initializer was rejected. Layout: + math_binary kept out of the typed executor (a codegen-unit shift had + inlined it: nsieve +4% at equal instructions), boxed_equality and + get_named pinned, executor re-scanned to 0x200. +- Stack run 3babb930 vs main f2b21ab6 (30 blocks, cycles, quiet host; + `target/comparison/perf23-3babb930`): external geomean **0.994** against + main and **0.772 against QuickJS-NG**; gaussian-blur (JetStream) 0.952, + imaging-darkroom 0.963, tinderbox 0.965, desaturate 0.971. Worst: + ai-astar 1.023 (byte-identical binaries at two paths differ by 1-2% + there), regexp-dna 1.015; sentinels 1.003. Slowest against QuickJS-NG: + xparb 1.46, tofte 1.41, validate-input 1.31, binary-trees 1.31. - Stack run 88e90f89 vs main 8d7c580e (30 blocks, cycles, quiet host; `target/comparison/perf22-88e90f89`): perf21's and perf22's units. External geomean **0.962** against main and **0.782 against diff --git a/tools/benchmark/front_end.py b/tools/benchmark/front_end.py index eda7a266f..d83eefa7a 100644 --- a/tools/benchmark/front_end.py +++ b/tools/benchmark/front_end.py @@ -23,7 +23,6 @@ import argparse import math -import statistics import sys import tempfile from pathlib import Path @@ -51,10 +50,14 @@ def _flags(binary: Path, reference: Path | None) -> list[str]: def _cost(tool: CounterTool, binary: Path, flags: list[str], script: Path, pairs: int, timeout: float) -> tuple[float, float]: - """Median instructions and cycles of running `script`.""" - taken = [sample(tool, [str(binary), *flags, str(script)], timeout) for _ in range(pairs)] - return (statistics.median(s.instructions for s in taken), - statistics.median(s.cycles for s in taken)) + """Least instructions and cycles of running `script`, after one + discarded run: a binary's first process after a build or a pause runs + slower, which inflated the empty-script baseline past small front ends. + Noise only adds to a cost, so the minimum estimates it best.""" + argv = [str(binary), *flags, str(script)] + sample(tool, argv, timeout) + taken = [sample(tool, argv, timeout) for _ in range(pairs)] + return (min(s.instructions for s in taken), min(s.cycles for s in taken)) def _wrap(bundle: Path, work: Path) -> Path: