Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions html/eap/benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# EAP benchmarks

Compare three ways to render HTML:

- `fresh`: create a renderer for each call.
- `threadLocal`: reuse a renderer on each worker thread.
- `sharedKeyedPool`: reuse the production pool with one key shared by all workers.

Each case checks its HTML output before measurement.

## Run

From the repository root, run (default 4 workers):

```shell
html/gradlew -p html -Pcompose.html.eap.enabled=true \
:html-eap-benchmarks:benchmark
```

Defaults: 1 or 64 rows, two JVM forks, two one-second warmups, and three one-second measurements.
Results are saved under `html/eap/benchmarks/build/reports/benchmarks`.

For a short smoke test of all strategies and row counts:

```shell
html/gradlew -p html -Pcompose.html.eap.enabled=true \
:html-eap-benchmarks:smokeBenchmark
```

## Add a benchmark

Add an `open` Kotlin class under `src/main/kotlin` with `kotlinx.benchmark` annotations.
Use `@JvmField` on `@Param` properties so JMH can populate them.
59 changes: 59 additions & 0 deletions html/eap/benchmarks/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import kotlinx.benchmark.gradle.JvmBenchmarkTarget
import org.gradle.jvm.tasks.Jar
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
kotlin("jvm")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlinx.benchmark") version "0.4.15"
}

val composeVersion = providers.gradleProperty("compose.version").get()
val browserVersion = providers.gradleProperty("compose.html.eap.kotlinx-browser-common-subset.version").get()
val benchmarkVersion = "0.4.15"

dependencies {
implementation(project(":html-core-eap"))
implementation("org.jetbrains.compose.runtime:runtime:$composeVersion")
implementation("org.jetbrains.compose.html:kotlinx-browser-common-subset:$browserVersion")
implementation("org.jetbrains.kotlinx:kotlinx-benchmark-runtime:$benchmarkVersion")
}

// Allow the benchmark to access renderer internals.
tasks.named<KotlinCompile>("compileKotlin") {
friendPaths.from(
project(":html-core-eap").tasks.named<Jar>("jvmJar")
.flatMap { it.archiveFile }
)
}

// Match the library's Java compatibility.
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}

tasks.withType<JavaCompile>().configureEach {
options.release.set(11)
}

