The Daily Newsstand · Free, Always
Thursday, October 1, 2026

Мышь 1000 Гц на macOS 26: NSEvent даёт ~119 событий в секунду, GCMouse ~844. Замер тремя способами

Translate

Мышь на 1000 Гц, экран MacBook Pro на 120 Гц. Я записал одно и то же движение тремя способами сразу. Системный CGEventTap увидел 843,5 события в секунду. NSEvent.mouseMoved в окне обычного Cocoa-приложения получил 118,5, по одному на кадр экрана. GCMouse из GameController отдал 843,6 и в моём прогоне ни о каких разрешениях не спросил. Ниже подробности, 17 строк кода на Swift, рекордер целиком и список того, чего я не проверял.

Всё снято на одной мыши, одном маке и одной версии macOS, по одному прогону на условие.

Откуда вопрос

Прицел в шутере на MacBook казался ватным даже при 100+ fps. Раньше я уже мерил NSEvent: события приходят раз в кадр экрана, как и здесь. На форуме разработчиков Apple есть тред Changes in Mouse Event Reporting Frequency in macOS 26.2, судя по названию, о том же. Что именно изменилось в 26.2, я не знаю: сравнения до и после у меня нет, все числа сняты на 26.6.2. Меня интересовало другое: прореживается ли так же то, что отдаёт GameController. Проще всего было записать.

Стенд

MacBook Pro 14" (M3 Pro), macOS 26.6.2, встроенный экран 120 Гц (кадр 8,33 мс). Мышь Logitech G309 через приёмник LIGHTSPEED, опрос 1000 Гц. Одна запись на 20 секунд, 25 сентября 2026: я водил мышью по окну, три потока писались одновременно. NSEvent работал с настройками по умолчанию. Других мышей, других частот и повторов нет.

Три способа наблюдать одно движение (CGEventTap дальше называю просто «тап»):

Поток

Где в цепочке

Метка времени

тап (CGEventTap, listen-only)

до AppKit, уровень сессии

метка самого события, секунды от загрузки

NSEvent, local monitor

то, что AppKit отдаёт окну

секунды от загрузки, одна метка на склеенное событие

GCMouse

GameController

lastEventTimestamp, секунды от эпохи Unix

Числа сняты не тем рекордером, что в конце статьи, а отдельными логгерами. В основном прогоне (мышь) тап писал процесс из терминала, которому я выдал разрешение, а окно с NSEvent и GCMouse было отдельным приложением со своим bundle id и без выданных разрешений. Прогон на трекпаде целиком шёл из терминала, о нём ниже. Компактная версия в одном файле лежит в конце. Эталоном служит тап, а не сам HID: записи IOHIDManager в этих прогонах нет, так что «сколько шлёт мышь» здесь значит «сколько событий видит системный тап».

Запись начинается с первого движения. Сначала таймер шёл от запуска, и три записи вышли пустыми: я в тот момент не двигал мышь. Сравниваю на общем отрезке, где есть все три потока, 20,058 с. Интервал — разница меток соседних событий, «полоса кадра» — интервалы от 6 до 10 мс, то есть около периода экрана.

Результат

событий

в секунду

медиана интервала

p99

короче 2 мс

тап

16 919

843,5

0,99 мс

5,90 мс

88,3 %

NSEvent

2 377

118,5

8,07 мс

14,87 мс

0,3 %

GCMouse

16 921

843,6

0,99 мс

5,96 мс

87,5 %

Одни и те же 100 мс обычного движения, сверху вниз: тап (90 событий), NSEvent (12, по одному на кадр 8,33 мс), GCMouse (90). Ниже гистограммы интервалов за все 20 секунд

Одни и те же 100 мс обычного движения, сверху вниз: тап (90 событий), NSEvent (12, по одному на кадр 8,33 мс), GCMouse (90). Ниже гистограммы интервалов за все 20 секунд

Тап и GCMouse почти неотличимы: медианы одинаковые, счёт расходится на два события из шестнадцати тысяч. У NSEvent 91,0 % интервалов лежат в полосе 6–10 мс. GCMouse в таблице посчитан по моменту вызова обработчика, а не по lastEventTimestamp. Почему, ниже.

