mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-11-01 07:53:26 +00:00
Gate geochat scaling regressions in the performance floor check (#1791)
* Gate geochat scaling regressions in the performance floor check * Base the geochat format floor on its first CI run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
88ee1ae6db
commit
044d1ea68c
185
bitchatTests/Performance/PerformanceBaselineTests+Geochat.swift
Normal file
185
bitchatTests/Performance/PerformanceBaselineTests+Geochat.swift
Normal file
@ -0,0 +1,185 @@
|
||||
//
|
||||
// PerformanceBaselineTests+Geochat.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Geochat (location channel) benchmarks at a busy-room size. Each guards a
|
||||
// cost that used to grow with the number of participants N:
|
||||
//
|
||||
// - the people sheet resolved every row's color by rebuilding the whole
|
||||
// roster, so one render was O(N^2);
|
||||
// - every roster rebuild hex-encoded our own key once per participant with
|
||||
// String(format:) per byte;
|
||||
// - every inbound event (presence heartbeats arrive ~N/60 times a second)
|
||||
// rebuilt and republished the roster.
|
||||
//
|
||||
// perf-floors.json keeps each floor far above those regression values.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
extension PerformanceBaselineTests {
|
||||
/// The room size at which the regressions above cost seconds.
|
||||
private static let geochatParticipants = 500
|
||||
|
||||
// MARK: - Geochat people list colors
|
||||
|
||||
/// One people-sheet render resolves a color for every row.
|
||||
func testGeochatPeopleListColors() {
|
||||
let fixture = GeochatPerfFixture(participants: Self.geochatParticipants)
|
||||
let pubkeys = fixture.pubkeys
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
let start = Date()
|
||||
for pubkey in pubkeys {
|
||||
_ = fixture.viewModel.colorForNostrPubkey(pubkey, isDark: true)
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
}
|
||||
|
||||
reportThroughput("geochat.peopleListColors", samples: samples, operations: pubkeys.count, unit: "rows")
|
||||
}
|
||||
|
||||
// MARK: - Geochat participant heartbeats
|
||||
|
||||
/// Presence heartbeats and messages from people already in the room.
|
||||
func testGeochatParticipantHeartbeats() {
|
||||
let fixture = GeochatPerfFixture(participants: Self.geochatParticipants)
|
||||
let tracker = fixture.viewModel.participantTracker
|
||||
let pubkeys = fixture.pubkeys
|
||||
let events = 2_000
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
let start = Date()
|
||||
for index in 0..<events {
|
||||
tracker.recordParticipant(pubkeyHex: pubkeys[index % pubkeys.count], geohash: GeochatPerfFixture.geohash)
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
}
|
||||
|
||||
XCTAssertEqual(tracker.visiblePeople.count, pubkeys.count)
|
||||
reportThroughput("geochat.participantHeartbeat", samples: samples, operations: events, unit: "events")
|
||||
}
|
||||
|
||||
// MARK: - Geochat message formatting
|
||||
|
||||
/// First render of new geochat messages (formatting cache miss), whose
|
||||
/// sender color comes from the roster palette.
|
||||
func testGeochatFormatNewMessages() {
|
||||
let fixture = GeochatPerfFixture(participants: Self.geochatParticipants)
|
||||
let batchCount = 16 // > XCTest's default 10 measure iterations
|
||||
let batchSize = 200
|
||||
let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in
|
||||
(0..<batchSize).map { index in
|
||||
let pubkey = fixture.pubkeys[(batch * batchSize + index) % fixture.pubkeys.count]
|
||||
return BitchatMessage(
|
||||
id: "geo-fmt-\(batch)-\(index)",
|
||||
sender: "peer\(index)#\(pubkey.suffix(4))",
|
||||
content: "anyone near the station tonight? heading over in ten minutes",
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_000 + Double(index)),
|
||||
isRelay: false,
|
||||
senderPeerID: PeerID(nostr: pubkey)
|
||||
)
|
||||
}
|
||||
}
|
||||
var pass = 0
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
precondition(pass < batches.count, "add more pre-built batches")
|
||||
let batch = batches[pass]
|
||||
pass += 1
|
||||
let start = Date()
|
||||
for message in batch {
|
||||
_ = fixture.viewModel.formatMessageAsText(message, colorScheme: .dark)
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
}
|
||||
|
||||
reportThroughput("geochat.formatMessage", samples: samples, operations: batchSize, unit: "messages")
|
||||
}
|
||||
|
||||
// MARK: - Hex encoding
|
||||
|
||||
/// Nostr public keys are hex-encoded on roster and formatting paths.
|
||||
func testHexEncodePublicKeys() {
|
||||
let keys: [Data] = (0..<1_000).map { index in
|
||||
Data((0..<32).map { UInt8(truncatingIfNeeded: index &* 31 &+ $0) })
|
||||
}
|
||||
let rounds = 10
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
let start = Date()
|
||||
var characters = 0
|
||||
for _ in 0..<rounds {
|
||||
for key in keys {
|
||||
characters += key.hexEncodedString().utf8.count
|
||||
}
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
XCTAssertEqual(characters, rounds * keys.count * 64)
|
||||
}
|
||||
|
||||
reportThroughput("hex.encodePublicKey", samples: samples, operations: rounds * keys.count, unit: "keys")
|
||||
}
|
||||
}
|
||||
|
||||
/// A real `ChatViewModel` in a geohash channel with `participants` people
|
||||
/// present, each nicknamed the way inbound events nickname them.
|
||||
@MainActor
|
||||
private final class GeochatPerfFixture {
|
||||
static let geohash = "u4pruyd"
|
||||
|
||||
let viewModel: ChatViewModel
|
||||
let pubkeys: [String]
|
||||
|
||||
init(participants: Int) {
|
||||
let keychain = MockKeychain()
|
||||
let locationSuite = "PerformanceBaselineTests.geochat.\(UUID().uuidString)"
|
||||
let locationStorage = UserDefaults(suiteName: locationSuite) ?? .standard
|
||||
locationStorage.removePersistentDomain(forName: locationSuite)
|
||||
viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
|
||||
identityManager: MockIdentityManager(keychain),
|
||||
transport: MockTransport(),
|
||||
conversations: ConversationStore(),
|
||||
locationManager: LocationChannelManager(storage: locationStorage)
|
||||
)
|
||||
|
||||
// The view model delivers its initial (mesh) channel asynchronously,
|
||||
// which clears the tracker's active geohash. Let startup settle
|
||||
// before seeding the room so it can't empty mid-benchmark.
|
||||
Self.drainMainQueue()
|
||||
|
||||
// In the channel the way a channel switch leaves the view model, so
|
||||
// display names and colors take their real paths (which compare
|
||||
// against our own key for this geohash).
|
||||
viewModel.activeChannel = .location(GeohashChannel(level: .neighborhood, geohash: Self.geohash))
|
||||
viewModel.currentGeohash = Self.geohash
|
||||
let tracker = viewModel.participantTracker
|
||||
tracker.setActiveGeohash(Self.geohash)
|
||||
pubkeys = (0..<participants).map { String(format: "%064x", 0x6E0_0000 + $0) }
|
||||
for (index, pubkey) in pubkeys.enumerated() {
|
||||
viewModel.setGeoNickname("peer\(index)", forPubkey: pubkey)
|
||||
tracker.recordParticipant(pubkeyHex: pubkey, geohash: Self.geohash)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the main run loop until it has been idle for a few polls.
|
||||
private static func drainMainQueue() {
|
||||
var idlePolls = 0
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while idlePolls < 3, Date() < deadline {
|
||||
if CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.001, true) == .handledSource {
|
||||
idlePolls = 0
|
||||
} else {
|
||||
idlePolls += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -42,7 +42,8 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
/// appended to the file named by `BITCHAT_PERF_LOG` (if set): under
|
||||
/// `swift test --parallel` the runner swallows stdout of passing tests,
|
||||
/// so the CI floor gate (scripts/check-perf-floors.sh) reads the file.
|
||||
private func reportThroughput(_ name: String, samples: [TimeInterval], operations: Int, unit: String) {
|
||||
/// Internal so benchmark extensions in other files report the same way.
|
||||
func reportThroughput(_ name: String, samples: [TimeInterval], operations: Int, unit: String) {
|
||||
guard !samples.isEmpty else { return }
|
||||
let avg = samples.reduce(0, +) / Double(samples.count)
|
||||
let opsPerSec = avg > 0 ? Double(operations) / avg : .infinity
|
||||
|
||||
@ -34,6 +34,16 @@
|
||||
"store.steadyStateAppend_before": 2315,
|
||||
"store.steadyStateAppend": 53976
|
||||
},
|
||||
"_reference_local_numbers_2026_10": {
|
||||
"geochat.peopleListColors_before": 112,
|
||||
"geochat.peopleListColors": 406134,
|
||||
"geochat.participantHeartbeat_before": 110,
|
||||
"geochat.participantHeartbeat": 1581264,
|
||||
"geochat.formatMessage_before": 107,
|
||||
"geochat.formatMessage": 18006,
|
||||
"hex.encodePublicKey_before": 64366,
|
||||
"hex.encodePublicKey": 1194627
|
||||
},
|
||||
"floors": {
|
||||
"nostrInbound.fresh": 450,
|
||||
"nostrInbound.duplicate": 250000,
|
||||
@ -46,7 +56,11 @@
|
||||
"pipeline.publicIngest": 2400,
|
||||
"store.append": 48000,
|
||||
"store.steadyStateAppend": 10000,
|
||||
"store.audit": 70
|
||||
"store.audit": 70,
|
||||
"geochat.peopleListColors": 40000,
|
||||
"geochat.participantHeartbeat": 150000,
|
||||
"geochat.formatMessage": 2500,
|
||||
"hex.encodePublicKey": 250000
|
||||
},
|
||||
"_slowest_observed_ci_numbers_2026_06": {
|
||||
"nostrInbound.fresh": 918,
|
||||
@ -60,5 +74,11 @@
|
||||
"pipeline.publicIngest": 5006,
|
||||
"store.append": 97423,
|
||||
"store.audit": 140
|
||||
},
|
||||
"_slowest_observed_ci_numbers_2026_10": {
|
||||
"geochat.peopleListColors": 212895,
|
||||
"geochat.participantHeartbeat": 717459,
|
||||
"geochat.formatMessage": 5176,
|
||||
"hex.encodePublicKey": 655398
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user