Skip to content

Reduce Compose HTML EAP rendering overhead - #5768

Merged
Clemens Koprolin (clemenskoprolin) merged 2 commits into
masterfrom
clemenskoprolin/compose-html-ssr-performance
Oct 23, 2026
Merged

Clemens Koprolin (clemenskoprolin) merged 2 commits into
masterfrom
clemenskoprolin/compose-html-ssr-performance

Conversation

@clemenskoprolin

Copy link
Copy Markdown
Collaborator

This PR further reduces rendering overhead in Compose HTML EAP string, streaming and hydrated-document rendering.

Performance changes include:

  • HTML wrappers: avoid redundant composition groups.
  • CSS serialization: reuse value conversions when priority or removal requires another pass.
  • JVM CSS numbers: speed up fractional formatting while preserving rounding and notation.
  • JVM HTML escaping: use native searches and bulk copying, with a lazily initialized lookup table for long strings that need escaping.
  • Rendering state: avoid redundant writer state updates and reuse raw-text tag metadata.

Document-root and table-text validation now run only in strict mode. Fast mode assumes valid document structure and table content.

In our benchmarks, this improves warmed subsequent renders by 35% overall, with fractional styles improving by ~50%.

Involves CMP-10836

Testing

Run from the html directory:

./gradlew -Pcompose.html.eap.enabled=true \
  :html-core-eap:jvmTest \
  :html-core-eap:jsBrowserTest \
  :html-core-eap:wasmJsBrowserTest \
  :html-svg-eap:jvmTest \
  :html-svg-eap:jsBrowserTest \
  :html-svg-eap:wasmJsBrowserTest

Release Notes

N/A

Avoid redundant composition groups in HTML wrappers, reuse CSS value
conversions and reduce writer state updates. Reuse raw-text tag metadata
and limit document-root and table-text validation to strict mode.

Speed up JVM fractional CSS formatting while preserving rounding and
notation. Use native searches and bulk copying for HTML escaping, with a
lazily initialized lookup table for long strings that require escaping.
// Walk backwards to retain fallbacks without repeatedly scanning earlier declarations.
// Empty assignments remove a property. Normal assignments reset its earlier priority.
return declarations.asReversed().filter { declaration ->
val included = BooleanArray(declarations.size)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How large can it be in practice? Not many I assume.

Two thoughts:

  • perhaps the bits in Long (64 bits) are enough to hold the necessary amount of flags
  • Otherwise, for jvm this can use java.util.BitSet to support more than 64 values

But it's fine to keep your current implementation until we see any evidences in the profiler.
adding a comment to consider BitSet for k/jvm would be enough

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

In our current test workloads, we only have 5-8 declarations. But we can't expect that to be always the case, so we'd still need a fallback with Long.
Long wasn't faster in complete SSR and reduced allocation by 0.5%. I think the simpler implementation without the fallback is worth keeping, or WDYT?

I also tried BitSet - it was about 5% slower.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Got it. TIL: BitSet is slower than BooleanArray (at least in this case).

if (declaration.important) append(" !important")
}
}
return buildString {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I see this function (toStyleAttributeValue) has 2 use cases:

  • the resulting String is written into a StringBuilder
  • the resulting String is used as String directly

It might make sense to have 2 overloads:

  • 1: a new variant taking the StringBuilder as a parameter
  • 2: same signature as now but implemented via the 1st overload

which of the existing benchmark workloads "exercises" this function?
Here too, I think no rush to apply my suggestion. Maybe adding a comment in the code is enough.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Especially fractional-style, but also data-table.

I quickly tried adding two overloads on fractional-style, it had 3.4% less allocation, but 2.7% more render time. It still produces an intermediate CSS String because the SSR caller subsequently HTML-escapes it. I'll add a comment to further look into it in the future.

}

internal actual fun StringBuilder.appendEscapedHtmlString(value: String, attribute: Boolean): Boolean {
if (value.length < 256) return false

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What's the reasoning behind choosing this specific number - 256 ?
Might make sense to declare a const and reuse it here and in other places.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It's a heuristic I found using the benchmarks. The overhead of using the specialized serializer serializer for short strings seems to be too high, so we continue using the existing path paths there.
I'll make it a constant & add a short comment.

@clemenskoprolin
Clemens Koprolin (clemenskoprolin) merged commit 82dee5e into master Oct 23, 2026
2 checks passed
@clemenskoprolin
Clemens Koprolin (clemenskoprolin) deleted the clemenskoprolin/compose-html-ssr-performance branch October 23, 2026 13:03
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