perf: coalesce scrollbar updates during bulk output (#2116)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun 2026-03-28 12:55:27 +09:00 committed by GitHub
parent c51f0f15c4
commit 2d51c14ba1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

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