Implement class selector fast path and add related tests for performance optimization

This commit is contained in:
wuggy 2026-09-07 14:12:03 -07:00
commit 80cfaf0891
8 changed files with 233 additions and 2 deletions

View file

@ -2927,6 +2927,35 @@ FindMatchingElementsWithId(const nsAString& aId, nsINode* aRoot,
// Actually find elements matching aSelectorList (which must not be
// null) and which are descendants of aRoot and put them in aList. If
// onlyFirstMatch, then stop once the first one is found.
template<bool onlyFirstMatch, class Collector, class T>
static void
FindMatchingElementsWithClass(nsINode* aRoot, nsIAtom* aClass,
nsCaseTreatment aCaseTreatment, T& aList)
{
Collector results;
for (nsIContent* cur = aRoot->GetFirstChild(); cur;
cur = cur->GetNextNode(aRoot)) {
if (!cur->IsElement()) {
continue;
}
const nsAttrValue* classes = cur->AsElement()->GetClasses();
if (classes && classes->Contains(aClass, aCaseTreatment)) {
if (onlyFirstMatch) {
aList.AppendElement(cur->AsElement());
return;
}
results.AppendElement(cur->AsElement());
}
}
const uint32_t len = results.Length();
if (len) {
aList.SetCapacity(len);
for (uint32_t i = 0; i < len; ++i) {
aList.AppendElement(results.ElementAt(i));
}
}
}
template<bool onlyFirstMatch, class Collector, class T>
MOZ_ALWAYS_INLINE static void
FindMatchingElements(nsINode* aRoot, nsCSSSelectorList* aSelectorList, T &aList,
@ -2934,6 +2963,23 @@ FindMatchingElements(nsINode* aRoot, nsCSSSelectorList* aSelectorList, T &aList,
{
nsIDocument* doc = aRoot->OwnerDoc();
// Parsed selectors are already cached by the document. A lone class needs
// only an atom lookup per element, not the general CSS matching context.
nsCSSSelector* selector = aSelectorList->mSelectors;
if (!aSelectorList->mNext && !selector->mNext &&
selector->mClassList && !selector->mClassList->mNext &&
!selector->mLowercaseTag && !selector->mIDList &&
!selector->mAttrList && !selector->mPseudoClassList &&
!selector->mNegations && selector->mNameSpace == kNameSpaceID_Unknown &&
selector->IsRestrictedSelector() && !selector->IsHybridPseudoElement()) {
nsCaseTreatment caseTreatment =
doc->GetCompatibilityMode() == eCompatibility_NavQuirks
? eIgnoreCase : eCaseMatters;
FindMatchingElementsWithClass<onlyFirstMatch, Collector>(
aRoot, selector->mClassList->mAtom, caseTreatment, aList);
return;
}
TreeMatchContext matchingContext(false, nsRuleWalker::eRelevantLinkUnvisited,
doc, TreeMatchContext::eNeverMatchVisited);
doc->FlushPendingLinkUpdates();

View file

@ -0,0 +1,59 @@
function checkClassSelectors(parse, equal) {
function check(root, selector, expected) {
var all = root.querySelectorAll(selector);
equal(all.length, expected.length, selector + " count");
for (var i = 0; i < expected.length; i++) {
equal(all[i].id, expected[i], selector + " order " + i);
}
equal(root.querySelector(selector), all.length ? all[0] : null,
selector + " first match");
}
var doc = parse('<!doctype html><html><body>' +
'<section id="root" class="toggle"><div id="a" class="toggle completed">' +
'<span id="b" class="other toggle"></span></div>' +
'<div id="c" class="TOGGLE"></div><button id="d" class="toggle\t extra"></button>' +
'<svg xmlns="http://www.w3.org/2000/svg"><g id="svg" class="toggle"/></svg>' +
'</section></body></html>', 'text/html');
var root = doc.getElementById('root');
for (var repeat = 0; repeat < 10; repeat++) {
check(root, '.toggle', ['a', 'b', 'd', 'svg']);
check(root, '*|*.toggle', ['a', 'b', 'd', 'svg']);
check(root, '.t\\6f ggle', ['a', 'b', 'd', 'svg']);
check(root, '.TOGGLE', ['c']);
check(root, '.missing', []);
check(root, '.toggle.completed', ['a']);
check(root, 'button.toggle', ['d']);
check(root, '.toggle:not(.completed)', ['b', 'd', 'svg']);
check(root, '.toggle, .other', ['a', 'b', 'd', 'svg']);
}
check(doc, '.toggle', ['root', 'a', 'b', 'd', 'svg']);
var snapshot = root.querySelectorAll('.toggle');
doc.getElementById('a').className = '';
root.removeChild(doc.getElementById('d'));
doc.getElementById('c').className = 'toggle';
check(root, '.toggle', ['b', 'c', 'svg']);
equal(snapshot.length, 4, 'querySelectorAll remains a static snapshot');
equal(snapshot[0].id, 'a', 'snapshot retains a changed element');
equal(snapshot[2].id, 'd', 'snapshot retains a removed element');
var fragment = doc.createDocumentFragment();
fragment.appendChild(root);
check(fragment, '.toggle', ['root', 'b', 'c', 'svg']);
check(root, '.toggle', ['b', 'c', 'svg']);
var quirks = parse('<html><body><div id="lower" class="toggle"></div>' +
'<div id="upper" class="TOGGLE"></div></body></html>', 'text/html');
equal(quirks.compatMode, 'BackCompat', 'quirks document');
check(quirks, '.toggle', ['lower', 'upper']);
check(quirks, '.TOGGLE', ['lower', 'upper']);
var xml = parse('<root><item id="plain" class="toggle"/>' +
'<item id="upper" class="TOGGLE"/>' +
'<item xmlns="urn:test" id="namespaced" class="toggle"/></root>',
'application/xml');
check(xml, '.toggle', ['plain', 'namespaced']);
check(xml, '|*.toggle', ['plain']);
check(xml, '*|*.toggle', ['plain', 'namespaced']);
}

View file

@ -1,5 +1,6 @@
[DEFAULT]
support-files =
file_class_selector_checks.js
audio.ogg
audioEndedDuringPlaying.webm
iframe_bug962251.html
@ -615,6 +616,7 @@ skip-if = os == "mac" # Different tab focus behavior on mac
[test_caretPositionFromPoint.html]
[test_change_policy.html]
[test_classList.html]
[test_class_selector_fast_path.html]
[test_clearTimeoutIntervalNoArg.html]
[test_constructor-assignment.html]
[test_constructor.html]

View file

@ -0,0 +1,10 @@
<!doctype html>
<meta charset="utf-8">
<title>Class selector query fast path</title>
<script src="/tests/SimpleTest/SimpleTest.js"></script>
<script src="file_class_selector_checks.js"></script>
<script>
checkClassSelectors(function(source, type) {
return new DOMParser().parseFromString(source, type);
}, is);
</script>

View file

@ -0,0 +1,38 @@
// Focused JSON workloads motivated by Speedometer 2.1's in-memory TodoMVC store.
// Run with a JS shell, or xpcshell -f. Lower times are better. This does not
// measure Speedometer's DOM, layout, event dispatch, or overall suite score.
(function() {
var iterations = 2000;
var checksum = 0;
function measure(name, records) {
var text = JSON.stringify(records);
var samples = [];
for (var i = 0; i < 100; i++)
checksum += JSON.parse(text).length;
for (var sample = 0; sample < 7; sample++) {
if (typeof gc === 'function')
gc();
var start = Date.now();
for (var i = 0; i < iterations; i++)
checksum += JSON.parse(text).length;
samples.push(Date.now() - start);
}
samples.sort(function(a, b) { return a - b; });
print(name + ': median=' + samples[3] + ' ms; samples=' + samples.join(','));
}
var todos = [], unicode = [], collisions = [], unique = [];
for (var i = 0; i < 100; i++) {
todos.push({ id: i, title: 'Something to do ' + i, completed: false });
unicode.push({ '\u0101name': i, '\u03bbvalue': 'value', '\u4e2d': false });
collisions.push({ item: i, identifier: i + 1, index: i + 2 });
var record = {};
record['uniqueName' + i] = i;
unique.push(record);
}
measure('TodoMVC-shaped records', todos);
measure('two-byte names', unicode);
measure('cache collisions', collisions);
measure('unique names', unique);
measure('small parse', [todos[0]]);
print('checksum=' + checksum);
})();

View file

@ -0,0 +1,43 @@
// Repeated names, collisions, encodings, and escaped names must all agree.
var keys = ['id', 'title', 'completed', 'items', 'item', '', 'identifier',
'i', 'a', 'same', 'samesize', '\u00e9', '\u0101', '\ud800',
'quote"key', 'slash\\key', 'line\nkey', '__proto__'];
var records = [];
for (var i = 0; i < 100; i++) {
var record = Object.create(null);
for (var k = 0; k < keys.length; k++)
record[keys[k]] = i + k;
records.push(record);
}
var text = JSON.stringify(records);
for (var repeat = 0; repeat < 30; repeat++) {
var parsed = JSON.parse(text);
assertEq(parsed.length, records.length);
for (var i = 0; i < parsed.length; i++) {
for (var k = 0; k < keys.length; k++)
assertEq(parsed[i][keys[k]], records[i][keys[k]]);
}
}
assertEq(JSON.parse('[{"title":1},{"titleLonger":2}]')[1].titleLonger, 2);
assertEq(JSON.parse('[{"titleLonger":1},{"title":2}]')[1].title, 2);
assertEq(JSON.parse('[{"title":1},{"t\\u0069tle":2}]')[1].title, 2);
assertEq(JSON.parse('{"id":1,"id":2}').id, 2);
assertEq(JSON.parse('{"id":1}', function(key, value) {
return key === 'id' ? JSON.parse('{"id":2}').id : value;
}).id, 2);
for (var bad of ['[{"title":1},{"title', '[{"title":1},{"title"',
'[{"title":1},{"titleX":}]', '[{"title":1},{"ti\ntle":2}]']) {
var threw = false;
try { JSON.parse(bad); } catch (e) { threw = e instanceof SyntaxError; }
assertEq(threw, true);
}
// Keep cached atoms alive across allocations made while parsing later values.
var large = '[{"uncommonPropertyForGC":0},' +
'{"other":[' + new Array(20000).fill('"allocation"').join(',') + ']},' +
'{"uncommonPropertyForGC":1}]';
for (var i = 0; i < 3; i++) {
gc();
assertEq(JSON.parse(large)[2].uncommonPropertyForGC, 1);
}

View file

@ -43,6 +43,10 @@ JSONParserBase::~JSONParserBase()
void
JSONParserBase::trace(JSTracer* trc)
{
for (auto& atom : propertyNameCache) {
if (atom)
TraceRoot(trc, &atom, "JSONParser cached property name");
}
for (size_t i = 0; i < stack.length(); i++) {
if (stack[i].state == FinishArrayElement) {
ElementVector& elements = stack[i].elements();
@ -123,6 +127,24 @@ JSONParser<CharT>::readString()
return token(Error);
}
// Arrays of records repeatedly use the same property names. Verify the
// entire name and closing quote before skipping scanning and atomization.
// Only the unescaped path below populates this cache.
if (ST == JSONParser::PropertyName && *current != '"') {
JSAtom* atom = propertyNameCache[size_t(*current) % PropertyNameCacheSize];
if (atom && size_t(end - current) > atom->length() &&
current[atom->length()] == '"') {
JS::AutoCheckCannotGC nogc;
bool matches = atom->hasLatin1Chars()
? EqualChars(atom->latin1Chars(nogc), current.get(), atom->length())
: EqualChars(atom->twoByteChars(nogc), current.get(), atom->length());
if (matches) {
current += atom->length() + 1;
return stringToken(atom);
}
}
}
/*
* Optimization: if the source contains no escaped characters, create the
* string directly from the source text.
@ -137,6 +159,9 @@ JSONParser<CharT>::readString()
: NewStringCopyN<CanGC>(cx, start.get(), length);
if (!str)
return token(OOM);
if (ST == JSONParser::PropertyName && length) {
propertyNameCache[size_t(*start) % PropertyNameCacheSize] = &str->asAtom();
}
return stringToken(str);
}

View file

@ -33,6 +33,11 @@ class MOZ_STACK_CLASS JSONParserBase
const ErrorHandling errorHandling;
// Reuse unescaped property names within this parse. The cache is bounded
// and traced with the parser, including during compacting GC.
static const size_t PropertyNameCacheSize = 8;
JSAtom* propertyNameCache[PropertyNameCacheSize];
enum Token { String, Number, True, False, Null,
ArrayOpen, ArrayClose,
ObjectOpen, ObjectClose,
@ -109,6 +114,7 @@ class MOZ_STACK_CLASS JSONParserBase
JSONParserBase(JSContext* cx, ErrorHandling errorHandling)
: cx(cx),
errorHandling(errorHandling),
propertyNameCache{},
stack(cx),
freeElements(cx),
freeProperties(cx)
@ -129,8 +135,10 @@ class MOZ_STACK_CLASS JSONParserBase
#ifdef DEBUG
, lastToken(mozilla::Move(other.lastToken))
#endif
{}
{
for (size_t i = 0; i < PropertyNameCacheSize; i++)
propertyNameCache[i] = other.propertyNameCache[i];
}
Value numberValue() const {
MOZ_ASSERT(lastToken == Number);