Skip to content

VisionPoseLatency.kt

src/jvmMain/kotlin/afield/graphics/visionos/VisionPoseLatency.kt · 288 lines · cited by Field on Apple Vision Pro

package afield.graphics.visionos

import afield.geometry.path.div
import afield.geometry.path.midpoint
import afield.geometry.path.minus
import afield.geometry.path.plus
import afield.geometry.path.then
import afield.geometry.path.times
import afield.lang.remotejs.JsFun

import afield.ui.output.prettySummary
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong

Where the time goes between a head moving and the resulting image reaching it.

This attributes latency to stages rather than reporting one number, because the plausible explanations for "it feels laggy" are spread across five machines' worth of stages, and two of those stages hold frames back in order to do their job: the receiver's jitter buffer and the producer's render fence.

Two clocks, and why the headline number needs neither reconciled

The head's clock (LayerRenderer.Clock) and this process's System.nanoTime are unrelated. That would normally make one-way timings unmeasurable. It does not here, because of a property of the wire we already have: a pose's predictedDisplayTime is in the head's clock, and the media wire carries it back to the head as poseDisplayTimeNanos. So the head can subtract two of its own timestamps and get motion-to-photon with no clock agreement at all. That is why the headline figure is measured on the device and reported here, rather than computed here.

The offset is still wanted, for splitting the remainder into "our side" and "their side". It is estimated NTP-style from PING/PONG round trips, keeping the sample with the smallest round trip rather than an average: the minimum is the one least polluted by queuing in either direction, and averaging RTTs on a contended Wi-Fi link estimates the contention, not the offset.

The two stages that hold frames back

The receiver runs a jitter buffer (RTTStream.bufferFrames) that holds frames so that network jitter does not become dropped frames. It is reported as its own span. Without that split, the buffer doing exactly its job looks like the network being slow, and the obvious "fix" — shrinking it — trades smoothness for a better number.

Likewise SUBMIT_TO_ENCODED includes the render fence: the producer only signals a surface once its GPU work has completed, so scene complexity lands in that span rather than anywhere else.

What this cannot see

The device's last observable moment is the decoded frame reaching the display texture. Actual photons are the compositor's business, up to one refresh later, and with the present path still on RealityKit there is no hook for it. So the headline is a lower bound, short by up to one compositor frame. That closes when the present path moves to Compositor Services, which is also what makes reprojection possible; it is not worth faking before then.

object VisionPoseLatency

object VisionPoseLatency {

const val PING = "visionpose/ping"

const val PONG = "visionpose/pong"

const val REPORT = "visionpose/latency"

How many samples each span keeps. At 60 Hz this is about thirty seconds of history.

    private const val WINDOW = 2048

const val POSE_TRANSIT = "pose uplink transit"

Spans this process can time on its own clock.

const val POSE_AGE_AT_LATCH = "pose age when latched"

const val LATCH_TO_SUBMIT = "latch to frame submit"

const val SUBMIT_TO_ENCODED = "submit to encoded (incl. render fence)"

    private val spans = ConcurrentHashMap<String, Span>()
    private val pings = ConcurrentHashMap<Long, Long>()
    private val pongCount = AtomicLong(0)

var clockOffsetNanos: Long

head clock ≈ this clock + clockOffsetNanos. Zero until a pong lands; offsetKnown says which, because a zero offset is also a legitimate estimate and silently treating "unknown" as "aligned" would report host-side spans as if the two machines shared a clock.

    @Volatile
    var clockOffsetNanos: Long = 0L
        private set

var offsetKnown: Boolean

    @Volatile
    var offsetKnown: Boolean = false
        private set

var bestRoundTripNanos: Long

Smallest round trip seen, the sample the offset is taken from.

    @Volatile
    var bestRoundTripNanos: Long = Long.MAX_VALUE
        private set

A bounded ring of samples with percentiles. Not a histogram: the tail is the interesting part.

    private class Span(val name: String) {
        private val values = LongArray(WINDOW)
        private var count = 0
        private var next = 0

        @Synchronized
        fun add(nanos: Long) {
            values[next] = nanos
            next = (next + 1) % WINDOW
            if (count < WINDOW) count++
        }

