Skip to content

Commit 368aeb1

Browse files
committed
Add LiveKit device benchmark matrix
1 parent 0190e34 commit 368aeb1

78 files changed

Lines changed: 15943 additions & 245 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ xcuserdata/
66
*.xcodeproj
77
!Examples/DeviceHarness/LiveKitNativeDeviceHarness.xcodeproj/
88
!Examples/DeviceHarness/LiveKitNativeDeviceHarness.xcodeproj/project.pbxproj
9+
!Examples/OfficialLiveKitBenchmarkHarness/OfficialLiveKitBenchmarkHarness.xcodeproj/
10+
!Examples/OfficialLiveKitBenchmarkHarness/OfficialLiveKitBenchmarkHarness.xcodeproj/project.pbxproj
911
*.xcworkspace
12+
!Examples/OfficialLiveKitBenchmarkHarness/OfficialLiveKitBenchmarkHarness.xcodeproj/project.xcworkspace/
13+
!Examples/OfficialLiveKitBenchmarkHarness/OfficialLiveKitBenchmarkHarness.xcodeproj/project.xcworkspace/xcshareddata/
14+
!Examples/OfficialLiveKitBenchmarkHarness/OfficialLiveKitBenchmarkHarness.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
15+
!Examples/OfficialLiveKitBenchmarkHarness/OfficialLiveKitBenchmarkHarness.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
1016
*.hmap
1117
*.ipa
1218
*.dSYM.zip

Benchmarks/Baselines/official-livekit-webrtc-template.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ srtcp.packet_protect_unprotect,Official LiveKit Swift SDK + WebRTC baseline,0,0,
1414
dtls_srtp.exporter_split,Official LiveKit Swift SDK + WebRTC baseline,0,0,0,replace with measured release-build value
1515
dtls_srtp.session_protect_unprotect,Official LiveKit Swift SDK + WebRTC baseline,0,0,0,replace with measured release-build value
1616
h264.packetize_depacketize,Official LiveKit Swift SDK + WebRTC baseline,0,0,0,replace with measured release-build value
17+
h265.packetize_depacketize,Official LiveKit Swift SDK + WebRTC baseline,0,0,0,replace with measured release-build value
1718
vp8.payload_depacketize,Official LiveKit Swift SDK + WebRTC baseline,0,0,0,replace with measured release-build value
1819
opus.rtp_packetize_depacketize,Official LiveKit Swift SDK + WebRTC baseline,0,0,0,replace with measured release-build value
1920
sctp.dcep_open_ack_roundtrip,Official LiveKit Swift SDK + WebRTC baseline,0,0,0,replace with measured release-build value

