mobench-sdk 0.1.41

Rust SDK for mobile benchmarking with timing harness and Android/iOS builders
Documentation
import Foundation
import Darwin

private let defaultFunction = "{{DEFAULT_FUNCTION}}"
private let defaultIterations: UInt32 = 20
private let defaultWarmup: UInt32 = 3

struct BenchParams {
    let function: String
    let iterations: UInt32
    let warmup: UInt32

    private struct EncodedBenchSpec: Decodable {
        let function: String
        let iterations: UInt32
        let warmup: UInt32
    }

    static func fromBundle() -> BenchParams? {
        guard let url = Bundle.main.url(forResource: "bench_spec", withExtension: "json") else {
            print("[BenchRunner] No bench_spec.json found in bundle, will use process info or defaults")
            return nil
        }
        do {
            let data = try Data(contentsOf: url)
            let decoded = try JSONDecoder().decode(EncodedBenchSpec.self, from: data)
            print("[BenchRunner] Loaded config from bench_spec.json: function=\(decoded.function), iterations=\(decoded.iterations), warmup=\(decoded.warmup)")
            return BenchParams(function: decoded.function, iterations: decoded.iterations, warmup: decoded.warmup)
        } catch {
            print("[BenchRunner] ERROR: Failed to parse bench_spec.json: \(error)")
            return nil
        }
    }

    static func fromProcessInfo() -> BenchParams {
        let info = ProcessInfo.processInfo
        var function = defaultFunction
        var iterations = defaultIterations
        var warmup = defaultWarmup

        if let envFunction = info.environment["BENCH_FUNCTION"], !envFunction.isEmpty {
            function = envFunction
        }
        if let envIterations = info.environment["BENCH_ITERATIONS"], let parsed = UInt32(envIterations) {
            iterations = parsed
        }
        if let envWarmup = info.environment["BENCH_WARMUP"], let parsed = UInt32(envWarmup) {
            warmup = parsed
        }

        for arg in info.arguments {
            if arg.hasPrefix("--bench-function="), let value = arg.split(separator: "=", maxSplits: 1).last {
                function = String(value)
            } else if arg.hasPrefix("--bench-iterations="),
                      let value = arg.split(separator: "=", maxSplits: 1).last,
                      let parsed = UInt32(value) {
                iterations = parsed
            } else if arg.hasPrefix("--bench-warmup="),
                      let value = arg.split(separator: "=", maxSplits: 1).last,
                      let parsed = UInt32(value) {
                warmup = parsed
            }
        }

        print("[BenchRunner] Resolved params: function=\(function), iterations=\(iterations), warmup=\(warmup)")
        return BenchParams(function: function, iterations: iterations, warmup: warmup)
    }

    static func resolved() -> BenchParams {
        if let bundled = fromBundle() {
            return bundled
        }
        return fromProcessInfo()
    }
}

struct BenchmarkResult {
    let displayText: String
    let jsonReport: String
}

enum {{PROJECT_NAME_PASCAL}}FFI {
    static func runCurrentBenchmark() -> BenchmarkResult {
        let params = BenchParams.resolved()
        return run(params: params)
    }

    static func run(params: BenchParams) -> BenchmarkResult {
        do {
            let processMemorySampler = ProcessMemorySampler()
            processMemorySampler.start()
            let rawReport: [String: Any]
            do {
                rawReport = try NativeBenchRunner.run(params: params)
            } catch {
                _ = processMemorySampler.stop()
                throw error
            }
            let runProcessPeakMemoryKb = processMemorySampler.stop()
            let report = generateJSONReport(rawReport, runProcessPeakMemoryKb: runProcessPeakMemoryKb)
            let displayText = formatBenchReport(report)
            let jsonReport = serializeJSON(report)
            return BenchmarkResult(displayText: displayText, jsonReport: jsonReport)
        } catch {
            print("[BenchRunner] ERROR: Benchmark failed: \(error)")
            let message = error.localizedDescription
            return BenchmarkResult(
                displayText: "Benchmark error: \(message)",
                jsonReport: "{\"error\": true, \"message\": \"\(escapeJSON(message))\"}"
            )
        }
    }

    private static func generateJSONReport(_ rawReport: [String: Any], runProcessPeakMemoryKb: UInt64?) -> [String: Any] {
        var json = rawReport
        let spec = json["spec"] as? [String: Any] ?? [:]
        let function = spec["name"] as? String ?? defaultFunction
        json["function"] = function

        let samples = json["samples"] as? [[String: Any]] ?? []
        let durations = samples.compactMap { coerceToUInt64($0["duration_ns"] ?? 0) }
        json["samples_ns"] = durations

        if !durations.isEmpty {
            let sum = durations.reduce(0, +)
            let mean = sum / UInt64(durations.count)
            json["stats"] = [
                "min_ns": durations.min() ?? 0,
                "max_ns": durations.max() ?? 0,
                "avg_ns": Double(sum) / Double(durations.count),
                "mean_ns": mean,
                "median_ns": median(durations)
            ] as [String: Any]
            json["mean_ns"] = mean
            json["min_ns"] = durations.min() ?? 0
            json["max_ns"] = durations.max() ?? 0
        }

        let cpuSamplesMs = samples.compactMap { sample in
            sample["cpu_time_ms"].flatMap(coerceToUInt64)
        }
        let peakSamplesKb = samples.compactMap { sample in
            sample["peak_memory_kb"].flatMap(coerceToUInt64)
        }
        let processPeakSamplesKb = samples.compactMap { sample in
            sample["process_peak_memory_kb"].flatMap(coerceToUInt64)
        }
        var resources: [String: Any] = [
            "platform": "ios",
            "memory_process": "benchmark_app",
            "timestamp_ms": Int64(Date().timeIntervalSince1970 * 1000)
        ]
        if !cpuSamplesMs.isEmpty {
            let total = cpuSamplesMs.reduce(0, +)
            resources["cpu_total_ms"] = total
            resources["cpu_median_ms"] = median(cpuSamplesMs)
            resources["elapsed_cpu_ms"] = total
        }
        if let peak = peakSamplesKb.max() {
            resources["peak_memory_kb"] = peak
            resources["peak_memory_growth_kb"] = peak
        }
        if let processPeak = processPeakSamplesKb.max() ?? runProcessPeakMemoryKb {
            resources["process_peak_memory_kb"] = processPeak
        }
        json["resources"] = resources
        return json
    }

    private static func formatBenchReport(_ report: [String: Any]) -> String {
        let spec = report["spec"] as? [String: Any] ?? [:]
        let function = spec["name"] as? String ?? defaultFunction
        let iterations = coerceToUInt64(spec["iterations"] ?? defaultIterations) ?? UInt64(defaultIterations)
        let warmup = coerceToUInt64(spec["warmup"] ?? defaultWarmup) ?? UInt64(defaultWarmup)
        let samples = report["samples"] as? [[String: Any]] ?? []

        var output = "=== Benchmark Results ===\n\n"
        output += "Function: \(function)\n"
        output += "Iterations: \(iterations)\n"
        output += "Warmup: \(warmup)\n\n"
        output += "Samples (\(samples.count)):\n"
        for (index, sample) in samples.enumerated() {
            let duration = coerceToUInt64(sample["duration_ns"] ?? 0) ?? 0
            output += "  \(index + 1). \(formatDuration(duration))\n"
        }

        if let stats = report["stats"] as? [String: Any] {
            output += "\nStatistics:\n"
            output += "  Min: \(formatDuration(coerceToUInt64(stats["min_ns"] ?? 0) ?? 0))\n"
            output += "  Max: \(formatDuration(coerceToUInt64(stats["max_ns"] ?? 0) ?? 0))\n"
            output += "  Avg: \(formatDuration(coerceToUInt64(stats["mean_ns"] ?? 0) ?? 0))\n"
        }

        return output
    }

    private static func serializeJSON(_ value: [String: Any]) -> String {
        do {
            let data = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
            return String(data: data, encoding: .utf8) ?? "{}"
        } catch {
            print("[BenchRunner] ERROR: Failed to serialize JSON report: \(error)")
            return "{}"
        }
    }