Почему не 1000 в секунду? Простоями это не объяснить: пауз длиннее 20 мс было десять, вместе 0,3 с из 20. Без них тап даёт около 856 событий в секунду, а интервалы и в движении неровные: медиана 0,99 мс, p90 2,11 мс. Почему в движении тап видит около 850 событий, а не 1000, я не выяснял: чтобы развести мышь, приёмник и систему, нужна запись IOHIDManager, а её нет.

NSEvent склеивает события

На один интервал между двумя NSEvent приходится в среднем 7,12 события тапа (медиана 8, максимум 9), в 94,9 % интервалов таких событий два и больше. Само движение сохраняется. Пройденный путь по X (сумма модулей смещений) у тапа 83 743, у NSEvent 83 611; по Y обе суммы равны 18 894. Расхождение в 0,2 % по X, вероятно, даёт разнонаправленное движение внутри одной склейки. Пропадает положение внутри кадра. Как это отражается на прицеле, здесь не измерено.

GCMouse: 17 строк

import GameController

let queue = DispatchQueue(label: "mouse", qos: .userInteractive)

func attach(_ mouse: GCMouse) {
    mouse.handlerQueue = queue                       // обработчик идёт на своей очереди, не на главной
    mouse.mouseInput?.mouseMovedHandler = { input, dx, dy in
        // dx, dy: относительное смещение (Float); знак dy противоположен NSEvent.deltaY (измерено).
        // input.lastEventTimestamp в моих логах — секунды от эпохи Unix, а не от загрузки.
        print(dx, dy, input.lastEventTimestamp)
    }
}

GCMouse.mice().forEach(attach)                       // мыши, подключённые к моменту запуска
NotificationCenter.default.addObserver(forName: .GCMouseDidConnect, object: nil, queue: .main) { note in
    if let mouse = note.object as? GCMouse { attach(mouse) }
}

Фрагмент сам по себе не программа: в скрипте без запущенного цикла (RunLoop, NSApplication.run()) процесс просто завершится, а уведомление с queue: .main не сработает. Работает ли GCMouse, пока приложение не на переднем плане, я не проверял: у GCController есть shouldMonitorBackgroundEvents, для мыши я его не трогал. Рабочий пример — рекордер в конце.

Обработчик я вынес на свою очередь на всякий случай, чтобы главная не вносила задержку в метки. Сравнения с главной очередью у меня нет. Все 16 924 значения dx в записи целые.

Масштаб дельт

Сумма модулей смещений по X за общий отрезок: 83 743 у тапа и 95 680 у GCMouse, то есть в 1,143 раза больше. По Y 18 894 против 21 694 (в 1,148 раза). Если резать движение на окна от 50 мс до 1 с и сравнивать смещения по окнам, наклон регрессии GCMouse к тапу по X получается от 1,137 до 1,148 в зависимости от размера окна. По квартилям скорости оценки лежат между 1,12 и 1,20 (тоже зависят от размера окна). Явной зависимости от скорости я не увидел, но в каждом квартиле выборка небольшая, так что это оценка, а не доказательство. У встроенного трекпада он тот же: по X 25 066 против 28 754 (1,147), по Y 18 247 против 20 959 (1,149).

В заголовке SDK у mouseMovedHandler сказано, что дельта «сырая» и не зависит от настроек чувствительности. Мои данные с этим не спорят, но и не подтверждают: с отсчётами HID я дельты не сравнивал. Есть совпадение, которое просится в объяснение. После записи я прочитал defaults read -g com.apple.mouse.scaling и получил 0.875, а 1/0,875 = 1,143, то есть ровно мой множитель. Но значение я прочитал 29 сентября, а не во время записи, и у трекпада множитель тот же, хотя ключа com.apple.trackpad.scaling в глобальных настройках у меня нет. Так что «система умножает сырые счётчики на скорость указателя» пока рабочая гипотеза, а не вывод. Проверить просто: поменять скорость указателя и записать заново. Я этого не делал.

Метки времени и задержка

