Skip to content

Make Declarations#to_s ~24x faster: its clean-up regexp matched at every character - #193

Merged
grosser merged 3 commits into
premailer:masterfrom
czj:perf/declarations-to-s-regexp
Oct 26, 2026
Merged

grosser merged 3 commits into
premailer:masterfrom
czj:perf/declarations-to-s-regexp

Conversation

@czj

@czj czj commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

RuleSet::Declarations#to_s cleans its output with gsub!(/^[\s^({)]+|[\n\r\f\t]*|\s+$/mx, ''). [\n\r\f\t]* matches the empty string, so gsub! performs a replacement at every position of the string, and since the leftmost alternative always wins, the trailing \s+$ branch can never match. That's probably what the # TODO: Clean-up regexp doesn't seem to work above it was about :)

The first commit uses /^[\s^({)]+|[\n\r\f\t]+/ instead: same output on 700,000 random strings, ~24x faster per call. Through premailer, it was 74% of all gsub! time and 11.6% of the inlining wall time on our order confirmation email.

The second commit is smaller: normalize_property skips the downcased copy when the name is already lowercase ASCII (the internal margin-top-style literals always are), and create_dimensions_shorthand! fills a plain hash instead of chaining enumerators. Happy to split it out if you'd rather review them separately.

On our 156 transactional emails, inlined with premailer, output byte for byte identical before and after:

CPU per email objects per email
3.3.0 6.9–7.2 ms 16,059
this branch 6.1–6.2 ms 12,603 (−21%)

The suite passes (246 runs), RuboCop is clean.

Pre-Merge Checklist

  • CHANGELOG.md updated with short summary

🤖 Generated with Claude Code, but prompted, read, and verified by @czj, a caring human :)

czj added 2 commits October 23, 2026 18:16
… every character

`[\n\r\f\t]*` matches the empty string, so gsub! performed a replacement at
every position of the declarations string. Leftmost alternation also made the
trailing `\s+$` branch unreachable. `+` with the dead branch dropped gives the
same output (fuzzed on 200k random strings) ~24x faster.
normalize_property returned a fresh downcased copy on every lookup, even for
the already normalized literals used internally (margin-top, ...). Skip the
copy when the name is already lowercase ASCII. create_dimensions_shorthand!
built two enumerators and a pair array per direction; fill a plain hash.
@czj czj changed the title Speed up Declarations#to_s: its clean-up regexp matched at every character Make Declarations#to_s ~24x faster: its clean-up regexp matched at every character Oct 23, 2026
Comment thread lib/css_parser/rule_set.rb Outdated
attr_writer :declarations

def normalize_property(property)
return property if property.is_a?(String) && property.match?(NORMALIZED_PROPERTY_RX)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we move the property = property.to_s up to avoid the string check and use the match more often ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, done in 7ec0f07: the property goes through to_s first, so symbols like :color now take the fast path too instead of only strings. Thanks!

end
# TODO: Clean-up regexp doesn't seem to work
str.gsub!(/^[\s^({)]+|[\n\r\f\t]*|\s+$/mx, '')
str.gsub!(/^[\s^({)]+|[\n\r\f\t]+/, '')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this used to remove trailing whitespaces, don't we need that ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm "since the leftmost alternative always wins, the trailing \s+$ branch can never match"

str.gsub!(/^[\s^({)]+|[\n\r\f\t]*|\s+$/mx, '')

the problem is the middle, it matches every string, so that end never worked

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so yeah this should work and we did not really need the /m or /x either

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, the * in the middle matched the empty string everywhere, so \s+$ never got a chance; the str.strip! right after is what actually removes the trailing whitespace. And yes, the /m and /x flags are already gone in this version. Thanks a lot for the careful review, and for maintaining css_parser!

@grosser grosser left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks pretty good, not sure if it hits all the previous edge-cases

@czj

czj commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

To follow up on "not sure if it hits all the previous edge-cases": I ran old vs new side by side on 1,000,000 random strings mixing \n\r\f\t\v, ^({)}, NBSP, 
, NUL and non-ASCII letters (É, ß, İ), both for the to_s clean-up and for normalize_property (as strings and as symbols). Zero differences. Anything uppercase, padded or non-ASCII still takes the original downcase + strip! path. Thanks again for the quick review!

@grosser
grosser merged commit 073430e into premailer:master Oct 26, 2026
5 checks passed
@grosser

grosser commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

lmk if you want a new release or if there is more coming

@czj
czj deleted the perf/declarations-to-s-regexp branch October 26, 2026 05:09
@czj

czj commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

More coming! I found more allocation cuts across parsing, shorthands and merge: −40% objects when inlining our emails, −48% on Mastodon's mailer CSS, output unchanged. I'll send them as 2 small PRs that are easy to review, over the next day or two: one for declarations, where most of the gain is, and one for shorthands and media types. Release now or wait for them, both are fine with me. And if you'd rather we stop here, just say so, no hard feelings at all :)

@czj

czj commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Here they are: #194 for declarations and #195 for shorthands and media types. They touch different methods and can be merged in any order, only CHANGELOG.md will conflict and I'll rebase whichever lands last. That's all from me, so a release once they're in (or whenever suits you) would be perfect. Thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants