Skip to content

perf(kotlin): resolve operands without compiling a regex - #55

Merged
rapatao merged 2 commits into
mainfrom
perf/kotlin-operand-regex
Aug 31, 2026
Merged

perf(kotlin): resolve operands without compiling a regex#55
rapatao merged 2 commits into
mainfrom
perf/kotlin-operand-regex

Conversation

@rapatao

@rapatao rapatao commented Aug 31, 2026

Copy link
Copy Markdown
Owner

KotlinContext.asValue compiled up to four Regex patterns per binary expression, once per evaluation, and trimmed the same string twice. Operand parsing is on the hot path of the engine people pick for throughput.

Patterns hoisted to one QUOTED val, one trim(), and unwrap moved off regex to removePrefix/removeSuffix.

Results

./gradlew :kotlin-evaluator:bench -PbenchIterations=2000, 147-expression suite, three runs per variant in one session at full power. The middle row keeps unwrap on regex with its patterns hoisted, to separate the two effects:

unwrap implementation ops/s (3 runs) median p50 p99
inline Regex (before) 604,481 / 623,338 / 627,181 623,338 207us 502us
hoisted Regex vals 693,595 / 695,530 / 679,200 693,595 174us 521us
removePrefix/removeSuffix (after) 782,637 / 790,565 / 778,255 782,637 166us 331us

26% end to end, no overlap between the bands. Hoisting is ~11%, dropping regex a further ~13%. A compiled pattern is still slower: replace(Regex, String) allocates a Matcher and walks the input every call, while removePrefix is a startsWith plus at most one substring and allocates nothing when the quote is absent, which is the common case since field paths carry no quotes. The tail shows it clearest, p99 falls to 331us only on the stdlib variant.

Kotlin's lead over Rhino goes from ~1.6x to ~2.2x.

Behaviour

Unchanged, and the suite covers the cases that decide it: "null" vs "\"null\"" vs null, the []unkown$ key that must throw and be swallowed by OnFailure, and quoted literals against field paths.

Two equivalences worth stating:

  • QUOTED is unanchored because Regex.matches is a full-input match, making the original ^/$ redundant. Both when branches were testing the same predicate.
  • removeSurrounding("\"") was not used. It strips only when both quotes are present, while unwrap strips each independently, and rawValue calls unwrap on unquoted strings. A key such as "abc would resolve differently and change the OnFailure outcome.

Docs

The README Performance section moves to BENCHMARKS.md, which also records the run-to-run spread per engine. The README keeps the comparison table and a link.

`KotlinContext.asValue` built four `Regex` instances inline on the
operand path: two in the `when` that decides string literal vs field
path, two more in `unwrap`. `process` calls `asValue` on both operands,
so a single binary expression compiled up to four patterns on every
evaluation. `trim()` also ran twice in the same `when`.

The patterns collapse to one hoisted val, the string case is entered
once with a single `trim()`, and `unwrap` drops regex entirely.

```kotlin
private fun Any?.asValue(): Any? {
    val result = when {
        this !is String -> this
        this == "null" -> null
        else -> {
            val trimmed = this.trim()
            if (QUOTED.matches(trimmed)) trimmed.unwrap() else trimmed.rawValue()
        }
    }
    ...
}

private fun String.unwrap() = this.trim()
    .removePrefix("\"")
    .removeSuffix("\"")
```

`QUOTED` is unanchored. `Regex.matches` is a full-input match, so the
`^` and `$` in the original `Regex("^\".*\"$")` were redundant and both
`when` branches tested the same predicate. `.` still does not match a
newline, so a multiline quoted literal falls through to `rawValue` as
before.

`unwrap` uses `removePrefix`/`removeSuffix` and not
`removeSurrounding("\"")`. Those are not equivalent: `unwrap` strips a
leading and a trailing quote independently, `removeSurrounding` strips
only when both are present. `rawValue` calls `unwrap` on strings that
did not match `QUOTED`, so a key such as `"abc` resolves to `abc`
today and would resolve to `"abc` under `removeSurrounding`, changing
the map lookup and the throw/no-throw outcome for `OnFailure`.

No test changes.
The Performance section was the longest part of the README and is
reference material, not something a reader needs while picking an
engine. It moves to BENCHMARKS.md with the run-to-run spread of each
engine recorded alongside the headline figures, since two of the four
configurations vary by more than 30% between runs.

The README keeps the engine comparison table and a link.
@rapatao
rapatao merged commit 2edbf1a into main Aug 31, 2026
4 checks passed
@rapatao
rapatao deleted the perf/kotlin-operand-regex branch August 31, 2026 18:56
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.

1 participant