Skip to content

Add end-to-end session lifecycle benchmarks - #1114

Open
xianshijing-lk wants to merge 1 commit into
mainfrom
sxian/CLT-3306/session-lifecycle-benchmarks
Open

Add end-to-end session lifecycle benchmarks#1114
xianshijing-lk wants to merge 1 commit into
mainfrom
sxian/CLT-3306/session-lifecycle-benchmarks

Conversation

@xianshijing-lk

Copy link
Copy Markdown
Contributor

BM-CONN decomposes connect() into its internal phases — good for "where does connect time go", not for "how long does a user wait for the thing they asked for". BM-SESSION measures the milestones themselves, across two participants in one room.

Metrics

All wall-clock milliseconds, all from one session per iteration so the sums hold per-run:

metric measured from → to
connect_ms publisher room.connect() call → return
peer_visible_ms subscriber connect() call → publisher's participantDidConnect
pub_to_sub_ms publish(videoTrack:) call → subscriber's didSubscribeTrack
first_frame_ms didSubscribeTrack → first frame at the subscriber's renderer
publish_to_first_frame_ms publish call → first frame (end to end)
data_rtt_ms publisher sends on a topic → subscriber echoes → publisher receives
disconnect_ms publisher room.disconnect() call → return

Two variants, BM-SESSION-001-DualPC and BM-SESSION-002-SinglePC, mirroring BM-CONN.

Frames come from a buffer-backed track fed synthetic pixel buffers rather than a camera, so this runs headless with no device permissions.

First results (LiveKit Cloud staging, 15 iterations)

metric dual PC p50 single PC p50 delta
connect_ms 556 489 −67 (−12%)
peer_visible_ms 210 208 −2
pub_to_sub_ms 222 308 +86 (+39%)
first_frame_ms 192 103 −89
publish_to_first_frame_ms 412 412 0
data_rtt_ms 124 126 +2
disconnect_ms 5 4 −1

The reason to have this benchmark, in one row: single PC buys 67 ms on connect and gives back 86 ms on publish→subscribe, netting exactly zero on time-to-first-frame. The connect-phase benchmark cannot see that, because the cost lands after connect() returns.

