Тест мыши 1000 Гц на macOS 26: почему GCMouse точнее NSEvent

Прокомментировать Просмотры: 5

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

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

Все измерения производились на одном комплекте оборудования (мышь и ноутбук), с идентичной версией macOS и ровно по одному прогону на каждый сценарий.

Предпосылки исследования

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

Тестовый стенд

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

Три подхода к мониторингу единого движения (далее CGEventTap именуется просто «тап»):

Поток данных

Позиция в конвейере обработки

Штамп времени

тап (CGEventTap, пассивный режим)

До фреймворка AppKit, на уровне сеанса

Оригинальная метка события, отсчет в секундах с момента загрузки системы

NSEvent, локальный монитор

Данные, транслируемые 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 %

Показатели тапа и интерфейса GCMouse практически идентичны: медианные значения совпадают, а расхождение по количеству элементов составляет всего два события на шестнадцать тысяч. У NSEvent около 91,0 % интервалов укладываются в диапазон 6–10 мс. В сводной таблице для GCMouse расчет выполнен по моменту срабатывания callback-функции, а не на основе lastEventTimestamp, и причины этого поясняются ниже.

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

Агрегация событий в NSEvent

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

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, но для мыши я его не активировал. Полноценный рабочий пример доступен в конце материала.

Callback-функцию я намеренно перенес в кастомную очередь调度, дабы исключить малейшие задержки таймстемпов со стороны главного потока. Сравнений с поведением на главной очереди я не проводил. Все 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 %) таймстемп дублирует метку предыдущего вызова, присутствуют даже группы из трех одинаковых значений. В потоке тапа дубликатов нет, минимальный интервал составляет 44 мкс. При этом для 720 из указанных 817 пар дельты различаются, то есть речь не идет о повторной доставке одного и того же движения. Природа появления дубликатов остается загадкой.

    Сущность временной метки.

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

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

Права доступа

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

В случае с GCMouse ситуация выглядит следующим образом: графическое окно с NSEvent и GCMouse запускалось как отдельное приложение с уникальным bundle id, чтобы исключить наследование прав терминала. Функция IOHIDCheckAccess вернула статус unknown (неопределено), а не denied (отказано). Запрос на предоставление доступа не всплывал. Это чисто эмпирическое наблюдение, в логах строки на этот счет нет. Эксперимент с трекпадом запускался непосредственно из терминала, поэтому в заголовке его лога зафиксировано состояние 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. Запросы разрешений не требуются, обработчик работает в своей очереди.
//    Фиксируем время срабатывания callback: 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). Исследовался лишь стандартный курсор поверх окна.

  • Реальные игровые приложения. Тестировалось лишь примитивное окно. Замерялись моменты доставки событий, тогда как итоговая плавность прицеливания, задержка ввода (input lag) и кадровый отклик не оценивались.

    Аппаратная база и ОС. Использовался единственный манипулятор (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, вычисляя временную метку по моменту выработки callback-обработчика. Прежде чем внедрять данный подход в игровой проект, я бы проверил неохваченные аспекты: функционирование со скрытым курсором, поведение в фоновом режиме и корректность масштабирования дельт на различных скоростях перемещения.

 

Источник

Поделиться:

Похожие статьи

Поиск по играм, новостям и статьям…

Введите не менее двух символов

Введите не менее двух символов