-
-
Notifications
You must be signed in to change notification settings - Fork 380
Add baseline benchmark for history write path (pre Phase 1 concurrency fixes) #3593
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
MattBDev
wants to merge
6
commits into
main
Choose a base branch
from
claude/history-write-path-benchmark
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+318
−4
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
a13548f
Add JMH-alternative baseline benchmark for history write path
MattBDev 669f063
Address Copilot review: fix y-range, add contended warmup, tighten de…
MattBDev f2ebb39
Narrow benchmark's catch(Throwable) to catch(Exception)
MattBDev fb41424
Fix header op-count mismatch and silently-dropped worker Errors
MattBDev 9a8844e
Bound ready.await(), report failed temp-dir cleanup
MattBDev d04722c
Clarify that the ops-threw count is a lower bound, not a full count
MattBDev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
281 changes: 281 additions & 0 deletions
281
worldedit-core/src/test/java/com/fastasyncworldedit/core/history/HistoryWriteBenchmark.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,281 @@ | ||
| package com.fastasyncworldedit.core.history; | ||
|
|
||
| import com.fastasyncworldedit.core.history.changeset.FaweStreamChangeSet; | ||
| import com.sk89q.worldedit.util.io.file.SafeFiles; | ||
| import com.sk89q.worldedit.world.NullWorld; | ||
| import com.sk89q.worldedit.world.World; | ||
|
|
||
| import java.io.File; | ||
| import java.io.IOException; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.UUID; | ||
| import java.util.concurrent.CountDownLatch; | ||
| import java.util.concurrent.ExecutionException; | ||
| import java.util.concurrent.ExecutorService; | ||
| import java.util.concurrent.Executors; | ||
| import java.util.concurrent.Future; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.concurrent.atomic.AtomicLong; | ||
|
|
||
| /** | ||
| * Hand-rolled micro-benchmark for the {@code com.fastasyncworldedit.core.history} write hot path | ||
| * ({@link DiskStorageHistory} and {@link MemoryOptimizedHistory}). | ||
| * | ||
| * <p>This is intentionally NOT a JMH benchmark. Wiring the {@code me.champeau.jmh} plugin into this | ||
| * repository's Gradle 9 multi-module build (custom ANTLR source generation, annotation processors, | ||
| * platform sub-modules) was judged likely to eat more time than the number itself was worth, so a | ||
| * plain warmup + measured-loop benchmark using {@link System#nanoTime()} was used instead, per the | ||
| * task's documented fallback. The goal is a repeatable, comparable number across a "before" and | ||
| * "after" run of the Phase 1 concurrency fixes, not a polished benchmarking harness.</p> | ||
| * | ||
| * <p>Covers, per the write hot path that Phase 1 touches:</p> | ||
| * <ul> | ||
| * <li>single-threaded {@code add(x, y, z, from, to)} throughput for both change set types</li> | ||
| * <li>a contended variant with multiple threads calling {@code add(...)} concurrently on the | ||
| * <em>same</em> instance, which exercises the lazy stream-init ({@code getBlockOS}) and the | ||
| * non-atomic {@code blockSize++} counter -- exactly the paths Phase 1's fixes touch</li> | ||
| * </ul> | ||
| * | ||
| * <p>Run via the {@code historyBenchmark} Gradle task added in {@code worldedit-core/build.gradle.kts}:</p> | ||
| * <pre>{@code ./gradlew :worldedit-core:historyBenchmark}</pre> | ||
| */ | ||
| public final class HistoryWriteBenchmark { | ||
|
|
||
| private static final int WARMUP_OPS = 500_000; | ||
| private static final int MEASURED_OPS = 5_000_000; | ||
| private static final int TRIALS = 5; | ||
| private static final int THREADS = Math.max(2, Math.min(8, Runtime.getRuntime().availableProcessors())); | ||
|
|
||
| private HistoryWriteBenchmark() { | ||
| } | ||
|
|
||
| public static void main(String[] args) throws Exception { | ||
| System.out.println("=== FAWE history write-path baseline benchmark ==="); | ||
| // Contended trials split MEASURED_OPS across THREADS with integer division, so the actual | ||
| // op count they run can be slightly less than MEASURED_OPS when THREADS doesn't divide it | ||
| // evenly - report() itself always uses the real per-trial count for throughput, but print | ||
| // the two separately here so this header doesn't overclaim a single measuredOps for every | ||
| // benchmark variant. | ||
| int contendedMeasuredOps = (MEASURED_OPS / THREADS) * THREADS; | ||
| System.out.printf( | ||
| "warmupOps=%d measuredOps=%d (contended runs: %d actual, %d threads) trials=%d%n%n", | ||
| WARMUP_OPS, MEASURED_OPS, contendedMeasuredOps, THREADS, TRIALS | ||
| ); | ||
|
|
||
| runSingleThreaded("DiskStorageHistory (single-thread)", HistoryWriteBenchmark::newDiskStorageHistory); | ||
| runSingleThreaded("MemoryOptimizedHistory (single-thread)", HistoryWriteBenchmark::newMemoryOptimizedHistory); | ||
| runContended("DiskStorageHistory (contended, " + THREADS + " threads)", HistoryWriteBenchmark::newDiskStorageHistory); | ||
| runContended( | ||
| "MemoryOptimizedHistory (contended, " + THREADS + " threads)", | ||
| HistoryWriteBenchmark::newMemoryOptimizedHistory | ||
| ); | ||
|
|
||
| System.out.println("=== done ==="); | ||
| } | ||
|
|
||
| private interface HistoryFactory { | ||
|
|
||
| FaweStreamChangeSet create() throws IOException; | ||
|
|
||
| } | ||
|
|
||
| /** | ||
| * {@link NullWorld} whose {@code getMinY()}/{@code getMaxY()} do not reach into | ||
| * {@code WorldEdit.getInstance()} (which would require a fully bootstrapped platform). Every | ||
| * other call used by the write hot path is already a no-op / dummy in {@link NullWorld}. | ||
| */ | ||
| private static final class BenchWorld extends NullWorld { | ||
|
|
||
| @Override | ||
| public int getMinY() { | ||
| return -64; | ||
| } | ||
|
|
||
| @Override | ||
| public int getMaxY() { | ||
| return 319; | ||
| } | ||
|
|
||
| } | ||
|
|
||
| private static World world() { | ||
| return new BenchWorld(); | ||
| } | ||
|
|
||
| private static FaweStreamChangeSet newDiskStorageHistory() throws IOException { | ||
| Path dir = Files.createTempDirectory("fawe-history-bench-"); | ||
| return new DiskStorageHistory(dir.toFile(), world(), UUID.randomUUID(), 0); | ||
| } | ||
|
|
||
| private static FaweStreamChangeSet newMemoryOptimizedHistory() { | ||
| return new MemoryOptimizedHistory(world()); | ||
| } | ||
|
|
||
| private static void doAdd(FaweStreamChangeSet changeSet, int i) { | ||
| // Bitmasks rather than i % 256 / i % 384: 384 is not a power of two, so that modulo | ||
| // compiles to an actual integer division, which would otherwise inflate the measured | ||
| // per-op cost with work that has nothing to do with add() itself. y needs the 384-value | ||
| // range of BenchWorld's height (-64..319), so fold the 9-bit 0..511 mask down to 0..383 | ||
| // with a single conditional subtract (still no division), then offset by minY. | ||
| int x = i & 0xFF; | ||
| int rawY = (i >>> 8) & 0x1FF; | ||
| int y = (rawY >= 384 ? rawY - 384 : rawY) - 64; | ||
| int z = (i >>> 17) & 0xFF; | ||
| changeSet.add(x, y, z, 1, 2); | ||
| } | ||
|
|
||
| private static void closeAndCleanup(FaweStreamChangeSet changeSet) { | ||
| try { | ||
| changeSet.close(); | ||
| } catch (Exception e) { | ||
| // The contended benchmark deliberately hammers the pre-fix DCL race in getBlockOS(), | ||
| // which can leave the underlying (non-thread-safe) compression stream corrupted. | ||
| // close()/flush() can then throw unchecked exceptions (e.g. AIOOBE from LZ4). That is | ||
| // itself a data point (see the error count printed by report()), not a benchmark bug - | ||
| // swallow it here so remaining trials still run. Only Exception, not Throwable: a | ||
| // real Error (OutOfMemoryError, StackOverflowError) must still propagate and stop the | ||
| // run rather than being treated as expected race noise. | ||
| System.err.println(" (close() threw " + e + ")"); | ||
| } | ||
| if (changeSet instanceof DiskStorageHistory dsh) { | ||
| File dir = dsh.getBDFile().getParentFile(); | ||
| try { | ||
| SafeFiles.tryHardToDeleteDir(dir.toPath()); | ||
| } catch (IOException e) { | ||
| // Not fatal to the benchmark, but silently ignoring this let failed cleanups | ||
| // accumulate unnoticed across repeated runs (this benchmark can create sizeable | ||
| // on-disk histories). At least report it so it's diagnosable. | ||
| System.err.println(" (failed to clean up " + dir + ": " + e + ")"); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static void runSingleThreaded(String label, HistoryFactory factory) throws Exception { | ||
| long[] elapsedNanos = new long[TRIALS]; | ||
| for (int trial = 0; trial < TRIALS; trial++) { | ||
| FaweStreamChangeSet changeSet = factory.create(); | ||
| int i = 0; | ||
| for (; i < WARMUP_OPS; i++) { | ||
| doAdd(changeSet, i); | ||
| } | ||
| long start = System.nanoTime(); | ||
| for (int j = 0; j < MEASURED_OPS; j++, i++) { | ||
| doAdd(changeSet, i); | ||
| } | ||
| elapsedNanos[trial] = System.nanoTime() - start; | ||
| closeAndCleanup(changeSet); | ||
| } | ||
| report(label, elapsedNanos, MEASURED_OPS, 0); | ||
| } | ||
|
|
||
| private static void runContended(String label, HistoryFactory factory) throws Exception { | ||
| int opsPerThread = MEASURED_OPS / THREADS; | ||
| long[] elapsedNanos = new long[TRIALS]; | ||
| long totalErrors = 0; | ||
| for (int trial = 0; trial < TRIALS; trial++) { | ||
| // Warm up JIT/class-loading on a throwaway instance rather than the measured one: the | ||
| // measured instance must still start with uninitialized lazy streams so this trial | ||
| // keeps exercising the lazy stream-init race under contention, which is the entire | ||
| // point of the contended variant. | ||
| FaweStreamChangeSet warmupChangeSet = factory.create(); | ||
| for (int i = 0; i < WARMUP_OPS; i++) { | ||
| doAdd(warmupChangeSet, i); | ||
| } | ||
| closeAndCleanup(warmupChangeSet); | ||
|
|
||
| FaweStreamChangeSet changeSet = factory.create(); | ||
| ExecutorService pool = Executors.newFixedThreadPool(THREADS); | ||
| CountDownLatch ready = new CountDownLatch(THREADS); | ||
| CountDownLatch go = new CountDownLatch(1); | ||
| AtomicLong errorCount = new AtomicLong(); | ||
| // submit() discards its Future by default, and a FutureTask captures any Throwable | ||
| // (Exception or Error) rather than propagating it to the calling thread - so an | ||
| // Error escaping the loop below would previously vanish silently instead of actually | ||
| // propagating, contradicting the comment inside the loop. Keep the futures and check | ||
| // them after the pool finishes so a real Error is still surfaced. | ||
| List<Future<?>> futures = new ArrayList<>(THREADS); | ||
| for (int t = 0; t < THREADS; t++) { | ||
| final int base = t * opsPerThread; | ||
| futures.add(pool.submit(() -> { | ||
| ready.countDown(); | ||
| try { | ||
| go.await(); | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| return; | ||
| } | ||
| for (int j = 0; j < opsPerThread; j++) { | ||
| try { | ||
| doAdd(changeSet, base + j); | ||
| } catch (Exception t2) { | ||
| // Only Exception: a real Error must propagate rather than being | ||
| // counted as expected race noise (see closeAndCleanup above). | ||
| errorCount.incrementAndGet(); | ||
| } | ||
| } | ||
| })); | ||
| } | ||
| if (!ready.await(1, TimeUnit.MINUTES)) { | ||
| pool.shutdownNow(); | ||
| throw new IllegalStateException("Benchmark worker threads did not all start in time"); | ||
| } | ||
| long start = System.nanoTime(); | ||
| go.countDown(); | ||
| pool.shutdown(); | ||
| if (!pool.awaitTermination(5, TimeUnit.MINUTES)) { | ||
| pool.shutdownNow(); | ||
| throw new IllegalStateException("Benchmark threads did not finish in time"); | ||
| } | ||
| for (Future<?> future : futures) { | ||
| try { | ||
| future.get(); | ||
| } catch (ExecutionException e) { | ||
| throw new IllegalStateException("Benchmark worker thread failed", e.getCause()); | ||
| } | ||
| } | ||
| elapsedNanos[trial] = System.nanoTime() - start; | ||
| totalErrors += errorCount.get(); | ||
| closeAndCleanup(changeSet); | ||
| } | ||
| report(label, elapsedNanos, (long) opsPerThread * THREADS, totalErrors); | ||
| } | ||
|
|
||
| private static void report(String label, long[] elapsedNanos, long opsPerTrial, long totalErrors) { | ||
| System.out.println(label + ":"); | ||
| long totalElapsedNanos = 0; | ||
| for (int trial = 0; trial < elapsedNanos.length; trial++) { | ||
| double seconds = elapsedNanos[trial] / 1_000_000_000.0; | ||
| double opsPerSec = opsPerTrial / seconds; | ||
| double nsPerOp = (double) elapsedNanos[trial] / opsPerTrial; | ||
| totalElapsedNanos += elapsedNanos[trial]; | ||
| System.out.printf( | ||
| " trial %d: %.2f ms, %,.0f ops/sec, %.1f ns/op%n", | ||
| trial + 1, elapsedNanos[trial] / 1_000_000.0, opsPerSec, nsPerOp | ||
| ); | ||
| } | ||
| // Aggregate throughput (total ops / total time) rather than the mean of the per-trial | ||
| // rates, so a single slow/fast trial (GC pause, OS scheduling) is weighted by how long it | ||
| // actually ran instead of counting equally with the others. | ||
| long totalOps = opsPerTrial * elapsedNanos.length; | ||
| double avgOpsPerSec = totalOps / (totalElapsedNanos / 1_000_000_000.0); | ||
| System.out.printf(" average: %,.0f ops/sec (%.1f ns/op)", avgOpsPerSec, 1_000_000_000.0 / avgOpsPerSec); | ||
| if (totalErrors > 0) { | ||
| // This only counts exceptions that escape FaweStreamChangeSet#add(...) into this | ||
| // benchmark's own catch block. add() itself catches IOException internally and | ||
| // prints a stack trace rather than throwing, so IO-related failures from the race | ||
| // aren't reflected here - this is a lower bound on "ops threw", not a full count. | ||
| System.out.printf( | ||
| " -- %d/%d ops threw past add() (unsynchronized shared stream access; add() itself " | ||
| + "swallows some IOExceptions, so this undercounts total failures)%n", | ||
| totalErrors, totalOps | ||
| ); | ||
| } else { | ||
| System.out.println(); | ||
| } | ||
| System.out.println(); | ||
| } | ||
|
|
||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.