From 85d44730026786b1268ff6724a4ec3bd762dc609 Mon Sep 17 00:00:00 2001 From: Paul J Thordarson Date: Sun, 4 May 2025 09:27:02 -0400 Subject: [PATCH 1/5] Add terminal key reader benchmarks --- .../TerminalKeyReaderBenchmark.scala | 175 ++++++++++++++++++ build.sbt | 22 ++- project/plugins.sbt | 1 + 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala diff --git a/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala b/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala new file mode 100644 index 0000000..a8540bc --- /dev/null +++ b/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala @@ -0,0 +1,175 @@ +/* + * Copyright 2024 Creative Scala + * + * Licensed 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 terminus.benchmark + +import org.openjdk.jmh.annotations._ +import terminus._ +import terminus.effect._ +import java.util.concurrent.TimeUnit +import scala.util.Random +import scala.concurrent.duration._ +import cats.syntax.show._ + +/** + * Benchmark for TerminalKeyReader that measures the performance of processing + * large amounts of input data containing both regular characters and escape sequences. + * + * To run: + * sbt "benchmark/Jmh/run -i 10 -wi 5 -f 2 -t 1 terminus.benchmark.TerminalKeyReaderBenchmark" + * + * Parameters: + * -i: Number of iterations + * -wi: Number of warmup iterations + * -f: Number of forks + * -t: Number of threads + */ +@State(Scope.Benchmark) +@BenchmarkMode(Array(Mode.AverageTime)) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS) +@Fork(1) +class TerminalKeyReaderBenchmark { + + // Common escape sequences + val escapeSequences = Array( + s"${Ascii.ESC}[A", // Up arrow + s"${Ascii.ESC}[B", // Down arrow + s"${Ascii.ESC}[C", // Right arrow + s"${Ascii.ESC}[D", // Left arrow + s"${Ascii.ESC}OP", // F1 + s"${Ascii.ESC}OQ", // F2 + s"${Ascii.ESC}OR", // F3 + s"${Ascii.ESC}OS", // F4 + s"${Ascii.ESC}[15~", // F5 + s"${Ascii.ESC}[17~", // F6 + s"${Ascii.ESC}[1;2A", // Shift+Up + s"${Ascii.ESC}[1;5A", // Ctrl+Up + s"${Ascii.ESC}[1;6A", // Ctrl+Shift+Up + s"${Ascii.ESC}[3~", // Delete + s"${Ascii.ESC}[3;5~", // Ctrl+Delete + s"${Ascii.ESC}[3;6~" // Ctrl+Shift+Delete + ) + + // Control characters + val controlChars = Array( + Ascii.NUL.toChar, // Ctrl+@ + Ascii.SOH.toChar, // Ctrl+A + Ascii.STX.toChar, // Ctrl+B + Ascii.ETX.toChar, // Ctrl+C + Ascii.EOT.toChar, // Ctrl+D + Ascii.ENQ.toChar, // Ctrl+E + Ascii.ACK.toChar, // Ctrl+F + Ascii.BEL.toChar // Ctrl+G + ) + + // Large mixed input with approximately 10,000 characters + val largeInput: String = { + val sb = new StringBuilder() + val rand = new Random(42) // Fixed seed for reproducibility + + // Generate approximately 10,000 characters + val targetLength = 10000 + while (sb.length < targetLength) { + val choice = rand.nextInt(100) + + if (choice < 80) { + // 80% chance of regular ASCII char + sb.append((rand.nextInt(94) + 32).toChar) // Printable ASCII + } else if (choice < 90) { + // 10% chance of control char + sb.append(controlChars(rand.nextInt(controlChars.length))) + } else { + // 10% chance of escape sequence + sb.append(escapeSequences(rand.nextInt(escapeSequences.length))) + } + } + + sb.toString + } + + // Varying sized inputs for benchmarking scaling behavior + val smallInput: String = largeInput.substring(0, 1000) + val mediumInput: String = largeInput.substring(0, 5000) + + @Benchmark + def benchmarkSmallInput(): Int = { + val reader = new StringBufferReader(smallInput) + var count = 0 + + while (true) { + reader.readKey() match { + case Eof => return count + case key: Key => + count += 1 + } + } + + count + } + + @Benchmark + def benchmarkMediumInput(): Int = { + val reader = new StringBufferReader(mediumInput) + var count = 0 + + while (true) { + reader.readKey() match { + case Eof => return count + case key: Key => + count += 1 + } + } + + count + } + + @Benchmark + def benchmarkLargeInput(): Int = { + val reader = new StringBufferReader(largeInput) + var count = 0 + + while (true) { + reader.readKey() match { + case Eof => return count + case key: Key => + count += 1 + } + } + + count + } + + // Benchmark that also prints each key (tests Show[Key] performance) + @Benchmark + def benchmarkLargeInputWithShow(): Int = { + val reader = new StringBufferReader(largeInput) + var count = 0 + val sb = new StringBuilder() + + while (true) { + reader.readKey() match { + case Eof => return count + case key: Key => + count += 1 + sb.append(key.show) + } + } + + count + } +} \ No newline at end of file diff --git a/build.sbt b/build.sbt index 6da3f96..8ac9494 100644 --- a/build.sbt +++ b/build.sbt @@ -61,6 +61,12 @@ commands += Command.command("build") { state => state } +// Run this (runBenchmark) to run the TerminalKeyReader benchmark +commands += Command.command("runBenchmark") { state => + "benchmark/Jmh/run -i 10 -wi 5 -f 2 -t 1 terminus.benchmark.TerminalKeyReaderBenchmark" :: + state +} + lazy val commonSettings = Seq( libraryDependencies ++= Seq( Dependencies.munit.value, @@ -72,7 +78,7 @@ lazy val commonSettings = Seq( ) ) -lazy val root = tlCrossRootProject.aggregate(core, unidocs) +lazy val root = tlCrossRootProject.aggregate(core, unidocs, benchmark) lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) .in(file("core")) @@ -177,3 +183,17 @@ lazy val examples = crossProject(JSPlatform, JVMPlatform) .dependsOn(core.js) ) .dependsOn(core) + +lazy val benchmark = project + .in(file("benchmark")) + .enablePlugins(JmhPlugin) + .settings( + commonSettings, + name := "terminus-benchmark", + libraryDependencies ++= Seq( + "org.openjdk.jmh" % "jmh-core" % "1.37", + "org.openjdk.jmh" % "jmh-generator-annprocess" % "1.37" + ), + mimaPreviousArtifacts := Set.empty + ) + .dependsOn(core.jvm) diff --git a/project/plugins.sbt b/project/plugins.sbt index 6413cc7..d1b750e 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -7,3 +7,4 @@ addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.7.7") addSbtPlugin("org.typelevel" % "sbt-typelevel-site" % "0.7.7") addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.7") addSbtPlugin("org.creativescala" % "creative-scala-theme" % "0.4.0") +addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.7") From 2f2564e6f7ab3fb48ba127972c1f07c9bc3919cc Mon Sep 17 00:00:00 2001 From: Paul J Thordarson Date: Sun, 4 May 2025 10:08:11 -0400 Subject: [PATCH 2/5] Benchmark refactor --- .../TerminalKeyReaderBenchmark.scala | 104 +++++------------- 1 file changed, 29 insertions(+), 75 deletions(-) diff --git a/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala b/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala index a8540bc..4c33fd0 100644 --- a/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala +++ b/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala @@ -21,8 +21,6 @@ import terminus._ import terminus.effect._ import java.util.concurrent.TimeUnit import scala.util.Random -import scala.concurrent.duration._ -import cats.syntax.show._ /** * Benchmark for TerminalKeyReader that measures the performance of processing @@ -44,6 +42,8 @@ import cats.syntax.show._ @Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS) @Fork(1) class TerminalKeyReaderBenchmark { + val percentControl = 10 + val percentEscape = 10 // Common escape sequences val escapeSequences = Array( @@ -67,109 +67,63 @@ class TerminalKeyReaderBenchmark { // Control characters val controlChars = Array( - Ascii.NUL.toChar, // Ctrl+@ - Ascii.SOH.toChar, // Ctrl+A - Ascii.STX.toChar, // Ctrl+B - Ascii.ETX.toChar, // Ctrl+C - Ascii.EOT.toChar, // Ctrl+D - Ascii.ENQ.toChar, // Ctrl+E - Ascii.ACK.toChar, // Ctrl+F - Ascii.BEL.toChar // Ctrl+G + Ascii.NUL, // Ctrl+@ + Ascii.SOH, // Ctrl+A + Ascii.STX, // Ctrl+B + Ascii.ETX, // Ctrl+C + Ascii.EOT, // Ctrl+D + Ascii.ENQ, // Ctrl+E + Ascii.ACK, // Ctrl+F + Ascii.BEL // Ctrl+G ) // Large mixed input with approximately 10,000 characters - val largeInput: String = { + private def input(targetLength: Int): String = { val sb = new StringBuilder() val rand = new Random(42) // Fixed seed for reproducibility - // Generate approximately 10,000 characters - val targetLength = 10000 while (sb.length < targetLength) { val choice = rand.nextInt(100) - if (choice < 80) { - // 80% chance of regular ASCII char - sb.append((rand.nextInt(94) + 32).toChar) // Printable ASCII - } else if (choice < 90) { - // 10% chance of control char - sb.append(controlChars(rand.nextInt(controlChars.length))) - } else { - // 10% chance of escape sequence + if (choice < percentEscape) + // Print escape sequence sb.append(escapeSequences(rand.nextInt(escapeSequences.length))) - } + else if (choice < (percentControl + percentEscape)) + // Print control sequence + sb.append(controlChars(rand.nextInt(controlChars.length))) + else + // Printable Ascii character + sb.append((rand.nextInt(94) + 32).toChar) } sb.toString } // Varying sized inputs for benchmarking scaling behavior - val smallInput: String = largeInput.substring(0, 1000) - val mediumInput: String = largeInput.substring(0, 5000) + val smallInput: String = input(1000) + val mediumInput: String = input(5000) + val largeInput: String = input(10000) @Benchmark - def benchmarkSmallInput(): Int = { - val reader = new StringBufferReader(smallInput) - var count = 0 - - while (true) { - reader.readKey() match { - case Eof => return count - case key: Key => - count += 1 - } - } - - count - } + def benchmarkSmallInput(): Int = benchmark(smallInput) @Benchmark - def benchmarkMediumInput(): Int = { - val reader = new StringBufferReader(mediumInput) - var count = 0 - - while (true) { - reader.readKey() match { - case Eof => return count - case key: Key => - count += 1 - } - } - - count - } + def benchmarkMediumInput(): Int = benchmark(mediumInput) @Benchmark - def benchmarkLargeInput(): Int = { - val reader = new StringBufferReader(largeInput) - var count = 0 - - while (true) { - reader.readKey() match { - case Eof => return count - case key: Key => - count += 1 - } - } - - count - } + def benchmarkLargeInput(): Int = benchmark(largeInput) - // Benchmark that also prints each key (tests Show[Key] performance) - @Benchmark - def benchmarkLargeInputWithShow(): Int = { - val reader = new StringBufferReader(largeInput) + private def benchmark(input: String): Int = { + val reader = new StringBufferReader(input) var count = 0 - val sb = new StringBuilder() while (true) { reader.readKey() match { case Eof => return count - case key: Key => - count += 1 - sb.append(key.show) + case _: Key => count += 1 } } - + count } } \ No newline at end of file From 1a66de024106ebcafad859be84a76330d32abe1a Mon Sep 17 00:00:00 2001 From: Paul J Thordarson Date: Sun, 4 May 2025 10:47:25 -0400 Subject: [PATCH 3/5] Run prePR --- .github/workflows/ci.yml | 4 +- .../TerminalKeyReaderBenchmark.scala | 85 +++++++++---------- 2 files changed, 43 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33667df..51635a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,11 +83,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p unidocs/target core/native/target core/js/target examples/js/target core/jvm/target examples/jvm/target project/target + run: mkdir -p unidocs/target core/native/target core/js/target examples/js/target core/jvm/target examples/jvm/target benchmark/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar unidocs/target core/native/target core/js/target examples/js/target core/jvm/target examples/jvm/target project/target + run: tar cf targets.tar unidocs/target core/native/target core/js/target examples/js/target core/jvm/target examples/jvm/target benchmark/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') diff --git a/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala b/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala index 4c33fd0..c51d4bf 100644 --- a/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala +++ b/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala @@ -16,25 +16,22 @@ package terminus.benchmark -import org.openjdk.jmh.annotations._ -import terminus._ -import terminus.effect._ +import org.openjdk.jmh.annotations.* +import terminus.* +import terminus.effect.* import java.util.concurrent.TimeUnit import scala.util.Random -/** - * Benchmark for TerminalKeyReader that measures the performance of processing - * large amounts of input data containing both regular characters and escape sequences. - * - * To run: - * sbt "benchmark/Jmh/run -i 10 -wi 5 -f 2 -t 1 terminus.benchmark.TerminalKeyReaderBenchmark" - * - * Parameters: - * -i: Number of iterations - * -wi: Number of warmup iterations - * -f: Number of forks - * -t: Number of threads - */ +/** Benchmark for TerminalKeyReader that measures the performance of processing + * large amounts of input data containing both regular characters and escape + * sequences. + * + * To run: sbt "benchmark/Jmh/run -i 10 -wi 5 -f 2 -t 1 + * terminus.benchmark.TerminalKeyReaderBenchmark" + * + * Parameters: -i: Number of iterations -wi: Number of warmup iterations -f: + * Number of forks -t: Number of threads + */ @State(Scope.Benchmark) @BenchmarkMode(Array(Mode.AverageTime)) @OutputTimeUnit(TimeUnit.MILLISECONDS) @@ -47,22 +44,22 @@ class TerminalKeyReaderBenchmark { // Common escape sequences val escapeSequences = Array( - s"${Ascii.ESC}[A", // Up arrow - s"${Ascii.ESC}[B", // Down arrow - s"${Ascii.ESC}[C", // Right arrow - s"${Ascii.ESC}[D", // Left arrow - s"${Ascii.ESC}OP", // F1 - s"${Ascii.ESC}OQ", // F2 - s"${Ascii.ESC}OR", // F3 - s"${Ascii.ESC}OS", // F4 - s"${Ascii.ESC}[15~", // F5 - s"${Ascii.ESC}[17~", // F6 - s"${Ascii.ESC}[1;2A", // Shift+Up - s"${Ascii.ESC}[1;5A", // Ctrl+Up - s"${Ascii.ESC}[1;6A", // Ctrl+Shift+Up - s"${Ascii.ESC}[3~", // Delete - s"${Ascii.ESC}[3;5~", // Ctrl+Delete - s"${Ascii.ESC}[3;6~" // Ctrl+Shift+Delete + s"${Ascii.ESC}[A", // Up arrow + s"${Ascii.ESC}[B", // Down arrow + s"${Ascii.ESC}[C", // Right arrow + s"${Ascii.ESC}[D", // Left arrow + s"${Ascii.ESC}OP", // F1 + s"${Ascii.ESC}OQ", // F2 + s"${Ascii.ESC}OR", // F3 + s"${Ascii.ESC}OS", // F4 + s"${Ascii.ESC}[15~", // F5 + s"${Ascii.ESC}[17~", // F6 + s"${Ascii.ESC}[1;2A", // Shift+Up + s"${Ascii.ESC}[1;5A", // Ctrl+Up + s"${Ascii.ESC}[1;6A", // Ctrl+Shift+Up + s"${Ascii.ESC}[3~", // Delete + s"${Ascii.ESC}[3;5~", // Ctrl+Delete + s"${Ascii.ESC}[3;6~" // Ctrl+Shift+Delete ) // Control characters @@ -74,28 +71,28 @@ class TerminalKeyReaderBenchmark { Ascii.EOT, // Ctrl+D Ascii.ENQ, // Ctrl+E Ascii.ACK, // Ctrl+F - Ascii.BEL // Ctrl+G + Ascii.BEL // Ctrl+G ) // Large mixed input with approximately 10,000 characters private def input(targetLength: Int): String = { val sb = new StringBuilder() val rand = new Random(42) // Fixed seed for reproducibility - - while (sb.length < targetLength) { + + while sb.length < targetLength do { val choice = rand.nextInt(100) - - if (choice < percentEscape) + + if choice < percentEscape then // Print escape sequence sb.append(escapeSequences(rand.nextInt(escapeSequences.length))) - else if (choice < (percentControl + percentEscape)) + else if choice < (percentControl + percentEscape) then // Print control sequence sb.append(controlChars(rand.nextInt(controlChars.length))) else // Printable Ascii character sb.append((rand.nextInt(94) + 32).toChar) } - + sb.toString } @@ -112,18 +109,18 @@ class TerminalKeyReaderBenchmark { @Benchmark def benchmarkLargeInput(): Int = benchmark(largeInput) - + private def benchmark(input: String): Int = { val reader = new StringBufferReader(input) var count = 0 - - while (true) { + + while true do { reader.readKey() match { - case Eof => return count + case Eof => return count case _: Key => count += 1 } } count } -} \ No newline at end of file +} From 4f2327e94b795f92d112025b5b308495116e5673 Mon Sep 17 00:00:00 2001 From: Paul J Thordarson Date: Sun, 4 May 2025 10:54:07 -0400 Subject: [PATCH 4/5] Remove `return` --- .../terminus/benchmark/TerminalKeyReaderBenchmark.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala b/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala index c51d4bf..992ec05 100644 --- a/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala +++ b/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala @@ -113,10 +113,11 @@ class TerminalKeyReaderBenchmark { private def benchmark(input: String): Int = { val reader = new StringBufferReader(input) var count = 0 + var done = false - while true do { + while !done do { reader.readKey() match { - case Eof => return count + case Eof => done = true case _: Key => count += 1 } } From b7f360903b67f0885f9548b059f17e4e214da7de Mon Sep 17 00:00:00 2001 From: Paul J Thordarson Date: Sun, 4 May 2025 15:03:44 -0400 Subject: [PATCH 5/5] More benchmark tweaks --- .../terminus/benchmark/TerminalKeyReaderBenchmark.scala | 8 +------- build.sbt | 5 ++--- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala b/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala index 992ec05..eea4cb3 100644 --- a/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala +++ b/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala @@ -25,19 +25,13 @@ import scala.util.Random /** Benchmark for TerminalKeyReader that measures the performance of processing * large amounts of input data containing both regular characters and escape * sequences. - * - * To run: sbt "benchmark/Jmh/run -i 10 -wi 5 -f 2 -t 1 - * terminus.benchmark.TerminalKeyReaderBenchmark" - * - * Parameters: -i: Number of iterations -wi: Number of warmup iterations -f: - * Number of forks -t: Number of threads */ @State(Scope.Benchmark) @BenchmarkMode(Array(Mode.AverageTime)) @OutputTimeUnit(TimeUnit.MILLISECONDS) @Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) @Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS) -@Fork(1) +@Fork(2) class TerminalKeyReaderBenchmark { val percentControl = 10 val percentEscape = 10 diff --git a/build.sbt b/build.sbt index 8ac9494..1f4bc50 100644 --- a/build.sbt +++ b/build.sbt @@ -61,9 +61,8 @@ commands += Command.command("build") { state => state } -// Run this (runBenchmark) to run the TerminalKeyReader benchmark -commands += Command.command("runBenchmark") { state => - "benchmark/Jmh/run -i 10 -wi 5 -f 2 -t 1 terminus.benchmark.TerminalKeyReaderBenchmark" :: +commands += Command.command("benchmarks") { state => + "benchmark/Jmh/run terminus.benchmark.TerminalKeyReaderBenchmark" :: state }