You are viewing a plain text version of this content. The canonical link for it is here.
Posted to oak-commits@jackrabbit.apache.org by da...@apache.org on 2026/10/06 04:40:22 UTC

(jackrabbit-oak) 11/15: OAK-12426 : benchmark document cache implementations

This is an automated email from the ASF dual-hosted git repository.

rishabhdaim pushed a commit to branch issue/OAK-12436
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git

commit d054ee49bfa9775946e259ed8852b4572e5835e6
Author: rishabhdaim <ri...@gmail.com>
AuthorDate: Tue Sep 29 13:45:19 2026 +0530

    OAK-12426 : benchmark document cache implementations
    
    Co-authored-by: Copilot <22...@users.noreply.github.com>
---
 oak-benchmarks/pom.xml                             |   5 +
 .../jackrabbit/oak/benchmark/BenchmarkRunner.java  |   1 +
 .../oak/benchmark/DocumentCacheBenchmark.java      | 266 +++++++++++++++++++++
 3 files changed, 272 insertions(+)

diff --git a/oak-benchmarks/pom.xml b/oak-benchmarks/pom.xml
index 0cdf97b98c..47cc67ef78 100644
--- a/oak-benchmarks/pom.xml
+++ b/oak-benchmarks/pom.xml
@@ -107,6 +107,11 @@
             <artifactId>oak-core-spi</artifactId>
             <version>${project.version}</version>
         </dependency>
+        <dependency>
+            <groupId>org.apache.jackrabbit</groupId>
+            <artifactId>oak-store-document</artifactId>
+            <version>${project.version}</version>
+        </dependency>
         <dependency>
             <groupId>org.apache.jackrabbit</groupId>
             <artifactId>oak-commons</artifactId>
diff --git a/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/BenchmarkRunner.java b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/BenchmarkRunner.java
index f7de6c3b6a..afe2d4eae1 100644
--- a/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/BenchmarkRunner.java
+++ b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/BenchmarkRunner.java
@@ -216,6 +216,7 @@ public class BenchmarkRunner {
                                 benchmarkOptions.getFlatStructure().value(options),
                                 benchmarkOptions.getReport().value(options)),
                         new CreateNodesBenchmark(),
+                        new DocumentCacheBenchmark(),
                         new ManyNodes(options.has(benchmarkOptions.getVerbose())),
                         new ObservationTest(),
                         new RevisionGCTest(),