benchmark {
configurations {
configureEach {
// Preserve @Fork rather than the runner's default of one fork.
advanced("jvmForks", "definedByJmh")
}
register("smoke") {
warmups = 1
iterations = 1
iterationTime = 100
iterationTimeUnit = "ms"
}
}
targets {
register("main") {
this as JvmBenchmarkTarget
jmhVersion = "1.37"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/

package org.jetbrains.compose.web

import androidx.compose.runtime.Composable
import kotlinx.benchmark.*
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.Span
import org.jetbrains.compose.web.dom.Text
import org.openjdk.jmh.annotations.Fork
import org.openjdk.jmh.annotations.Threads

/** Compares renderer reuse strategies with the same HTML workload on concurrent workers. */
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(BenchmarkTimeUnit.MICROSECONDS)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 3, time = 1)
@Fork(value = 2, jvmArgsAppend = ["-Xms256m", "-Xmx256m"])
@Threads(4)
open class RendererPoolBenchmark {
@Param("1", "64")
@JvmField
var rows: Int = 0

// Keep the previous cache shape for a direct before/after comparison.
private val threadLocal = ThreadLocal.withInitial { HashMap<String, HtmlRenderer>() }
private lateinit var content: @Composable () -> Unit

@Setup
open fun setup() {
content = {
repeat(rows) {
Div {
Text("row <&>😀")
Span { Text("tail") }
}
}
}
// Check identical output for every strategy before measuring performance.
val expected = "<div>row &lt;&amp;&gt;😀<span>tail</span></div>".repeat(rows)
check(expected, fresh())
check(expected, threadLocal())
check(expected, sharedKeyedPool())
}

// Allocation baseline: each call owns a new renderer.
@Benchmark
open fun fresh(): String = render(HtmlRenderer())

// Each worker reuses its own renderer.
@Benchmark
open fun threadLocal(): String {
val renderer = threadLocal.get().getOrPut("template") { HtmlRenderer() }
return render(renderer)
}

// Workers borrow exclusive renderers from the same keyed pool.
@Benchmark
open fun sharedKeyedPool(): String {
val pool = checkNotNull(getHtmlRendererPool("benchmark-template"))
val renderer = pool.borrow()
val result = render(renderer)
pool.recycle(renderer)
return result
}

private fun render(renderer: HtmlRenderer): String {
// Use the same rendering path in every case, excluding public validation wrappers.
return renderer.render(
hydratable = true,
requireHtmlDocumentRoot = false,
chunkSink = null,
chunkSize = 2048,
prefix = "",
content = content,
)
}

private fun check(expected: String, actual: String) {
if (expected != actual) {
throw AssertionError("Every storage strategy must render the expected HTML")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,30 @@
package org.jetbrains.compose.web

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider

/**
* Composes [content] once and sends HTML chunks synchronously to [sink]. Concatenating the chunks
* produces the same HTML as [composeHtmlToString] with the same arguments.
*
* [chunkSize] defaults to 2048 and is a target minimum measured in UTF-16 code units. Tags and individual `Text`
* calls stay intact, as do elements whose contents require validation. Leading-newline repair
* for `pre`, `textarea`, and `listing` happens before their first content is emitted.
* A chunk can exceed [chunkSize], and no empty chunks are sent.
* calls stay intact, as do elements whose contents require validation. A chunk can exceed [chunkSize],
* and no empty chunks are sent.
*
* Rendering behavior and limitations follow [composeHtmlToString].
* Each render has fresh remembered state; a non-null [key] reuses string and streaming storage.
* Keyed calls are not thread-safe, even with different keys. Snapshot state changes are discarded.
* Coroutine effects such as `LaunchedEffect` do not run, while `SideEffect` and `DisposableEffect` still execute.
* Calls are thread-safe. Synchronize shared mutable data accessed by [content].
*
* [sink] runs synchronously while rendering and may block. Output already sent cannot be retracted
* if rendering or the sink throws. Failed rendering storage is reset before the exception is rethrown.
* if rendering or the sink throws. Failed rendering storage is discarded.
*
* @param sink receives each non-empty HTML chunk.
* @param chunkSize target minimum chunk size in UTF-16 code units. Defaults to 2048.
* @param hydratable whether to emit text-boundary markers needed to hydrate adjacent `Text` calls.
* Set to `false` when the output will not be hydrated.
* @param key optional key for reusing rendering storage across calls.
* Use a stable key per template. Reuse is best-effort. `null` disables pooling.
* @param validateStrictly overrides the default strict-validation setting for this render.
* @throws IllegalArgumentException if [chunkSize] is not positive or content cannot be serialized.
* @throws IllegalStateException if rendering reenters with the same non-null [key], or [content]
* accesses `currentRecomposeScope`.
* @param content the composable content to render once.
* @throws IllegalArgumentException if [chunkSize] is not positive or [content] cannot be safely serialized as HTML.
* @throws IllegalStateException if [content] accesses `currentRecomposeScope`.
* @throws UnsupportedOperationException if [content] uses an unsupported composition operation.
*/
fun composeHtmlToStream(
Expand All @@ -40,14 +40,9 @@ fun composeHtmlToStream(
validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict,
content: @Composable () -> Unit,
) {
require(chunkSize > 0) { "chunkSize must be positive" }
val chunkSink: (StringBuilder) -> Unit = { sink(it.toString()) }
val validatedContent: @Composable () -> Unit = {
CompositionLocalProvider(LocalHtmlValidationMode provides htmlValidationMode(validateStrictly)) {
content()
}
}
composeHtmlString(
hydratable = hydratable, key = key, chunkSink = chunkSink, chunkSize = chunkSize, content = validatedContent,
hydratable = hydratable, key = key, chunkSink = chunkSink, chunkSize = chunkSize,
content = htmlValidatedContent(validateStrictly, content),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,26 @@ import org.jetbrains.compose.web.dom.LocalComposeHtmlContext

/**
* Composes [content] once into an HTML string without creating browser DOM nodes.
* With no [key], rendering storage is discarded after the HTML has been written.
* With a key, storage is retained for later string and streaming calls.
* Keyed calls are not thread-safe, even with different keys.
* Snapshot state changes made while rendering are discarded afterwards.
* Coroutine effects such as `LaunchedEffect` do not run. `SideEffect` and
* `DisposableEffect` still execute.
*
* Snapshot state changes are discarded after rendering. Coroutine effects such as
* `LaunchedEffect` do not run. `SideEffect` and `DisposableEffect` still execute.
*
* Calls are thread-safe. Synchronize shared mutable data accessed by [content].
* See [composeHtmlToStream] for validation and failure handling.
*
* Inline styles preserve CSS fallbacks but do not fully emulate CSSOM validation and mutation.
* Custom callbacks passed to `AttrsScope.prop(...)` are ignored during string rendering.
*
* On the JVM or Node, set `COMPOSE_HTML_VALIDATE_STRICTLY=true` to enable additional class and
* duplicate foreign-attribute checks by default.
* On the JVM or Node.js, set `COMPOSE_HTML_VALIDATE_STRICTLY=true` to enable additional class and
* duplicate foreign-attribute checks.
*
* @param hydratable whether to emit text-boundary markers required to hydrate adjacent `Text`
* @param hydratable whether to emit text-boundary markers needed to hydrate adjacent `Text`
* nodes. Set to `false` when the output will not be hydrated.
* @param key when non-null, reuses rendering storage across calls.
* @param key optional key for reusing rendering storage across calls.
* Use a stable key per template. Reuse is best-effort. `null` disables pooling.
* @param validateStrictly overrides the default strict-validation setting for this render.
* @throws IllegalArgumentException if a raw-text element contains unsafe text or element children,
* serialized `noscript` contents contain a `</noscript>` end tag, or ordinary text, RCDATA,
* or attribute values contain NUL (U+0000), which HTML parsing cannot preserve.
* @throws IllegalStateException if rendering reenters with the same non-null [key], or [content]
* accesses `currentRecomposeScope`.
* @throws IllegalArgumentException if [content] cannot be safely serialized as HTML.
* @throws IllegalStateException if [content] accesses `currentRecomposeScope`.
* @throws UnsupportedOperationException if [content] uses an unsupported composition operation.
*/
fun composeHtmlToString(
Expand All @@ -47,12 +45,7 @@ fun composeHtmlToString(
validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict,
content: @Composable () -> Unit,
): String {
val validatedContent: @Composable () -> Unit = {
CompositionLocalProvider(LocalHtmlValidationMode provides htmlValidationMode(validateStrictly)) {
content()
}
}
return composeHtmlString(hydratable = hydratable, key = key, content = validatedContent)
return composeHtmlString(hydratable = hydratable, key = key, content = htmlValidatedContent(validateStrictly, content))
}

/** Shared single-pass composition and writer for fragments, documents, and streaming output. */
Expand All @@ -65,22 +58,29 @@ internal fun composeHtmlString(
key: String? = null,
content: @Composable () -> Unit,
): String {
val renderer = if (key == null) HtmlRenderer(reusable = false) else renderers.getOrPut(key) { HtmlRenderer() }
return renderer.render(hydratable, requireHtmlDocumentRoot, chunkSink, chunkSize, prefix, content)
if (chunkSink != null) require(chunkSize > 0) { "chunkSize must be positive" }

val pool = getHtmlRendererPool(key)
val renderer = pool?.borrow() ?: HtmlRenderer()

// Return storage to the pool only after rendering, effect cleanup and snapshot disposal succeed.
val result = renderer.render(hydratable, requireHtmlDocumentRoot, chunkSink, chunkSize, prefix, content)
pool?.recycle(renderer)
return result
}

private const val LARGE_BUFFER_THRESHOLD = 64 * 1024
private const val BUFFER_SHRINK_FACTOR = 4
private val renderers = mutableMapOf<String, HtmlRenderer>()
private val renderers = HtmlRendererPools(maxKeys = 64, maxIdleRenderers = 8)

internal fun getHtmlRendererPool(key: String?): HtmlRendererPool? = if (key == null) null else renderers[key]

/** Owns reusable rendering storage for a key, or for a single unkeyed render. */
internal class HtmlRenderer(private val reusable: Boolean = true) {
private var attrsBuilders = AttrsBuilderPool()
/** Storage exclusively owned by one active render, then optionally returned to a pool. */
internal class HtmlRenderer {
private val attrsBuilders = AttrsBuilderPool()
private var output = StringBuilder()
private var composer = SinglePassComposer()
private var rendering = false
private val composer = SinglePassComposer()

internal val bufferCapacity: Int get() = output.capacity()
internal val bufferLength: Int get() = output.length

fun render(
hydratable: Boolean,
Expand All @@ -90,13 +90,9 @@ internal class HtmlRenderer(private val reusable: Boolean = true) {
prefix: String,
content: @Composable () -> Unit,
): String {
check(!rendering) { "Reentrant HTML rendering with the same key is not supported" }
val snapshot = Snapshot.takeMutableSnapshot()
rendering = true
try {
output.setLength(0)
output.append(prefix)
composer.reset()
val context = HtmlStringWriterContext(hydratable, attrsBuilders, output, chunkSink, chunkSize)
return snapshot.enter {
var failure: Throwable? = null
Expand All @@ -109,7 +105,8 @@ internal class HtmlRenderer(private val reusable: Boolean = true) {
composer.applyEffects()
context.finish(requireHtmlDocumentRoot).also {
if (isOversized(output.capacity(), context.peakBufferSize)) {
output.trimToSize()
if (chunkSink == null) output.trimToSize()
else output = StringBuilder(context.peakBufferSize)
}
}
} catch (cause: Throwable) {
Expand All @@ -119,20 +116,25 @@ internal class HtmlRenderer(private val reusable: Boolean = true) {
composer.dispose(failure)
}
}
} catch (failure: Throwable) {
// Only retained renderers need replacement storage after a failed request.
if (reusable) {
attrsBuilders = AttrsBuilderPool()
output = StringBuilder()
composer = SinglePassComposer()
}
throw failure
} finally {
rendering = false
snapshot.dispose()
try {
snapshot.dispose()
} finally {
// Clear the output before reuse. The underlying buffer is not erased.
output.setLength(0)
}
}
}
}

internal fun htmlValidatedContent(
validateStrictly: Boolean,
content: @Composable () -> Unit,
): @Composable () -> Unit = {
CompositionLocalProvider(LocalHtmlValidationMode provides htmlValidationMode(validateStrictly)) {
content()
}
}

private fun isOversized(capacity: Int, used: Int): Boolean =
capacity > LARGE_BUFFER_THRESHOLD && used.toLong() * BUFFER_SHRINK_FACTOR < capacity
Loading
Loading