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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 0.13.0 - 2026-09-23

- Split the generated registry's `render` dispatch into chunks of at most 256 templates and move the
subclass `instanceof` fallback and the `supportsReturnType` supertype chain into their own methods.
With about 215 or more templates, the single dispatch method exceeded HotSpot's 8000-byte limit for
JIT compilation and stayed interpreted: ReAI's web-app registry (353 templates, 13,137 bytes) paid
about 190 ns per render versus about 10 ns for the split form. Every dispatch method is now far
below the limit at any application size. Rendered output is unchanged.
- Remove `HtmlOutput.url(TrustedUrl)`. Generated renderers never called it, and calling it directly
skipped the context-path resolution that `TrustedUrl` values receive in templates.
- Compile identifier and route-joining regular expressions once instead of once per catalog key,
template or route.

## 0.12.2 - 2026-09-19

- Avoid copying and reformatting generated renderer sources; write their final indentation directly.
Expand Down
19 changes: 7 additions & 12 deletions ISOLATED_PROJECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,21 +84,16 @@ See [Gradle's migration guide](https://docs.gradle.org/9.7.1/userguide/isolated_
[Eteo #833](https://github.com/beint-no/eteo/pull/833), and
[Ecomtools #380](https://github.com/beint-no/ecomtools/pull/380).

[Gradle #39057](https://github.com/gradle/gradle/pull/39057) proposes an isolated-projects regression test and API
contract clarification for `ResolvedComponentResult.moduleVersion` through the lazy resolution result. It covers
late producer versions, renamed archives, unrelated explicit capabilities and included-build identity. Both targeted
integration tests, CodeNarc and Checkstyle pass. Maintainer confirmation of that contract is still required.
[Gradle #39057](https://github.com/gradle/gradle/pull/39057), an isolated-projects regression test and API
contract clarification for `ResolvedComponentResult.moduleVersion`, was closed without merging on 2026-09-10.

[The Spring Boot follow-up](https://github.com/spring-projects/spring-boot/issues/43755#issuecomment-5551490296)
links this evidence and asks how the existing public custom-archive API should preserve compatibility when supplied
only an artifact provider. The previously closed capability-based patch has not been resubmitted.

[KSP #3189](https://github.com/google/ksp/pull/3189) uses Gradle's effective `BuildFeatures` state, including CLI
precedence, while preserving explicit KSP opt-in and older Gradle support. Six focused tests pass, including actual
generated-source compilation and configuration-cache reuse. Plugin validation and formatting checks pass too.
Keep `ksp.project.isolation.enabled=true` until a release containing the fix is adopted.
[KSP #3189](https://github.com/google/ksp/pull/3189), which would have read Gradle's effective `BuildFeatures`
state, was closed without merging on 2026-09-05. Keep `ksp.project.isolation.enabled=true`.

Both contributions disclose AI assistance. The Gradle draft needs human review and DCO sign-off; the KSP draft needs
Google's contributor agreement. These are pending upstream contributions, not released fixes. Once accepted and
released, retest normal and isolated `bootJar`, custom layer coordinates, included builds, IDE import and cache reuse
before enabling isolation globally. No forked Spring Boot or KSP artifact is used by the consumers.
No forked Spring Boot or KSP artifact is used by the consumers. If upstream support lands, retest normal and
isolated `bootJar`, custom layer coordinates, included builds, IDE import and cache reuse before enabling
isolation globally.
47 changes: 42 additions & 5 deletions PERFORMANCE_AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,10 @@ and allocations are recorded in [the benchmark results](benchmark/results/2026-0

| Priority | Finding | Next step |
| --- | --- | --- |
| Highest follow-up | Generated renderers share one Java source and static resource, with aggregating KSP dependencies. ReAI's existing local output contains 314 renderer classes in a 13.49 MB source file, plus a 1.51 MB static resource. | Implemented as per-template sources and resources after 0.11.2; see the per-template section below. |
| Medium | Each expression property lookup can walk KSP properties and supertypes again, including properties from shared layouts. | Profile symbol-resolution time, then consider a cache confined to one processor invocation. Keep missing-property diagnostics and generic/inherited property behavior intact. |
| Medium | Generated `supports`, `supportsReturnType`, request-data checks, and render dispatch use linear checks. | Add a benchmark with hundreds of page models and the full Spring handler path before replacing dispatch with a map or `ClassValue`. Preserve custom `TemplateSet` behavior and subclass handling. |
| Medium | Servlet rendering allocates an 8 KiB body buffer and a 1 KiB output buffer, then buffers the whole response. | Measure full pages and small HTMX responses through the Spring adapter. Tune sizing only with allocation and latency evidence. Streaming changes failure handling and content-length behavior, so it needs separate design work. |
| Highest follow-up | Generated renderers share one Java source and static resource, with aggregating KSP dependencies. ReAI's existing local output contains 314 renderer classes in a 13.49 MB source file, plus a 1.51 MB static resource. | Implemented in 0.12.0 as 32 hash-assigned source files, each with its own resource; one file per template was measured and rejected. See the per-template section below. |
| Medium | Each expression property lookup can walk KSP properties and supertypes again, including properties from shared layouts. | Implemented in 0.12.1 as a cache confined to one processor invocation. |
| Medium | Generated `supports`, `supportsReturnType`, request-data checks, and render dispatch use linear checks. | Implemented in 0.11.2 as a class index; 0.13.0 splits the dispatch so it stays JIT-compilable. See the 0.11.2 follow-up and 0.13.0 sections below. |
| Medium | Servlet rendering allocates an 8 KiB body buffer and a 1 KiB output buffer, then buffers the whole response. | Measured in the 0.11.2 follow-up and left unchanged: larger buffers cost 30–70% on small HTMX responses. Streaming changes failure handling and content-length behavior, so it needs separate design work. |
| Lower | CSS and message-usage checks scan production sources/classes across all modules. | Measure their actual task time and cache hit rates in ReAI. Both tasks are already cacheable; reducing scope must preserve cross-module validation. |

The existing local generated-file sizes are supporting evidence of compilation scope,
Expand Down Expand Up @@ -245,7 +245,7 @@ replacing the form-error stream with a loop, and using a linear string join for
source arguments. None has evidence of a substantial benefit in these consumers;
the Java-only compilation path is not used by these four Kotlin applications. The
larger property-resolution, dispatch, response-buffering, and incremental-compilation
ideas above still need dedicated profiling and compatibility work.
ideas were followed up in the later sections.

Reproduce the message measurements with:

Expand Down Expand Up @@ -446,3 +446,40 @@ override was also left unchanged: ordinary Gradle builds do not validate debugge

Raw measurements, including all warmups and the isolated candidates, are in
[`2026-09-19-compiler-small-wins.json`](benchmark/results/2026-09-19-compiler-small-wins.json).

## 0.13.0 — registry dispatch below the JIT limit — 23 September 2026

The 0.11.2 index made dispatch constant time in a replica benchmark, but the shipped
registry kept the subclass `instanceof` chain in the same `render` method as the index
switch. That costs about 37 bytes of bytecode per template, so from roughly 215 templates
the method exceeds HotSpot's 8000-byte `HugeMethodLimit` and, with the default
`-XX:+DontCompileHugeMethods`, is never JIT-compiled. ReAI's web-app registry is 353
templates with a 13,137-byte `render`, and production uses default JIT flags. Bedri
(1,334 bytes) and Eteo (1,001 bytes) were below the limit.

JMH replica of the generated shape at 353 templates (Apple M5 Max, OpenJDK 27, three
forks, five one-second warmups and measurements, models visited in a stride-7 cycle; the
host was under unrelated load, which widens the interpreted error):

| Registry shape | ns/op |
| --- | ---: |
| 0.12.2: index switch and `instanceof` chain in `render` | 195.1 ± 142.9 |
| 0.12.2 with `-XX:-DontCompileHugeMethods` | 11.9 ± 0.6 |
| 0.13.0: dispatcher, 256-template chunks, separate fallback | 10.2 ± 0.2 |

The control run attributes the whole difference to the method being interpreted. The
generated registry now has a small `render` that switches on `index / 256` to private
chunk methods (about 4 KB each at most), while the unchanged `instanceof` chain lives in
`renderSubtype` and the `isAssignableFrom` chain in `isSupertypeOfModel`. Both are off the
exact-class hot path. The fallback chain keeps every model because KSP's `isOpen()` cannot
see all-open compiler plugins or Java library classes, so dropping "final" models would
not be provably safe. With ReAI compiled against the candidate, the largest methods are
`renderSubtype` at 6,727 bytes, `render0` at 4,127 bytes and `isSupertypeOfModel` at 3,419 bytes;
`render` is 108 bytes.

Validation: ReAI's web-app generated output against 0.12.2 differs only in
`ThimTemplates.java`; all 32 renderer part sources, their resources and the message usage
manifest are byte-identical. ReAI (web-app, bedri), Utin, Eteo and Ecomtools compile
against the candidate via `-PthimBuild`. `RegistryGeneratorTest` compiles a 600-template
registry with an open model and a subclass, renders every model, and keeps each
per-request method below 8000 bytes; the example golden renders are unchanged.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ pluginManagement {
}

plugins {
id("no.beint.thim.settings") version "0.11.2"
id("no.beint.thim.settings") version "0.13.0"
}
```

Expand All @@ -93,7 +93,7 @@ plugins {
}
```

The plugin supplies the dependency-free runtime and the build-time compiler, and tracks templates and messages as compilation inputs. When the Spring Boot plugin is present it also adds the Spring MVC adapter automatically, regardless of plugin application order. Plain Spring applications can opt in explicitly with `implementation("no.beint.thim:spring:0.11.2")`. Compiled template jars publish their registries through Java's service loader, so templates can live in any application module.
The plugin supplies the dependency-free runtime and the build-time compiler, and tracks templates and messages as compilation inputs. When the Spring Boot plugin is present it also adds the Spring MVC adapter automatically, regardless of plugin application order. Plain Spring applications can opt in explicitly with `implementation("no.beint.thim:spring:0.13.0")`. Compiled template jars publish their registries through Java's service loader, so templates can live in any application module.

```kotlin
thim {
Expand Down Expand Up @@ -328,7 +328,7 @@ Its default fixtures contain 100 and 1,000 fragment calls. To measure an applica

```shell
./gradlew :benchmark:jmhJar
java -jar benchmark/build/libs/benchmark-0.11.2-jmh.jar TemplateCompilerBenchmark \
java -jar benchmark/build/libs/benchmark-0.13.0-jmh.jar TemplateCompilerBenchmark \
-p templatesDirectory=/absolute/path/to/src/main/resources/templates -p elements=100 \
-f 2 -prof gc
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ internal class MessageGenerator(private val catalog: MessageCatalog) {
return javaIdentifier(candidate, "message")
}

private fun words(value: String): List<String> = value.split(Regex("[^A-Za-z0-9]+"))
private fun words(value: String): List<String> = value.split(NON_ALPHANUMERIC)
.filter(String::isNotEmpty)

private fun javaIdentifier(value: String, fallback: String): String {
Expand Down
111 changes: 111 additions & 0 deletions compiler/src/main/kotlin/no/beint/thim/compiler/RegistryGenerator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package no.beint.thim.compiler

/** One compiled page model: its class, the generated renderer that serves it, and its request-data use. */
internal data class RegistryEntry(
val modelName: String,
val rendererReference: String,
val usesRequestDataValues: Boolean,
)

/** Generates the module's [no.beint.thim.TemplateSet], which dispatches page models to their renderers. */
internal class RegistryGenerator(private val entries: List<RegistryEntry>) {
fun generate(packageName: String, className: String): String = buildString {
val chunks = entries.withIndex().chunked(DISPATCH_CHUNK)
appendLine("package $packageName;")
appendLine()
appendLine("import java.io.IOException;")
appendLine("import no.beint.thim.HtmlOutput;")
appendLine("import no.beint.thim.RenderContext;")
appendLine("import no.beint.thim.TemplateSet;")
appendLine()
appendLine("public final class $className implements TemplateSet {")
appendLine(" // Exact page-model classes resolve in constant time; the instanceof chain in renderSubtype")
appendLine(" // only serves subclasses of open models, matching the previous linear dispatch.")
appendLine(" private static final java.util.Map<Class<?>, Integer> INDEX = index();")
appendLine()
appendLine(" // Built imperatively: javac's inference over one Map.ofEntries call with hundreds of")
appendLine(" // distinct Class arguments took several seconds for a large application.")
appendLine(" private static java.util.Map<Class<?>, Integer> index() {")
appendLine(" var index = new java.util.HashMap<Class<?>, Integer>(${entries.size * 2});")
entries.forEachIndexed { index, template ->
appendLine(" index.put(${template.modelName}.class, $index);")
}
appendLine(" return java.util.Map.copyOf(index);")
appendLine(" }")
appendLine()
appendLine(" private static final boolean[] REQUEST_DATA_VALUES = {")
appendLine(" " + entries.joinToString(", ") { it.usesRequestDataValues.toString() })
appendLine(" };")
appendLine()
appendLine(" @Override")
appendLine(" public boolean supports(Class<?> modelType) {")
appendLine(" return INDEX.containsKey(modelType);")
appendLine(" }")
appendLine()
appendLine(" @Override")
appendLine(" public boolean supportsReturnType(Class<?> returnType) {")
appendLine(" // Spring supplies the runtime type when a value exists; Object chiefly represents a null return.")
appendLine(" if (returnType == Object.class) {")
appendLine(" return false;")
appendLine(" }")
appendLine(" return INDEX.containsKey(returnType) || isSupertypeOfModel(returnType);")
appendLine(" }")
appendLine()
appendLine(" private static boolean isSupertypeOfModel(Class<?> returnType) {")
appendLine(" return " + entries.joinToString(" ||\n ") {
"returnType.isAssignableFrom(${it.modelName}.class)"
} + ";")
appendLine(" }")
appendLine()
appendLine(" @Override")
appendLine(" public boolean usesRequestDataValues(Class<?> modelType) {")
appendLine(" var index = INDEX.get(modelType);")
appendLine(" return index != null && REQUEST_DATA_VALUES[index];")
appendLine(" }")
appendLine()
appendLine(" // Dispatch is split so every method stays far below HotSpot's 8000-byte limit for JIT")
appendLine(" // compilation; one switch over every template left large applications interpreted.")
appendLine(" @Override")
appendLine(" public void render(Object model, RenderContext context, HtmlOutput output) throws IOException {")
appendLine(" var index = INDEX.get(model.getClass());")
appendLine(" if (index == null) {")
appendLine(" renderSubtype(model, context, output);")
appendLine(" return;")
appendLine(" }")
appendLine(" switch (index / $DISPATCH_CHUNK) {")
chunks.indices.forEach { chunk ->
appendLine(" case $chunk -> render$chunk(index, model, context, output);")
}
appendLine(" default -> throw new IllegalStateException(\"Unknown template index \" + index);")
appendLine(" }")
appendLine(" }")
chunks.forEachIndexed { chunk, templates ->
appendLine()
appendLine(" private static void render$chunk(int index, Object model, RenderContext context, HtmlOutput output) throws IOException {")
appendLine(" switch (index) {")
templates.forEach { (index, template) ->
appendLine(" case $index -> ${template.rendererReference}.render((${template.modelName}) model, context, output);")
}
appendLine(" default -> throw new IllegalStateException(\"Unknown template index \" + index);")
appendLine(" }")
appendLine(" }")
}
appendLine()
appendLine(" // Only instances of subclasses of open page models reach this chain.")
appendLine(" private static void renderSubtype(Object model, RenderContext context, HtmlOutput output) throws IOException {")
entries.forEach {
appendLine(" if (model instanceof ${it.modelName} typed) {")
appendLine(" ${it.rendererReference}.render(typed, context, output);")
appendLine(" return;")
appendLine(" }")
}
appendLine(" throw new IllegalArgumentException(\"No compiled template for \" + model.getClass().getName());")
appendLine(" }")
appendLine("}")
}

internal companion object {
/** Templates per dispatch method: at most about 4 KB of bytecode, half HotSpot's JIT limit. */
const val DISPATCH_CHUNK = 256
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ internal class RendererGenerator(
// incremental Java compilation keeps the registry file's stale class list across the
// layout change, and reusing those class names made it delete every renderer class
// while recompiling only the edited one.
val rendererName = modelName.replace(Regex("[^A-Za-z0-9_]"), "_") + "Renderer"
val rendererName = modelName.replace(NON_IDENTIFIER_CHARACTER, "_") + "Renderer"
val code = CodeWriter(staticContent, holderName)
val locales = if (usesMessages(nodes)) {
catalog.supportedLocales.filterTo(linkedSetOf()) { it != catalog.defaultLocale }
Expand Down Expand Up @@ -1646,3 +1646,5 @@ private fun KSType.isBoolean(): Boolean =

private fun KSType.isEnum(): Boolean =
(declaration as? KSClassDeclaration)?.classKind == ClassKind.ENUM_CLASS

private val NON_IDENTIFIER_CHARACTER = Regex("[^A-Za-z0-9_]")
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ internal class RouteCatalog(
private val matchingRoutes = mutableMapOf<String, List<Route>>()
private val trustedPatterns: List<List<RouteSegment>> = trustedPaths.map(::trustedPattern)

fun isEmpty(): Boolean = routes.isEmpty()

fun check(
path: String,
httpMethod: String,
Expand Down Expand Up @@ -299,7 +297,7 @@ internal class RouteCatalog(
}.takeIf { !it.isNullOrBlank() }

private fun combine(prefix: String, path: String): String {
val joined = "/${prefix.trim('/')}/${path.trim('/')}".replace(Regex("/+"), "/")
val joined = "/${prefix.trim('/')}/${path.trim('/')}".replace(REPEATED_SLASHES, "/")
return if (joined.length > 1) joined.trimEnd('/') else joined
}

Expand Down Expand Up @@ -361,3 +359,5 @@ internal class RouteCatalog(

}
}

private val REPEATED_SLASHES = Regex("/+")
Loading
Loading