Время события GCMouse я брал по моменту вызова обработчика (ProcessInfo.processInfo.systemUptime), а не по lastEventTimestamp. Причины три.

  • Часы. Метки тапа и NSEvent идут от загрузки, и первая метка в обоих логах одна и та же: 744709,998853. lastEventTimestamp — секунды от эпохи Unix: в логе трекпада первая метка 1790332272,76, то есть 25 сентября 2026, 10:31:12 UTC. Если пересчитывать её как Date() − systemUptime в каждом вызове, время дрожит на микросекунду: 232 шага назад на 16 924 события, все ровно по 1 мкс. На статистику это не влияет, но смещение между часами лучше вычислить один раз.

  • Повторы. У 817 вызовов из 16 924 (4,8 %) метка совпадает с меткой предыдущего вызова, есть и 51 тройка. У тапа повторов нет, самый короткий интервал 44 мкс. В 720 из этих 817 пар дельты разные, так что это не повторная доставка одного движения. Откуда повторы, не знаю.

  • Смысл метки. Обычно она на 7 мкс раньше вызова обработчика (медиана, p90 15 мкс, p99 61 мкс), поэтому по ней почти нельзя отличить время устройства от момента получения.

Насколько поздно приходят события. Вызов обработчика GCMouse происходит через 0,13 мс после предшествующего события тапа (медиана, p90 0,19 мс, p99 0,75 мс, по всем 16 924 вызовам). NSEvent доходит через 0,71 мс после собственной метки (медиана, p90 1,76 мс, p99 5,95 мс). Две оговорки. У склеенного события метка, видимо, принадлежит последнему из склеенных, так что первый отчёт пачки ждал дольше. И я исхожу из того, что метка тапа и systemUptime идут по одним часам: первые метки тапа и NSEvent совпали до микросекунды.

Разрешения

Тап не создавался, пока я не выдал разрешение терминалу: без него создание возвращает nil. Какая именно панель нужна для listen-only тапа на 26.6.2, я не проверял. Судя по API (IOHIDCheckAccess с kIOHIDRequestTypeListenEvent), тот же «Мониторинг ввода» относится и к IOHIDManager. В этих прогонах я его не открывал, так что молчит он без разрешения или возвращает ошибку при открытии, не знаю.

С GCMouse так: окно с NSEvent и GCMouse запускалось отдельным приложением со своим bundle id, чтобы не унаследовать разрешения терминала. IOHIDCheckAccess вернул unknown, то есть «не выяснено», а не «отказано». Запрос не появился. Это моё наблюдение, строки в логе про это нет. Прогон на трекпаде я запускал из терминала, в заголовке его лога стоит granted, поэтому о разрешениях он ничего не говорит.

Трекпад как контроль

Тот же замер со встроенного трекпада, около половины первого того же дня, из терминала. Общий отрезок 20,227 с. Тап: 2 365 событий (116,9 в секунду), GCMouse: 2 365 (116,9), NSEvent: 2 290 (113,2), то есть на 75 событий, или 3,2 %, меньше. Склейка здесь всегда пара: больше двух событий тапа на интервал не бывало, интервалов с двумя 3,3 %. События трекпада и так идут примерно с частотой экрана (по тапу медиана интервала 8,07 мс), и склеивать почти нечего; что тут делает сам трекпад, а что система, я не разделял. Похоже, заметный ущерб NSEvent наносит устройствам быстрее экрана. На двух устройствах это догадка.

Грабли при записи

  • Локальный монитор NSEvent получает движение, только пока окно активно и курсор над ним, и acceptsMouseMovedEvents должен быть включён. Поэтому рекордер ниже растягивает окно на весь экран: если курсор выходит из окна, счёт NSEvent проседает, а отношение тап к NSEvent завышается.

  • Мой логгер при старте ставит курсор в центр окна. Первое событие в тапе и NSEvent из-за этого огромное (−197, −234), а первое у GCMouse маленькое (1, 1). Похоже на перестановку курсора, отдельно я это не проверял. Суммы по всему логу и по общему отрезку из-за него расходятся. В компактном рекордере курсор не двигается.

  • С ad-hoc подписью каждая пересборка приложения сбрасывает выданные разрешения. После пересборки проверяйте, что осталось выдано.

  • Число событий в секунду зависит от того, как вы водили рукой. Сравнивайте только на общем отрезке.

Как воспроизвести

