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
8 changes: 4 additions & 4 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v3
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
java-version: '21'
cache: 'gradle'

- name: run project tests
run: ./gradlew test --no-daemon
- name: run project checks
run: ./gradlew check --no-daemon
86 changes: 49 additions & 37 deletions BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ Every evaluator module ships a `bench` task that replays the full test rule set
./gradlew :kotlin-evaluator:bench -PbenchWide=200
```

Every engine replays the same rule set against the same input objects. Nothing in
`com.rapatao.projects.ruleset.engine.cases` branches on the engine, and each `bench` task reads the same three
properties in the same order, so a difference between two rows is the engine and not the workload.

Each iteration evaluates the 173 expressions from `com.rapatao.projects.ruleset.engine.cases.TestData` against the same
input object, after 100 warmup iterations. Results are printed and written to `bench_<engine>.txt`.

Expand All @@ -26,6 +30,11 @@ JVM's per-thread counter on the benchmark thread, and reports `n/a` on a JVM tha
element list. Nothing the rules read changes, only how much input surrounds it, which separates a per-call cost that
scales with the input from one that scales with the rule.

Both the default and the wide root are maps, and the wide one is built from the default one, so the two runs differ by
width alone and never by what the root is. That matters because the engines do not treat the two root kinds alike: a
map root is a hash lookup where a typed root is a reflective property read. Mixing the two across the columns would
price that difference as if it were width.

Two things to set up before trusting a run:

* Run at full power. On a laptop in a power saving mode the whole suite lands 25 to 30% low, uniformly across engines.
Expand All @@ -35,31 +44,32 @@ Two things to set up before trusting a run:
## Results

2000 iterations of the 173 expression suite, 346,000 evaluations per engine, Apple M3 Pro, Amazon Corretto 21.0.11,
three runs per configuration in one session at full power, medians below. These are relative magnitudes, not absolute figures: the harness is a
simple timing loop, not JMH, and the GraalJS run is interpreter-only because Corretto is not a GraalVM JDK.
three runs per configuration in one session, medians below. These are relative magnitudes, not absolute figures: the
harness is a simple timing loop, not JMH, and the GraalJS run is interpreter-only because Corretto is not a GraalVM JDK.

| engine | ops/s | avg per iteration | stddev | p50 | p99 | relative cost |
|----------------------|-----------|-------------------|--------|---------|---------|---------------|
| Kotlin | 1,506,886 | 115us | 93us | 83us | 425us | 1x |
| Rhino | 288,132 | 600us | 240us | 490us | 1.41ms | ~5.2x |
| GraalJS (reused ctx) | 240,662 | 719us | 439us | 580us | 2.58ms | ~6.3x |
| GraalJS | 8,959 | 19.31ms | 640us | 19.09ms | 21.08ms | ~168x |
| Kotlin | 2,598,127 | 67us | 58us | 49us | 234us | 1x |
| Rhino | 325,099 | 522us | 185us | 446us | 1.36ms | ~8.0x |
| GraalJS (reused ctx) | 250,370 | 678us | 478us | 550us | 2.43ms | ~10.4x |
| GraalJS | 9,026 | 18.90ms | 1.30ms | 18.55ms | 22.89ms | ~288x |

Run-to-run spread differs by engine, and sets how large a difference has to be before it means anything:

| engine | observed across runs | p99 vs p50 |
|----------------------|------------------------|------------|
| Kotlin | 1,454,000 to 1,621,000 | ~5.1x |
| Rhino | 287,000 to 320,000 | ~2.9x |
| GraalJS (reused ctx) | 237,000 to 257,000 | ~4.4x |
| GraalJS | 8,771 to 9,176 | ~1.1x |
| Kotlin | 2,384,000 to 2,598,000 | ~4.8x |
| Rhino | 318,000 to 332,000 | ~3.1x |
| GraalJS (reused ctx) | 243,000 to 255,000 | ~4.4x |
| GraalJS | 8,863 to 9,156 | ~1.2x |

The three fast configurations move by about 10% across runs and their p99 is 3 to 5 times their p50. The tail is GC
and JIT, not the engine, so read a difference smaller than that as noise. Default GraalJS is the exception on both
counts: an iteration is so dominated by context creation that nothing else is visible in it.

The Kotlin engine on a wide input is the least stable measurement here, spanning 1,409,000 to 2,195,000 across its
three runs. Its iteration is short enough that the loop measures the JVM more than the engine.
The Kotlin engine is the least stable measurement here across *sessions*, even though its three runs above sit within
9% of each other. Its iteration is about 67us, short enough that the loop measures the JVM as much as the engine, so
compare its number only against another run from the same session.

### Allocation

Expand All @@ -68,44 +78,45 @@ collections that ran during the measured loop:

| engine | alloc per evaluation | vs Kotlin | gc during the run |
|----------------------|----------------------|-----------|-------------------|
| Kotlin | 893 B | 1x | 2, 3ms |
| GraalJS (reused ctx) | 5,850 B | ~6.6x | 15, 19ms |
| Rhino | 11,741 B | ~13.1x | 17, 22ms |
| GraalJS | 128,763 B | ~144x | 425, 186ms |
| Kotlin | 375 B | 1x | 1, 2ms |
| GraalJS (reused ctx) | 5,744 B | ~15.3x | 16, 20ms |
| Rhino | 11,462 B | ~30.6x | 18, 14ms |
| GraalJS | 128,094 B | ~342x | 282, 139ms |

This is the steadiest number the harness produces: it varies by under 0.5% across runs, where throughput varies by 5%.
This is the steadiest number the harness produces: it varies by under 2% across runs, where throughput varies by 10%.

The order is not the throughput order. Reused-context GraalJS allocates less per evaluation than Rhino and is still
slower, so Rhino's cost is not allocation-bound: it compiles a fresh script per operator invocation, and compilation
is work rather than garbage. Default GraalJS allocates a whole polyglot `Context` per call, which is the 144x.
The order is not the throughput order. Reused-context GraalJS allocates half of what Rhino does per evaluation and is
still slower, so Rhino's cost is not allocation-bound: it compiles a fresh script per operator invocation, and
compilation is work rather than garbage. Default GraalJS allocates a whole polyglot `Context` per call, which is
the 342x.

### Input width

The same run with `-PbenchWide=200`: identical rules reading identical fields, under a root carrying 200 extra scalar
fields and a 200 element list.
fields and a 200 element list. Both columns root at a map, so the width is the only difference between them.

| engine | ops/s default | ops/s wide(200) | wide is | alloc default | alloc wide(200) | wide allocates |
|----------------------|---------------|-----------------|---------------|---------------|-----------------|----------------|
| Kotlin | 1,506,886 | 1,768,634 | unchanged | 893 B | 872 B | unchanged |
| Rhino | 288,132 | 138,658 | ~2.1x slower | 11,741 B | 23,788 B | ~2.0x more |
| GraalJS (reused ctx) | 240,662 | 16,313 | ~14.8x slower | 5,850 B | 85,596 B | ~14.6x more |
| GraalJS | 8,959 | 5,875 | ~1.5x slower | 128,763 B | 208,161 B | ~1.6x more |
| Kotlin | 2,598,127 | 2,489,220 | unchanged | 375 B | 384 B | unchanged |
| Rhino | 325,099 | 135,358 | ~2.4x slower | 11,462 B | 23,729 B | ~2.1x more |
| GraalJS (reused ctx) | 250,370 | 16,471 | ~15.2x slower | 5,744 B | 85,539 B | ~14.9x more |
| GraalJS | 9,026 | 5,924 | ~1.5x slower | 128,094 B | 208,110 B | ~1.6x more |

Each factor compares the two columns to its left, within the same row. A row varies the input only: the engine and its
configuration are held constant across it, so `reuseContextPerThread` is on in both columns of the reused row and off
in both columns of the row below it. The Results table above prices the reuse setting.

For both JS engines the allocation factor tracks the throughput factor, which identifies the cost: they inject every
top-level entry of the input into the scope on every `evaluate`, and pay for it whether a rule reads it or not.
Neither pays for *depth*, since a nested object is handed over whole and JS walks into it lazily. Reused-context
GraalJS runs 157 collections over the wide input against 15 over the default one, and Rhino 34 against 17.
top-level entry of the input into the scope on every `evaluate`, and pay for it whether a rule reads it or not. Neither
pays for *depth*, since a nested object is handed over whole and JS walks into it lazily. Reused-context GraalJS runs
157 collections over the wide input against 16 over the default one, and Rhino 34 against 18.

Default GraalJS shows the smallest factor because context creation, at ~19ms per iteration, dominates the injection.
Default GraalJS shows the smallest factor because context creation, at ~18.9ms per iteration, dominates the injection.
In the reused-context mode the injection is the dominant remaining cost.

The Kotlin engine resolves the paths a rule names and never visits the rest, so its cost tracks the rule. Its two
columns overlap across runs, with the wide one reading slightly faster: that input roots at a `Map` while the default
one roots at a data class, so one hash lookup replaces one reflective property read.
The Kotlin engine resolves the paths a rule names and never visits the rest, so its cost tracks the rule and not the
input: its two columns are flat in both throughput and allocation, across a root that carries 202 entries instead
of 1.

`GraalJS (reused ctx)` is the same engine with `reuseContextPerThread = true`. Closing the per-call context and
injecting the input into a per-evaluation object costs the default mode about 4% (9,750 to 9,391 ops/s), and buys
Expand All @@ -126,7 +137,8 @@ The `Evaluator` contract sets up a fresh evaluation context on every `evaluate`
These rows come from one tight loop over a single rule, after 50,000 warmup calls, so they isolate the steady-state
cost. They are not comparable to the suite numbers above, which include cold and JIT-transient iterations. That loop is
not part of this repository and the `bench` tasks do not reproduce it. The Kotlin row predates both the current operand
parsing and the removal of input flattening.
parsing and the removal of input flattening, so read it as history rather than as the engine's current cost: the
suite tables above are the current measurement.

Reading of the table:

Expand All @@ -149,9 +161,9 @@ Reading of the table:
* On GraalJS, set `reuseContextPerThread = true` unless rules are untrusted or deliberately write globals. It is the
single largest win available on that engine
* On the JS engines, pass the narrowest input object that satisfies the rule: both inject every top-level entry per
call, worth 2.1x on Rhino and 14.8x on reused-context GraalJS for 200 extra fields. Nesting the parts a rule does not
call, worth 2.4x on Rhino and 15.2x on reused-context GraalJS for 200 extra fields. Nesting the parts a rule does not
read one level deeper avoids it. The Kotlin engine reads only the paths a rule names and is flat here
* Watch allocation, not just throughput, if the service is latency-sensitive: an evaluation costs 893 B on the Kotlin
* Watch allocation, not just throughput, if the service is latency-sensitive: an evaluation costs 375 B on the Kotlin
engine and 128 KB on default GraalJS, and that is what fills the nursery and sets the GC rate under load
* Prefer `Map` inputs over arbitrary objects when the data is already in that shape: the object path goes through
Kotlin reflection. On the Kotlin engine this is now a small difference, since the properties of each class are
Expand All @@ -167,5 +179,5 @@ Both JS engines used to rebuild their whole evaluation environment per `evaluate
Rhino no longer does: it shares one sealed set of standard objects and gives each evaluation a child scope, worth about
7.4x on this suite (3.07ms to 417us per iteration) with no loss of isolation, so there is nothing to opt into. On
GraalJS the equivalent is opt-in because it does trade isolation: `reuseContextPerThread = true` keeps one context per
thread, worth roughly 25x (15.6ms to about 0.6ms), at the cost of rules on one thread sharing a context. Keep it off
for untrusted rules or rules that write globals.
thread, worth roughly 28x (18.9ms to 678us per iteration), at the cost of rules on one thread sharing a context. Keep
it off for untrusted rules or rules that write globals.
4 changes: 0 additions & 4 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,6 @@ kover {
coverageUnits = CoverageUnit.INSTRUCTION
it.minValue = 90
}
bound {
coverageUnits = CoverageUnit.BRANCH
it.minValue = 90
}
}
}
it.html {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ abstract class Evaluator(
abstract fun name(): String

/**
* Return the operator implementation for the given name.
* Return the operator implementation for the given name. The name is matched case insensitively.
*
* @return The operator.
*/
fun operator(name: String): Operator? = declaredOperators[name]
fun operator(name: String): Operator? = declaredOperators[name.lowercase()]

private fun List<Expression>.processNoneMatch(context: EvalContext): Boolean {
return this.none {
Expand Down Expand Up @@ -95,10 +95,11 @@ abstract class Evaluator(

private fun Expression.processExpression(context: EvalContext): Boolean {
return usingFailureWrapper(this.onFailure) {
requireNotNull(this.operator) { "expression operator must not be null" }
val name = this.operator
requireNotNull(name) { "expression operator must not be null" }

val operator = operator(this.operator)
requireNotNull(operator) { "Unknown operator: $operator" }
val operator = operator(name)
requireNotNull(operator) { "Unknown operator: $name" }

context.process(left = this.left, operator = operator, right = this.right)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ data class Expression(
* @return Boolean value indicating whether the object is valid.
*/
fun isValid(engine: Evaluator): Boolean {
val any = anyMatch?.map { it.isValid(engine) }?.firstOrNull { !it } ?: true
val none = noneMatch?.map { it.isValid(engine) }?.firstOrNull { !it } ?: true
val all = allMatch?.map { it.isValid(engine) }?.firstOrNull { !it } ?: true
val any = anyMatch?.all { it.isValid(engine) } ?: true
val none = noneMatch?.all { it.isValid(engine) } ?: true
val all = allMatch?.all { it.isValid(engine) } ?: true

val something = (any && none && all) || parseable()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,4 @@ fun Any.asExpression(): Expression =
* @param use The behavior to be applied when evaluation fails.
* @return An [Expression] with the specified [OnFailure] behavior.
*/
infix fun Expression.ifFail(use: OnFailure): Expression = Expression(
left = this.left, operator = this.operator, right = this.right, onFailure = use,
)
infix fun Expression.ifFail(use: OnFailure): Expression = this.copy(onFailure = use)
2 changes: 0 additions & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
benManerVersionsVersion=0.52.0
credentialsVersion=3.0
detektVersion=1.23.6
graalvmPolyglotVersion=24.1.0
hamcrestVersion=3.0
jacksonVersion=3.1.3
jacocoVersion=0.8.7
javaVersion=21
junitPlatformVersion=1.11.2
junitVersion=5.11.2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import com.rapatao.projects.ruleset.engine.types.OnFailure.THROW
import com.rapatao.projects.ruleset.engine.types.OnFailure.TRUE
import com.rapatao.projects.ruleset.engine.types.builder.extensions.equalsTo
import com.rapatao.projects.ruleset.engine.types.builder.extensions.ifFail
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
Expand Down Expand Up @@ -64,15 +66,15 @@ class SerializationTest {
val json = """
{
"left": "field",
"operator": "equals",
"operator": "EqUaLs",
"right": 10,
"onFailure": "true"
}
""".trimIndent()

val matcherFromJson = mapper.readValue<Expression>(json)
val matcher = "field" equalsTo 10 ifFail TRUE

compareMatcher(matcher, matcherFromJson)
assertThat(matcherFromJson.isValid(KotlinEvaluator()), equalTo(true))
assertThat(matcherFromJson.onFailure, equalTo(TRUE))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,23 @@ class KotlinContext(
this == "null" -> null
else -> {
val trimmed = this.trim()
if (QUOTED.matches(trimmed)) trimmed.unwrap() else trimmed.rawValue()
if (trimmed.isQuoted()) trimmed.unwrap() else trimmed.rawValue()
}
}

/** A quoted literal, which a regex would have to match across newlines to recognize. */
private fun String.isQuoted(): Boolean = this.length >= QUOTED_MIN && this[0] == '"' && this[this.length - 1] == '"'

@Suppress("ReturnCount")
private fun String.rawValue(): Any? {
val key = this.unwrap()

key.toBigIntegerOrNull()?.let { return it }
key.toBigDecimalOrNull()?.let { return it }
// A path never starts like a number, so the number parses, which scan the whole text, are skipped for it.
if (key.startsLikeNumber()) {
key.toBigIntegerOrNull()?.let { return it }
key.toBigDecimalOrNull()?.let { return it }
}

key.toBooleanStrictOrNull()?.let { return it }

val resolved = InputPath.resolve(inputData, key)
Expand All @@ -75,12 +82,17 @@ class KotlinContext(
return resolved
}

private fun String.startsLikeNumber(): Boolean = this.isNotEmpty() && (this[0].isDigit() || this[0] in NUMBER_LEAD)

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

private companion object {
// Regex.matches is a full-input match, so no anchors are needed.
private val QUOTED = Regex("\".*\"")
/** `""` is the shortest quoted literal, so a single `"` is a path and not an empty one. */
private const val QUOTED_MIN = 2

/** The non-digit characters a number literal can start with. */
private const val NUMBER_LEAD = "-+."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ class KotlinPathResolutionTest {
absent("", Holder(value = "x"))
}

@Test
@DisplayName("a quoted literal is a literal even when it spans lines or is empty")
fun assertQuotedLiteral() {
resolves("\"a\nb\"", "\"a\nb\"", mapOf("a" to 1))
resolves("\"\"", "\"\"", mapOf("a" to 1))
// A lone quote is not a literal, so it stays a path, and no path is named that way here.
absent("\"", mapOf("a" to 1))
}

@Test
@DisplayName("cached reflection resolves the same path across evaluations")
fun assertRepeatedReflectionResolution() {
Expand Down
Loading
Loading