        @Synchronized
        fun snapshot(): LongArray = values.copyOf(count).also { it.sort() }

        val n: Int @Synchronized get() = count
    }

    private fun span(name: String) = spans.computeIfAbsent(name) { Span(it) }

fun record(name: String, nanos: Long)

    fun record(name: String, nanos: Long) {
        // A negative span means the two ends disagree about order — a clock estimate that has moved,
        // or a frame attributed to the wrong pose. Counting it as zero would quietly flatter the
        // result, so drop it and let `n` fall short of the frame count instead.
        if (nanos >= 0) span(name).add(nanos)
    }

clock offset

fun ping(): String

Build a ping. Send it to a head; acceptPong closes the loop.

    fun ping(): String {
        val sent = System.nanoTime()
        pings[sent] = sent
        // Trim rather than grow if a head never answers.
        if (pings.size > 64) pings.keys.sorted().take(pings.size - 64).forEach { pings.remove(it) }
        return """{"channel":"$VISION_POSE_CHANNEL","type":"$PING","version":$VISION_POSE_VERSION,""" +
            """"hostSendNanos":$sent}"""
    }

fun acceptPong(hostSendNanos: Long, headRecvNanos: Long, headSendNanos: Long)

Close a round trip.

rtt = (hostRecv - hostSend) - (headSend - headRecv), so the head's own processing delay is removed rather than being charged to the network. The offset is the midpoint of the two crossings, which assumes the path is symmetric — the standard assumption, and the reason to keep the minimum-RTT sample where it is least wrong.

    fun acceptPong(hostSendNanos: Long, headRecvNanos: Long, headSendNanos: Long) {
        val hostRecv = System.nanoTime()
        pings.remove(hostSendNanos) ?: return   // not ours, or already retired
        val headHold = headSendNanos - headRecvNanos
        val roundTrip = (hostRecv - hostSendNanos) - headHold
        if (roundTrip < 0) return
        pongCount.incrementAndGet()
        if (roundTrip < bestRoundTripNanos) {
            bestRoundTripNanos = roundTrip
            clockOffsetNanos = ((headRecvNanos - hostSendNanos) + (headSendNanos - hostRecv)) / 2
            offsetKnown = true
        }
    }

fun headToHostNanos(headNanos: Long): Long = headNanos - clockOffsetNanos

Convert a head-clock instant into this process's clock. Only meaningful once offsetKnown.

per-frame host bookkeeping

    private class InFlight(val poseArrivalNanos: Long, val poseSentHeadNanos: Long, val latchNanos: Long)

    private val inFlight = ConcurrentHashMap<Long, InFlight>()

fun latched(seq: Long, pose: VisionPoseFrame)

A stereo target latched pose and is about to render stream frame seq.

Called from the render thread once per streamed frame.

    fun latched(seq: Long, pose: VisionPoseFrame) {
        val now = System.nanoTime()
        record(POSE_AGE_AT_LATCH, now - pose.receivedAtNanos)
        if (offsetKnown) {
            // The head stamped `sentAt` in its clock; bring it here and the uplink's own transit
            // falls out. Without the offset this is unknowable, hence the guard.
            record(POSE_TRANSIT, pose.receivedAtNanos - headToHostNanos((pose.sentAt * 1e9).toLong()))
        }
        inFlight[seq] = InFlight(pose.receivedAtNanos, (pose.sentAt * 1e9).toLong(), now)
        if (inFlight.size > 256) inFlight.keys.sorted().take(inFlight.size - 256).forEach { inFlight.remove(it) }
    }

fun submitted(seq: Long)

The producer has signalled the surface for seq to the encoder.

    fun submitted(seq: Long) {
        val f = inFlight[seq] ?: return
        record(LATCH_TO_SUBMIT, System.nanoTime() - f.latchNanos)
        submitNanos[seq] = System.nanoTime()
        if (submitNanos.size > 256) submitNanos.keys.sorted().take(submitNanos.size - 256).forEach { submitNanos.remove(it) }
    }

    private val submitNanos = ConcurrentHashMap<Long, Long>()

fun encoded(seq: Long)

The helper reported {"ev":"encoded","seq":…}.