Сохраните код в three-streams.swift, соберите swiftc -O three-streams.swift -o three-streams, запустите ./three-streams 20 и водите мышью по окну. Программа сама берёт общий отрезок и печатает по строке на каждый поток: число событий, события в секунду, медиану интервала, p99 и долю интервалов короче 2 мс. Ключ --no-coalescing выключает склейку NSEvent (isMouseCoalescingEnabled = false). С ним я не мерил, это самый очевидный следующий опыт.

Чтобы проверить вопрос о разрешениях, положите бинарник в .app со своим CFBundleIdentifier (нужны Contents/MacOS/<бинарник> и Info.plist), подпишите codesign -f -s - и запускайте через LaunchServices: open -n -W --stdout /tmp/three-streams.out Имя.app --args --no-tap. Прямой запуск бинарника из терминала не годится: macOS припишет процесс терминалу, и проверка теряет смысл. Вывод появится в файле после конца записи. Ключ --no-tap не даёт создавать тап, чтобы не смешивать то, что просит тап, с тем, что просит GCMouse. Рекордер напечатает IOHIDCheckAccess для процесса. Приложению разрешений не выдавайте, иначе проверять нечего.

Компактный рекордер собирается (swiftc -O, Swift 6.3.2), но я его не запускал: числа выше сняты отдельными логгерами, а не им.

Рекордер целиком, 86 строк
// swiftc -O three-streams.swift -o three-streams && ./three-streams 20
// Опции: --no-tap (не создавать системный тап), --no-coalescing (NSEvent.isMouseCoalescingEnabled = false).
// Пишет одно и то же движение мыши тремя способами сразу: CGEventTap, NSEvent (local monitor), GCMouse.
import AppKit
import GameController
import IOKit.hid

final class Log: @unchecked Sendable {
    private var v: [Double] = []
    private let lock = NSLock()
    func add(_ t: Double) { lock.lock(); v.append(t); lock.unlock() }
    var values: [Double] { lock.lock(); defer { lock.unlock() }; return v.sorted() }
}
let tapLog = Log(), nsLog = Log(), gcLog = Log()
let args = Array(CommandLine.arguments.dropFirst())
let seconds = args.compactMap(Double.init).first ?? 20
let app = NSApplication.shared
if args.contains("--no-coalescing") { NSEvent.isMouseCoalescingEnabled = false }

// 1) Системный тап: стоит раньше AppKit, метка события идёт от железа. Нужно разрешение macOS;
//    без него tapCreate возвращает nil, и рекордер пишет только NSEvent и GCMouse.
func startTap() {
    let types: [CGEventType] = [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged]
    let mask = types.reduce(CGEventMask(0)) { $0 | (CGEventMask(1) << CGEventMask($1.rawValue)) }
    guard let tap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, options: .listenOnly,
                                      eventsOfInterest: mask, callback: { _, type, event, _ in
        if type != .tapDisabledByTimeout && type != .tapDisabledByUserInput { tapLog.add(Double(event.timestamp) / 1e9) }
        return Unmanaged.passUnretained(event)
    }, userInfo: nil) else {
        print("тап не создан (нет разрешения?), пишу только NSEvent и GCMouse")
        return
    }
    CFRunLoopAddSource(CFRunLoopGetMain(), CFMachPortCreateRunLoopSource(nil, tap, 0), .commonModes)
}
if !args.contains("--no-tap") { startTap() }

