Skip to content

Commit 3b78e59

Browse files
committed
test: add a JVM throughput micro-benchmark
1 KiB throughput benchmark; representative numbers added to the README.
1 parent 6c0c626 commit 3b78e59

2 files changed

Lines changed: 133 additions & 0 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,19 @@ serialBytes // Flow<ByteArray> of raw reads
165165

166166
Invalid encoded input throws `CobsDecodeException`.
167167

168+
## Benchmarks
169+
170+
Single-threaded JVM throughput on a 1 KiB payload (JDK 25, AMD Ryzen 7 3800XT
171+
under WSL2) — ballpark micro-benchmark numbers:
172+
173+
| Operation | Throughput |
174+
| --------- | ---------- |
175+
| `Cobs.encode` | ~580 MB/s |
176+
| `Cobs.decode` | ~850 MB/s |
177+
| `Cobsr.encode` | ~600 MB/s |
178+
179+
Run with `COBS_BENCH=1 ./gradlew :cobs:testDebugUnitTest --tests '*BenchmarkTest*' --rerun-tasks`.
180+
168181
## Build
169182

170183
Requires JDK 17 or newer (CI builds on JDK 25) and the Android SDK
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2026 Alexander Salas Bastidas <ajsb85@firechip.dev>
2+
// SPDX-License-Identifier: MIT
3+
4+
package dev.firechip.cobs
5+
6+
import java.io.File
7+
import java.util.Locale
8+
import kotlin.random.Random
9+
import org.junit.Assume.assumeTrue
10+
import org.junit.Test
11+
12+
/**
13+
* A lightweight JVM throughput micro-benchmark for [Cobs] and [Cobsr].
14+
*
15+
* This is DEV tooling, not a correctness test. Like [ConformanceTest], it is
16+
* skipped during the normal unit-test job and only runs when the `COBS_BENCH`
17+
* environment variable is set, so it never slows the default test/CI run:
18+
*
19+
* ```
20+
* COBS_BENCH=1 ./gradlew :cobs:testDebugUnitTest --tests '*BenchmarkTest*'
21+
* ```
22+
*
23+
* Methodology: a fixed 1 KiB pseudo-random payload (~1 byte in 8 is `0x00`, so
24+
* the COBS block-splitting path is exercised) is encoded/decoded in a tight
25+
* loop. Each operation is warmed up for [WARMUP] iterations (to let the JIT
26+
* compile it) and then timed over [ITERS] iterations with [System.nanoTime].
27+
* Throughput is reported as decimal MB/s, i.e.
28+
* `(payload bytes x iterations) / elapsed seconds / 1e6`. A running `sink`
29+
* consumes every result so the timed work cannot be optimised away.
30+
*
31+
* JVM micro-benchmarks are approximate (no forking, GC/JIT noise); treat the
32+
* numbers as ballpark single-threaded throughput, not JMH-grade measurements.
33+
*/
34+
class BenchmarkTest {
35+
36+
private companion object {
37+
const val PAYLOAD_SIZE = 1024
38+
const val WARMUP = 50_000
39+
const val ITERS = 1_000_000
40+
const val SEED = 0xC0B5_C0DEL
41+
}
42+
43+
/** A 1 KiB payload: mostly non-zero with ~1-in-8 bytes set to `0x00`. */
44+
private fun makePayload(size: Int): ByteArray {
45+
val rng = Random(SEED)
46+
return ByteArray(size) {
47+
if (rng.nextInt(8) == 0) 0.toByte() else rng.nextInt(1, 256).toByte()
48+
}
49+
}
50+
51+
private fun throughputMbps(payloadBytes: Int, iters: Int, elapsedNanos: Long): Double {
52+
val seconds = elapsedNanos / 1_000_000_000.0
53+
return payloadBytes.toDouble() * iters / seconds / 1_000_000.0
54+
}
55+
56+
@Test
57+
fun throughput() {
58+
assumeTrue(
59+
"set COBS_BENCH to run the throughput benchmark",
60+
!System.getenv("COBS_BENCH").isNullOrBlank(),
61+
)
62+
63+
val payload = makePayload(PAYLOAD_SIZE)
64+
val cobsEncoded = Cobs.encode(payload)
65+
66+
// The sink accumulates a byte of every output so the JIT cannot drop the
67+
// timed calls as dead code.
68+
var sink = 0L
69+
70+
// --- COBS encode ---
71+
repeat(WARMUP) { sink += Cobs.encode(payload)[0].toLong() }
72+
var start = System.nanoTime()
73+
repeat(ITERS) { sink += Cobs.encode(payload)[0].toLong() }
74+
val cobsEncodeMbps = throughputMbps(PAYLOAD_SIZE, ITERS, System.nanoTime() - start)
75+
76+
// --- COBS decode ---
77+
repeat(WARMUP) { sink += Cobs.decode(cobsEncoded)[0].toLong() }
78+
start = System.nanoTime()
79+
repeat(ITERS) { sink += Cobs.decode(cobsEncoded)[0].toLong() }
80+
val cobsDecodeMbps = throughputMbps(PAYLOAD_SIZE, ITERS, System.nanoTime() - start)
81+
82+
// --- COBS/R encode ---
83+
repeat(WARMUP) { sink += Cobsr.encode(payload)[0].toLong() }
84+
start = System.nanoTime()
85+
repeat(ITERS) { sink += Cobsr.encode(payload)[0].toLong() }
86+
val cobsrEncodeMbps = throughputMbps(PAYLOAD_SIZE, ITERS, System.nanoTime() - start)
87+
88+
val zeroCount = payload.count { it.toInt() == 0 }
89+
val report = buildString {
90+
appendLine("=== cobs_codec_kt throughput benchmark ===")
91+
appendLine(
92+
"JVM: ${System.getProperty("java.vm.name")} " +
93+
System.getProperty("java.version"),
94+
)
95+
appendLine(
96+
"OS/arch: ${System.getProperty("os.name")} " +
97+
"${System.getProperty("os.version")} ${System.getProperty("os.arch")}",
98+
)
99+
appendLine(
100+
"payload: $PAYLOAD_SIZE bytes, $zeroCount zero (" +
101+
String.format(Locale.ROOT, "%.1f", zeroCount * 100.0 / PAYLOAD_SIZE) +
102+
"%), cobs-encoded ${cobsEncoded.size} bytes",
103+
)
104+
appendLine("loops: warmup=$WARMUP timed=$ITERS (single-threaded)")
105+
appendLine("---")
106+
appendLine("COBS encode: " + fmt(cobsEncodeMbps) + " MB/s")
107+
appendLine("COBS decode: " + fmt(cobsDecodeMbps) + " MB/s")
108+
appendLine("COBS/R encode: " + fmt(cobsrEncodeMbps) + " MB/s")
109+
appendLine("(decimal MB = 1e6 bytes; MB/s = payload bytes x iters / elapsed s)")
110+
appendLine("sink=$sink")
111+
}
112+
print(report)
113+
114+
System.getenv("COBS_BENCH_OUT")
115+
?.takeIf { it.isNotBlank() }
116+
?.let { File(it).writeText(report) }
117+
}
118+
119+
private fun fmt(mbps: Double): String = String.format(Locale.ROOT, "%,.1f", mbps)
120+
}

0 commit comments

Comments
 (0)