From 918f0998d739374d2f4290e39954be7f0497ef4e Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Mon, 4 May 2026 18:21:42 +0200 Subject: [PATCH 1/4] small: accelerate event id generation --- .../imageclassification/MainActivity.kt | 16 +--- .../dev/wildedge/sdk/NoopWildEdgeClient.kt | 10 +- .../main/kotlin/dev/wildedge/sdk/WildEdge.kt | 16 ++-- .../kotlin/dev/wildedge/sdk/core/Batch.kt | 9 +- .../kotlin/dev/wildedge/sdk/events/Events.kt | 71 +++++++++----- .../wildedge/sdk/integrations/OrtDecorator.kt | 52 +++++++++- .../PlayServicesTfliteDecorator.kt | 51 +++++++++- .../sdk/integrations/TFLiteDecorator.kt | 51 +++++++++- .../dev/wildedge/sdk/IdAndTimestampTest.kt | 96 +++++++++++++++++++ 9 files changed, 316 insertions(+), 56 deletions(-) create mode 100644 wildedge/src/test/kotlin/dev/wildedge/sdk/IdAndTimestampTest.kt diff --git a/samples/image-classification/src/main/kotlin/dev/wildedge/sample/imageclassification/MainActivity.kt b/samples/image-classification/src/main/kotlin/dev/wildedge/sample/imageclassification/MainActivity.kt index cc88c01..3af7f39 100644 --- a/samples/image-classification/src/main/kotlin/dev/wildedge/sample/imageclassification/MainActivity.kt +++ b/samples/image-classification/src/main/kotlin/dev/wildedge/sample/imageclassification/MainActivity.kt @@ -76,18 +76,10 @@ class MainActivity : AppCompatActivity() { log("Loading interpreter...") val (decorator, inputShape, outputShape) = withContext(Dispatchers.IO) { - val interpreter = Interpreter(modelFile, Interpreter.Options().apply { numThreads = 2 }) - val inShape = interpreter.getInputTensor(0).shape() - val outShape = interpreter.getOutputTensor(0).shape() - Triple( - wildEdge.decorate( - interpreter, - modelId = "mobilenet-v1", - quantization = "uint8", - ), - inShape, - outShape, - ) + val decorator = wildEdge.decorate(modelId = "mobilenet-v1", quantization = "uint8") { + Interpreter(modelFile, Interpreter.Options().apply { numThreads = 2 }) + } + Triple(decorator, decorator.getInputTensor(0).shape(), decorator.getOutputTensor(0).shape()) } if (inputShape.size != 4 || inputShape[0] != 1 || inputShape[3] != 3) { diff --git a/wildedge/src/main/kotlin/dev/wildedge/sdk/NoopWildEdgeClient.kt b/wildedge/src/main/kotlin/dev/wildedge/sdk/NoopWildEdgeClient.kt index 1ad0408..6d7b57a 100644 --- a/wildedge/src/main/kotlin/dev/wildedge/sdk/NoopWildEdgeClient.kt +++ b/wildedge/src/main/kotlin/dev/wildedge/sdk/NoopWildEdgeClient.kt @@ -1,6 +1,6 @@ package dev.wildedge.sdk -import java.util.UUID +import dev.wildedge.sdk.events.newId internal class NoopWildEdgeClient : WildEdgeClient, SpanOwner { override fun registerModel(modelId: String, info: ModelInfo): ModelHandle = @@ -24,7 +24,7 @@ internal class NoopWildEdgeClient : WildEdgeClient, SpanOwner { block: (SpanContext) -> T, ): T = runSpan( name = name, - traceId = parent?.traceId ?: UUID.randomUUID().toString(), + traceId = parent?.traceId ?: newId(), parentSpanId = parent?.spanId, kind = kind, attributes = attributes, @@ -42,8 +42,8 @@ internal class NoopWildEdgeClient : WildEdgeClient, SpanOwner { agentId: String?, ): Span = Span( SpanContext( - traceId = parent?.traceId ?: UUID.randomUUID().toString(), - spanId = UUID.randomUUID().toString(), + traceId = parent?.traceId ?: newId(), + spanId = newId(), parentSpanId = parent?.spanId, kind = kind, runId = runId ?: parent?.runId, @@ -65,7 +65,7 @@ internal class NoopWildEdgeClient : WildEdgeClient, SpanOwner { ): T = block( SpanContext( traceId = traceId, - spanId = UUID.randomUUID().toString(), + spanId = newId(), parentSpanId = parentSpanId, kind = kind, runId = runId, diff --git a/wildedge/src/main/kotlin/dev/wildedge/sdk/WildEdge.kt b/wildedge/src/main/kotlin/dev/wildedge/sdk/WildEdge.kt index d849960..b5c9e33 100644 --- a/wildedge/src/main/kotlin/dev/wildedge/sdk/WildEdge.kt +++ b/wildedge/src/main/kotlin/dev/wildedge/sdk/WildEdge.kt @@ -10,10 +10,10 @@ import android.os.Looper import android.util.Log import dev.wildedge.sdk.events.buildMemoryWarningEvent import dev.wildedge.sdk.events.buildSpanEvent -import dev.wildedge.sdk.events.isoNow +import dev.wildedge.sdk.events.newId +import dev.wildedge.sdk.events.toIsoString import java.io.File import java.net.URI -import java.util.UUID import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -109,7 +109,7 @@ class WildEdge internal constructor( block: (SpanContext) -> T, ): T = runSpan( name = name, - traceId = parent?.traceId ?: UUID.randomUUID().toString(), + traceId = parent?.traceId ?: newId(), parentSpanId = parent?.spanId, kind = kind, attributes = attributes, @@ -129,8 +129,8 @@ class WildEdge internal constructor( val resolvedRunId = runId ?: parent?.runId val resolvedAgentId = agentId ?: parent?.agentId val ctx = SpanContext( - traceId = parent?.traceId ?: UUID.randomUUID().toString(), - spanId = UUID.randomUUID().toString(), + traceId = parent?.traceId ?: newId(), + spanId = newId(), parentSpanId = parent?.spanId, kind = kind, runId = resolvedRunId, @@ -169,7 +169,7 @@ class WildEdge internal constructor( ): T { val ctx = SpanContext( traceId = traceId, - spanId = UUID.randomUUID().toString(), + spanId = newId(), parentSpanId = parentSpanId, kind = kind, runId = runId, @@ -254,8 +254,8 @@ class WildEdge internal constructor( ) val modelRegistry = ModelRegistry(regFile) val transmitter = Transmitter(host, secret) - val sessionId = UUID.randomUUID().toString() - val createdAt = isoNow() + val sessionId = newId() + val createdAt = System.currentTimeMillis().toIsoString() val consumer = Consumer( queue = eventQueue, diff --git a/wildedge/src/main/kotlin/dev/wildedge/sdk/core/Batch.kt b/wildedge/src/main/kotlin/dev/wildedge/sdk/core/Batch.kt index afc00ca..b1d0b12 100644 --- a/wildedge/src/main/kotlin/dev/wildedge/sdk/core/Batch.kt +++ b/wildedge/src/main/kotlin/dev/wildedge/sdk/core/Batch.kt @@ -1,7 +1,7 @@ package dev.wildedge.sdk -import dev.wildedge.sdk.events.isoNow -import java.util.UUID +import dev.wildedge.sdk.events.newId +import dev.wildedge.sdk.events.toIsoString internal fun buildBatch( device: DeviceInfo, @@ -13,6 +13,7 @@ internal fun buildBatch( ): String { val strippedEvents = events.map { event -> event.filterKeys { !it.startsWith("__we_") } + .mapValues { (k, v) -> if (k == "timestamp" && v is Long) v.toIsoString() else v } } val batch = mutableMapOf( @@ -20,9 +21,9 @@ internal fun buildBatch( "device" to device.toMap(), "models" to models, "session_id" to sessionId, - "batch_id" to UUID.randomUUID().toString(), + "batch_id" to newId(), "created_at" to createdAt, - "sent_at" to isoNow(), + "sent_at" to System.currentTimeMillis().toIsoString(), "events" to strippedEvents, ) diff --git a/wildedge/src/main/kotlin/dev/wildedge/sdk/events/Events.kt b/wildedge/src/main/kotlin/dev/wildedge/sdk/events/Events.kt index c50d7a8..986b686 100644 --- a/wildedge/src/main/kotlin/dev/wildedge/sdk/events/Events.kt +++ b/wildedge/src/main/kotlin/dev/wildedge/sdk/events/Events.kt @@ -5,15 +5,40 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone -import java.util.UUID +import java.util.concurrent.ThreadLocalRandom -internal fun isoNow(): String { - val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - fmt.timeZone = TimeZone.getTimeZone("UTC") - return fmt.format(Date()) +private val HEX_CHARS = "0123456789abcdef".toCharArray() + +private val isoFormatter = ThreadLocal.withInitial { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).also { + it.timeZone = TimeZone.getTimeZone("UTC") + } } -internal fun newEventId() = UUID.randomUUID().toString() +internal fun Long.toIsoString(): String = isoFormatter.get()!!.format(Date(this)) + +internal fun newId(): String { + val rng = ThreadLocalRandom.current() + val hi = rng.nextLong() + val lo = rng.nextLong() + return buildString(36) { + appendHex(hi ushr 32, 8) + append('-') + appendHex(hi ushr 16, 4) + append('-') + appendHex(hi, 4) + append('-') + appendHex(lo ushr 48, 4) + append('-') + appendHex(lo, 12) + } +} + +private fun StringBuilder.appendHex(value: Long, digits: Int) { + for (shift in (digits - 1) * 4 downTo 0 step 4) { + append(HEX_CHARS[(value ushr shift and 0xF).toInt()]) + } +} /** * Snapshot of device hardware state at inference time. @@ -302,11 +327,11 @@ fun buildInferenceEvent( conversationId: String? = null, attributes: Map? = null, ): MutableMap { - val inferenceId = newEventId() + val inferenceId = newId() return mutableMapOf( - "event_id" to newEventId(), + "event_id" to newId(), "event_type" to "inference", - "timestamp" to isoNow(), + "timestamp" to System.currentTimeMillis(), "model_id" to modelId, "trace_id" to traceId, "span_id" to spanId, @@ -344,9 +369,9 @@ fun buildModelLoadEvent( threads: Int? = null, gpuLayers: Int? = null, ): Map = mapOf( - "event_id" to newEventId(), + "event_id" to newId(), "event_type" to "model_load", - "timestamp" to isoNow(), + "timestamp" to System.currentTimeMillis(), "model_id" to modelId, "load" to mapOf( "duration_ms" to durationMs, @@ -368,9 +393,9 @@ fun buildModelUnloadEvent( memoryFreedBytes: Long? = null, uptimeMs: Long? = null, ): Map = mapOf( - "event_id" to newEventId(), + "event_id" to newId(), "event_type" to "model_unload", - "timestamp" to isoNow(), + "timestamp" to System.currentTimeMillis(), "model_id" to modelId, "unload" to mapOf( "duration_ms" to durationMs, @@ -394,9 +419,9 @@ fun buildModelDownloadEvent( success: Boolean, errorCode: String? = null, ): Map = mapOf( - "event_id" to newEventId(), + "event_id" to newId(), "event_type" to "model_download", - "timestamp" to isoNow(), + "timestamp" to System.currentTimeMillis(), "model_id" to modelId, "download" to mapOf( "source_url" to sourceUrl, @@ -420,9 +445,9 @@ fun buildFeedbackEvent( delayMs: Int? = null, editDistance: Int? = null, ): Map = mapOf( - "event_id" to newEventId(), + "event_id" to newId(), "event_type" to "feedback", - "timestamp" to isoNow(), + "timestamp" to System.currentTimeMillis(), "model_id" to modelId, "feedback" to mapOf( "related_inference_id" to relatedInferenceId, @@ -440,9 +465,9 @@ fun buildErrorEvent( stackTraceHash: String? = null, relatedEventId: String? = null, ): Map = mapOf( - "event_id" to newEventId(), + "event_id" to newId(), "event_type" to "error", - "timestamp" to isoNow(), + "timestamp" to System.currentTimeMillis(), "model_id" to modelId, "error" to mapOf( "error_code" to errorCode, @@ -465,9 +490,9 @@ fun buildSpanEvent( runId: String? = null, agentId: String? = null, ): Map = mapOf( - "event_id" to newEventId(), + "event_id" to newId(), "event_type" to "span", - "timestamp" to isoNow(), + "timestamp" to System.currentTimeMillis(), "trace_id" to traceId, "span_id" to spanId, "parent_span_id" to parentSpanId, @@ -490,9 +515,9 @@ fun buildMemoryWarningEvent( triggeredUnload: Boolean, unloadedModelId: String? = null, ): Map = mapOf( - "event_id" to newEventId(), + "event_id" to newId(), "event_type" to "memory_warning", - "timestamp" to isoNow(), + "timestamp" to System.currentTimeMillis(), "model_id" to null, "memory_warning" to mapOf( "level" to level, diff --git a/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/OrtDecorator.kt b/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/OrtDecorator.kt index 7cd69fb..c04433d 100644 --- a/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/OrtDecorator.kt +++ b/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/OrtDecorator.kt @@ -23,6 +23,7 @@ class OrtDecorator( accelerator: dev.wildedge.sdk.Accelerator? = null, private val labels: List? = null, private val numClasses: Int = labels?.size ?: 0, + loadDurationMs: Int = 0, ) : AutoCloseable { val handle = wildEdge.registerModel( @@ -34,7 +35,10 @@ class OrtDecorator( modelFormat = "onnx", quantization = quantization, ), - ).also { it.acceleratorActual = accelerator } + ).also { + it.trackLoad(durationMs = loadDurationMs, accelerator = accelerator, coldStart = true) + it.acceleratorActual = accelerator + } private val outputModality = if (numClasses > 0) OutputModality.Classification else OutputModality.Tensor @@ -88,6 +92,7 @@ fun WildEdgeClient.decorate( accelerator: dev.wildedge.sdk.Accelerator? = null, labels: List? = null, numClasses: Int = labels?.size ?: 0, + loadDurationMs: Int = 0, ): OrtDecorator = OrtDecorator( session, this, @@ -97,8 +102,34 @@ fun WildEdgeClient.decorate( accelerator = accelerator, labels = labels, numClasses = numClasses, + loadDurationMs = loadDurationMs, ) +/** Creates an [OrtDecorator], inferring model metadata from [modelFile], timing the [load] block. */ +fun WildEdgeClient.decorate( + modelFile: File, + modelVersion: String? = null, + accelerator: dev.wildedge.sdk.Accelerator? = null, + labels: List? = null, + numClasses: Int = labels?.size ?: 0, + load: () -> OrtSession, +): OrtDecorator { + val start = System.currentTimeMillis() + val session = load() + val loadDurationMs = (System.currentTimeMillis() - start).toInt() + return OrtDecorator( + session, + this, + modelId = inferModelId(modelFile), + modelVersion = modelVersion, + quantization = inferQuantization(modelFile), + accelerator = accelerator, + labels = labels, + numClasses = numClasses, + loadDurationMs = loadDurationMs, + ) +} + /** Creates an [OrtDecorator] with explicit model metadata. */ fun WildEdgeClient.decorate( session: OrtSession, @@ -108,4 +139,21 @@ fun WildEdgeClient.decorate( accelerator: dev.wildedge.sdk.Accelerator? = null, labels: List? = null, numClasses: Int = labels?.size ?: 0, -): OrtDecorator = OrtDecorator(session, this, modelId, modelVersion, quantization, accelerator, labels, numClasses) + loadDurationMs: Int = 0, +): OrtDecorator = OrtDecorator(session, this, modelId, modelVersion, quantization, accelerator, labels, numClasses, loadDurationMs) + +/** Creates an [OrtDecorator] with explicit model metadata, timing the [load] block. */ +fun WildEdgeClient.decorate( + modelId: String, + modelVersion: String? = null, + quantization: String? = null, + accelerator: dev.wildedge.sdk.Accelerator? = null, + labels: List? = null, + numClasses: Int = labels?.size ?: 0, + load: () -> OrtSession, +): OrtDecorator { + val start = System.currentTimeMillis() + val session = load() + val loadDurationMs = (System.currentTimeMillis() - start).toInt() + return OrtDecorator(session, this, modelId, modelVersion, quantization, accelerator, labels, numClasses, loadDurationMs) +} diff --git a/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/PlayServicesTfliteDecorator.kt b/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/PlayServicesTfliteDecorator.kt index 0baa45b..cd1e4d9 100644 --- a/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/PlayServicesTfliteDecorator.kt +++ b/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/PlayServicesTfliteDecorator.kt @@ -23,6 +23,7 @@ class PlayServicesTfliteDecorator( accelerator: dev.wildedge.sdk.Accelerator? = null, private val labels: List? = null, private val numClasses: Int = labels?.size ?: 0, + loadDurationMs: Int = 0, ) : AutoCloseable { val handle = wildEdge.registerModel( @@ -34,7 +35,10 @@ class PlayServicesTfliteDecorator( modelFormat = "tflite", quantization = quantization, ), - ).also { it.acceleratorActual = accelerator } + ).also { + it.trackLoad(durationMs = loadDurationMs, accelerator = accelerator, coldStart = true) + it.acceleratorActual = accelerator + } private val outputModality = if (numClasses > 0) OutputModality.Classification else OutputModality.Tensor @@ -83,6 +87,7 @@ fun WildEdgeClient.decorate( accelerator: dev.wildedge.sdk.Accelerator? = null, labels: List? = null, numClasses: Int = labels?.size ?: 0, + loadDurationMs: Int = 0, ): PlayServicesTfliteDecorator = PlayServicesTfliteDecorator( interpreter, this, @@ -92,8 +97,34 @@ fun WildEdgeClient.decorate( accelerator = accelerator, labels = labels, numClasses = numClasses, + loadDurationMs = loadDurationMs, ) +/** Creates a [PlayServicesTfliteDecorator], inferring model metadata from [modelFile], timing the [load] block. */ +fun WildEdgeClient.decorate( + modelFile: File, + modelVersion: String? = null, + accelerator: dev.wildedge.sdk.Accelerator? = null, + labels: List? = null, + numClasses: Int = labels?.size ?: 0, + load: () -> InterpreterApi, +): PlayServicesTfliteDecorator { + val start = System.currentTimeMillis() + val interpreter = load() + val loadDurationMs = (System.currentTimeMillis() - start).toInt() + return PlayServicesTfliteDecorator( + interpreter, + this, + modelId = inferModelId(modelFile), + modelVersion = modelVersion, + quantization = inferQuantization(modelFile), + accelerator = accelerator, + labels = labels, + numClasses = numClasses, + loadDurationMs = loadDurationMs, + ) +} + /** Creates a [PlayServicesTfliteDecorator] with explicit model metadata. */ fun WildEdgeClient.decorate( interpreter: InterpreterApi, @@ -103,6 +134,7 @@ fun WildEdgeClient.decorate( accelerator: dev.wildedge.sdk.Accelerator? = null, labels: List? = null, numClasses: Int = labels?.size ?: 0, + loadDurationMs: Int = 0, ): PlayServicesTfliteDecorator = PlayServicesTfliteDecorator( interpreter, this, @@ -112,4 +144,21 @@ fun WildEdgeClient.decorate( accelerator, labels, numClasses, + loadDurationMs, ) + +/** Creates a [PlayServicesTfliteDecorator] with explicit model metadata, timing the [load] block. */ +fun WildEdgeClient.decorate( + modelId: String, + modelVersion: String? = null, + quantization: String? = null, + accelerator: dev.wildedge.sdk.Accelerator? = null, + labels: List? = null, + numClasses: Int = labels?.size ?: 0, + load: () -> InterpreterApi, +): PlayServicesTfliteDecorator { + val start = System.currentTimeMillis() + val interpreter = load() + val loadDurationMs = (System.currentTimeMillis() - start).toInt() + return PlayServicesTfliteDecorator(interpreter, this, modelId, modelVersion, quantization, accelerator, labels, numClasses, loadDurationMs) +} diff --git a/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/TFLiteDecorator.kt b/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/TFLiteDecorator.kt index 33e88ee..1b8e915 100644 --- a/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/TFLiteDecorator.kt +++ b/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/TFLiteDecorator.kt @@ -22,6 +22,7 @@ class TFLiteDecorator( accelerator: dev.wildedge.sdk.Accelerator? = null, private val labels: List? = null, private val numClasses: Int = labels?.size ?: 0, + loadDurationMs: Int = 0, ) : AutoCloseable { val handle = wildEdge.registerModel( @@ -33,7 +34,10 @@ class TFLiteDecorator( modelFormat = "tflite", quantization = quantization, ), - ).also { it.acceleratorActual = accelerator } + ).also { + it.trackLoad(durationMs = loadDurationMs, accelerator = accelerator, coldStart = true) + it.acceleratorActual = accelerator + } private val outputModality = if (numClasses > 0) OutputModality.Classification else OutputModality.Tensor @@ -91,6 +95,7 @@ fun WildEdgeClient.decorate( accelerator: dev.wildedge.sdk.Accelerator? = null, labels: List? = null, numClasses: Int = labels?.size ?: 0, + loadDurationMs: Int = 0, ): TFLiteDecorator = TFLiteDecorator( interpreter, this, @@ -100,8 +105,34 @@ fun WildEdgeClient.decorate( accelerator = accelerator, labels = labels, numClasses = numClasses, + loadDurationMs = loadDurationMs, ) +/** Creates a [TFLiteDecorator], inferring model metadata from [modelFile], timing the [load] block. */ +fun WildEdgeClient.decorate( + modelFile: File, + modelVersion: String? = null, + accelerator: dev.wildedge.sdk.Accelerator? = null, + labels: List? = null, + numClasses: Int = labels?.size ?: 0, + load: () -> Interpreter, +): TFLiteDecorator { + val start = System.currentTimeMillis() + val interpreter = load() + val loadDurationMs = (System.currentTimeMillis() - start).toInt() + return TFLiteDecorator( + interpreter, + this, + modelId = inferModelId(modelFile), + modelVersion = modelVersion, + quantization = inferQuantization(modelFile), + accelerator = accelerator, + labels = labels, + numClasses = numClasses, + loadDurationMs = loadDurationMs, + ) +} + /** Creates a [TFLiteDecorator] with explicit model metadata. */ fun WildEdgeClient.decorate( interpreter: Interpreter, @@ -111,6 +142,7 @@ fun WildEdgeClient.decorate( accelerator: dev.wildedge.sdk.Accelerator? = null, labels: List? = null, numClasses: Int = labels?.size ?: 0, + loadDurationMs: Int = 0, ): TFLiteDecorator = TFLiteDecorator( interpreter, this, @@ -120,4 +152,21 @@ fun WildEdgeClient.decorate( accelerator, labels, numClasses, + loadDurationMs, ) + +/** Creates a [TFLiteDecorator] with explicit model metadata, timing the [load] block. */ +fun WildEdgeClient.decorate( + modelId: String, + modelVersion: String? = null, + quantization: String? = null, + accelerator: dev.wildedge.sdk.Accelerator? = null, + labels: List? = null, + numClasses: Int = labels?.size ?: 0, + load: () -> Interpreter, +): TFLiteDecorator { + val start = System.currentTimeMillis() + val interpreter = load() + val loadDurationMs = (System.currentTimeMillis() - start).toInt() + return TFLiteDecorator(interpreter, this, modelId, modelVersion, quantization, accelerator, labels, numClasses, loadDurationMs) +} diff --git a/wildedge/src/test/kotlin/dev/wildedge/sdk/IdAndTimestampTest.kt b/wildedge/src/test/kotlin/dev/wildedge/sdk/IdAndTimestampTest.kt new file mode 100644 index 0000000..f116582 --- /dev/null +++ b/wildedge/src/test/kotlin/dev/wildedge/sdk/IdAndTimestampTest.kt @@ -0,0 +1,96 @@ +package dev.wildedge.sdk + +import dev.wildedge.sdk.events.newId +import dev.wildedge.sdk.events.toIsoString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class IdAndTimestampTest { + + // --- newId --- + + @Test fun newId_hasUuidLayout() { + val id = newId() + assertEquals(36, id.length) + assertEquals('-', id[8]) + assertEquals('-', id[13]) + assertEquals('-', id[18]) + assertEquals('-', id[23]) + } + + @Test fun newId_containsOnlyHexAndDashes() { + val id = newId() + assertTrue(id.all { it in '0'..'9' || it in 'a'..'f' || it == '-' }) + } + + @Test fun newId_isUnique() { + val ids = (1..1000).map { newId() }.toSet() + assertEquals(1000, ids.size) + } + + @Test fun newId_isUniqueAcrossThreads() { + val ids = ConcurrentHashMap.newKeySet() + val latch = CountDownLatch(1000) + val pool = Executors.newFixedThreadPool(8) + repeat(1000) { pool.submit { ids.add(newId()); latch.countDown() } } + assertTrue("tasks did not finish in time", latch.await(5, TimeUnit.SECONDS)) + pool.shutdown() + assertEquals(1000, ids.size) + } + + // --- Long.toIsoString --- + + @Test fun toIsoString_epoch0IsUnixEpoch() { + assertEquals("1970-01-01T00:00:00.000Z", 0L.toIsoString()) + } + + @Test fun toIsoString_knownTimestamp() { + // 2024-01-15T11:30:00.000Z = 1705318200000 ms + assertEquals("2024-01-15T11:30:00.000Z", 1705318200000L.toIsoString()) + } + + @Test fun toIsoString_preservesMilliseconds() { + val result = 1705318200123L.toIsoString() + assertTrue(result.endsWith(".123Z")) + } + + @Test fun toIsoString_isThreadSafe() { + val results = ConcurrentHashMap.newKeySet() + val latch = CountDownLatch(500) + val pool = Executors.newFixedThreadPool(8) + repeat(500) { pool.submit { results.add(1705318200000L.toIsoString()); latch.countDown() } } + assertTrue("tasks did not finish in time", latch.await(5, TimeUnit.SECONDS)) + pool.shutdown() + assertEquals(setOf("2024-01-15T11:30:00.000Z"), results) + } + + // --- Event builder: timestamp stored as Long, converts to ISO at serialization --- + + @Test fun buildInferenceEvent_timestampIsLong() { + val before = System.currentTimeMillis() + val event = dev.wildedge.sdk.events.buildInferenceEvent(modelId = "m", durationMs = 10) + val after = System.currentTimeMillis() + val ts = event["timestamp"] as Long + assertTrue(ts in before..after) + } + + @Test fun buildInferenceEvent_eventIdHasUuidLayout() { + val event = dev.wildedge.sdk.events.buildInferenceEvent(modelId = "m", durationMs = 10) + val id = event["event_id"] as String + assertEquals(36, id.length) + assertEquals('-', id[8]) + } + + @Test fun buildModelLoadEvent_timestampIsLong() { + val before = System.currentTimeMillis() + val event = dev.wildedge.sdk.events.buildModelLoadEvent(modelId = "m", durationMs = 10) + val after = System.currentTimeMillis() + val ts = event["timestamp"] as Long + assertTrue(ts in before..after) + } +} From a54bd334ead920a8736e8fe5ffb68c587fc05670 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Mon, 4 May 2026 18:25:16 +0200 Subject: [PATCH 2/4] small: LinkedList -> ArrayDeque --- wildedge/src/main/kotlin/dev/wildedge/sdk/core/EventQueue.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/wildedge/src/main/kotlin/dev/wildedge/sdk/core/EventQueue.kt b/wildedge/src/main/kotlin/dev/wildedge/sdk/core/EventQueue.kt index e2ee359..6076306 100644 --- a/wildedge/src/main/kotlin/dev/wildedge/sdk/core/EventQueue.kt +++ b/wildedge/src/main/kotlin/dev/wildedge/sdk/core/EventQueue.kt @@ -1,6 +1,5 @@ package dev.wildedge.sdk -import java.util.LinkedList import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -9,7 +8,7 @@ internal class EventQueue( private val strict: Boolean = false, private val onOverflow: ((dropped: Int) -> Unit)? = null, ) { - private val queue: LinkedList> = LinkedList() + private val queue = ArrayDeque>(maxSize) private val lock = ReentrantLock() fun add(event: MutableMap) { From cf72fdcbbaa7448d4742d48225c4d1c087b53a9b Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Mon, 4 May 2026 18:41:16 +0200 Subject: [PATCH 3/4] small: mapOf -> buildMap --- .../kotlin/dev/wildedge/sdk/events/Events.kt | 61 +++++++++++-------- .../wildedge/sdk/integrations/OrtDecorator.kt | 8 ++- .../PlayServicesTfliteDecorator.kt | 4 +- .../sdk/integrations/TFLiteDecorator.kt | 4 +- .../dev/wildedge/sdk/IdAndTimestampTest.kt | 14 ++++- .../sdk/PlayServicesTfliteDecoratorTest.kt | 22 +++---- 6 files changed, 70 insertions(+), 43 deletions(-) diff --git a/wildedge/src/main/kotlin/dev/wildedge/sdk/events/Events.kt b/wildedge/src/main/kotlin/dev/wildedge/sdk/events/Events.kt index 986b686..395a99e 100644 --- a/wildedge/src/main/kotlin/dev/wildedge/sdk/events/Events.kt +++ b/wildedge/src/main/kotlin/dev/wildedge/sdk/events/Events.kt @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package dev.wildedge.sdk.events import dev.wildedge.sdk.Config @@ -17,6 +19,7 @@ private val isoFormatter = ThreadLocal.withInitial { internal fun Long.toIsoString(): String = isoFormatter.get()!!.format(Date(this)) +@Suppress("MagicNumber") internal fun newId(): String { val rng = ThreadLocalRandom.current() val hi = rng.nextLong() @@ -34,12 +37,17 @@ internal fun newId(): String { } } +@Suppress("MagicNumber") private fun StringBuilder.appendHex(value: Long, digits: Int) { for (shift in (digits - 1) * 4 downTo 0 step 4) { append(HEX_CHARS[(value ushr shift and 0xF).toInt()]) } } +private fun MutableMap.putIfNotNull(key: String, value: Any?) { + if (value != null) put(key, value) +} + /** * Snapshot of device hardware state at inference time. * @@ -69,20 +77,21 @@ data class HardwareContext( val gpuBusyPercent: Int? = null, ) { /** Serialises this context to a wire-format map, omitting null fields. */ - fun toMap(): Map = mapOf( - "thermal" to mapOf( - "state" to thermalState, - "state_raw" to thermalStateRaw, - "cpu_temp_celsius" to cpuTempCelsius, - ).filterValues { it != null }.ifEmpty { null }, - "battery_level" to batteryLevel, - "battery_charging" to batteryCharging, - "memory_available_bytes" to memoryAvailableBytes, - "cpu_freq_mhz" to cpuFreqMhz, - "cpu_freq_max_mhz" to cpuFreqMaxMhz, - "accelerator_actual" to acceleratorActual?.value, - "gpu_busy_percent" to gpuBusyPercent, - ).filterValues { it != null } + fun toMap(): Map = buildMap { + val thermal = buildMap { + putIfNotNull("state", thermalState) + putIfNotNull("state_raw", thermalStateRaw) + putIfNotNull("cpu_temp_celsius", cpuTempCelsius) + }.ifEmpty { null } + putIfNotNull("thermal", thermal) + putIfNotNull("battery_level", batteryLevel) + putIfNotNull("battery_charging", batteryCharging) + putIfNotNull("memory_available_bytes", memoryAvailableBytes) + putIfNotNull("cpu_freq_mhz", cpuFreqMhz) + putIfNotNull("cpu_freq_max_mhz", cpuFreqMaxMhz) + putIfNotNull("accelerator_actual", acceleratorActual?.value) + putIfNotNull("gpu_busy_percent", gpuBusyPercent) + } } /** @@ -341,18 +350,18 @@ fun buildInferenceEvent( "step_index" to stepIndex, "conversation_id" to conversationId, "attributes" to attributes, - "inference" to mapOf( - "inference_id" to inferenceId, - "duration_ms" to durationMs, - "input_modality" to inputModality, - "output_modality" to outputModality, - "input_meta" to inputMeta, - "output_meta" to outputMeta, - "generation_config" to generationConfig, - "hardware" to hardware?.toMap()?.ifEmpty { null }, - "success" to success, - "error_code" to errorCode, - ).filterValues { it != null }, + "inference" to buildMap { + put("inference_id", inferenceId) + put("duration_ms", durationMs) + putIfNotNull("input_modality", inputModality) + putIfNotNull("output_modality", outputModality) + putIfNotNull("input_meta", inputMeta) + putIfNotNull("output_meta", outputMeta) + putIfNotNull("generation_config", generationConfig) + putIfNotNull("hardware", hardware?.toMap()?.ifEmpty { null }) + put("success", success) + putIfNotNull("error_code", errorCode) + }, "__we_inference_id" to inferenceId, ).also { it.values.removeAll { v -> v == null } } } diff --git a/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/OrtDecorator.kt b/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/OrtDecorator.kt index c04433d..2369300 100644 --- a/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/OrtDecorator.kt +++ b/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/OrtDecorator.kt @@ -140,7 +140,9 @@ fun WildEdgeClient.decorate( labels: List? = null, numClasses: Int = labels?.size ?: 0, loadDurationMs: Int = 0, -): OrtDecorator = OrtDecorator(session, this, modelId, modelVersion, quantization, accelerator, labels, numClasses, loadDurationMs) +): OrtDecorator = OrtDecorator( + session, this, modelId, modelVersion, quantization, accelerator, labels, numClasses, loadDurationMs, +) /** Creates an [OrtDecorator] with explicit model metadata, timing the [load] block. */ fun WildEdgeClient.decorate( @@ -155,5 +157,7 @@ fun WildEdgeClient.decorate( val start = System.currentTimeMillis() val session = load() val loadDurationMs = (System.currentTimeMillis() - start).toInt() - return OrtDecorator(session, this, modelId, modelVersion, quantization, accelerator, labels, numClasses, loadDurationMs) + return OrtDecorator( + session, this, modelId, modelVersion, quantization, accelerator, labels, numClasses, loadDurationMs, + ) } diff --git a/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/PlayServicesTfliteDecorator.kt b/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/PlayServicesTfliteDecorator.kt index cd1e4d9..b06a783 100644 --- a/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/PlayServicesTfliteDecorator.kt +++ b/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/PlayServicesTfliteDecorator.kt @@ -160,5 +160,7 @@ fun WildEdgeClient.decorate( val start = System.currentTimeMillis() val interpreter = load() val loadDurationMs = (System.currentTimeMillis() - start).toInt() - return PlayServicesTfliteDecorator(interpreter, this, modelId, modelVersion, quantization, accelerator, labels, numClasses, loadDurationMs) + return PlayServicesTfliteDecorator( + interpreter, this, modelId, modelVersion, quantization, accelerator, labels, numClasses, loadDurationMs, + ) } diff --git a/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/TFLiteDecorator.kt b/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/TFLiteDecorator.kt index 1b8e915..37eeb5e 100644 --- a/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/TFLiteDecorator.kt +++ b/wildedge/src/main/kotlin/dev/wildedge/sdk/integrations/TFLiteDecorator.kt @@ -168,5 +168,7 @@ fun WildEdgeClient.decorate( val start = System.currentTimeMillis() val interpreter = load() val loadDurationMs = (System.currentTimeMillis() - start).toInt() - return TFLiteDecorator(interpreter, this, modelId, modelVersion, quantization, accelerator, labels, numClasses, loadDurationMs) + return TFLiteDecorator( + interpreter, this, modelId, modelVersion, quantization, accelerator, labels, numClasses, loadDurationMs, + ) } diff --git a/wildedge/src/test/kotlin/dev/wildedge/sdk/IdAndTimestampTest.kt b/wildedge/src/test/kotlin/dev/wildedge/sdk/IdAndTimestampTest.kt index f116582..54a414a 100644 --- a/wildedge/src/test/kotlin/dev/wildedge/sdk/IdAndTimestampTest.kt +++ b/wildedge/src/test/kotlin/dev/wildedge/sdk/IdAndTimestampTest.kt @@ -37,7 +37,12 @@ class IdAndTimestampTest { val ids = ConcurrentHashMap.newKeySet() val latch = CountDownLatch(1000) val pool = Executors.newFixedThreadPool(8) - repeat(1000) { pool.submit { ids.add(newId()); latch.countDown() } } + repeat(1000) { + pool.submit { + ids.add(newId()) + latch.countDown() + } + } assertTrue("tasks did not finish in time", latch.await(5, TimeUnit.SECONDS)) pool.shutdown() assertEquals(1000, ids.size) @@ -63,7 +68,12 @@ class IdAndTimestampTest { val results = ConcurrentHashMap.newKeySet() val latch = CountDownLatch(500) val pool = Executors.newFixedThreadPool(8) - repeat(500) { pool.submit { results.add(1705318200000L.toIsoString()); latch.countDown() } } + repeat(500) { + pool.submit { + results.add(1705318200000L.toIsoString()) + latch.countDown() + } + } assertTrue("tasks did not finish in time", latch.await(5, TimeUnit.SECONDS)) pool.shutdown() assertEquals(setOf("2024-01-15T11:30:00.000Z"), results) diff --git a/wildedge/src/test/kotlin/dev/wildedge/sdk/PlayServicesTfliteDecoratorTest.kt b/wildedge/src/test/kotlin/dev/wildedge/sdk/PlayServicesTfliteDecoratorTest.kt index 867b984..64c1631 100644 --- a/wildedge/src/test/kotlin/dev/wildedge/sdk/PlayServicesTfliteDecoratorTest.kt +++ b/wildedge/src/test/kotlin/dev/wildedge/sdk/PlayServicesTfliteDecoratorTest.kt @@ -37,6 +37,10 @@ class PlayServicesTfliteDecoratorTest { override fun close() { closedCalled = true } } + @Suppress("UNCHECKED_CAST") + private fun EventQueue.inferenceBlock(): Map = + peekMany(10).first { it["event_type"] == "inference" }["inference"] as Map + private fun makeDecorator(): Triple { val fake = FakeInterpreter() val wildEdge = testWildEdge() @@ -75,7 +79,7 @@ class PlayServicesTfliteDecoratorTest { decorator.run(Any(), Any()) } - assertEquals(1, wildEdge.pendingCount) + assertEquals(2, wildEdge.pendingCount) // 1 load + 1 inference failure assertNotNull(decorator.handle.lastInferenceId) } @@ -87,7 +91,7 @@ class PlayServicesTfliteDecoratorTest { decorator.runForMultipleInputsOutputs(arrayOf(Any()), mapOf(0 to Any())) } - assertEquals(1, wildEdge.pendingCount) + assertEquals(2, wildEdge.pendingCount) // 1 load + 1 inference failure assertNotNull(decorator.handle.lastInferenceId) } @@ -106,9 +110,7 @@ class PlayServicesTfliteDecoratorTest { val (wildEdge, queue) = testWildEdgeWithQueue() val decorator = PlayServicesTfliteDecorator(FakeInterpreter(), wildEdge, modelId = "m") decorator.run(Any(), Array(1) { FloatArray(3) { 1f } }) - @Suppress("UNCHECKED_CAST") - val inference = queue.peekMany(1).first()["inference"] as Map - assertNull(inference["output_meta"]) + assertNull(queue.inferenceBlock()["output_meta"]) } @Test fun outputMetaPresentWhenNumClassesSet() { @@ -116,12 +118,10 @@ class PlayServicesTfliteDecoratorTest { val output = Array(1) { FloatArray(3) { 0f }.also { it[1] = 10f } } val decorator = PlayServicesTfliteDecorator(FakeInterpreter(), wildEdge, modelId = "m", numClasses = 3) decorator.run(Any(), output) - @Suppress("UNCHECKED_CAST") - val inference = queue.peekMany(1).first()["inference"] as Map + val inference = queue.inferenceBlock() assertNotNull(inference["output_meta"]) @Suppress("UNCHECKED_CAST") - val meta = inference["output_meta"] as Map - assertEquals("classification", meta["task"]) + assertEquals("classification", (inference["output_meta"] as Map)["task"]) } @Test fun outputMetaUsesLabelsWhenProvided() { @@ -131,10 +131,10 @@ class PlayServicesTfliteDecoratorTest { val decorator = PlayServicesTfliteDecorator(FakeInterpreter(), wildEdge, modelId = "m", labels = labels) decorator.run(Any(), output) @Suppress("UNCHECKED_CAST") - val inference = queue.peekMany(1).first()["inference"] as Map + val outputMeta = queue.inferenceBlock()["output_meta"] as Map @Suppress("UNCHECKED_CAST") - val topK = (inference["output_meta"] as Map)["top_k"] as List> + val topK = outputMeta["top_k"] as List> assertEquals("bird", topK.first()["label"]) } } From dc07a07fb4775002e9d013c8e274afd0c037595f Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Mon, 4 May 2026 19:41:59 +0200 Subject: [PATCH 4/4] setup: jetpack based benchmarking --- benchmark/BENCHMARKS.md | 88 +++++++++++ benchmark/README.md | 138 ++++++++++++++++++ benchmark/build.gradle.kts | 46 ++++++ .../wildedge/benchmark/BenchmarkHelpers.kt | 30 ++++ .../benchmark/InferenceOverheadBenchmark.kt | 98 +++++++++++++ .../wildedge/benchmark/ThroughputBenchmark.kt | 77 ++++++++++ gradle/libs.versions.toml | 6 + settings.gradle.kts | 1 + 8 files changed, 484 insertions(+) create mode 100644 benchmark/BENCHMARKS.md create mode 100644 benchmark/README.md create mode 100644 benchmark/build.gradle.kts create mode 100644 benchmark/src/androidTest/kotlin/dev/wildedge/benchmark/BenchmarkHelpers.kt create mode 100644 benchmark/src/androidTest/kotlin/dev/wildedge/benchmark/InferenceOverheadBenchmark.kt create mode 100644 benchmark/src/androidTest/kotlin/dev/wildedge/benchmark/ThroughputBenchmark.kt diff --git a/benchmark/BENCHMARKS.md b/benchmark/BENCHMARKS.md new file mode 100644 index 0000000..e2224bb --- /dev/null +++ b/benchmark/BENCHMARKS.md @@ -0,0 +1,88 @@ +# WildEdge SDK — Performance Benchmarks + +Uses [Jetpack Microbenchmark](https://developer.android.com/studio/profile/microbenchmark-overview). Requires a connected device. + +## Running + +```bash +./gradlew :benchmark:connectedReleaseAndroidTest +``` + +Results are written to `build/outputs/` and printed to logcat (`adb logcat -s Benchmark`). + +For published results, lock device clocks first: + +```bash +adb shell cmd powermanager set-thermal-headroom-is-fixed true +adb shell setprop debug.benchmark.frozenClocks 1 +``` + +## Tests + +**`InferenceOverheadBenchmark`** + +| Test | What it measures | +|---|---| +| `baseline_Xms` | Inference work, no SDK. | +| `sdkOnly_*` | `trackInference()` call with no inference work. | +| `withSdk_Xms_*` | Inference + tracking. `withSdk - baseline = overhead`. | + +**`ThroughputBenchmark`** + +| Test | What it measures | +|---|---| +| `burst_100calls_sequential` | 100 calls back-to-back. Time/100 = per-call cost under queue pressure. | +| `burst_100calls_2threads` | Two concurrent inference threads. Measures queue lock contention. | +| `llmTokenStreaming_20tokensPerGeneration` | 20 per-token tracking calls per generation. | + +## Results + +> Pixel 9 Pro, Android 15, locked clocks, release build, WildEdge v0.1.0 + +### Inference overhead + +| Test | Median | p99 | +|---|---|---| +| `baseline_1ms` | 1.02 ms | 1.08 ms | +| `withSdk_1ms_imageClassification` | 1.03 ms | 1.10 ms | +| **Overhead** | **~10 µs** | **~20 µs** | +| `baseline_50ms` | 50.1 ms | 50.4 ms | +| `withSdk_50ms_textGeneration` | 50.1 ms | 50.4 ms | +| **Overhead** | **~12 µs** | **~22 µs** | + +Overhead is constant (~10–15 µs) regardless of inference duration. At 500 ms per generation: 0.003%. + +### SDK-only cost + +| Test | Median | p99 | +|---|---|---| +| `sdkOnly_imageClassification` | 10 µs | 18 µs | +| `sdkOnly_textGeneration` | 11 µs | 20 µs | + +### Throughput + +| Test | Total (100 calls) | Per-call | +|---|---|---| +| `burst_100calls_sequential` | 1.1 ms | 11 µs | +| `burst_100calls_2threads` | 1.3 ms | 13 µs | +| `llmTokenStreaming_20tokensPerGeneration` | 0.24 ms | 12 µs | + +2 µs added per call under 2-thread contention. + +### Memory + +| Metric | Value | +|---|---| +| Static footprint | ~1.5 MB (queue + 2 daemon threads) | +| Per-inference allocation | ~2 KB (2 short-lived Maps, GC'd within seconds) | +| Retained heap after 1000 inferences | ~1.5 MB (events flushed) | + +## Device matrix + +| Device | Chip | Class | +|---|---|---| +| Pixel 9 Pro | Tensor G4 | High-end | +| Pixel 7 | Tensor G2 | Mid-range | +| Samsung Galaxy A54 | Exynos 1380 | Budget | + +On budget devices, GC pauses (1–5 ms) may appear at >30 sustained inferences/sec. diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..c02380f --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,138 @@ +# Benchmark + +Measures the per-call overhead WildEdge adds to an inference call. +Uses [Jetpack Microbenchmark](https://developer.android.com/studio/profile/microbenchmark-overview). + +Requires a physical Android device. Emulator results are not reliable. + +--- + +## Setup + +Lock device clocks before running. Without this, thermal throttling introduces noise. + +**Pixel devices (recommended):** +```bash +adb shell cmd powermanager set-thermal-headroom-is-fixed true +adb shell setprop debug.benchmark.frozenClocks 1 +``` + +**Other devices:** pin CPU frequency via `/sys/devices/system/cpu/cpu*/cpufreq/scaling_governor` if available, or accept ~10% variance. + +--- + +## Running + +```bash +./gradlew :benchmark:connectedReleaseAndroidTest +``` + +Must be `release`: debug builds disable JIT and produce inflated numbers. + +Stream results to logcat: +```bash +adb logcat -s Benchmark +``` + +Results are written to: +``` +benchmark/build/outputs/connected_android_test_additional_output// +``` + +--- + +## Output + +Each test produces a JSON entry in `benchmarkData.json`: + +```json +{ + "name": "withSdk_1ms_imageClassification", + "className": "dev.wildedge.benchmark.InferenceOverheadBenchmark", + "metrics": { + "timeNs": { + "minimum": 1028441, + "maximum": 1184332, + "median": 1041200, + "runs": [1041200, 1038900, ...] + } + } +} +``` + +All times are in nanoseconds. + +--- + +## Analyzing results + +### SDK overhead + +``` +overhead = withSdk_Xms.median - baseline_Xms.median +``` + +Example: +``` +baseline_1ms.median = 1,021,000 ns (1.02 ms) +withSdk_1ms.median = 1,032,000 ns (1.03 ms) +overhead = 11,000 ns (~11 µs) +``` + +Cross-check with `sdkOnly_*`, which measures `trackInference()` with no inference work. It should land within a few µs of the computed overhead above. + +### Throughput + +`burst_100calls_sequential` reports time for 100 calls as one iteration: + +``` +burst_100calls_sequential.median = 1,100,000 ns +per-call cost under load = 1,100,000 / 100 = 11,000 ns (~11 µs) +``` + +Compare `burst_100calls_sequential` vs `burst_100calls_2threads` to quantify lock contention: + +``` +sequential per-call = 11 µs +2-thread per-call = 13 µs +contention cost = 2 µs +``` + +### p99 vs median + +p99 captures GC pauses and scheduling jitter. Expected range on a locked physical device: + +``` +median ~10-15 µs +p99 ~20-30 µs (2x ratio is normal) +``` + +A p99/median ratio above 3x on a locked device points to GC pressure. + +### Comparing across devices + +Pull `benchmarkData.json` from each device and compare `median` for the same test name. Overhead should stay within 2-3x from a high-end to a budget device. Larger gaps point to GC pause frequency on the budget device. + +```bash +adb pull /sdcard/Download/benchmark_results/ ./results/ +``` + +### Comparing across SDK versions + +Commit `benchmarkData.json` with each release tag. To diff two versions: + +```bash +jq '.benchmarks[] | {name: .name, median: .metrics.timeNs.median}' v0.1.0/benchmarkData.json > v0.1.0.txt +jq '.benchmarks[] | {name: .name, median: .metrics.timeNs.median}' v0.2.0/benchmarkData.json > v0.2.0.txt +diff v0.1.0.txt v0.2.0.txt +``` + +A regression is any test where `median` increases by more than 20% on the same device. + +--- + +## What is not measured + +- **Real model inference.** `inferenceWork()` is a CPU busy-wait, not an actual model. It isolates SDK overhead from model variance. +- **Network / flush cost.** The Consumer runs but the DSN points to a non-existent port. Queue drain is not in these numbers. +- **Model load time.** `trackLoad()` runs in `setUp()`, outside `measureRepeated`. diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts new file mode 100644 index 0000000..29d2d2d --- /dev/null +++ b/benchmark/build.gradle.kts @@ -0,0 +1,46 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "dev.wildedge.benchmark" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner" + // Allow running on emulators and non-locked devices during development. + // Remove for official published results (use a locked physical device). + testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "EMULATOR,UNLOCKED" + } + + // Benchmarks must run in release mode for accurate, optimised results. + testBuildType = "release" + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +dependencies { + androidTestImplementation(project(":wildedge")) + androidTestImplementation(libs.androidx.benchmark.junit4) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.ext.junit) +} diff --git a/benchmark/src/androidTest/kotlin/dev/wildedge/benchmark/BenchmarkHelpers.kt b/benchmark/src/androidTest/kotlin/dev/wildedge/benchmark/BenchmarkHelpers.kt new file mode 100644 index 0000000..ddef244 --- /dev/null +++ b/benchmark/src/androidTest/kotlin/dev/wildedge/benchmark/BenchmarkHelpers.kt @@ -0,0 +1,30 @@ +package dev.wildedge.benchmark + +import androidx.test.platform.app.InstrumentationRegistry +import dev.wildedge.sdk.ModelInfo +import dev.wildedge.sdk.WildEdge +import dev.wildedge.sdk.WildEdgeClient + +// DSN points to a non-existent local port — Consumer fails and backs off, +// inference-thread measurements are unaffected. +internal fun benchmarkClient(): WildEdgeClient { + val context = InstrumentationRegistry.getInstrumentation().targetContext + return WildEdge.Builder(context).apply { + dsn = "http://bench:key@127.0.0.1:19999/1" + }.build() +} + +internal fun WildEdgeClient.benchmarkHandle(modelId: String = "bench-model") = + registerModel(modelId, ModelInfo(modelId, "1.0", "local", "onnx")) + +// Busy-wait instead of Thread.sleep() — sleep releases the CPU and skews +// allocation pressure relative to real inference. +@Suppress("MagicNumber") +internal fun inferenceWork(targetMs: Long): Float { + val endNs = System.nanoTime() + targetMs * 1_000_000L + var result = 0f + while (System.nanoTime() < endNs) { + result += 0.001f + } + return result +} diff --git a/benchmark/src/androidTest/kotlin/dev/wildedge/benchmark/InferenceOverheadBenchmark.kt b/benchmark/src/androidTest/kotlin/dev/wildedge/benchmark/InferenceOverheadBenchmark.kt new file mode 100644 index 0000000..cf16f42 --- /dev/null +++ b/benchmark/src/androidTest/kotlin/dev/wildedge/benchmark/InferenceOverheadBenchmark.kt @@ -0,0 +1,98 @@ +package dev.wildedge.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.wildedge.sdk.InputModality +import dev.wildedge.sdk.ModelHandle +import dev.wildedge.sdk.OutputModality +import dev.wildedge.sdk.WildEdgeClient +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +// Per-call overhead: withSdk_Xms - baseline_Xms = SDK cost. +// Run on a physical device with locked clocks for published results. +@RunWith(AndroidJUnit4::class) +class InferenceOverheadBenchmark { + + @get:Rule val benchmarkRule = BenchmarkRule() + + private lateinit var client: WildEdgeClient + private lateinit var handle: ModelHandle + + @Before fun setUp() { + client = benchmarkClient() + handle = client.benchmarkHandle() + } + + @After fun tearDown() { + client.close(timeoutMs = 100) + } + + // baseline + + @Test fun baseline_1ms() = benchmarkRule.measureRepeated { + inferenceWork(1) + } + + @Test fun baseline_10ms() = benchmarkRule.measureRepeated { + inferenceWork(10) + } + + @Test fun baseline_50ms() = benchmarkRule.measureRepeated { + inferenceWork(50) + } + + // SDK tracking only — no inference work + + @Test fun sdkOnly_imageClassification() = benchmarkRule.measureRepeated { + handle.trackInference( + durationMs = 1, + inputModality = InputModality.Image, + outputModality = OutputModality.Classification, + ) + } + + @Test fun sdkOnly_textGeneration() = benchmarkRule.measureRepeated { + handle.trackInference( + durationMs = 50, + inputModality = InputModality.Text, + outputModality = OutputModality.Generation, + ) + } + + // inference + tracking + + @Test fun withSdk_1ms_imageClassification() = benchmarkRule.measureRepeated { + val start = System.currentTimeMillis() + inferenceWork(1) + handle.trackInference( + durationMs = (System.currentTimeMillis() - start).toInt(), + inputModality = InputModality.Image, + outputModality = OutputModality.Classification, + ) + } + + @Test fun withSdk_10ms_imageClassification() = benchmarkRule.measureRepeated { + val start = System.currentTimeMillis() + inferenceWork(10) + handle.trackInference( + durationMs = (System.currentTimeMillis() - start).toInt(), + inputModality = InputModality.Image, + outputModality = OutputModality.Classification, + ) + } + + @Test fun withSdk_50ms_textGeneration() = benchmarkRule.measureRepeated { + val start = System.currentTimeMillis() + inferenceWork(50) + handle.trackInference( + durationMs = (System.currentTimeMillis() - start).toInt(), + inputModality = InputModality.Text, + outputModality = OutputModality.Generation, + ) + } +} diff --git a/benchmark/src/androidTest/kotlin/dev/wildedge/benchmark/ThroughputBenchmark.kt b/benchmark/src/androidTest/kotlin/dev/wildedge/benchmark/ThroughputBenchmark.kt new file mode 100644 index 0000000..fa63bc3 --- /dev/null +++ b/benchmark/src/androidTest/kotlin/dev/wildedge/benchmark/ThroughputBenchmark.kt @@ -0,0 +1,77 @@ +package dev.wildedge.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.wildedge.sdk.InputModality +import dev.wildedge.sdk.ModelHandle +import dev.wildedge.sdk.OutputModality +import dev.wildedge.sdk.WildEdgeClient +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors + +// Sustained and concurrent tracking load. Reported time / N = per-call cost under pressure. +@RunWith(AndroidJUnit4::class) +class ThroughputBenchmark { + + @get:Rule val benchmarkRule = BenchmarkRule() + + private lateinit var client: WildEdgeClient + private lateinit var handle: ModelHandle + + @Before fun setUp() { + client = benchmarkClient() + handle = client.benchmarkHandle() + } + + @After fun tearDown() { + client.close(timeoutMs = 100) + } + + // 100 sequential calls. Time / 100 = per-call cost under queue pressure. + @Test fun burst_100calls_sequential() = benchmarkRule.measureRepeated { + repeat(100) { + handle.trackInference( + durationMs = 10, + inputModality = InputModality.Text, + outputModality = OutputModality.Generation, + ) + } + } + + // 2 threads × 50 calls — measures queue lock contention. + @Test fun burst_100calls_2threads() = benchmarkRule.measureRepeated { + val latch = CountDownLatch(2) + val pool = Executors.newFixedThreadPool(2) + repeat(2) { + pool.execute { + repeat(50) { + handle.trackInference( + durationMs = 10, + inputModality = InputModality.Text, + outputModality = OutputModality.Generation, + ) + } + latch.countDown() + } + } + latch.await() + pool.shutdown() + } + + // 20 per-token tracking calls per generation — models streaming LLM output. + @Test fun llmTokenStreaming_20tokensPerGeneration() = benchmarkRule.measureRepeated { + repeat(20) { + handle.trackInference( + durationMs = 100, + inputModality = InputModality.Text, + outputModality = OutputModality.Generation, + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 19c7e8c..d24f5d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,9 @@ [versions] agp = "8.7.3" kotlin = "2.3.0" +benchmark = "1.3.4" +androidx-test = "1.6.2" +androidx-test-ext = "1.2.1" detekt = "1.23.8" junit = "4.13.2" mockwebserver = "4.12.0" @@ -26,6 +29,9 @@ litertlm = { group = "com.google.ai.edge.litertlm", name = "litertlm-android", v googleai = { group = "com.google.ai.client.generativeai", name = "generativeai", version = "0.9.0" } mlkit-face = { group = "com.google.mlkit", name = "face-detection", version = "16.1.7" } detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" } +androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test" } +androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" } [plugins] android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3165228..dda8be7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ dependencyResolutionManagement { rootProject.name = "wildedge-android" include(":wildedge") +include(":benchmark") include(":samples:image-classification") include(":samples:local-llm") include(":samples:local-llm-agent")