// 2) NSEvent: то, что AppKit отдаёт окну. Приходит, только пока окно активно и курсор над ним.
//    Отсчёт идёт от первого движения, так запись не бывает пустой.
func report() {
    let streams = [("тап", tapLog.values), ("NSEvent", nsLog.values), ("GCMouse", gcLog.values)].filter { !$0.1.isEmpty }
    guard let lo = streams.map({ $0.1.first! }).max(), let hi = streams.map({ $0.1.last! }).min(), hi > lo else {
        print("ничего не записалось"); return
    }
    print(String(format: "общий отрезок: %.3f с", hi - lo))
    for (name, all) in streams {
        let t = all.filter { $0 >= lo && $0 <= hi }
        let gaps = zip(t, t.dropFirst()).map { ($1 - $0) * 1000 }.sorted()
        guard !gaps.isEmpty else { continue }
        let under2 = 100 * Double(gaps.filter { $0 < 2 }.count) / Double(gaps.count)
        print(name.padding(toLength: 8, withPad: " ", startingAt: 0) + String(format: "%6d событий  %6.1f/с  медиана интервала %.2f мс  p99 %.2f мс  короче 2 мс: %.1f%%",
              t.count, Double(t.count) / (hi - lo), gaps[gaps.count / 2], gaps[Int(0.99 * Double(gaps.count - 1))], under2))
    }
}
var started = false
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged]) { e in
    nsLog.add(e.timestamp)
    if !started { started = true; DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { report(); app.terminate(nil) } }
    return e
}
DispatchQueue.main.asyncAfter(deadline: .now() + 120) {
    if !started { print("NSEvent не пришёл за 2 минуты: окно не активно или курсор вне окна"); app.terminate(nil) }
}

// 3) GameController. Разрешения не запрашиваем, обработчик идёт на своей очереди.
//    Пишем момент вызова обработчика: lastEventTimestamp у части вызовов повторяется.
let queue = DispatchQueue(label: "gcmouse", qos: .userInteractive)
func attach(_ mouse: GCMouse) {
    mouse.handlerQueue = queue
    mouse.mouseInput?.mouseMovedHandler = { _, _, _ in gcLog.add(ProcessInfo.processInfo.systemUptime) }
}
GCMouse.mice().forEach(attach)
NotificationCenter.default.addObserver(forName: .GCMouseDidConnect, object: nil, queue: .main) { note in
    if let mouse = note.object as? GCMouse { attach(mouse) }
}
let access = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent)   // только проверка, окна не показывает
print("Мониторинг ввода у этого процесса:", access == kIOHIDAccessTypeGranted ? "выдан" : access == kIOHIDAccessTypeDenied ? "отказано" : "unknown (не выяснено)")
print("Двигайте мышь по окну, отсчёт начнётся с первого движения")

app.setActivationPolicy(.regular)
let window = NSWindow(contentRect: NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 800, height: 500),
                      styleMask: [.titled], backing: .buffered, defer: false)   // окно на весь экран: курсор из него не выйдет
window.title = "Двигайте мышь по окну"
window.acceptsMouseMovedEvents = true
window.makeKeyAndOrderFront(nil)
app.activate(ignoringOtherApps: true)
app.run()

Что не проверено

  • Фон. Во всех прогонах окно было впереди. shouldMonitorBackgroundEvents для мыши не пробовал.

  • Скрытый или захваченный курсор, как в играх (CGAssociateMouseAndMouseCursorPosition). Только обычный курсор над окном.

  • Настоящая игра. Только простое окно. Измерены моменты прихода событий, а прицел, fps и задержку «ввод → экран» я не мерил.

  • Железо и системы. Одна мышь (Logitech G309, 1000 Гц), встроенный трекпад, один Mac (M3 Pro), macOS 26.6.2, экран 120 Гц. Внешние мониторы, 60 Гц, Intel и другие версии macOS не смотрел. Что было до 26.2, знаю только по названию чужого треда.

  • Повторяемость. Один прогон на условие, разброса нет. Трекпад снят отдельно и из терминала.

  • Эталон. Записи IOHIDManager нет, «что шлёт мышь» здесь равно «что видит системный тап».

  • Масштаб дельт относительно HID, скорость указателя во время записи и знак Y относительно HID.

  • Причины: кто склеивает события (WindowServer или AppKit), поможет ли isMouseCoalescingEnabled = false, откуда повторы меток GCMouse, множитель 1,14 и «около 850, а не 1000», правда ли первое огромное смещение это перестановка курсора, спрашивает ли macOS что-нибудь про GCMouse на чистой учётной записи.

Что из этого следует

Положение курсора и обычный интерфейс я бы брал из NSEvent. Относительное движение мыши брал бы из GCMouse, а время события считал по моменту вызова обработчика. Перед тем как опираться на это в игре, проверил бы то, чего у меня нет: работу со скрытым курсором, работу в фоне и масштаб дельт при разных скоростях указателя.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.