Skip to content

Cache parsed FHIRPath expressions - #117

Open
ellykits wants to merge 1 commit into
ohs-foundation:mainfrom
ellykits:cache-parsed-expressions
Open

Cache parsed FHIRPath expressions#117
ellykits wants to merge 1 commit into
ohs-foundation:mainfrom
ellykits:cache-parsed-expressions

Conversation

@ellykits

@ellykits ellykits commented Aug 4, 2026

Copy link
Copy Markdown

evaluateExpression re-ran the ANTLR lexer and parser on every call. For the search-parameter indexing path, where a small fixed set of expressions is evaluated against every resource, that re-parse is the dominant cost (measured ~6-10x faster eval once cached on a representative Patient/Observation search-param set).

Cache the parse tree by expression string. The tree is read-only during evaluation and initialize() already resets all per-eval state, so reuse is semantics-preserving; the HL7 spec suite passes unchanged. Invalid expressions still throw before the cache stores, so error behaviour is unchanged. The cache is unbounded, sized by the caller's distinct expressions (a fixed search-parameter set in the indexing use case).

evaluateExpression re-ran the ANTLR lexer and parser on every call. For
the search-parameter indexing path, where a small fixed set of
expressions is evaluated against every resource, that re-parse is the
dominant cost (measured ~6-10x faster eval once cached on a
representative Patient/Observation search-param set).

Cache the parse tree by expression string. The tree is read-only during
evaluation and initialize() already resets all per-eval state, so reuse
is semantics-preserving; the HL7 spec suite passes unchanged. Invalid
expressions still throw before the cache stores, so error behaviour is
unchanged. The cache is unbounded, sized by the caller's distinct
expressions (a fixed search-parameter set in the indexing use case).
@ellykits
ellykits requested a review from a team August 4, 2026 11:03
@ellykits

ellykits commented Aug 4, 2026

Copy link
Copy Markdown
Author

I did some bechmark with claude see the source for the benchmark and the report.

FHIRPath parse cache: evaluation throughput

For fifteen distinct expressions each parsed 20,000 times resulting to 300k evaluation.

300,000 evaluations, mean of 5 runs

before  ████████████████████████████████████████████████  3,707 ms
after   █████                                               410 ms
Metric Before (dc710ed) After (de7fdcd) Delta
Elapsed, 300k evaluations 3,707 ms 410 ms 9.0× faster
Cost per evaluation 12,358 ns 1,369 ns −89%
Throughput 81,168 evals/s 741,973 evals/s +814%

Parsing accounted for approximately 89% of per-evaluation cost. It is now incurred once per distinct
expression string.

Applicability

The measured workload is the search-parameter indexing pattern: a fixed expression set evaluated
against every incoming resource. Fifteen distinct expressions were each parsed 20,000 times in the
uncached variant. Bulk indexing should realise substantially the full 9× improvement.

The improvement is proportional to the expression reuse ratio. A caller evaluating each expression
once realises no gain and incurs the memory cost of the cache, which is unbounded and sized by the
caller's distinct expressions.

Measurements

Five runs per variant, each in a fresh JVM. FhirPathEngine.kt was the only file that differed
between variants.

Run Before (ms) After (ms)
1 3,515 335
2 4,100 436
3 3,640 379
4 3,744 486
5 3,537 415
Mean 3,707 410
Median 3,640 415
Std dev 238 57

The distributions do not overlap: the slowest cached run (486 ms) exceeds the fastest uncached run
(3,515 ms) by 7.2×. Run-to-run variation is 6.4% CV uncached and 13.9% CV cached, well within the
measured effect size.

Procedure

./gradlew :fhir-path:cleanJvmTest :fhir-path:jvmTest \
  --tests dev.ohs.fhir.fhirpath.ParseCacheBenchmark --no-build-cache

--no-build-cache is required. The project sets org.gradle.caching=true, and without the flag
Gradle restores prior test outputs rather than re-executing the task, yielding byte-identical
timings across runs.

Environment: JDK 21.0.8 (Temurin), Linux 6.8, 16 cores, 31 GB RAM.