The pub_to_sub regression is the MediaSectionsRequirement round trip: in single PC the client must add receive m-sections and renegotiate before a subscription completes, where dual PC has the server offer on a separate transport. That is precisely what preallocating receive sections was meant to remove (#1112, currently blocked server-side).

Usage

cd Benchmarks
LK_BENCHMARK=1 LIVEKIT_URL=wss://… LIVEKIT_API_KEY=… LIVEKIT_API_SECRET=… \
  swiftly run +xcode swift package --disable-sandbox benchmark \
  --filter "BM-SESSION-001-DualPC"

--filter needs the exact full name. Note that switching branches under Benchmarks/ (a path dependency) can leave a stale .build referencing files from the other branch; rm -rf Benchmarks/.build clears it.

Definitions worth confirming

These are modelled on a metric set from another SDK's harness, and three definitions are judgement calls I'd rather align than guess:

  • peer_visible_ms includes the subscriber's own connect(). Measuring only the propagation after connect returns is also defensible and would be a much smaller number.
  • first_frame_ms starts at didSubscribeTrack and attaches the renderer at that moment, so it includes encoder start and the first keyframe — hence ~100–200 ms rather than single digits. Starting the clock at renderer attach instead would report near-zero.
  • disconnect_ms is just the call duration (~5 ms); it does not wait for server-side teardown to be observable.

Happy to re-cut any of them.

🤖 Generated with Claude Code

BM-CONN decomposes connect() into its internal phases, which answers "where
does connect time go" but not "how long does a user wait for the thing they
asked for". BM-SESSION measures the milestones themselves, across two
participants in one room: connect, peer visibility, publish to subscription,
first frame, data round trip, and disconnect.

Every metric comes from a single session per iteration, so the phase sums
hold per-run rather than only in aggregate -- percentiles of separate
metrics come from different runs and cannot be subtracted, which is easy to
get wrong when reading the connect-phase table.

Frames come from a buffer-backed track fed synthetic pixel buffers rather
than a camera, so the benchmark runs headless without device permissions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

⚠️ This PR does not contain any files in the .changes directory.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +48 to +64
return try await withThrowingTaskGroup(of: Double.self) { group in
group.addTask {
try await withCheckedThrowingContinuation { continuation in
let fired: Double? = self._state.mutate { state in
if let fired = state.fired { return fired }
state.waiters.append(continuation)
return nil
}
if let fired { continuation.resume(returning: fired) }
}
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
throw BenchmarkTimeout()
}
defer { group.cancelAll() }
return try await group.next()!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Lifecycle timeouts hang forever

When an event never arrives, wait leaves its checked continuation suspended after timeout. The benchmark hangs forever instead of reporting failure.

Prompt for agents
Rework EventLatch.wait in Benchmarks/LiveKitBenchmark/BenchmarkSupport/SessionObservers.swift so timeout and task cancellation remove and resume the registered waiter. Cancelling the task-group child cannot cancel a checked continuation, and withThrowingTaskGroup waits for that child before leaving scope. Consider storing identifiable waiter records and resolving each continuation exactly once from fire, timeout, or cancellation. Ensure timeout actually returns BenchmarkTimeout and does not leave stale continuations in StateSync.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +121 to +128
try await publisher.connect(url: config.url,
token: tokenGen.generate(roomName: roomName, identity: "bench-pub"))
benchmark.measurement(mConnect, Int(nowMs() - connectStart))

// peer_visible_ms — from the subscriber starting its connect to the publisher seeing it.
let peerStart = nowMs()
try await subscriber.connect(url: config.url,
token: tokenGen.generate(roomName: roomName, identity: "bench-sub"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Failed sessions leave rooms connected

After either connection succeeds, any later thrown error skips both disconnect calls. Failed runs leave active rooms and media resources behind.

Prompt for agents
Add failure-safe asynchronous teardown around runSession in Benchmarks/LiveKitBenchmark/SessionLifecycleBenchmark.swift. Once either Room connects, every exit path must disconnect both publisher and subscriber, including failures from the second connect, publication, lifecycle waits, and data publishing. Preserve the existing disconnect_ms measurement for the successful publisher teardown and avoid measuring cleanup performed only after an error.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@xianshijing-lk

Copy link
Copy Markdown
Contributor Author

Correction and completed matrix

The results in the description were measured on sxian/CLT-3305 (#1110 + #1111 + the default flip + an unlanded data channel fix), not on this branch. The description does not say so, which makes it misleading — full four-cell matrix below, all against staging, 15 iterations each.

metric (p50 ms) main + dual main + single stack + dual stack + single
connect_ms 561 554 556 489
peer_visible_ms 209 210 210 208
pub_to_sub_ms 220 311 222 308
first_frame_ms 193 102 192 103
publish_to_first_frame_ms 415 413 412 412
data_rtt_ms 125 125 124 126
disconnect_ms 6 4 5 4

Three things this settles that the two-column table did not:

1. The stack does not touch dual PC. main+dual vs stack+dual is within noise on all seven metrics (561→556, 209→210, 220→222, 193→192, 415→412, 125→124, 6→5). Every change in the stack is single-PC-gated, and this is a much broader confirmation than the single D_TRANSPORT number in #1111.

2. The connect win is the stack, not single PC. main+single is 554 — statistically the same as main+dual's 561. Only stack+single reaches 489. Single PC on its own buys nothing on connect; offer-with-join is the entire gain, and it only runs on the single-PC path.

3. The pub_to_sub regression is single PC itself, not the stack. 220 → 311 on main alone, and the stack neither causes nor fixes it. It is the MediaSectionsRequirement round trip, which is what preallocating receive sections (#1112) would remove.

Net on publish_to_first_frame_ms: 415 / 413 / 412 / 412 — identical across all four. Faster connect, slower subscribe, same time to first frame.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant