Skip to content

Add JMH benchmarks (CAE-2930)#4523

Open
ggivo wants to merge 36 commits into
masterfrom
topic/ggivo/CAE-2930-setup-benchmark-nightly
Open

Add JMH benchmarks (CAE-2930)#4523
ggivo wants to merge 36 commits into
masterfrom
topic/ggivo/CAE-2930-setup-benchmark-nightly

Conversation

@ggivo
Copy link
Copy Markdown
Collaborator

@ggivo ggivo commented May 12, 2026

JMH benchmark migration & expansion

Migrates legacy manual benchmarks to a unified JMH suite and adds new workload and pub/sub coverage.

Added benchmarks

  • CRC16Benchmark — hash slot CRC16 computation (string + byte[] paths).
  • SafeEncoderBenchmark — UTF-8 encode/decode hot paths.
  • ProtocolReadBenchmark — RESP2/RESP3 parser throughput, including cache-aware and push-frame variants.
  • JedisGetSetBenchmark — single-threaded GET/SET + pipelined GET/SET baseline using Jedis.
  • RedisClientGetSetBenchmark — pooled GET/SET + pipelined GET/SET using RedisClient at T1 / T8 / T64.
  • GetSetMixedR90W10Benchmark — fixed 90/10 read/write workload over a 1000-key working set; emits comparable troughput numbers for Jedis (T1), JedisPool (T1/T8), RedisClient (T1/T8), and RedisClient + client-side che (T1/T8). Replaces the legacy PoolBenchmark and RedisClientCSCBenchmark.
  • PubSubPushBenchmark — end-to-end publish → onMessage round-trip throughput, complementing the protocolReadBenchmark` push-frame microbenchmark.

Infrastructure

  • JmhMain IDE launcher with per-suite include patterns.
  • Maven jmh profile wires src/test/jmh as an additional test-source root and runs JMH with JSON result output.

Example CI run

https://github.com/redis/jedis/actions/runs/25842479419/job/75930489878


Note

Medium Risk
Adds a scheduled GitHub Action that runs benchmarks in CI, starts Redis in Docker, and auto-pushes results to the benchmark-data branch; misconfiguration could impact CI stability or repo branches. Code changes are mostly test/benchmark-only but execute networked Redis workloads that flush DB during runs.

Overview
Adds a new JMH benchmark suite under src/test/jmh (protocol parsing/encoding, CRC16, UTF-8 encoding, GET/SET + pipelining for Jedis and RedisClient, mixed 90/10 workload comparisons, and pub/sub round-trip throughput), plus an IDE launcher (JmhMain) and supporting utilities.

Updates pom.xml to include JMH dependencies and a jmh Maven profile that compiles JMH sources, skips normal tests, and runs org.openjdk.jmh.Main to emit benchmarks.json.

Replaces the placeholder benchmark workflow with a nightly + manual GitHub Action that provisions Redis via Docker, runs mvn -Pjmh, and auto-pushes results to the benchmark-data branch; docs.yml now builds docs from master and embeds the benchmark dashboard from benchmark-data into the published site.

Reviewed by Cursor Bugbot for commit 7b57c57. Bugbot is set up for automated code reviews on this repo. Configure here.

@jit-ci
Copy link
Copy Markdown

jit-ci Bot commented May 12, 2026

🛡️ Jit Security Scan Results

CRITICAL HIGH MEDIUM

✅ No security findings were detected in this PR


Security scan by Jit

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 12, 2026

Test Results

  191 files  ± 0    191 suites  ±0   9m 25s ⏱️ -26s
7 605 tests  - 12  6 885 ✅  - 644  720 💤 +632  0 ❌ ±0 
7 613 runs   - 24  6 885 ✅  - 664  728 💤 +640  0 ❌ ±0 

Results for commit 7b57c57. ± Comparison against base commit 2a2c46c.

This pull request removes 14 and adds 2 tests. Note that renamed tests count towards both.
redis.clients.jedis.commands.unified.search.FTHybridCommandsTestBase$SupportedScorersTest[1] ‑ testScorer(Scorer, double, double)[1][1]
redis.clients.jedis.commands.unified.search.FTHybridCommandsTestBase$SupportedScorersTest[1] ‑ testScorer(Scorer, double, double)[1][2]
redis.clients.jedis.commands.unified.search.FTHybridCommandsTestBase$SupportedScorersTest[1] ‑ testScorer(Scorer, double, double)[1][3]
redis.clients.jedis.commands.unified.search.FTHybridCommandsTestBase$SupportedScorersTest[1] ‑ testScorer(Scorer, double, double)[1][4]
redis.clients.jedis.commands.unified.search.FTHybridCommandsTestBase$SupportedScorersTest[1] ‑ testScorer(Scorer, double, double)[1][5]
redis.clients.jedis.commands.unified.search.FTHybridCommandsTestBase$SupportedScorersTest[1] ‑ testScorer(Scorer, double, double)[1][6]
redis.clients.jedis.commands.unified.search.FTHybridCommandsTestBase$SupportedScorersTest[1] ‑ testScorer(Scorer, double, double)[1][7]
redis.clients.jedis.commands.unified.search.FTHybridCommandsTestBase$SupportedScorersTest[2] ‑ testScorer(Scorer, double, double)[2][1]
redis.clients.jedis.commands.unified.search.FTHybridCommandsTestBase$SupportedScorersTest[2] ‑ testScorer(Scorer, double, double)[2][2]
redis.clients.jedis.commands.unified.search.FTHybridCommandsTestBase$SupportedScorersTest[2] ‑ testScorer(Scorer, double, double)[2][3]
…
redis.clients.jedis.commands.unified.search.FTHybridCommandsTestBase$SupportedScorersTest ‑ testScorer(Scorer, double, double)[1]
redis.clients.jedis.commands.unified.search.FTHybridCommandsTestBase$SupportedScorersTest ‑ testScorer(Scorer, double, double)[2]
This pull request skips 630 tests.
redis.clients.jedis.commands.commandobjects.CommandObjectsStringCommandsTest[1] ‑ testMsetexNx_parametrized(String, MSetExParams)[1][1]
redis.clients.jedis.commands.commandobjects.CommandObjectsStringCommandsTest[1] ‑ testMsetexNx_parametrized(String, MSetExParams)[1][2]
redis.clients.jedis.commands.commandobjects.CommandObjectsStringCommandsTest[1] ‑ testMsetexNx_parametrized(String, MSetExParams)[1][3]
redis.clients.jedis.commands.commandobjects.CommandObjectsStringCommandsTest[1] ‑ testMsetexNx_parametrized(String, MSetExParams)[1][4]
redis.clients.jedis.commands.commandobjects.CommandObjectsStringCommandsTest[1] ‑ testMsetexNx_parametrized(String, MSetExParams)[1][5]
redis.clients.jedis.commands.commandobjects.CommandObjectsStringCommandsTest[2] ‑ testMsetexNx_parametrized(String, MSetExParams)[2][1]
redis.clients.jedis.commands.commandobjects.CommandObjectsStringCommandsTest[2] ‑ testMsetexNx_parametrized(String, MSetExParams)[2][2]
redis.clients.jedis.commands.commandobjects.CommandObjectsStringCommandsTest[2] ‑ testMsetexNx_parametrized(String, MSetExParams)[2][3]
redis.clients.jedis.commands.commandobjects.CommandObjectsStringCommandsTest[2] ‑ testMsetexNx_parametrized(String, MSetExParams)[2][4]
redis.clients.jedis.commands.commandobjects.CommandObjectsStringCommandsTest[2] ‑ testMsetexNx_parametrized(String, MSetExParams)[2][5]
…

♻️ This comment has been updated with latest results.

@ggivo ggivo force-pushed the topic/ggivo/CAE-2930-setup-benchmark-nightly branch from df14ad1 to 5a01eab Compare May 13, 2026 04:59
@ggivo ggivo changed the title Add JMH benchmarks for protocol reads with baseline/cache-aware comparisons Add JMH benchmarks May 14, 2026
@ggivo ggivo marked this pull request as ready for review May 14, 2026 06:02
@ggivo ggivo requested review from atakavci and uglide and removed request for uglide May 14, 2026 06:02
Comment thread src/test/jmh/redis/clients/jedis/benchmark/CRC16Benchmark.java Outdated
Comment thread .github/workflows/docs.yml
@ggivo ggivo changed the title Add JMH benchmarks Add JMH benchmarks (CAE-2930) May 14, 2026
Comment thread src/test/jmh/redis/clients/jedis/benchmark/SafeEncoderBenchmark.java Outdated
Copy link
Copy Markdown
Contributor

@uglide uglide left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good initiative, but needs some polishing.

Comment thread .github/workflows/benchmarks.yml Outdated
Comment thread .github/workflows/benchmarks.yml Outdated
run: |
docker run --name redis-benchmark-test \
-p 6379:6379 \
-d redis:8.6.3 \
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to use without a patch :

Suggested change
-d redis:8.6.3 \
-d redis:8.6 \

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean 8.6.0?
I think benchmarks should run against pinned version

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then its better to use 8.2, because it's an LTS version

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about pinned to 8.2.6 (latest 8.2), make sure we don't pick patch releases

* Recreate streams before each benchmark invocation. This is necessary because streams get
* consumed during reading.
*/
@Setup(Level.Invocation)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a big expert on JMH, but the docs say that:

This level is only usable for benchmarks taking more than a millisecond per single Benchmark method invocation. It is a good idea to validate the impact for your case on ad-hoc basis as well.

From: https://javadoc.io/doc/org.openjdk.jmh/jmh-core/1.1.1/org/openjdk/jmh/annotations/Level.html#Invocation

So, for ProtocolRead, we need some kind of batching.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the batching approach you described. Summary:

  • Removed @Setup(Level.Invocation) recreateStreams() and the eight pre-allocated RedisInputStream fields.
  • Pre-allocated only the source byte[]s (the cheap part). A small stream(byte[]) helper wraps a fresh RedisInputStream per op — required because RedisInputStream retains internal buf/count/limit state and has no public reset.
  • Each @Benchmark is now a tight BATCH_SIZE-loop with @OperationsPerInvocation(BATCH_SIZE) (BATCH_SIZE = 100).
  • Applied the same batching to encodeSetCommand for consistency.

Trade-off worth flagging: stream construction now lives inside the measured op rather than excluded via Setup. Reported ns/op will be slightly higher than before, but the measurement is jitter-free and JMH no longer emits the sub-microsecond invocation-setup warning. I think that's the right call — Setup-excluded numbers were optimistic anyway since the JMH machinery transitions between setup and measurement aren't free.

Verified locally: compiles under mvn -Pjmh test-compile with JDK 8 (Azul Zulu 1.8.0_422). Formatter already applied.

GitHub can't render this as a one-click suggestion block here because the parent comment is anchored to a single diff position rather than a multi-line range — pasting the full new file body for review:

Full ProtocolReadBenchmark.java (lines 18 → EOF)
/**
 * Comprehensive JMH Benchmark for Jedis Protocol operations. This benchmark covers: 1. Baseline
 * protocol operations (parsing, encoding) 2. Cache-aware protocol reads (push notification
 * overhead) 3. Push invalidation message processing at scale (1, 10, 100 pattern) 4. Realistic
 * mixed scenarios (push messages + regular responses) Run with: mvn -Pjmh clean test
 * <p>
 * Each benchmark wraps a {@code BATCH_SIZE} loop, with {@link OperationsPerInvocation} declaring
 * the per-invocation op count. Streams are constructed inside the loop from pre-allocated byte
 * arrays — {@link RedisInputStream} retains internal read state, so a fresh wrapper is required per
 * op. This pattern replaces {@code @Setup(Level.Invocation)}, which JMH flags as unreliable for
 * sub-microsecond operations.
 */
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@Fork(1)
@Warmup(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
@Threads(1)
public class ProtocolReadBenchmark {

  private static final int BATCH_SIZE = 100;

  // ========== BASELINE PROTOCOL DATA ==========

  private static final byte[] KEY = "123456789".getBytes();
  private static final byte[] VAL = "FooBar".getBytes();

  private static final byte[] SIMPLE_STRING_BYTES = "+OK\r\n".getBytes();
  private static final byte[] BULK_STRING_BYTES = "$5\r\nHello\r\n".getBytes();
  private static final byte[] ARRAY_BYTES = "*3\r\n$3\r\nfoo\r\n$3\r\nbar\r\n$3\r\nbaz\r\n"
      .getBytes();
  private static final byte[] MULTI_BULK_BYTES = "*4\r\n$3\r\nfoo\r\n$13\r\nbarbarbarfooz\r\n$5\r\nHello\r\n$5\r\nWorld\r\n"
      .getBytes();

  // ========== PUSH INVALIDATION DATA ==========

  // >2\r\n$10\r\ninvalidate\r\n*1\r\n$3\r\nkey\r\n
  private byte[] singlePushBytes;
  // >2\r\n$10\r\ninvalidate\r\n*100\r\n... (100 keys)
  private byte[] largePushBytes;

  // Mixed: N pushes followed by a regular response
  private byte[] mixedSinglePushBytes;
  private byte[] mixedTenPushBytes;
  private byte[] mixedHundredPushBytes;

  // Cache for testing
  private Cache cache;

  @Setup
  public void setup() {
    // Single push with 1 key
    singlePushBytes = ">2\r\n$10\r\ninvalidate\r\n*1\r\n$3\r\nkey\r\n".getBytes();

    // Large push with 100 keys invalidated at once
    StringBuilder largePush = new StringBuilder(">2\r\n$10\r\ninvalidate\r\n*100\r\n");
    for (int i = 0; i < 100; i++) {
      String key = "key:" + i;
      largePush.append("$").append(key.length()).append("\r\n").append(key).append("\r\n");
    }
    largePushBytes = largePush.toString().getBytes();

    // 1 push + response
    mixedSinglePushBytes = ">2\r\n$10\r\ninvalidate\r\n*1\r\n$3\r\nkey\r\n+OK\r\n".getBytes();

    // 10 pushes + response
    StringBuilder mixed10 = new StringBuilder();
    for (int i = 0; i < 10; i++) {
      mixed10.append(">2\r\n$10\r\ninvalidate\r\n*1\r\n$4\r\nkey").append(i).append("\r\n");
    }
    mixed10.append("+OK\r\n");
    mixedTenPushBytes = mixed10.toString().getBytes();

    // 100 pushes + response (heavy invalidation scenario)
    StringBuilder mixed100 = new StringBuilder();
    for (int i = 0; i < 100; i++) {
      mixed100.append(">2\r\n$10\r\ninvalidate\r\n*1\r\n$5\r\nkey").append(String.format("%02d", i))
          .append("\r\n");
    }
    mixed100.append("+OK\r\n");
    mixedHundredPushBytes = mixed100.toString().getBytes();

    cache = CacheFactory.getCache(
      CacheConfig.builder().maxSize(10000).cacheable(DefaultCacheable.INSTANCE).build());
  }

  private static RedisInputStream stream(byte[] bytes) {
    return new RedisInputStream(new ByteArrayInputStream(bytes));
  }

  // ========== BASELINE PROTOCOL OPERATIONS (NO CACHE) ==========

  /**
   * Baseline: Read simple string without cache.
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void readSimpleString(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.read(stream(SIMPLE_STRING_BYTES)));
    }
  }

  /**
   * Baseline: Read bulk string without cache.
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void readBulkString(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.read(stream(BULK_STRING_BYTES)));
    }
  }

  /**
   * Baseline: Read array without cache.
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void readArray(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.read(stream(ARRAY_BYTES)));
    }
  }

  /**
   * Baseline: Read multi-bulk response without cache.
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void readMultiBulkResponse(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.read(stream(MULTI_BULK_BYTES)));
    }
  }

  /**
   * Encode SET command (baseline protocol performance).
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void encodeSetCommand(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      RedisOutputStream out = new RedisOutputStream(new ByteArrayOutputStream(8192));
      Protocol.sendCommand(out, new CommandArguments(Protocol.Command.SET).key(KEY).add(VAL));
      blackhole.consume(out);
    }
  }

  // ========== CACHE-AWARE READS: Overhead of checking for push messages ==========

  /**
   * Cache-aware: Read simple string with cache (checks for push messages but none present). Compare
   * to readSimpleString to measure overhead.
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void cacheAwareReadSimpleString(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.read(stream(SIMPLE_STRING_BYTES), cache));
    }
  }

  /**
   * Cache-aware: Read bulk string with cache (checks for push messages but none present). Compare
   * to readBulkString to measure overhead.
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void cacheAwareReadBulkString(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.read(stream(BULK_STRING_BYTES), cache));
    }
  }

  /**
   * Cache-aware: Read array with cache (checks for push messages but none present). Compare to
   * readArray to measure overhead.
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void cacheAwareReadArray(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.read(stream(ARRAY_BYTES), cache));
    }
  }

  /**
   * Cache-aware: Read multi-bulk response with cache (checks for push messages but none present).
   * Compare to readMultiBulkResponse to measure overhead.
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void cacheAwareReadMultiBulkResponse(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.read(stream(MULTI_BULK_BYTES), cache));
    }
  }

  // ========== PUSH MESSAGE PROCESSING: Direct invalidation overhead ==========

  /**
   * Process single push invalidation message (1 key). Measures cost of processing one cache
   * invalidation.
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void processSinglePushInvalidation(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.readPushes(stream(singlePushBytes), cache, true));
    }
  }

  /**
   * Process large push invalidation (100 keys in single message). Simulates mass invalidation event
   * (e.g., FLUSHDB on tracked keys).
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void processLargePushInvalidation(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.readPushes(stream(largePushBytes), cache, true));
    }
  }

  // ========== MIXED STREAMS: Realistic scenarios with push + response ==========

  /**
   * Read response preceded by 1 push invalidation. Most common realistic scenario.
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void readWith1PushMessage(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.read(stream(mixedSinglePushBytes), cache));
    }
  }

  /**
   * Read response preceded by 10 push invalidations. Moderate invalidation burst before response.
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void readWith10PushMessages(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.read(stream(mixedTenPushBytes), cache));
    }
  }

  /**
   * Read response preceded by 100 push invalidations. Heavy invalidation scenario (worst-case for
   * read latency).
   */
  @Benchmark
  @OperationsPerInvocation(BATCH_SIZE)
  public void readWith100PushMessages(Blackhole blackhole) throws Exception {
    for (int i = 0; i < BATCH_SIZE; i++) {
      blackhole.consume(Protocol.read(stream(mixedHundredPushBytes), cache));
    }
  }
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will try to avoid recreating the stream on each read, so let me try an alternative approach.
Work on batches, but pre-populate the stream with BATCH*RESPONSES,

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5045cc4. Configure here.

Comment thread .github/workflows/docs.yml
Comment thread src/test/jmh/redis/clients/jedis/benchmark/ProtocolReadBenchmark.java Outdated
@ggivo ggivo requested a review from uglide May 15, 2026 19: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.

2 participants