diff --git a/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/DocumentCacheBenchmark.java b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/DocumentCacheBenchmark.java
new file mode 100644
index 0000000000..252a214cf9
--- /dev/null
+++ b/oak-benchmarks/src/main/java/org/apache/jackrabbit/oak/benchmark/DocumentCacheBenchmark.java
@@ -0,0 +1,266 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.benchmark;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.SplittableRandom;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.jackrabbit.oak.cache.CacheLIRS;
+import org.apache.jackrabbit.oak.cache.EmpiricalWeigher;
+import org.apache.jackrabbit.oak.cache.api.Cache;
+import org.apache.jackrabbit.oak.cache.api.CacheBuilder;
+import org.apache.jackrabbit.oak.cache.api.CacheStatsSnapshot;
+import org.apache.jackrabbit.oak.fixture.RepositoryFixture;
+import org.apache.jackrabbit.oak.plugins.document.util.StringValue;
+
+/**
+ * Compares CacheLIRS and Caffeine under representative DocumentNodeStore cache workloads.
+ *
+ * <p>The benchmark uses the Oak cache API, {@link EmpiricalWeigher}, fixed-size
+ * {@link StringValue} keys and values, and identical maximum weights. It covers steady-state
+ * reads, a drifting active set, explicit invalidation followed by reload, and concurrent reads.
+ * Each scenario runs with working sets at 0.5, 1, and 2 times the configured cache capacity.</p>
+ *
+ * <p>Run with:
+ * {@code java -Xmx2g -jar target/oak-benchmarks-*.jar
+ * benchmark DocumentCacheBenchmark Oak-MemoryNS}. Workload size can be adjusted with
+ * {@code -Ddocument.cache.entries}, {@code -Ddocument.cache.operations}, and
+ * {@code -Ddocument.cache.threads}.</p>
+ */
+public final class DocumentCacheBenchmark extends Benchmark {
+
+    private static final int CACHE_ENTRIES = Integer.getInteger("document.cache.entries", 10_000);
+    private static final int OPERATIONS = Integer.getInteger("document.cache.operations", 2_000_000);
+    private static final int THREADS = Integer.getInteger("document.cache.threads",
+            Math.max(2, Runtime.getRuntime().availableProcessors()));
+    private static final int WARMUP_OPERATIONS = Math.max(10_000, OPERATIONS / 10);
+    private static final int VALUE_LENGTH = 256;
+    private static final int LIRS_AVERAGE_WEIGHT = 2_000;
+    private static final long RANDOM_SEED = 42L;
+    private static final double[] WORKING_SET_RATIOS = {0.5, 1.0, 2.0};
+    private static final EmpiricalWeigher WEIGHER = new EmpiricalWeigher();
+    private static final int ENTRY_WEIGHT = WEIGHER.weigh(value(0), value(0));
+    private static final long MAXIMUM_WEIGHT = (long) CACHE_ENTRIES * ENTRY_WEIGHT;
+
+    private enum Policy {
+        CACHE_LIRS {
+            @Override
+            Cache<StringValue, StringValue> createCache() {
+                return CacheLIRS.<StringValue, StringValue>newBuilder()
+                        .maximumWeight(MAXIMUM_WEIGHT)
+                        .averageWeight(LIRS_AVERAGE_WEIGHT)
+                        .weigher(WEIGHER::weigh)
+                        .segmentCount(16)
+                        .recordStats()
+                        .build()
+                        .asOakCache();
+            }
+        },
+        CAFFEINE {
+            @Override
+            Cache<StringValue, StringValue> createCache() {
+                return CacheBuilder.<StringValue, StringValue>newBuilder()
+                        .maximumWeight(MAXIMUM_WEIGHT)
+                        .weigher(WEIGHER::weigh)
+                        .recordStats()
+                        .build();
+            }
+        };
+
+        abstract Cache<StringValue, StringValue> createCache();
+    }
+
+    private enum Scenario {
+        STEADY_STATE,
+        CHURN,
+        INVALIDATION,
+        CONCURRENT
+    }
+
+    private record Result(long elapsedNanos, long operations, long backendLoads,
+                          CacheStatsSnapshot stats) {
+    }
+
+    @Override
+    public void run(Iterable<RepositoryFixture> fixtures) {
+        validateConfiguration();
+        System.out.printf(Locale.ROOT,
+                "%nDocumentCacheBenchmark cacheEntries=%,d maxWeight=%,d operations=%,d threads=%d%n",
+                CACHE_ENTRIES, MAXIMUM_WEIGHT, OPERATIONS, THREADS);
+        System.out.println("scenario       ratio policy          ops/s      ns/op    hit%      misses"
+                + "   evictions backendLoads");
+
+        for (Scenario scenario : Scenario.values()) {
+            for (double ratio : WORKING_SET_RATIOS) {
+                int workingSetSize = Math.max(1, (int) (CACHE_ENTRIES * ratio));
+                for (Policy policy : Policy.values()) {
+                    runScenario(policy, scenario, workingSetSize, WARMUP_OPERATIONS);
+                    Result result = runScenario(policy, scenario, workingSetSize, OPERATIONS);
+                    printResult(scenario, ratio, policy, result);
+                }
+            }
+        }
+    }
+
+    private static Result runScenario(Policy policy, Scenario scenario, int workingSetSize,
+                                      int operations) {
+        Cache<StringValue, StringValue> cache = policy.createCache();
+        List<StringValue> keys = createValues(workingSetSize);
+        AtomicLong backendLoads = new AtomicLong();
+        long start = System.nanoTime();
+        long completed;
+        if (scenario == Scenario.CONCURRENT) {
+            completed = runConcurrent(cache, keys, operations, backendLoads);
+        } else {
+            completed = runSingleThreaded(cache, keys, scenario, operations, backendLoads);
+        }
+        long elapsed = System.nanoTime() - start;
+        cache.cleanUp();
+        return new Result(elapsed, completed, backendLoads.get(), cache.stats());
+    }
+
+    private static long runSingleThreaded(Cache<StringValue, StringValue> cache,
+                                          List<StringValue> keys,
+                                          Scenario scenario,
+                                          int operations,
+                                          AtomicLong backendLoads) {
+        SplittableRandom random = new SplittableRandom(RANDOM_SEED);
+        int churnOffset = 0;
+        for (int operation = 0; operation < operations; operation++) {
+            int index;
+            if (scenario == Scenario.CHURN) {
+                if (operation > 0 && operation % 1_000 == 0) {
+                    churnOffset = (churnOffset + Math.max(1, keys.size() / 100)) % keys.size();
+                }
+                index = (churnOffset + random.nextInt(Math.max(1, keys.size() / 2))) % keys.size();
+            } else {
+                index = hotColdIndex(random, keys.size());
+            }
+            StringValue key = keys.get(index);
+            if (scenario == Scenario.INVALIDATION && operation % 16 == 0) {
+                cache.invalidate(key);
+            }
+            cache.get(key, ignored -> {
+                backendLoads.incrementAndGet();
+                return key;
+            });
+        }
+        return operations;
+    }
+
+    private static long runConcurrent(Cache<StringValue, StringValue> cache,
+                                      List<StringValue> keys,
+                                      int operations,
+                                      AtomicLong backendLoads) {
+        ExecutorService executor = Executors.newFixedThreadPool(THREADS);
+        try {
+            List<Callable<Long>> tasks = new ArrayList<>();
+            int operationsPerThread = operations / THREADS;
+            int remainder = operations % THREADS;
+            for (int thread = 0; thread < THREADS; thread++) {
+                int taskOperations = operationsPerThread + (thread < remainder ? 1 : 0);
+                long seed = RANDOM_SEED + thread;
+                tasks.add(() -> runConcurrentTask(cache, keys, taskOperations, seed, backendLoads));
+            }
+            long completed = 0;
+            for (Future<Long> future : executor.invokeAll(tasks)) {
+                completed += future.get();
+            }
+            return completed;
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("Concurrent document cache benchmark was interrupted", e);
+        } catch (ExecutionException e) {
+            throw new IllegalStateException("Concurrent document cache benchmark task failed", e.getCause());
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    private static long runConcurrentTask(Cache<StringValue, StringValue> cache,
+                                          List<StringValue> keys,
+                                          int operations,
+                                          long seed,
+                                          AtomicLong backendLoads) {
+        SplittableRandom random = new SplittableRandom(seed);
+        for (int operation = 0; operation < operations; operation++) {
+            StringValue key = keys.get(hotColdIndex(random, keys.size()));
+            cache.get(key, ignored -> {
+                backendLoads.incrementAndGet();
+                return key;
+            });
+        }
+        return operations;
+    }
+
+    private static int hotColdIndex(SplittableRandom random, int workingSetSize) {
+        int hotSetSize = Math.max(1, workingSetSize / 5);
+        if (hotSetSize == workingSetSize) {
+            return 0;
+        }
+        if (random.nextInt(10) < 8) {
+            return random.nextInt(hotSetSize);
+        }
+        return hotSetSize + random.nextInt(workingSetSize - hotSetSize);
+    }
+
+    private static List<StringValue> createValues(int count) {
+        List<StringValue> values = new ArrayList<>(count);
+        for (int index = 0; index < count; index++) {
+            values.add(value(index));
+        }
+        return values;
+    }
+
+    private static StringValue value(int index) {
+        String prefix = String.format(Locale.ROOT, "document-%08d-", index);
+        return StringValue.fromString(prefix + "x".repeat(VALUE_LENGTH - prefix.length()));
+    }
+
+    private static void printResult(Scenario scenario, double ratio, Policy policy, Result result) {
+        double operationsPerSecond = result.operations * 1_000_000_000.0 / result.elapsedNanos;
+        double nanosPerOperation = (double) result.elapsedNanos / result.operations;
+        System.out.printf(Locale.ROOT, "%-14s %4.1fx %-11s %,12.0f %,10.1f %7.2f %,11d"
+                        + " %,11d %,12d%n",
+                scenario, ratio, policy, operationsPerSecond, nanosPerOperation,
+                result.stats.hitRate() * 100, result.stats.missCount(),
+                result.stats.evictionCount(), result.backendLoads);
+    }
+
+    private static void validateConfiguration() {
+        if (CACHE_ENTRIES <= 0) {
+            throw new IllegalArgumentException("document.cache.entries must be greater than 0");
+        }
+        if (OPERATIONS <= 0) {
+            throw new IllegalArgumentException("document.cache.operations must be greater than 0");
+        }
+        if (THREADS <= 0) {
+            throw new IllegalArgumentException("document.cache.threads must be greater than 0");
+        }
+        if (VALUE_LENGTH < 32) {
+            throw new IllegalStateException("VALUE_LENGTH must leave room for fixed-width keys");
+        }
+    }
+}