    private static func formatDuration(_ ns: UInt64) -> String {
        let ms = Double(ns) / 1_000_000.0
        if ms >= 1000.0 {
            return String(format: "%.3fs", ms / 1000.0)
        }
        return String(format: "%.3fms", ms)
    }
}

private enum NativeBenchRunner {
    static func run(params: BenchParams) throws -> [String: Any] {
        let spec: [String: Any] = [
            "name": params.function,
            "iterations": params.iterations,
            "warmup": params.warmup
        ]
        let specData = try JSONSerialization.data(withJSONObject: spec, options: [])
        var out = MobenchBuf()
        let status: Int32 = specData.withUnsafeBytes { bytes in
            let ptr = bytes.baseAddress?.assumingMemoryBound(to: UInt8.self)
            return mobench_run_benchmark_json(ptr, UInt(specData.count), &out)
        }

        if status != 0 {
            let message = mobench_last_error_message().map { String(cString: $0) } ?? "native benchmark failed"
            mobench_free_buf(&out)
            throw NativeBenchError.execution(message)
        }

        defer {
            mobench_free_buf(&out)
        }
        guard let ptr = out.ptr, out.len > 0 else {
            throw NativeBenchError.execution("native benchmark returned an empty report")
        }
        let data = Data(bytes: ptr, count: Int(out.len))
        let decoded = try JSONSerialization.jsonObject(with: data, options: [])
        guard let report = decoded as? [String: Any] else {
            throw NativeBenchError.execution("native benchmark returned non-object JSON")
        }
        return report
    }
}

private enum NativeBenchError: LocalizedError {
    case execution(String)

    var errorDescription: String? {
        switch self {
        case .execution(let message):
            return message
        }
    }
}

private final class ProcessMemorySampler {
    private let sampleInterval: TimeInterval
    private let queue = DispatchQueue(label: "mobench-process-memory-sampler")
    private var timer: DispatchSourceTimer?
    private var peakKb: UInt64 = 0

    init(sampleInterval: TimeInterval = 0.01) {
        self.sampleInterval = sampleInterval
    }

    func start() {
        queue.sync {
            guard timer == nil else {
                return
            }

            recordCurrentLocked()
            let source = DispatchSource.makeTimerSource(queue: queue)
            source.schedule(deadline: .now(), repeating: sampleInterval)
            source.setEventHandler { [weak self] in
                self?.recordCurrentLocked()
            }
            source.resume()
            timer = source
        }
    }

    func stop() -> UInt64? {
        queue.sync {
            timer?.cancel()
            timer = nil
            recordCurrentLocked()
            return peakKb > 0 ? peakKb : nil
        }
    }

    private func recordCurrentLocked() {
        if let currentKb = currentProcessResidentMemoryKb(), currentKb > peakKb {
            peakKb = currentKb
        }
    }
}

private func currentProcessResidentMemoryKb() -> UInt64? {
    var info = mach_task_basic_info()
    var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
    let result = withUnsafeMutablePointer(to: &info) {
        $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
            task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
        }
    }

    guard result == KERN_SUCCESS else {
        return nil
    }
    return UInt64(info.resident_size / 1024)
}

private func median(_ values: [UInt64]) -> UInt64 {
    let sorted = values.sorted()
    if sorted.isEmpty {
        return 0
    }
    if sorted.count % 2 == 0 {
        return (sorted[sorted.count / 2 - 1] + sorted[sorted.count / 2]) / 2
    }
    return sorted[sorted.count / 2]
}

private func coerceToUInt64(_ value: Any) -> UInt64? {
    if let value = value as? UInt64 {
        return value
    }
    if let value = value as? UInt32 {
        return UInt64(value)
    }
    if let value = value as? UInt {
        return UInt64(value)
    }
    if let value = value as? Int64, value >= 0 {
        return UInt64(value)
    }
    if let value = value as? Int, value >= 0 {
        return UInt64(value)
    }
    if let value = value as? NSNumber, value.int64Value >= 0 {
        return UInt64(value.uint64Value)
    }
    return nil
}

private func escapeJSON(_ value: String) -> String {
    value
        .replacingOccurrences(of: "\\", with: "\\\\")
        .replacingOccurrences(of: "\"", with: "\\\"")
}