Skip to content

Add Semaphore JMH benchmarks#10712

Draft
guizmaii wants to merge 1 commit into
series/2.xfrom
semaphore-benchmarks
Draft

Add Semaphore JMH benchmarks#10712
guizmaii wants to merge 1 commit into
series/2.xfrom
semaphore-benchmarks

Conversation

@guizmaii

@guizmaii guizmaii commented Apr 7, 2026

Copy link
Copy Markdown
Member

Summary

Adds comprehensive JMH benchmarks for Semaphore, comparing ZIO vs JDK vs Cats Effect across three scenarios:

  • SemaphoreContentionBenchmark — Contention with varying fiber counts (2, 10, 50, 100) and permit counts (1, 5, 10). The most realistic scenario.
  • SemaphoreFastPathBenchmark — No contention (100 permits, single fiber). Measures the overhead of the semaphore machinery itself on the fast path.
  • SemaphoreMultiPermitBenchmark — Multi-permit withPermits(n) with varying acquire sizes (1, 3, 5) and 10 total permits. Tests the partial allocation path.

Context

These benchmarks support the ongoing Semaphore optimization work:

Having a shared benchmark suite allows objective comparison of different implementation strategies.

Test plan

  • sbt benchmarks/compile passes
  • Run with sbt "benchmarks/Jmh/run -i 3 -wi 3 -f 3 -t 1 .*Semaphore.*"

Three benchmark classes covering different scenarios:
- SemaphoreContentionBenchmark: contention with varying fibers/permits
- SemaphoreFastPathBenchmark: no-contention fast path overhead
- SemaphoreMultiPermitBenchmark: multi-permit acquire (withPermits(n))

Each compares ZIO Semaphore vs JDK Semaphore vs Cats Effect Semaphore.
Copilot AI review requested due to automatic review settings April 7, 2026 12:28
@guizmaii guizmaii marked this pull request as draft April 7, 2026 12:29
@guizmaii guizmaii self-assigned this Apr 7, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a new JMH benchmark suite for Semaphore performance comparisons across ZIO, JDK, and Cats Effect, intended to support ongoing semaphore optimization work with repeatable measurements.

Changes:

  • Introduces a contention benchmark with configurable fiber/permit counts.
  • Adds a “fast path” (no contention) benchmark to measure acquire/release overhead.
  • Adds a multi-permit benchmark to exercise withPermits(n) / partial-allocation behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

sem <- ZIO.succeed(new JSemaphore(permits))
fiber <-
ZIO.forkAll(List.fill(fibers)(repeat(opsPerFiber) {
ZIO.acquireReleaseWith(ZIO.succeed(sem.acquire()))(_ => ZIO.succeed(sem.release()))(_ => ZIO.succeed(1))

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

JSemaphore.acquire() is a blocking, interruptible call (and may throw InterruptedException). Wrapping it in ZIO.succeed(...) runs the blocking call on the compute pool and turns interruptions/exceptions into defects, which can skew results or fail the benchmark. Prefer ZIO.attemptBlockingInterrupt(sem.acquire()) (or ZIO.blocking(ZIO.attempt(sem.acquire()))) in the acquire effect.

Suggested change
ZIO.acquireReleaseWith(ZIO.succeed(sem.acquire()))(_ => ZIO.succeed(sem.release()))(_ => ZIO.succeed(1))
ZIO.acquireReleaseWith(ZIO.attemptBlockingInterrupt(sem.acquire()))(_ => ZIO.succeed(sem.release()))(_ => ZIO.succeed(1))

Copilot uses AI. Check for mistakes.
Comment on lines +97 to +101
unsafeRun(for {
sem <- ZIO.succeed(new JSemaphore(100))
_ <- repeat(ops)(
ZIO.acquireReleaseWith(ZIO.succeed(sem.acquire()))(_ => ZIO.succeed(sem.release()))(_ => ZIO.succeed(1))
)

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

Same issue as above: JSemaphore.acquire() is blocking/interruptible and shouldn’t be run via ZIO.succeed(...) on the compute pool. Use ZIO.attemptBlockingInterrupt(sem.acquire()) (or equivalent) for the acquire effect to keep blocking off the main executor and preserve interruption semantics.

Copilot uses AI. Check for mistakes.
Comment on lines +153 to +155
ZIO.acquireReleaseWith(ZIO.succeed(sem.acquire(acquireSize)))(_ => ZIO.succeed(sem.release(acquireSize)))(_ =>
ZIO.succeed(1)
)

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

JSemaphore.acquire(acquireSize) is a blocking, interruptible call; wrapping it in ZIO.succeed(...) runs it on the compute pool and turns interruptions/exceptions into defects. Use ZIO.attemptBlockingInterrupt(sem.acquire(acquireSize)) (or ZIO.blocking(ZIO.attempt(...))) for the acquire effect.

Suggested change
ZIO.acquireReleaseWith(ZIO.succeed(sem.acquire(acquireSize)))(_ => ZIO.succeed(sem.release(acquireSize)))(_ =>
ZIO.succeed(1)
)
ZIO.acquireReleaseWith(ZIO.attemptBlockingInterrupt(sem.acquire(acquireSize)))(_ =>
ZIO.succeed(sem.release(acquireSize))
)(_ => ZIO.succeed(1))

Copilot uses AI. Check for mistakes.
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 3, timeUnit = TimeUnit.SECONDS, time = 3)
@Measurement(iterations = 3, timeUnit = TimeUnit.SECONDS, time = 3)
@Fork(3)

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

These benchmarks spawn/manage their own concurrency via fibers; it’s important that JMH doesn’t also add extra OS threads implicitly. Many existing benchmarks pin this explicitly via @Threads(1) (e.g. benchmarks/src/main/scala/zio/ForkAllBenchmark.scala:10). Consider adding @Threads(1) here to keep results stable/reproducible.

Suggested change
@Fork(3)
@Fork(3)
@Threads(1)

Copilot uses AI. Check for mistakes.
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 3, timeUnit = TimeUnit.SECONDS, time = 3)
@Measurement(iterations = 3, timeUnit = TimeUnit.SECONDS, time = 3)
@Fork(3)

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

Consider adding @Threads(1) to this benchmark class as well (consistent with other single-driver benchmarks like benchmarks/src/main/scala/zio/ForkAllBenchmark.scala:10). Since the benchmark already models contention via fibers, extra JMH threads can distort the intended scenario.

Suggested change
@Fork(3)
@Fork(3)
@Threads(1)

Copilot uses AI. Check for mistakes.
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 3, timeUnit = TimeUnit.SECONDS, time = 3)
@Measurement(iterations = 3, timeUnit = TimeUnit.SECONDS, time = 3)
@Fork(3)

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

Consider adding @Threads(1) here too, for the same reason as the other semaphore benchmarks: contention is modeled inside the benchmark via fibers, so multiple JMH threads can unintentionally multiply load and skew comparisons.

Suggested change
@Fork(3)
@Fork(3)
@Threads(1)

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +73
/**
* Benchmarks for the fast path (no contention).
*
* When permits > fibers, every acquire succeeds on the fast path. This measures
* the overhead of the semaphore machinery itself.
*/

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

The comment says this measures fast-path semaphore overhead, but the benchmark currently includes semaphore allocation/construction on every invocation (Semaphore.make(100L) / new JSemaphore(100) / CESemaphore(100L)). Consider moving semaphore creation into a @Setup (e.g., per-iteration/trial) and reusing it so the measurement reflects acquire/release overhead rather than construction cost.

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +51
sem <- Semaphore.make(permits.toLong)
fiber <- ZIO.forkAll(List.fill(fibers)(repeat(opsPerFiber)(sem.withPermit(ZIO.succeed(1)))))
_ <- fiber.join
} yield ())

@Benchmark
def javaSemaphore(): Unit =
unsafeRun(for {
sem <- ZIO.succeed(new JSemaphore(permits))
fiber <-
ZIO.forkAll(List.fill(fibers)(repeat(opsPerFiber) {
ZIO.acquireReleaseWith(ZIO.succeed(sem.acquire()))(_ => ZIO.succeed(sem.release()))(_ => ZIO.succeed(1))
}))
_ <- fiber.join

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

ZIO.forkAll(...) collects results into a Chunk, but the results are discarded. This adds allocation overhead unrelated to semaphore performance. Consider using ZIO.forkAllDiscard(...) here (and similarly in the other methods using forkAll) so the benchmark focuses on semaphore contention rather than result aggregation.

Suggested change
sem <- Semaphore.make(permits.toLong)
fiber <- ZIO.forkAll(List.fill(fibers)(repeat(opsPerFiber)(sem.withPermit(ZIO.succeed(1)))))
_ <- fiber.join
} yield ())
@Benchmark
def javaSemaphore(): Unit =
unsafeRun(for {
sem <- ZIO.succeed(new JSemaphore(permits))
fiber <-
ZIO.forkAll(List.fill(fibers)(repeat(opsPerFiber) {
ZIO.acquireReleaseWith(ZIO.succeed(sem.acquire()))(_ => ZIO.succeed(sem.release()))(_ => ZIO.succeed(1))
}))
_ <- fiber.join
sem <- Semaphore.make(permits.toLong)
_ <- ZIO.forkAllDiscard(List.fill(fibers)(repeat(opsPerFiber)(sem.withPermit(ZIO.succeed(1)))))
} yield ())
@Benchmark
def javaSemaphore(): Unit =
unsafeRun(for {
sem <- ZIO.succeed(new JSemaphore(permits))
_ <-
ZIO.forkAllDiscard(List.fill(fibers)(repeat(opsPerFiber) {
ZIO.acquireReleaseWith(ZIO.succeed(sem.acquire()))(_ => ZIO.succeed(sem.release()))(_ => ZIO.succeed(1))
}))

Copilot uses AI. Check for mistakes.
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