Benchmarks/LiveKitNativeBenchmarks/main.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,10 @@ func makeBenchmarks(blackhole: BenchmarkBlackhole) throws -> [BenchmarkCase] {
404404
h264NALUnit.append(Data(repeating: 0x11, count: 3_000))
405405
let h264Packetizer = H264RTPPacketizer(payloadType: 102, mtu: 1_200)
406406

407+
var h265NALUnit = H265NALUnitHeader.payloadHeader(type: H265NALUnitType.idrWithRADL.rawValue)
408+
h265NALUnit.append(Data(repeating: 0x33, count: 3_000))
409+
let h265Packetizer = H265RTPPacketizer(payloadType: 104, mtu: 1_200)
410+
407411
var vp8Payload = Data([0x10])
408412
vp8Payload.append(Data([0x9D, 0x01, 0x2A, 0x80, 0x02, 0xE0, 0x01]))
409413
vp8Payload.append(Data(repeating: 0x22, count: 1_000))
@@ -568,6 +572,20 @@ func makeBenchmarks(blackhole: BenchmarkBlackhole) throws -> [BenchmarkCase] {
568572
}
569573
blackhole.consume(units + packets.count)
570574
},
575+
BenchmarkCase(name: "h265.packetize_depacketize", category: "video", operationsPerSample: 50) {
576+
let packets = try h265Packetizer.packetize(
577+
nalUnits: [h265NALUnit],
578+
timestamp: 90_000,
579+
ssrc: 11,
580+
startingSequenceNumber: 1
581+
)
582+
let depacketizer = H265RTPDepacketizer()
583+
var units = 0
584+
for packet in packets {
585+
units += try depacketizer.append(packet).count
586+
}
587+
blackhole.consume(units + packets.count)
588+
},
571589
BenchmarkCase(name: "vp8.payload_depacketize", category: "video", operationsPerSample: 500) {
572590
let fragment = try vp8Depacketizer.depacketize(vp8Packet)
573591
blackhole.consume(fragment.payload.count + Int(fragment.sequenceNumber))

Examples/DeviceHarness/DeviceHarnessDiagnosticsSnapshot.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ struct DeviceHarnessDiagnosticsSnapshot {
1212
var subscriberRemoteAudioPublicationSIDs: [String]
1313
var subscriberRemoteVideoPublicationSIDs: [String]
1414
var batteryLevel: Float
15+
var batteryState: String
1516
var thermalState: String
1617
var memoryPeakResidentBytes: Int64
18+
var appUptimeSeconds: Double
19+
var processCPUTimeSeconds: Double
1720

1821
var textReport: String {
1922
[
@@ -48,8 +51,11 @@ struct DeviceHarnessDiagnosticsSnapshot {
4851
"apple_microphone_active_mode: \(diagnostics.appleMicrophoneModeState.activeMode.rawValue)",
4952
"apple_microphone_ui_available: \(diagnostics.appleMicrophoneModeState.systemUserInterfaceAvailable)",
5053
"battery_level: \(batteryLevel)",
54+
"battery_state: \(batteryState)",
5155
"thermal_state: \(thermalState)",
5256
"memory_peak_resident_bytes: \(memoryPeakResidentBytes)",
57+
"app_uptime_seconds: \(Self.formatSeconds(appUptimeSeconds))",
58+
"process_cpu_time_seconds: \(Self.formatSeconds(processCPUTimeSeconds))",
5359
].joined(separator: "\n")
5460
}
5561

@@ -60,4 +66,8 @@ struct DeviceHarnessDiagnosticsSnapshot {
6066
private static func formatSSRCs(_ ssrcs: [UInt32]) -> String {
6167
ssrcs.isEmpty ? "none" : ssrcs.sorted().map(String.init).joined(separator: ",")
6268
}
69+
70+
private static func formatSeconds(_ seconds: Double) -> String {
71+
String(format: "%.3f", seconds)
72+
}
6373
}

Examples/DeviceHarness/DeviceHarnessViewModel.swift

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ final class DeviceHarnessViewModel: ObservableObject {
4545
private var observers: [NSObjectProtocol] = []
4646
private var didRunAutomation = false
4747
private var allowDirectICE = false
48+
private var foregroundRecoveryEnabled = false
4849
private var subscriberToken = ""
4950
private var subscriberConnectTask: Task<Void, Never>?
5051
private var observedLifecycleEvents: Set<String> = []
@@ -60,6 +61,7 @@ final class DeviceHarnessViewModel: ObservableObject {
6061
private var foregroundSoakReconnectAttemptCount = 0
6162
private var isSoakReconnectInProgress = false
6263
private var soakReconnectLeaseExpiresAt: Date?
64+
private let appLaunchDate = Date()
6365
private static let voiceIsolationRecommendationKey =
6466
"LiveKitNativeDeviceHarness.didSuggestAppleVoiceIsolation"
6567

@@ -70,6 +72,8 @@ final class DeviceHarnessViewModel: ObservableObject {
7072
execution_environment: \(Self.executionEnvironment)
7173
device_model: \(UIDevice.current.model)
7274
system_version: \(UIDevice.current.systemName) \(UIDevice.current.systemVersion)
75+
app_uptime_seconds: \(Self.formatSeconds(appUptimeSeconds))
76+
process_cpu_time_seconds: \(Self.formatSeconds(DeviceHarnessProcessSampler.cpuTimeSeconds()))
7377
"""
7478
status = "App launch observed."
7579
diagnosticsText = appInstallReport
@@ -129,7 +133,7 @@ final class DeviceHarnessViewModel: ObservableObject {
129133
try await publishInitialMedia(
130134
room: room,
131135
audioOptions: AudioCaptureOptions(echoCancellation: true),
132-
cameraOptions: CameraCaptureOptions(width: 1280, height: 720, framesPerSecond: 30),
136+
cameraOptions: CameraCaptureOptions(width: 720, height: 1280, framesPerSecond: 30),
133137
cameraSimulcast: true
134138
)
135139
}
@@ -211,6 +215,7 @@ final class DeviceHarnessViewModel: ObservableObject {
211215
subscriberToken = environment["LIVEKIT_NATIVE_DEVICE_HARNESS_SUBSCRIBER_TOKEN"] ?? subscriberToken
212216
batterySaverWebRTC = environment["LIVEKIT_NATIVE_DEVICE_HARNESS_BATTERY_SAVER_WEBRTC"] == "1"
213217
allowDirectICE = environment["LIVEKIT_NATIVE_DEVICE_HARNESS_ALLOW_DIRECT_ICE"] == "1"
218+
foregroundRecoveryEnabled = environment["LIVEKIT_NATIVE_DEVICE_HARNESS_FOREGROUND_RECOVERY"] == "1"
214219
observedLifecycleEvents.removeAll()
215220
emitHarnessLog("scripted_validation: autostart requested")
216221
if batterySaverWebRTC {
@@ -242,7 +247,7 @@ final class DeviceHarnessViewModel: ObservableObject {
242247
diagnosticIntervalSeconds: Self.positiveInteger(
243248
environment["LIVEKIT_NATIVE_DEVICE_HARNESS_DIAGNOSTIC_INTERVAL_SECONDS"]
244249
) ?? 60,
245-
reconnectAfterSeconds: Self.positiveInteger(
250+
reconnectAfterSeconds: Self.nonNegativeInteger(
246251
environment["LIVEKIT_NATIVE_DEVICE_HARNESS_RECONNECT_AFTER_SECONDS"]
247252
)
248253
)
@@ -398,8 +403,11 @@ final class DeviceHarnessViewModel: ObservableObject {
398403
subscriberRemoteAudioPublicationSIDs: subscriberRemotePublications.audio,
399404
subscriberRemoteVideoPublicationSIDs: subscriberRemotePublications.video,
400405
batteryLevel: UIDevice.current.batteryLevel,
406+
batteryState: UIDevice.current.batteryState.benchmarkDescription,
401407
thermalState: thermalState.description,
402-
memoryPeakResidentBytes: DeviceHarnessMemorySampler.peakResidentBytes()
408+
memoryPeakResidentBytes: DeviceHarnessMemorySampler.peakResidentBytes(),
409+
appUptimeSeconds: appUptimeSeconds,
410+
processCPUTimeSeconds: DeviceHarnessProcessSampler.cpuTimeSeconds()
403411
).textReport
404412
emitHarnessLog(diagnosticsText)
405413
await abortSoakIfThermalStateDisallowed(thermalState)
@@ -759,10 +767,14 @@ final class DeviceHarnessViewModel: ObservableObject {
759767
if isSoakAutomationRunning {
760768
observedLifecycleEvents.insert("background-foreground-recovery")
761769
await refreshDiagnostics(reason: "background-foreground-recovery")
762-
scheduleForegroundSoakRecoveryIfNeeded(initialDiagnostics: diagnostics)
770+
if foregroundRecoveryEnabled {
771+
scheduleForegroundSoakRecoveryIfNeeded(initialDiagnostics: diagnostics)
772+
}
763773
return
764774
}
765-
await recoverAfterForegroundIfNeeded(initialDiagnostics: diagnostics)
775+
if foregroundRecoveryEnabled {
776+
await recoverAfterForegroundIfNeeded(initialDiagnostics: diagnostics)
777+
}
766778
observedLifecycleEvents.insert("background-foreground-recovery")
767779
await refreshDiagnostics(reason: "background-foreground-recovery")
768780
}
@@ -1058,7 +1070,7 @@ final class DeviceHarnessViewModel: ObservableObject {
10581070
let clampedDurationMinutes = min(max(durationMinutes, 1), 24 * 60)
10591071
let totalSeconds = clampedDurationMinutes * 60
10601072
let intervalSeconds = min(max(diagnosticIntervalSeconds, 5), totalSeconds)
1061-
let reconnectAtSeconds = min(
1073+
let reconnectAtSeconds = reconnectAfterSeconds == 0 ? nil : min(
10621074
max(reconnectAfterSeconds ?? max(30, totalSeconds / 2), 5),
10631075
max(5, totalSeconds - intervalSeconds)
10641076
)
@@ -1093,14 +1105,16 @@ final class DeviceHarnessViewModel: ObservableObject {
10931105
emitHarnessLog(Self.soakHarnessBuildMarker)
10941106
emitHarnessLog(soakLowLoadProfileMarker)
10951107
emitHarnessLog(
1096-
"soak_started: duration_minutes=\(clampedDurationMinutes) diagnostic_interval_seconds=\(intervalSeconds) reconnect_after_seconds=\(reconnectAtSeconds)"
1108+
"soak_started: duration_minutes=\(clampedDurationMinutes) diagnostic_interval_seconds=\(intervalSeconds) reconnect_after_seconds=\(reconnectAtSeconds.map(String.init) ?? "disabled")"
10971109
)
10981110
await refreshDiagnostics(reason: "soak-start", includeDataChannels: false)
10991111

11001112
while Int(Date().timeIntervalSince(start)) < totalSeconds {
11011113
let elapsedSeconds = Int(Date().timeIntervalSince(start))
11021114
var reconnectReason: String?
1103-
if reconnectCount + foregroundSoakReconnectCount == 0 && elapsedSeconds >= reconnectAtSeconds {
1115+
if let reconnectAtSeconds,
1116+
reconnectCount + foregroundSoakReconnectCount == 0,
1117+
elapsedSeconds >= reconnectAtSeconds {
11041118
reconnectReason = "scheduled"
11051119
}
11061120

@@ -1251,9 +1265,10 @@ final class DeviceHarnessViewModel: ObservableObject {
12511265
}
12521266

12531267
private var soakLowLoadProfileMarker: String {
1254-
let captureProfile = batterySaverWebRTC ? "320x180@10fps" : "default"
1268+
let captureProfile = batterySaverWebRTC ? "320x180@10fps" : "720x1280@30fps"
12551269
let bitrate = batterySaverWebRTC ? "250000" : "default"
1256-
return "soak_low_load_profile: battery_saver_webrtc=\(batterySaverWebRTC) capture=\(captureProfile) h264_bitrate=\(bitrate) active_video_layers_target=1 decode_subscriber_video=true subscriber_adaptive=false subscriber_allow_pause=false ice_transport_policy=\(allowDirectICE ? "all" : "relay")"
1270+
let activeVideoLayersTarget = batterySaverWebRTC ? 1 : 3
1271+
return "soak_low_load_profile: battery_saver_webrtc=\(batterySaverWebRTC) capture=\(captureProfile) h264_bitrate=\(bitrate) active_video_layers_target=\(activeVideoLayersTarget) decode_subscriber_video=true subscriber_adaptive=false subscriber_allow_pause=false ice_transport_policy=\(allowDirectICE ? "all" : "relay")"
12571272
}
12581273

12591274
private func reconnectForSoak(reason: String, attempt: Int) async -> Bool {
@@ -1741,6 +1756,13 @@ final class DeviceHarnessViewModel: ObservableObject {
17411756
return integer
17421757
}
17431758

1759+
private static func nonNegativeInteger(_ value: String?) -> Int? {
1760+
guard let value, let integer = Int(value), integer >= 0 else {
1761+
return nil
1762+
}
1763+
return integer
1764+
}
1765+
17441766
private static func joinedOrNone(_ values: [String]) -> String {
17451767
values.isEmpty ? "none" : values.sorted().joined(separator: ",")
17461768
}
@@ -1816,6 +1838,14 @@ final class DeviceHarnessViewModel: ObservableObject {
18161838
"physical-device"
18171839
#endif
18181840
}
1841+
1842+
private var appUptimeSeconds: Double {
1843+
Date().timeIntervalSince(appLaunchDate)
1844+
}
1845+
1846+
private static func formatSeconds(_ seconds: Double) -> String {
1847+
String(format: "%.3f", seconds)
1848+
}
18191849
}
18201850

18211851
private enum DeviceHarnessMemorySampler {
@@ -1828,6 +1858,37 @@ private enum DeviceHarnessMemorySampler {
18281858
}
18291859
}
18301860

1861+
private enum DeviceHarnessProcessSampler {
1862+
static func cpuTimeSeconds() -> Double {
1863+
var usage = rusage()
1864+
guard getrusage(RUSAGE_SELF, &usage) == 0 else {
1865+
return -1
1866+
}
1867+
return seconds(from: usage.ru_utime) + seconds(from: usage.ru_stime)
1868+
}
1869+
1870+
private static func seconds(from time: timeval) -> Double {
1871+
Double(time.tv_sec) + Double(time.tv_usec) / 1_000_000
1872+
}
1873+
}
1874+
1875+
private extension UIDevice.BatteryState {
1876+
var benchmarkDescription: String {
1877+
switch self {
1878+
case .unknown:
1879+
return "unknown"
1880+
case .unplugged:
1881+
return "unplugged"
1882+
case .charging:
1883+
return "charging"
1884+
case .full:
1885+
return "full"
1886+
@unknown default:
1887+
return "unknown"
1888+
}
1889+
}
1890+
}
1891+
18311892
private struct SubscriberCompanionDiscoveryError: LocalizedError {
18321893
var missingKinds: [String]
18331894
var remoteAudioSIDs: [String]

Examples/DeviceHarness/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
<string>Camera access is required to validate LiveKitNative video publishing on physical iOS devices.</string>
3636
<key>NSMicrophoneUsageDescription</key>
3737
<string>Microphone access is required to validate LiveKitNative audio publishing and playout on physical iOS devices.</string>
38+
<key>NSLocalNetworkUsageDescription</key>
39+
<string>Local network access is required to connect to the benchmark LiveKit server.</string>
3840
<key>UIBackgroundModes</key>
3941
<array>
4042
<string>audio</string>

Examples/DeviceHarness/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ The harness intentionally keeps the app small:
1515
in-process subscriber connection.
1616
- Includes a Battery Saver WebRTC toggle that opts into `.batterySaver` with
1717
automatic Low Power Mode and thermal adaptation.
18-
- Records diagnostics for lifecycle, route-change, interruption, battery,
19-
thermal, media continuity, and data-channel state.
18+
- Records diagnostics for lifecycle, route-change, interruption, battery
19+
level/state, thermal, media continuity, and data-channel state.
2020
- Emits app launch, permission, and diagnostics snapshots to the device log,
2121
including `execution_environment: physical-device` so proof jobs cannot pass
2222
with simulator-only logs.
@@ -63,6 +63,10 @@ physical-device room token. Pass `--allow-provisioning-updates` when Xcode must
6363
create or refresh the physical-device provisioning profile, or pass
6464
`--use-installed-app` when the harness app is already installed with the
6565
requested bundle id and the runner only needs to launch and collect soak proof.
66+
For short A/B decision runs against a previous library, set
67+
`--benchmark-log-only --reconnect-after-seconds 0`, run both libraries for
68+
10 minutes unplugged, and compare the logs with
69+
`scripts/write_device_power_benchmark_report.sh`.
6670
The report uses the same `release-gate-report.txt` field contract enforced by
6771
the strict release gate. The lower-level
6872
`scripts/write_device_harness_gate_report.sh` and
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleDisplayName</key>
6+
<string>Official LiveKit Benchmark</string>
7+
<key>CFBundleExecutable</key>
8+
<string>$(EXECUTABLE_NAME)</string>
9+
<key>CFBundleIdentifier</key>
10+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11+
<key>CFBundleName</key>
12+
<string>$(PRODUCT_NAME)</string>
13+
<key>CFBundlePackageType</key>
14+
<string>APPL</string>
15+
<key>CFBundleShortVersionString</key>
16+
<string>$(MARKETING_VERSION)</string>
17+
<key>CFBundleVersion</key>
18+
<string>$(CURRENT_PROJECT_VERSION)</string>
19+
<key>UILaunchScreen</key>
20+
<dict/>
21+
<key>UISupportedInterfaceOrientations</key>
22+
<array>
23+
<string>UIInterfaceOrientationPortrait</string>
24+
<string>UIInterfaceOrientationLandscapeLeft</string>
25+
<string>UIInterfaceOrientationLandscapeRight</string>
26+
</array>
27+
<key>UISupportedInterfaceOrientations~ipad</key>
28+
<array>
29+
<string>UIInterfaceOrientationPortrait</string>
30+
<string>UIInterfaceOrientationPortraitUpsideDown</string>
31+
<string>UIInterfaceOrientationLandscapeLeft</string>
32+
<string>UIInterfaceOrientationLandscapeRight</string>
33+
</array>
34+
<key>NSCameraUsageDescription</key>
35+
<string>Camera access is required to benchmark the official LiveKit Swift SDK on physical iOS devices.</string>
36+
<key>NSMicrophoneUsageDescription</key>
37+
<string>Microphone access is required to benchmark the official LiveKit Swift SDK on physical iOS devices.</string>
38+
<key>NSLocalNetworkUsageDescription</key>
39+
<string>Local network access is required to connect to the benchmark LiveKit server.</string>
40+
<key>UIBackgroundModes</key>
41+
<array>
42+
<string>audio</string>
43+
<string>voip</string>
44+
</array>
45+
</dict>
46+
</plist>

0 commit comments

Comments
 (0)