From 2d51c14ba139ca72bfcbd56757e8ba7b694d01d7 Mon Sep 17 00:00:00 2001 From: Jun Date: Sat, 28 Mar 2026 12:55:27 +0900 Subject: [PATCH] perf: coalesce scrollbar updates during bulk output (#2116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: coalesce high-frequency scrollbar updates to reduce main-thread pressure During bulk terminal output (e.g. `seq 1 100000`), GHOSTTY_ACTION_SCROLLBAR fires thousands of times per second. Previously each callback enqueued a separate DispatchQueue.main.async block that updated the scrollbar property and posted a NotificationCenter notification, causing the main thread to process thousands of redundant scroll-geometry recalculations. This change adds a lightweight coalescing layer: the action callback stores the latest scrollbar value behind an NSLock and schedules at most one async flush. The flush picks up whichever value is current at execution time, collapsing N callbacks into a single synchronizeScrollView() pass. Measured improvement on `time seq 1 10000`: - Before: ~0.052s (26% CPU — seq blocked on PTY backpressure) - After: expected ~0.025-0.030s (reduced main-thread contention) Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: use defer for NSLock release in scrollbar coalescing Address review feedback: wrap unlock() in defer blocks in both enqueueScrollbarUpdate and flushPendingScrollbar to guarantee lock release on any future early-return or exception path. Co-Authored-By: Claude Opus 4.6 (1M context) * perf: coalesce wakeup→tick dispatches to eliminate main-thread queue flooding During bulk terminal output, Ghostty's I/O thread fires wakeup_cb thousands of times per second. Previously each wakeup enqueued a separate DispatchQueue.main.async { tick() } block, flooding the main queue and starving the run loop. The main thread spent all its time draining tick blocks, creating PTY backpressure that blocked the writing process. Add a lightweight coalescing gate: scheduleTick() only enqueues a single async block; subsequent wakeups while the block is pending are no-ops. The pending tick picks up all accumulated state in one ghostty_app_tick() call, collapsing N wakeups into 1 main-thread dispatch. Combined with the earlier scrollbar coalescing, measured improvement: time seq 1 10000: - Ghostty standalone: 0.019s (82% CPU) - cmux before: 0.052s (26% CPU) ← main-thread saturated - cmux after: 0.016s (62% CPU) ← faster than standalone Co-Authored-By: Claude Opus 4.6 (1M context) * fix: release scrollbar lock before posting notification Move NotificationCenter.post outside the _scrollbarLock critical section in flushPendingScrollbar(). Holding the lock through observer dispatch would block the I/O thread's enqueueScrollbarUpdate() calls behind main-thread observer work, recreating the backpressure this change aims to eliminate. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- Sources/GhosttyTerminalView.swift | 77 ++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index db6cf9e1..bb21c0d0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -897,6 +897,11 @@ class GhosttyApp { private(set) var app: ghostty_app_t? private(set) var config: ghostty_config_t? + /// Coalesce wakeup → tick dispatches. The I/O thread may fire wakeup_cb + /// thousands of times per second during bulk output. We only need one + /// pending tick on the main queue at any time. + private var _tickScheduled = false + private let _tickLock = NSLock() private(set) var defaultBackgroundColor: NSColor = .windowBackgroundColor private(set) var defaultBackgroundOpacity: Double = 1.0 private static func resolveBackgroundLogURL( @@ -1091,9 +1096,7 @@ class GhosttyApp { runtimeConfig.userdata = Unmanaged.passUnretained(self).toOpaque() runtimeConfig.supports_selection_clipboard = true runtimeConfig.wakeup_cb = { userdata in - DispatchQueue.main.async { - GhosttyApp.shared.tick() - } + GhosttyApp.shared.scheduleTick() } runtimeConfig.action_cb = { app, target, action in return GhosttyApp.shared.handleAction(target: target, action: action) @@ -1816,7 +1819,22 @@ class GhosttyApp { #endif } + /// Schedule a single tick on the main queue, coalescing multiple wakeups. + func scheduleTick() { + _tickLock.lock() + defer { _tickLock.unlock() } + guard !_tickScheduled else { return } + _tickScheduled = true + DispatchQueue.main.async { + self.tick() + } + } + func tick() { + _tickLock.lock() + _tickScheduled = false + _tickLock.unlock() + guard let app = app else { return } let start = CACurrentMediaTime() @@ -2386,14 +2404,7 @@ class GhosttyApp { } case GHOSTTY_ACTION_SCROLLBAR: let scrollbar = GhosttyScrollbar(c: action.action.scrollbar) - DispatchQueue.main.async { - surfaceView.scrollbar = scrollbar - NotificationCenter.default.post( - name: .ghosttyDidUpdateScrollbar, - object: surfaceView, - userInfo: [GhosttyNotificationKey.scrollbar: scrollbar] - ) - } + surfaceView.enqueueScrollbarUpdate(scrollbar) return true case GHOSTTY_ACTION_CELL_SIZE: let cellSize = CGSize( @@ -4255,7 +4266,51 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { weak var terminalSurface: TerminalSurface? var scrollbar: GhosttyScrollbar? + /// Pending scrollbar value written from the action callback thread; + /// read and cleared on the main thread by `flushPendingScrollbar()`. + /// Access is guarded by `_scrollbarLock` because the action callback + /// fires on Ghostty's I/O thread while the flush runs on main. + private var _pendingScrollbar: GhosttyScrollbar? + private var _scrollbarFlushScheduled = false + private let _scrollbarLock = NSLock() var cellSize: CGSize = .zero + + /// Coalesce high-frequency scrollbar updates into a single main-thread + /// dispatch. The action callback (which may fire thousands of times per + /// second during bulk output like `seq 1 100000`) stores the latest value + /// and schedules exactly one async flush. + func enqueueScrollbarUpdate(_ newValue: GhosttyScrollbar) { + _scrollbarLock.lock() + defer { _scrollbarLock.unlock() } + // Store the latest value (always overwrites — only the newest matters). + _pendingScrollbar = newValue + let needsSchedule = !_scrollbarFlushScheduled + if needsSchedule { _scrollbarFlushScheduled = true } + + // If a flush is already scheduled, skip the dispatch — the scheduled + // block will pick up the latest value. + guard needsSchedule else { return } + DispatchQueue.main.async { [weak self] in + self?.flushPendingScrollbar() + } + } + + private func flushPendingScrollbar() { + _scrollbarLock.lock() + _scrollbarFlushScheduled = false + let pending = _pendingScrollbar + _pendingScrollbar = nil + _scrollbarLock.unlock() + + guard let pending else { return } + scrollbar = pending + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: self, + userInfo: [GhosttyNotificationKey.scrollbar: pending] + ) + } + var desiredFocus: Bool = false var suppressingReparentFocus: Bool = false var tabId: UUID?