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 new file mode 100644 index 0000000..eea4cb3 --- /dev/null +++ b/benchmark/src/main/scala/terminus/benchmark/TerminalKeyReaderBenchmark.scala @@ -0,0 +1,121 @@ +/* + * 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 + +/** Benchmark for TerminalKeyReader that measures the performance of processing + * large amounts of input data containing both regular characters and escape + * sequences. + */ +@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(2) +class TerminalKeyReaderBenchmark { + val percentControl = 10 + val percentEscape = 10 + + // 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, // 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 + private def input(targetLength: Int): String = { + val sb = new StringBuilder() + val rand = new Random(42) // Fixed seed for reproducibility + + while sb.length < targetLength do { + val choice = rand.nextInt(100) + + if choice < percentEscape then + // Print escape sequence + sb.append(escapeSequences(rand.nextInt(escapeSequences.length))) + 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 + } + + // Varying sized inputs for benchmarking scaling behavior + val smallInput: String = input(1000) + val mediumInput: String = input(5000) + val largeInput: String = input(10000) + + @Benchmark + def benchmarkSmallInput(): Int = benchmark(smallInput) + + @Benchmark + def benchmarkMediumInput(): Int = benchmark(mediumInput) + + @Benchmark + def benchmarkLargeInput(): Int = benchmark(largeInput) + + private def benchmark(input: String): Int = { + val reader = new StringBufferReader(input) + var count = 0 + var done = false + + while !done do { + reader.readKey() match { + case Eof => done = true + case _: Key => count += 1 + } + } + + count + } +} diff --git a/build.sbt b/build.sbt index 6da3f96..1f4bc50 100644 --- a/build.sbt +++ b/build.sbt @@ -61,6 +61,11 @@ commands += Command.command("build") { state => state } +commands += Command.command("benchmarks") { state => + "benchmark/Jmh/run terminus.benchmark.TerminalKeyReaderBenchmark" :: + state +} + lazy val commonSettings = Seq( libraryDependencies ++= Seq( Dependencies.munit.value, @@ -72,7 +77,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 +182,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")