Optimize stylesheet matching with class-based indexing:

Index class-anchored selectors by one mandatory class hash so widgets
  only evaluate rules associated with their own classes instead of
  scanning every class-only rule in the global bucket.

  Preserve selector correctness by validating the complete rule after
  candidate lookup, and retain stylesheet source order across independent
  index buckets for equal-specificity cascade resolution.

  Update index clearing, copying, marker removal, and style insertion
  paths. Add focused candidate coverage and a legacy full-scan benchmark,
  showing a 63% lookup-time reduction on a class-heavy stylesheet.
This commit is contained in:
Martín Lucas Golini
2026-07-10 19:31:15 -03:00
parent 3e29da6a2e
commit d29dbb1cc1
9 changed files with 201 additions and 10 deletions
@@ -631,6 +631,28 @@ Ensure class mutation APIs update hashes correctly.
# Phase 6: Add Class-Based Stylesheet Index # Phase 6: Add Class-Based Stylesheet Index
**Status: Implemented**
## Implementation State
- `StyleSheet` maintains a class-hash index in addition to the existing global, tag, ID, and
tag+ID index.
- Each style is owned by exactly one candidate bucket. The priority is ID/tag+ID, one rightmost
class anchor, tag, then global, so candidate collection does not require a per-element visited
allocation.
- Selector rules expose their sorted class hashes and currently select the first hash as the class
anchor.
- Class index state participates in clear, copy assignment, marker-based removal, and normal style
insertion paths.
- Per-stylesheet source-order metadata is used as the secondary sort key after specificity. This
preserves CSS cascade order when candidates originate from different index buckets.
- A focused candidate test covers global, tag, class-only, compound-class, tag+class, ID+class,
and unrelated-class rules.
- `Benchmark.CSSClassIndexLookup` exercises 1,024 class rules, of which 64 share a class with the
target widget. Across ten release runs, the legacy full scan had a 124.1 ms median and indexed
lookup had a 45.7 ms median for 20,000 lookups: a 63.2% reduction, or approximately 2.7x faster.
- The full release suite passes 735 tests with one skipped test.
## Motivation ## Motivation
This is expected to be the largest real-world win. This is expected to be the largest real-world win.
+7
View File
@@ -82,6 +82,13 @@ class EE_API StyleSheet {
Uint32 mMarker{ 0 }; Uint32 mMarker{ 0 };
std::vector<std::shared_ptr<StyleSheetStyle>> mNodes; std::vector<std::shared_ptr<StyleSheetStyle>> mNodes;
UnorderedMap<size_t, StyleSheetStyleVector> mNodeIndex; UnorderedMap<size_t, StyleSheetStyleVector> mNodeIndex;
// Class-anchored rules live in one bucket only; selector matching validates tag and other
// classes.
UnorderedMap<String::HashType, StyleSheetStyleVector> mClassNodeIndex;
// Candidate buckets are independent, so retain insertion order explicitly for equal
// specificity.
UnorderedMap<const StyleSheetStyle*, size_t> mStyleSourceOrder;
size_t mNextStyleSourceOrder{ 0 };
MediaQueryList::vector mMediaQueryList; MediaQueryList::vector mMediaQueryList;
KeyframesDefinitionMap mKeyframesMap; KeyframesDefinitionMap mKeyframesMap;
using ElementDefinitionCache = UnorderedMap<size_t, std::shared_ptr<ElementDefinition>>; using ElementDefinitionCache = UnorderedMap<size_t, std::shared_ptr<ElementDefinition>>;
+1 -1
View File
@@ -31,7 +31,7 @@ class EE_API StyleSheetSelector {
bool isStructurallyVolatile() const; bool isStructurallyVolatile() const;
const StyleSheetSelectorRule& getRule( const Uint32& index ); const StyleSheetSelectorRule& getRule( const Uint32& index ) const;
const std::string& getSelectorId() const; const std::string& getSelectorId() const;
@@ -136,6 +136,22 @@ class EE_API StyleSheetSelectorRule {
bool hasClass( const std::string& cls ) const; bool hasClass( const std::string& cls ) const;
/** @return True when this rule requires at least one CSS class. */
bool hasClasses() const { return !mClassHashes.empty(); }
/** @return Sorted unique hashes of every CSS class required by this rule. */
const std::vector<String::HashType>& getClassHashes() const { return mClassHashes; }
/**
* @return The mandatory class used to place this rule in the stylesheet candidate index.
* Every matching element must have this class, but the full rule is still validated by
* matches(). The first sorted hash is used for now; a future implementation may choose the
* rarest class to reduce the candidate bucket further.
*/
String::HashType getBestClassHash() const {
return mClassHashes.empty() ? 0 : mClassHashes.front();
}
bool hasPseudoClasses() const; bool hasPseudoClasses() const;
bool hasPseudoClass( const std::string& cls ) const; bool hasPseudoClass( const std::string& cls ) const;
+4 -1
View File
@@ -781,7 +781,10 @@ class EE_API UIWidget : public UINode {
/** @return Number of sorted unique CSS class hashes cached by this widget. */ /** @return Number of sorted unique CSS class hashes cached by this widget. */
inline Uint32 getClassHashCount() const { return static_cast<Uint32>( mClassHashes.size() ); } inline Uint32 getClassHashCount() const { return static_cast<Uint32>( mClassHashes.size() ); }
/** @return True if all the sorted unique CSS class hashes are applied to this widget. */ /** @return Sorted unique class hashes used for stylesheet candidate lookup and matching. */
inline const SmallVector<String::HashType, 1>& getClassHashes() const { return mClassHashes; }
/** @return True if all the sorted unique required hashes are applied to this widget. */
inline bool hasClassHashes( const std::vector<String::HashType>& requiredHashes ) const { inline bool hasClassHashes( const std::vector<String::HashType>& requiredHashes ) const {
const auto* hashes = mClassHashes.data(); const auto* hashes = mClassHashes.data();
const auto* hashesEnd = hashes + mClassHashes.size(); const auto* hashesEnd = hashes + mClassHashes.size();
@@ -7,6 +7,7 @@
#include <eepp/system/clock.hpp> #include <eepp/system/clock.hpp>
#include <eepp/system/filesystem.hpp> #include <eepp/system/filesystem.hpp>
#include <eepp/system/sys.hpp> #include <eepp/system/sys.hpp>
#include <eepp/ui/css/stylesheetparser.hpp>
#include <eepp/ui/css/stylesheetselector.hpp> #include <eepp/ui/css/stylesheetselector.hpp>
#include <eepp/ui/uimarkdownview.hpp> #include <eepp/ui/uimarkdownview.hpp>
#include <eepp/ui/uiscenenode.hpp> #include <eepp/ui/uiscenenode.hpp>
@@ -91,6 +92,64 @@ UTEST( Benchmark, CSSSelectorMatching ) {
Engine::destroySingleton(); Engine::destroySingleton();
} }
UTEST( Benchmark, CSSClassIndexLookup ) {
Engine::instance()->createWindow( WindowSettings( 800, 600, "CSS class index bench",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ) );
UISceneNode* sceneNode = UISceneNode::New();
SceneManager::instance()->add( sceneNode );
UIWidget* widget = UIWidget::NewWithTag( "div" );
widget->setClasses( { "button", "primary", "toolbar-item" } );
widget->setParent( sceneNode->getRoot() );
static constexpr int selectorCount = 1024;
std::string css;
for ( int i = 0; i < selectorCount; ++i ) {
const std::string selector =
i % 16 == 0 ? ".button" : ".unmatched-class-" + String::toString( i );
css += selector + " { width: " + String::toString( i + 1 ) + "px; }\n";
}
StyleSheetParser parser;
Clock constructionClock;
ASSERT_TRUE( parser.loadFromString( css ) );
const Time constructionElapsed = constructionClock.getElapsedTime();
const int matchingIterations = getSelectorMatchingIterations();
Uint64 legacyMatchCount = 0;
Clock legacyMatchingClock;
for ( int iteration = 0; iteration < matchingIterations; ++iteration ) {
for ( const auto& style : parser.getStyleSheet().getStyles() ) {
if ( style->isMediaValid() && style->getSelector().select( widget, false ) )
++legacyMatchCount;
}
}
const Time legacyMatchingElapsed = legacyMatchingClock.getElapsedTime();
Uint64 matchCount = 0;
Clock matchingClock;
for ( int iteration = 0; iteration < matchingIterations; ++iteration ) {
auto definition = parser.getStyleSheet().getElementStyles( widget, false );
matchCount += definition ? definition->getStyles().size() : 0;
}
const Time matchingElapsed = matchingClock.getElapsedTime();
EXPECT_EQ( static_cast<Uint64>( matchingIterations ) * 64u, legacyMatchCount );
EXPECT_EQ( legacyMatchCount, matchCount );
UTEST_PRINT_INFO(
String::format( "Stylesheet construction: %lld us", constructionElapsed.asMicroseconds() )
.c_str() );
UTEST_PRINT_INFO(
String::format( "Legacy full scan: %lld us", legacyMatchingElapsed.asMicroseconds() )
.c_str() );
UTEST_PRINT_INFO(
String::format( "Class index lookup: %lld us", matchingElapsed.asMicroseconds() ).c_str() );
UTEST_PRINT_INFO( String::format( "Stylesheet lookups: %d", matchingIterations ).c_str() );
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;
+57 -7
View File
@@ -15,6 +15,9 @@ void StyleSheet::clear() {
mMarker = 0; mMarker = 0;
mNodes.clear(); mNodes.clear();
mNodeIndex.clear(); mNodeIndex.clear();
mClassNodeIndex.clear();
mStyleSourceOrder.clear();
mNextStyleSourceOrder = 0;
mMediaQueryList.clear(); mMediaQueryList.clear();
mKeyframesMap.clear(); mKeyframesMap.clear();
mNodeCache.clear(); mNodeCache.clear();
@@ -57,11 +60,18 @@ void StyleSheet::setMarker( const Uint32& marker ) {
} }
void StyleSheet::removeAllWithMarker( const Uint32& marker ) { void StyleSheet::removeAllWithMarker( const Uint32& marker ) {
std::erase_if( mStyleSourceOrder,
[marker]( const auto& pair ) { return pair.first->getMarker() == marker; } );
std::erase_if( mNodeIndex, [marker]( auto& pair ) { std::erase_if( mNodeIndex, [marker]( auto& pair ) {
std::erase_if( pair.second, std::erase_if( pair.second,
[marker]( const auto& node ) { return node->getMarker() == marker; } ); [marker]( const auto& node ) { return node->getMarker() == marker; } );
return pair.second.empty(); // If true, the map entry is erased return pair.second.empty(); // If true, the map entry is erased
} ); } );
std::erase_if( mClassNodeIndex, [marker]( auto& pair ) {
std::erase_if( pair.second,
[marker]( const auto& node ) { return node->getMarker() == marker; } );
return pair.second.empty();
} );
std::erase_if( mNodes, [marker]( const auto& node ) { return node->getMarker() == marker; } ); std::erase_if( mNodes, [marker]( const auto& node ) { return node->getMarker() == marker; } );
@@ -76,12 +86,19 @@ void StyleSheet::removeAllWithMarker( const Uint32& marker ) {
} }
void StyleSheet::removeAllWithoutMarker( const Uint32& marker ) { void StyleSheet::removeAllWithoutMarker( const Uint32& marker ) {
std::erase_if( mStyleSourceOrder,
[marker]( const auto& pair ) { return pair.first->getMarker() != marker; } );
std::erase_if( mNodeIndex, [marker]( auto& pair ) { std::erase_if( mNodeIndex, [marker]( auto& pair ) {
std::erase_if( pair.second, [marker]( const auto& node ) { std::erase_if( pair.second, [marker]( const auto& node ) {
return node->getMarker() != marker; // Notice the != return node->getMarker() != marker; // Notice the !=
} ); } );
return pair.second.empty(); return pair.second.empty();
} ); } );
std::erase_if( mClassNodeIndex, [marker]( auto& pair ) {
std::erase_if( pair.second,
[marker]( const auto& node ) { return node->getMarker() != marker; } );
return pair.second.empty();
} );
std::erase_if( mNodes, [marker]( const auto& node ) { return node->getMarker() != marker; } ); std::erase_if( mNodes, [marker]( const auto& node ) { return node->getMarker() != marker; } );
@@ -165,6 +182,9 @@ StyleSheet& StyleSheet::operator=( const StyleSheet& other ) {
mMarker = other.mMarker; mMarker = other.mMarker;
mNodes = other.mNodes; mNodes = other.mNodes;
mNodeIndex = other.mNodeIndex; mNodeIndex = other.mNodeIndex;
mClassNodeIndex = other.mClassNodeIndex;
mStyleSourceOrder = other.mStyleSourceOrder;
mNextStyleSourceOrder = other.mNextStyleSourceOrder;
mMediaQueryList = other.mMediaQueryList; mMediaQueryList = other.mMediaQueryList;
mKeyframesMap = other.mKeyframesMap; mKeyframesMap = other.mKeyframesMap;
mNodeCache = other.mNodeCache; mNodeCache = other.mNodeCache;
@@ -172,9 +192,25 @@ StyleSheet& StyleSheet::operator=( const StyleSheet& other ) {
} }
bool StyleSheet::addStyleToNodeIndex( StyleSheetStyle* style ) { bool StyleSheet::addStyleToNodeIndex( StyleSheetStyle* style ) {
const std::string& id = style->getSelector().getSelectorId();
const std::string& tag = style->getSelector().getSelectorTagName();
if ( style->hasProperties() || style->hasVariables() ) { if ( style->hasProperties() || style->hasVariables() ) {
const auto& selector = style->getSelector();
const std::string& id = selector.getSelectorId();
const std::string& tag = selector.getSelectorTagName();
if ( id.empty() ) {
const auto& rule = selector.getRule( 0 );
if ( rule.hasClasses() ) {
// Use one mandatory class as the candidate anchor. Indexing every required class
// would duplicate the rule for elements that have several of them. The normal
// selector match below still validates the tag, all classes, and other constraints.
auto& nodes = mClassNodeIndex[rule.getBestClassHash()];
if ( std::find( nodes.begin(), nodes.end(), style ) == nodes.end() ) {
nodes.push_back( style );
return true;
}
Log::debug( "Ignored style %s", selector.getName().c_str() );
return false;
}
}
size_t nodeHash = this->nodeHash( "*" == tag ? "" : tag, id ); size_t nodeHash = this->nodeHash( "*" == tag ? "" : tag, id );
StyleSheetStyleVector& nodes = mNodeIndex[nodeHash]; StyleSheetStyleVector& nodes = mNodeIndex[nodeHash];
auto it = std::find( nodes.begin(), nodes.end(), style ); auto it = std::find( nodes.begin(), nodes.end(), style );
@@ -190,6 +226,7 @@ bool StyleSheet::addStyleToNodeIndex( StyleSheetStyle* style ) {
void StyleSheet::addStyle( std::shared_ptr<StyleSheetStyle> node ) { void StyleSheet::addStyle( std::shared_ptr<StyleSheetStyle> node ) {
if ( addStyleToNodeIndex( node.get() ) ) { if ( addStyleToNodeIndex( node.get() ) ) {
mStyleSourceOrder[node.get()] = mNextStyleSourceOrder++;
mNodes.push_back( node ); mNodes.push_back( node );
} }
addMediaQueryList( node->getMediaQueryList() ); addMediaQueryList( node->getMediaQueryList() );
@@ -229,10 +266,6 @@ void StyleSheet::combineStyleSheet( const StyleSheet& styleSheet ) {
addKeyframes( styleSheet.getKeyframes() ); addKeyframes( styleSheet.getKeyframes() );
} }
inline static bool StyleSheetNodeSort( const StyleSheetStyle* lhs, const StyleSheetStyle* rhs ) {
return lhs->getSelector().getSpecificity() < rhs->getSelector().getSpecificity();
}
// This is based on the RmlUi implementation. // This is based on the RmlUi implementation.
std::shared_ptr<ElementDefinition> StyleSheet::getElementStyles( UIWidget* element, std::shared_ptr<ElementDefinition> StyleSheet::getElementStyles( UIWidget* element,
const bool& applyPseudo ) const { const bool& applyPseudo ) const {
@@ -266,7 +299,24 @@ std::shared_ptr<ElementDefinition> StyleSheet::getElementStyles( UIWidget* eleme
} }
} }
std::stable_sort( applicableNodes.begin(), applicableNodes.end(), StyleSheetNodeSort ); for ( auto classHash : element->getClassHashes() ) {
auto itNodes = mClassNodeIndex.find( classHash );
if ( itNodes == mClassNodeIndex.end() )
continue;
for ( StyleSheetStyle* node : itNodes->second ) {
if ( node->isMediaValid() && node->getSelector().select( element, applyPseudo ) )
applicableNodes.push_back( node );
}
}
std::sort( applicableNodes.begin(), applicableNodes.end(),
[this]( const auto* lhs, const auto* rhs ) {
const auto lhsSpecificity = lhs->getSelector().getSpecificity();
const auto rhsSpecificity = rhs->getSelector().getSpecificity();
if ( lhsSpecificity != rhsSpecificity )
return lhsSpecificity < rhsSpecificity;
return mStyleSourceOrder.at( lhs ) < mStyleSourceOrder.at( rhs );
} );
if ( applicableNodes.empty() ) if ( applicableNodes.empty() )
return nullptr; return nullptr;
+1 -1
View File
@@ -340,7 +340,7 @@ bool StyleSheetSelector::isStructurallyVolatile() const {
return mStructurallyVolatile; return mStructurallyVolatile;
} }
const StyleSheetSelectorRule& StyleSheetSelector::getRule( const Uint32& index ) { const StyleSheetSelectorRule& StyleSheetSelector::getRule( const Uint32& index ) const {
return mSelectorRules[index]; return mSelectorRules[index];
} }
+34
View File
@@ -2914,6 +2914,40 @@ UTEST( UIHTML, HashedSelectorMatchingAndClassMutation ) {
Engine::destroySingleton(); Engine::destroySingleton();
} }
UTEST( UIHTML, ClassIndexedStyleSheetCandidates ) {
Engine::instance()->createWindow( WindowSettings( 1024, 768, "Class Index Test",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
UISceneNode* sceneNode = init_test_inline_block();
sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML(
R"html(<html><body><div id="target" class="foo bar"></div></body></html>)html" ) );
auto* target = sceneNode->getRoot()->find( "target" )->asType<UIWidget>();
ASSERT_TRUE( target != nullptr );
StyleSheetParser parser;
ASSERT_TRUE( parser.loadFromString( std::string_view( R"css(
* { color: white; }
div { background-color: black; }
.foo { width: 10px; }
.foo.bar { height: 20px; }
div.foo { min-width: 5px; }
#target.foo { max-width: 50px; }
.unrelated { opacity: 0.5; }
)css" ) ) );
auto definition = parser.getStyleSheet().getElementStyles( target, false );
ASSERT_TRUE( definition != nullptr );
EXPECT_EQ( 6u, definition->getStyles().size() );
EXPECT_TRUE( std::none_of( definition->getStyles().begin(), definition->getStyles().end(),
[]( const StyleSheetStyle* style ) {
return style->getSelector().getName() == ".unrelated";
} ) );
Engine::destroySingleton();
}
UTEST( UIHTML, BlockList ) { UTEST( UIHTML, BlockList ) {
Engine::instance()->createWindow( WindowSettings( 1024, 768, "Block List Test", Engine::instance()->createWindow( WindowSettings( 1024, 768, "Block List Test",
WindowStyle::Default, WindowBackend::Default, WindowStyle::Default, WindowBackend::Default,