    fun encoded(seq: Long) {
        val submitted = submitNanos.remove(seq) ?: return
        record(SUBMIT_TO_ENCODED, System.nanoTime() - submitted)
        inFlight.remove(seq)
    }

the device's half

var deviceSpans: Map<String, DeviceSpan>

Spans the head measured on its own clock, newest report wins.

    @Volatile
    var deviceSpans: Map<String, DeviceSpan> = emptyMap()
        private set

var deviceReportCount: Long

    @Volatile
    var deviceReportCount: Long = 0L
        private set

data class DeviceSpan(val p50Millis: Double, val p95Millis: Double, val maxMillis: Double, val n: Int)

    data class DeviceSpan(val p50Millis: Double, val p95Millis: Double, val maxMillis: Double, val n: Int)

fun acceptDeviceReport(spansByName: Map<String, DeviceSpan>)

    fun acceptDeviceReport(spansByName: Map<String, DeviceSpan>) {
        deviceSpans = spansByName
        deviceReportCount++
    }

readout

    private fun percentileMillis(sorted: LongArray, fraction: Double): Double {
        if (sorted.isEmpty()) return Double.NaN
        val index = ((sorted.size - 1) * fraction).toInt().coerceIn(0, sorted.size - 1)
        return sorted[index] / 1.0e6
    }

    private fun row(label: String, p50: Double, p95: Double, max: Double, n: Int): String =
        "  %-38s p50 %7.2f  p95 %7.2f  max %7.2f  n=%d".format(label, p50, p95, max, n)

fun report(): String

The whole chain, host spans then device spans, with the headline last.

Ordered as the frame travels rather than by magnitude, so a reader can see where rather than just how much. Milliseconds throughout.

    fun report(): String = buildString {
        appendLine("vision pose/stream latency")
        val offset = if (offsetKnown)
            "head clock = host clock %+.3f ms (best RTT %.2f ms, %d pong%s)".format(
                clockOffsetNanos / 1.0e6, bestRoundTripNanos / 1.0e6,
                pongCount.get(), if (pongCount.get() == 1L) "" else "s")
        else "clock offset UNKNOWN — no pong yet; host/device spans cannot be compared"
        appendLine("  $offset")
        appendLine("  --- this machine ---")
        listOf(POSE_TRANSIT, POSE_AGE_AT_LATCH, LATCH_TO_SUBMIT, SUBMIT_TO_ENCODED).forEach { name ->
            val s = spans[name]
            if (s == null || s.n == 0) {
                appendLine("  %-38s (no samples)".format(name))
            } else {
                val sorted = s.snapshot()
                appendLine(row(name, percentileMillis(sorted, 0.5), percentileMillis(sorted, 0.95),
                               percentileMillis(sorted, 1.0), sorted.size))
            }
        }
        appendLine("  --- the head ---")
        if (deviceSpans.isEmpty()) {
            appendLine("  (no report from the head yet)")
        } else {
            deviceSpans.forEach { (name, d) ->
                appendLine(row(name, d.p50Millis, d.p95Millis, d.maxMillis, d.n))
            }
        }
        append("  note: the head's last observable moment is the texture upload, so any ")
        append("motion-to-photon figure above is short by up to one compositor frame.")
    }

fun reset()

Clears every span, ping and device report, and the clock-offset estimate with them, so a measurement run starts from nothing. The offset has to be re-acquired after this.

    fun reset() {
        spans.clear()
        pings.clear()
        inFlight.clear()
        submitNanos.clear()
        deviceSpans = emptyMap()
        deviceReportCount = 0
        pongCount.set(0)
        clockOffsetNanos = 0
        offsetKnown = false
        bestRoundTripNanos = Long.MAX_VALUE
    }

override fun toString(): String

    override fun toString(): String = prettySummary(
        "VisionPoseLatency",
        "offsetKnown" to offsetKnown,
        "bestRoundTripMs" to if (bestRoundTripNanos == Long.MAX_VALUE) null else bestRoundTripNanos / 1.0e6,
        "deviceReports" to deviceReportCount,
    )
}