Limitations: measurement covers the JVM target only; the JS, Wasm and Native targets were not
measured and the ratio may differ, as ANTLR's relative cost varies by runtime. Timing uses
wall-clock measureTime rather than JMH, with no forked-JVM statistics or dead-code-elimination
guards. The effect size is robust to these limitations; small differences are not.

Benchmark source

fhir-path/src/commonTest/kotlin/dev/ohs/fhir/fhirpath/ParseCacheBenchmark.kt

package dev.ohs.fhir.fhirpath

import dev.ohs.fhir.model.r4.Resource
import kotlin.test.Test
import kotlin.time.measureTime
import kotlinx.serialization.json.Json

/**
 * Ad-hoc throughput benchmark for repeated evaluation of a fixed expression set (the engine's
 * search-parameter indexing pattern). Prints a `FHIRPATHBENCH` line; not an assertion test.
 */
class ParseCacheBenchmark {
  private val json = Json { ignoreUnknownKeys = true }

  private val patient: Resource =
    json.decodeFromString(
      """{"resourceType":"Patient","id":"p1","active":true,"gender":"female","birthDate":"1990-01-01",
         "name":[{"family":"Otieno","given":["Amina"]}],"address":[{"city":"Kibera"}],
         "identifier":[{"system":"urn:id","value":"X1"}],
         "managingOrganization":{"reference":"Organization/o1"}}"""
    )

  private val observation: Resource =
    json.decodeFromString(
      """{"resourceType":"Observation","id":"o1","status":"final",
         "code":{"coding":[{"system":"http://loinc.org","code":"29463-7"}]},
         "subject":{"reference":"Patient/p1"},"encounter":{"reference":"Encounter/e1"},
         "effectiveDateTime":"2026-01-01","valueQuantity":{"value":50,"unit":"kg"}}"""
    )

  private val patientExprs =
    listOf(
      "Patient.name",
      "Patient.name.given",
      "Patient.name.family",
      "Patient.birthDate",
      "Patient.gender",
      "Patient.active",
      "Patient.address.city",
      "Patient.identifier.value",
      "Patient.managingOrganization.reference",
    )

  private val obsExprs =
    listOf(
      "Observation.code.coding.code",
      "Observation.subject.reference",
      "Observation.encounter.reference",
      "Observation.status",
      "Observation.effectiveDateTime",
      "Observation.valueQuantity.value",
    )

  @Test
  fun benchmark() {
    val engine = FhirPathEngine.forR4()
    val work = listOf(patient to patientExprs, observation to obsExprs)
    val exprsPerRound = work.sumOf { it.second.size }
    val iterations = 20_000

    repeat(2_000) {
      work.forEach { (res, exprs) -> exprs.forEach { engine.evaluateExpression(it, res) } }
    }

    val evals = iterations.toLong() * exprsPerRound
    val elapsed = measureTime {
      repeat(iterations) {
        work.forEach { (res, exprs) -> exprs.forEach { engine.evaluateExpression(it, res) } }
      }
    }
    val ns = elapsed.inWholeNanoseconds
    val perEvalNs = ns / evals
    val evalsPerSec = evals * 1_000_000_000L / ns
    println(
      "FHIRPATHBENCH evals=$evals elapsed_ms=${elapsed.inWholeMilliseconds} " +
        "per_eval_ns=$perEvalNs evals_per_sec=$evalsPerSec"
    )
  }
}

@ellykits

ellykits commented Aug 6, 2026

Copy link
Copy Markdown
Author

Applicability

The measured workload is the search-parameter indexing pattern: a fixed expression set evaluated against every incoming resource. Fifteen distinct expressions were each parsed 20,000 times in the uncached variant. Bulk indexing should realise substantially the full 9× improvement.

The improvement is proportional to the expression reuse ratio. A caller evaluating each expression once realises no gain and incurs the memory cost of the cache, which is unbounded and sized by the caller's distinct expressions.

@jingtang10 Concerning your question about where this improvement is applicable and when it is not. The 9x is not really a constant and would be different, depending on the expression and number of evaluations being run.

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