Optimize CSS attribute selector matching:

Resolve standard property definitions and data-attribute classification
  when selectors are parsed, avoiding repeated specification lookups and
  attribute-name checks during matching.

  Fast-path existence-only data selectors without retrieving their values,
  while preserving all attribute operator behavior. Add standard-property
  coverage and an attribute-focused benchmark showing a 41.7% reduction
  in matching time.
This commit is contained in:
Martín Lucas Golini
2026-07-10 20:01:56 -03:00
parent 660d64c823
commit edf316a5b6
5 changed files with 120 additions and 44 deletions
@@ -907,6 +907,23 @@ with both `applyPseudo == false` and `applyPseudo == true` where applicable.
# Phase 8: Reduce Attribute Selector Cost # Phase 8: Reduce Attribute Selector Cost
**Status: Implemented**
## Implementation State
- Attribute names are hashed once during parsing to resolve and cache the standard
`PropertyDefinition`, avoiding repeated stylesheet-specification lookups while matching.
- Data-attribute classification is cached in each parsed attribute selector.
- Existence-only data selectors return after the map lookup without constructing or reading a
value string; value comparisons continue to use the stored `StyleSheetProperty` string directly.
- Tag, ID, and class checks remain ahead of attribute matching so unrelated elements reject early.
- Existing coverage exercises all supported data-attribute operators, including empty-value
existence. Additional checks cover standard-property existence, exact matching, and unknown
properties.
- `Benchmark.CSSAttributeSelectorMatching` covers all supported operators and a standard property.
Across seven release runs of 5.12 million selector calls, the median decreased from 155.9 ms to
90.9 ms, a 41.7% reduction.
## Motivation ## Motivation
Attribute selectors are less common than class selectors but expensive when evaluated. Attribute selectors are less common than class selectors but expensive when evaluated.
@@ -114,8 +114,10 @@ class EE_API StyleSheetSelectorRule {
struct AttributeSelector { struct AttributeSelector {
std::string name; std::string name;
AttributeOperator op{ AttributeOperator::None };
std::string value; std::string value;
const PropertyDefinition* propertyDefinition{ nullptr };
AttributeOperator op{ AttributeOperator::None };
bool isDataAttribute{ false };
}; };
static PseudoClasses toPseudoClass( std::string_view cls ); static PseudoClasses toPseudoClass( std::string_view cls );
@@ -9,12 +9,14 @@
#include <eepp/system/sys.hpp> #include <eepp/system/sys.hpp>
#include <eepp/ui/css/stylesheetparser.hpp> #include <eepp/ui/css/stylesheetparser.hpp>
#include <eepp/ui/css/stylesheetselector.hpp> #include <eepp/ui/css/stylesheetselector.hpp>
#include <eepp/ui/uihtmlwidget.hpp>
#include <eepp/ui/uimarkdownview.hpp> #include <eepp/ui/uimarkdownview.hpp>
#include <eepp/ui/uiscenenode.hpp> #include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uiscrollview.hpp> #include <eepp/ui/uiscrollview.hpp>
#include <eepp/ui/uithememanager.hpp> #include <eepp/ui/uithememanager.hpp>
#include <eepp/window/engine.hpp> #include <eepp/window/engine.hpp>
#include <array>
#include <cstdlib> #include <cstdlib>
using namespace EE; using namespace EE;
@@ -150,6 +152,53 @@ UTEST( Benchmark, CSSClassIndexLookup ) {
Engine::destroySingleton(); Engine::destroySingleton();
} }
UTEST( Benchmark, CSSAttributeSelectorMatching ) {
Engine::instance()->createWindow( WindowSettings( 800, 600, "CSS attribute selector bench",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
UIHTMLWidget* widget = UIHTMLWidget::New();
widget->setDataProperty( "data-empty", "" );
widget->setDataProperty( "data-role", "hero" );
widget->setDataProperty( "data-tags", "featured primary" );
widget->setDataProperty( "data-lang", "en-US" );
widget->setDataProperty( "data-id", "user-42" );
static constexpr int selectorCount = 1024;
const std::array<std::string, 8> selectorNames = { "[data-empty]",
"[data-role=\"hero\"]",
"[data-tags~=\"featured\"]",
"[data-lang|=\"en\"]",
"[data-id^=\"user-\"]",
"[data-id$=\"-42\"]",
"[data-id*=\"ser\"]",
"[width]" };
std::vector<StyleSheetSelector> selectors;
selectors.reserve( selectorCount );
for ( int i = 0; i < selectorCount; ++i )
selectors.emplace_back( selectorNames[i % selectorNames.size()] );
const int matchingIterations = getSelectorMatchingIterations();
Uint64 matchCount = 0;
Clock matchingClock;
for ( int iteration = 0; iteration < matchingIterations; ++iteration ) {
for ( const auto& selector : selectors )
matchCount += selector.select( widget, false );
}
const Time matchingElapsed = matchingClock.getElapsedTime();
EXPECT_EQ( static_cast<Uint64>( matchingIterations ) * selectorCount, matchCount );
UTEST_PRINT_INFO(
String::format( "Attribute selector matching: %lld us", matchingElapsed.asMicroseconds() )
.c_str() );
UTEST_PRINT_INFO(
String::format( "Attribute selector calls: %d", selectorCount * matchingIterations )
.c_str() );
widget->close();
Engine::destroySingleton();
}
static int getMarkdownFlushIterations() { static int getMarkdownFlushIterations() {
if ( const char* env = std::getenv( "EE_MARKDOWN_BENCH_FLUSH_ITERATIONS" ) ) { if ( const char* env = std::getenv( "EE_MARKDOWN_BENCH_FLUSH_ITERATIONS" ) ) {
Int32 val = markdownFlushIterations; Int32 val = markdownFlushIterations;
+47 -43
View File
@@ -168,8 +168,12 @@ void StyleSheetSelectorRule::parseFragment( const std::string& selectorFragment
attr.name = String::trim( buffer ); attr.name = String::trim( buffer );
attr.op = AttributeOperator::None; attr.op = AttributeOperator::None;
} }
attr.isDataAttribute = isDataAttributeName( attr.name );
if ( !attr.isDataAttribute )
attr.propertyDefinition =
StyleSheetSpecification::instance()->getProperty( String::hash( attr.name ) );
mAttributeSelectors.push_back( attr ); mAttributeSelectors.emplace_back( std::move( attr ) );
mSpecificity += SpecificityClass; mSpecificity += SpecificityClass;
buffer.clear(); buffer.clear();
return; return;
@@ -356,57 +360,57 @@ bool StyleSheetSelectorRule::matches( UIWidget* element, const bool& applyPseudo
if ( !mAttributeSelectors.empty() ) { if ( !mAttributeSelectors.empty() ) {
for ( const auto& attr : mAttributeSelectors ) { for ( const auto& attr : mAttributeSelectors ) {
bool attrExists = false; const std::string* elVal;
std::string elValStorage; std::string elValStorage;
const std::string* elVal = &elValStorage;
if ( element->isType( UI_TYPE_HTML_WIDGET ) && isDataAttributeName( attr.name ) ) { if ( attr.isDataAttribute && element->isType( UI_TYPE_HTML_WIDGET ) ) {
auto* htmlElement = element->asType<UIHTMLWidget>(); auto* htmlElement = element->asType<UIHTMLWidget>();
const auto* property = htmlElement->getDataProperty( attr.name ); const auto* property = htmlElement->getDataProperty( attr.name );
attrExists = property != nullptr; if ( property == nullptr )
if ( attrExists ) return false;
elVal = &property->value(); if ( attr.op == AttributeOperator::None )
continue;
elVal = &property->value();
} else { } else {
elValStorage = element->getPropertyString( attr.name ); elValStorage = element->getPropertyString( attr.propertyDefinition );
attrExists = !elValStorage.empty(); if ( elValStorage.empty() )
return false;
if ( attr.op == AttributeOperator::None )
continue;
elVal = &elValStorage;
} }
if ( !attrExists ) switch ( attr.op ) {
return false; case AttributeOperator::Exact: // =
if ( *elVal != attr.value )
if ( attr.op != AttributeOperator::None ) { return false;
switch ( attr.op ) { break;
case AttributeOperator::Exact: // = case AttributeOperator::StartsWith: // ^=
if ( *elVal != attr.value ) if ( !String::startsWith( *elVal, attr.value ) )
return false; return false;
break; break;
case AttributeOperator::StartsWith: // ^= case AttributeOperator::EndsWith: // $=
if ( !String::startsWith( *elVal, attr.value ) ) if ( !String::endsWith( *elVal, attr.value ) )
return false; return false;
break; break;
case AttributeOperator::EndsWith: // $= case AttributeOperator::Contains: // *=
if ( !String::endsWith( *elVal, attr.value ) ) if ( elVal->find( attr.value ) == std::string::npos )
return false; return false;
break; break;
case AttributeOperator::Contains: // *= case AttributeOperator::ContainsWord: { // ~= (Space-separated word check)
if ( elVal->find( attr.value ) == std::string::npos ) if ( !containsWord( *elVal, attr.value ) ) {
return false; return false;
break;
case AttributeOperator::ContainsWord: { // ~= (Space-separated word check)
if ( !containsWord( *elVal, attr.value ) ) {
return false;
}
break;
} }
case AttributeOperator::StartsWithDash: // |= (Exact match or starts with value break;
// + "-")
if ( !startsWithDashMatch( *elVal, attr.value ) ) {
return false;
}
break;
default:
break;
} }
case AttributeOperator::StartsWithDash: // |= (Exact match or starts with value
// + "-")
if ( !startsWithDashMatch( *elVal, attr.value ) ) {
return false;
}
break;
default:
break;
} }
} }
+4
View File
@@ -1552,6 +1552,10 @@ UTEST( UIHTML, DataProperties ) {
EXPECT_EQ( sceneNode->getRoot()->querySelectorAll( "[data-id*=\"ser\"]" ).size(), (size_t)1 ); EXPECT_EQ( sceneNode->getRoot()->querySelectorAll( "[data-id*=\"ser\"]" ).size(), (size_t)1 );
EXPECT_EQ( sceneNode->getRoot()->querySelectorAll( "[data-empty]" ).size(), (size_t)1 ); EXPECT_EQ( sceneNode->getRoot()->querySelectorAll( "[data-empty]" ).size(), (size_t)1 );
EXPECT_EQ( sceneNode->getRoot()->querySelectorAll( "[data-missing]" ).size(), (size_t)0 ); EXPECT_EQ( sceneNode->getRoot()->querySelectorAll( "[data-missing]" ).size(), (size_t)0 );
EXPECT_TRUE( StyleSheetSelector( "[width]" ).select( target, false ) );
EXPECT_TRUE( StyleSheetSelector( "[width=\"" + target->getPropertyString( "width" ) + "\"]" )
.select( target, false ) );
EXPECT_FALSE( StyleSheetSelector( "[unknown-property]" ).select( target, false ) );
EXPECT_TRUE( target->getDataPropertyString( "data-language" ) == "cpp" ); EXPECT_TRUE( target->getDataPropertyString( "data-language" ) == "cpp" );