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:
jack 2026-10-23 20:24:27 +02:00 committed by GitHub
parent 88ee1ae6db
commit 044d1ea68c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 208 additions and 2 deletions

View 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
}
}
}
}

View File

@ -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

View File

@ -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
}
}