Skip to content

Parse declarations with 2.3x fewer allocations (−32% per email through premailer) - #194

Open
czj wants to merge 4 commits into
premailer:masterfrom
czj:perf/declarations-fewer-allocations
Open

czj wants to merge 4 commits into
premailer:masterfrom
czj:perf/declarations-fewer-allocations

Conversation

@czj

@czj czj commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Following up on #193 :) Profiling premailer on our emails again, most of what css_parser still allocated came from storing and printing declarations. Every Value copied its string through sub even with nothing to clean. reduce and each_with_object built a [key, value] pair per declaration, casecmp? case-folded both strings, and replace_declaration! rebuilt its hash through zip.

This branch copies a value only when it really has a trailing semicolon, an !important or surrounding whitespace to remove, and freezes the cleaned copy with -value, so repeated values (0, #fff, bold…) share one String. Hashes are iterated with plain each. casecmp(IMPORTANT).zero? replaces casecmp?: I checked every codepoint, none case-folds into !important. The strips in CssParser.merge are gone too, stored values are already stripped.

One trade-off to flag: when the value passed in is frozen and needs no cleaning, Value#value is now that String or its interned twin rather than a fresh copy. Unfrozen strings are still copied, and never frozen or mutated, whether they need cleaning or not. There's a test for both cases.

Against today's master, which includes #193 and #195, a parsed declaration goes from 10.4 objects to 4.4 and declarations_to_s from 15 to 3. Measured end to end, median of 11 interleaved runs, Ruby 4.0.9 on an Apple M5, premailer 1.32.0, nokogiri 1.19.4:

objects bytes CPU
our 167 transactional emails through premailer, per email 13,948 → 9,448 (−32%) 871 → 693 KiB (−20%) ≈
Mastodon's mailer CSS, parsed then merged 42,088 → 24,586 (−42%) 2,371 → 1,679 KiB (−29%) −7.6%
premailer's own test files 26,682 → 18,460 (−31%) 1,733 → 1,424 KiB (−18%) −3%
rake benchmark, complex.css 8,068 → 6,365 (−21%) 517 → 427 KiB (−17%) ≈

All three corpora give byte-for-byte identical output. A differential fuzz against today's master (50,000 seeded random values, blocks, merges and shorthand round-trips, about 300,000 output lines) found no difference. The new allocation test fails on master, and is skipped on JRuby. The suite passes on Ruby 3.3 and 4.0, and RuboCop is clean.

Sister PR: #195, for shorthands and media types, now merged into this branch.

Pre-Merge Checklist

  • CHANGELOG.md updated with short summary

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

Value#value= copied its string through sub even with nothing to clean;
it now copies only when a trailing semicolon, !important or surrounding
whitespace has to go, and freezes the result with -value. reduce and
each_with_object over the declarations hash built a [key, value] pair
per entry, casecmp? case-folded both strings, replace_declaration!
rebuilt its hash through zip, and merge stripped already stripped values.

A parsed declaration goes from 10.6 to 4.6 objects, declarations_to_s
from 21 to 7; inlining our 336 emails with premailer allocates 32% fewer
objects, with byte-for-byte identical output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… of 3 runs

The unfrozen-value test only used a value that needed cleaning, so sub
copied it before -value ran; a clean one now goes straight through -value
and must come out neither frozen nor mutated. GC.stat counts allocations
of every thread, so the helper keeps the minimum of three runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
value = value.sub(TRAILING_SEMICOLON_RX, '') if value.include?(SEMICOLON)
self.important = value.include?('!') && value.match?(CssParser::IMPORTANT_IN_PROPERTY_RX)
value = value.sub(CssParser::IMPORTANT_IN_PROPERTY_RX, '') if important
value = value.strip if value.match?(SURROUNDING_WHITESPACE_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.

is this not the same ?

Suggested change
value = value.strip if value.match?(SURROUNDING_WHITESPACE_RX)
value.strip!

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.

Sadly not quite: when nothing was trimmed before, value is still the caller's string, so strip! would modify it (or raise on a frozen one). The new comment above the block says so, and test_unfrozen_values_are_neither_frozen_nor_mutated covers it.

Comment on lines -55 to +62
@value = value.freeze
@value = -value

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.

is this not the same as before ?

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.

Almost! Before, value was always a fresh copy from sub, so freezing it was harmless. Now it can be the caller's own string, and -value freezes a copy instead of it. I added a one-line comment.

value = value.to_s
value = value.sub(TRAILING_SEMICOLON_RX, '') if value.include?(SEMICOLON)
self.important = value.include?('!') && value.match?(CssParser::IMPORTANT_IN_PROPERTY_RX)
value = value.sub(CssParser::IMPORTANT_IN_PROPERTY_RX, '') if important

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.

not a fan of this double-checking and hand-optimizing 😢

if it really makes a difference leave some inline comments splitting the logic up explaining why it needs to be weird to be more performance

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.

Fair point, thank you! I dropped the include? pre-checks: they made value= alone 15–19% faster but didn't show on a whole parse. I kept only the match? guards, which keep clean values at zero allocations (4.4 vs 6.4 objects per declaration), and explained them in a comment.

Comment thread lib/css_parser/rule_set.rb Outdated
property.strip!
value.strip!
next if property.empty? || value.empty? || value.casecmp?(IMPORTANT)
next if property.empty? || value.empty? || value.casecmp(IMPORTANT) == 0

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.

prefer to keep the ? ... can't imagine that being slower

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.

I was surprised too, but on Ruby 4.0.9 casecmp? case-folds both strings: 2 objects per call and 2x slower, which takes a parse from 4.4 back to 6.4 objects per declaration. I went with casecmp(IMPORTANT).zero? so it still reads as a predicate, with a short comment. Happy to switch back if you prefer!

@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.

most of these look fine, some even improve readability like the .reduce change

@czj

czj commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

most of these look fine, some even improve readability like the .reduce change

Indeed. I aim to perform a readability review aligned with the repository’s code style after addressing each issue !

@czj

czj commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Will rebase with main ASAP to fix the conflict.

czj and others added 2 commits October 27, 2026 12:29
…-fewer-allocations

# Conflicts:
#	CHANGELOG.md
Value#value= now guards sub and strip with the regexps themselves:
same allocations, the include? short-cuts only made value= alone
15-19% faster, which does not show on a whole parse. Comments say why
the guards and -value are there, and why casecmp? is avoided (it
case-folds both strings: two objects per declaration). The rebuilt
declarations hash reads with each_with_index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@czj

czj commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Done ! You can merge if you want :)

@czj czj changed the title Parse declarations with 2.3x fewer allocations: −32% objects inlining emails Parse declarations with 2.3x fewer allocations Oct 27, 2026
@czj czj changed the title Parse declarations with 2.3x fewer allocations Parse declarations with 2.3x fewer allocations (−32% per email through premailer) Oct 27, 2026
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