Skip to content

Improve performance: JWT decoder cache - benchmark [do not merge] - #370

Draft
skjolber wants to merge 12 commits into
optimizeJwtDecodingfrom
optimizeJwtDecodingPerformanceTesting
Draft

skjolber wants to merge 12 commits into
optimizeJwtDecodingfrom
optimizeJwtDecodingPerformanceTesting

Conversation

@skjolber

@skjolber skjolber commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Simple AI-generated benchmarks for #361

Measurements are a bit crude - junit runs on local machine (16 + 16 cores). Only measures dummy calls.

Results are promising. Obiously for apps doing real work the relative impact is much smaller.

Results (2026-10-01, #361 at d16a3ae, CPU boost disabled)

HTTP and gRPC benchmarks use the Spring configuration, i.e. the default lru cache mode. RS256 tokens, dummy endpoints.

gRPC

Scenario No cache Cache Gain
1 client, same token 200.2 µs/call (4 996/s) 114.7 µs/call (8 719/s) 1.7×
10 parallel clients × 2 tokens 34 206 calls/s 55 021 calls/s 1.6×
Cold start, 10 clients, steady state (60 s) ~35 900 calls/s ~54 900 calls/s 1.5×

HTTP

Scenario No cache Cache Gain
1 client, same token 342.7 µs/request (2 918/s) 256.5 µs/request (3 899/s) 1.3×
10 parallel clients × 2 tokens 22 120 requests/s 28 953 requests/s 1.3×
Cold start, 10 clients, steady state (60 s) ~22 100 requests/s ~28 700 requests/s 1.3×

With the cache, cold start is at full throughput from the first second.

Decoder only (200 000 decodes, lru mode)

Scenario Time per decode vs. no cache
No cache (full RS256 verification) ~49.9 µs
Cache, 50 tokens round-robin 0.110 µs ~460×
Cache, single token 0.078 µs ~640×

Earlier results (2026-10-01, #361 at f1589540, CPU boost enabled)

Not directly comparable: CPU boost was enabled, and the decoder-only benchmark used fixed mode.

Scenario No cache Cache Gain
gRPC, 1 client, same token 76.3 µs/call 37.6 µs/call 2.0×
gRPC, 10 parallel clients × 2 tokens 88 919 calls/s 152 687 calls/s 1.7×
HTTP, 1 client, same token 151.6 µs/request 107.8 µs/request 1.4×
HTTP, 10 parallel clients × 2 tokens 46 856 requests/s 68 667 requests/s 1.5×
Decoder only, 50 tokens / single token ~31.5 µs 0.039 / 0.019 µs ~800× / ~1 670×

Running the benchmarks

The benchmarks are @Disabled (and tagged performance), and fail if JaCoCo is attached. To run them:

mvn -pl jwt-server/spring/jwt-spring-common,jwt-server/spring/jwt-spring-grpc-native,jwt-server/spring/jwt-spring-web -am test \
  -Djacoco.skip=true '-Dtest=*Benchmark*Test' -Dsurefire.failIfNoSpecifiedTests=false \
  '-DargLine=-Djunit.jupiter.conditions.deactivate=org.junit.*DisabledCondition'

@skjolber skjolber changed the title Improve performance: JWT decoder cache - benchmark Improve performance: JWT decoder cache - benchmark [do not merge] Sep 9, 2026
skjolber and others added 5 commits September 18, 2026 20:13
…erver into optimizeJwtDecodingPerformanceTesting
…erver into optimizeJwtDecodingPerformanceTesting
Mirrors the existing HTTP smoke benchmarks (DecodedJwtCacheHttpBenchmark*)
for real gRPC calls over a TCP loopback channel, and adds cold-start
(no warm-up) wall-clock interval benchmarks (1-5, 10, 15s) for both HTTP
and gRPC, using a round-robin pool of 20 tokens to simulate a realistic
number of concurrent clients.

New:
- jwt-spring-grpc-native/.../perf/GrpcBenchmarkSupport.java
- jwt-spring-grpc-native/.../perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java
- jwt-spring-grpc-native/.../perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java
- jwt-spring-grpc-native/.../perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java
- jwt-spring-grpc-native/.../perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java
- jwt-spring-web/.../perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java
- jwt-spring-web/.../perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java

Changed:
- jwt-spring-web/.../perf/HttpBenchmarkSupport.java: added
  measureColdStartIntervals() helper (mirrored into GrpcBenchmarkSupport).

All tests are @tag("performance") and @disabled by default, matching the
existing manual-benchmark convention.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Redesign the HTTP and gRPC decoded-JWT cache benchmarks to use one
dedicated connection (client) per thread running concurrently, instead
of a single thread round-robining through a token pool. Each parallel
client is synchronized to start simultaneously via a CyclicBarrier, so
measured throughput reflects real concurrent load rather than
serialized round-trip latency.

Client/token count is sized to abt-core's observed peak-hour (08:00/
16:00) concurrency per Entur Compass: 15 parallel clients, 2 tokens
each (30 tokens total).

Also replace TestRestTemplate with a minimal JDK HttpClient-based
RawHttpClientSupport in the HTTP benchmarks' timed hot path, removing
interceptor/Jackson overhead that was dominating measurements.

measureColdStartIntervals(Parallel) now reports only segment
throughput per checkpoint (not cumulative-since-start).
Reduce benchmark concurrency to 10 parallel clients, 2 tokens each (20
tokens total).

Add HttpBenchmarkSupport.assertJacocoAgentDisabled() and
GrpcBenchmarkSupport.assertJacocoAgentDisabled(), called from each
benchmark's readiness probe. They inspect the JVM's input arguments
and fail fast with a clear message if the JaCoCo agent is attached,
since coverage instrumentation adds substantial, uneven per-call
overhead that invalidates timing measurements. Benchmarks must be run
with -Djacoco.skip=true.
@skjolber

skjolber commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Local Benchmark results: 10 parallel clients, 2 tokens each

Each benchmark runs 10 independent client threads concurrently (each with its own connection), sharing 20 distinct JWT tokens (2 per client), all synchronized to start at the same instant. Benchmarks now assert the JaCoCo agent is not attached and fail fast otherwise, since coverage instrumentation would skew the timings.

Fixed-load (~10s each, 100k requests total)

Test 1 client 10 clients × 2 tokens Cache improvement
HTTP cached 8,479 req/s 68,978 req/s —
HTTP no-cache 6,428 req/s 49,062 req/s +40.6%
gRPC cached 24,002 calls/s 153,368 calls/s —
gRPC no-cache 12,694 calls/s 90,508 calls/s +69.5%

Cold-start segment throughput, 10 parallel clients (20 tokens)

t HTTP cached HTTP no-cache Δ gRPC cached gRPC no-cache Δ
1s 13,437 9,828 +36.7% 28,435 15,689 +81.2%
2s 46,574 28,112 +65.7% 134,747 65,613 +105.4%
3s 65,711 48,382 +35.8% 153,358 86,753 +76.8%
4s 67,240 50,514 +33.1% 153,572 90,028 +70.6%
5s 67,701 49,427 +37.0% 152,683 89,178 +71.2%
10s 66,847 50,530 +32.3% 155,183 89,891 +72.6%
15s 66,984 50,505 +32.6% 156,507 89,386 +75.1%
30s 67,053 50,533 +32.7% 154,634 89,924 +72.0%
60s 67,120 49,321 +36.1% 151,456 89,756 +68.7%

Both cache-enabled and cache-disabled scenarios reach a stable steady state within a few seconds. The decoded-JWT cache gives a consistent ~33–37% HTTP throughput improvement and ~69–105% gRPC throughput improvement under concurrent load.

@skjolber

skjolber commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Local Benchmark chart: cache enabled vs disabled

decoded-JWT cache benchmark chart

Left: fixed-load aggregate throughput (10 parallel clients, 2 tokens each). Middle/right: cold-start segment throughput over time for HTTP and gRPC (10 parallel clients, 20 tokens total, log time axis).

@sonarqubecloud

sonarqubecloud Bot commented Oct 1, 2026

Copy link
Copy Markdown

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