Дорогой Шерлок, вы знаете мою слабость к заметкам, задачам и конспектам ваших дедуктивных выкладок. Держу я всё это в приложении, которое сам и пишу: десктоп на Rust Tauri 2 поверх обычной папки на диске.
Новое дело выглядело скромно. Сорок с небольшим записей: несколько заметок, пара таблиц, остальное — ссылки на внешние документы. Решил все перетянуть из одной ветки дерева в другую, ошибся с папкой, нажал undo. И я увидел, как время остановилось. Это было не «подвисло на секунду», а именно вязкое перемещение документов из папки в папку и обратно.
Разберусь сам, подумал я. Делов на один вечер.
Счётчик повесил на границу между интерфейсом и Rust — то окошко, через которое webview передаёт вызовы в бэкенд. Перетащил те же 46 элементов…
1203 вызова
26 вызовов на каждую перетащенную строку. Для сравнения, Холмс: открытие всего воркспейса целиком — со сканированием дерева, метаданными, превью и построением поискового индекса — стоит 1293. А за перенос 46 строк мышью + undo заплатил почти столько же, сколько за запуск с нуля!
Полезной работы в этой тысяче было 46 вызовов, по одному на элемент. Остальные 1111 казались чем-то другим.
По итогу пришлось разбираться три дня и трижды чинить не то. Виновата всё время была одна и та же вещь, просто в разных обличьях: работа, которой не видно и на первый взгляд и на второй…
Как все замерялось
Счётчиков в коде не было — ни числа вызовов, ни отметок времени. Логи ситуацию не проясняли. Пришлось написать свой счетчик.
Дев-обёртка над invoke считает вызовы по имени команды и складывает их время. Сценарий называем сами, чтобы отличать разные замеры в дальнейшем:
__hiveIpc.reset(); __hiveIpc.startScenario("dnd-46"); // … тащим мышью … __hiveIpc.endScenario(); __hiveIpc.printGlobal();
Сразу оговорюсь, Холмс, чтобы меня не поймали на этом позже: замеры сделаны в дев-сборке. Число вызовов от типа сборки не зависит, а вот время каждого завышено. Дальше я опираюсь на счётчик и на соотношения, а не на абсолютные миллисекунды.
Дело первое: перенос, который дороже запуска
Первый анализ показал - в дампе 1027 вызовов из 1203 читали список задач хотя задачи и не переносились! У каждой папки в дереве может быть свой список дел, на строке рисуется бейдж с их числом. При переносе файлов задачи не меняются вообще — их там и быть не должно.
Сам по себе перенос - ложный след. После дропа документов код вызывал общий рефреш воркспейса, тот шёл в полную перезагрузку, и пересобирал индекс задач по всему дереву — по одному вызову на узел. Вот он и попался! В дампе рядом лежали улики, которые не оставляют сомнений: сканирование дерева, миграция названий заметок, чтение истории отмен, конфига и стилей. Всё то, что делается при первом запуске.
Каждый драгндроп открывал воркспейс заново. Я перевешивал картину и после этого проводил полную инвентаризацию квартиры.
Заодно отметьте мелкую деталь, Холмс, она пригодится далее: при каждом перемещении сущности приложение перечитывает и перезаписывает файл со связями.
Первым желанием было выкинуть рефреш всего дерева. К счастью, я сначала посмотрел, кто на него опирается. Выяснилось, что рефреш — не мусор, а контракт: на нём висело перечитывание истории undo-redo с диска перед записью нового шага, без которого ломалась отмена конвертации документов в папку (это отдельная функциональность), и сброс правил группировки, на который сознательно рассчитывали обработчики сортировки и переноса.
Оказалось уволить дворника было нельзя: он между делом единственный поливал цветы и забирал почту.
Пришлось сначала распределить обязанности — вынести два явных действия, «перечитать историю undo» и «перечитать конфиг стилей дерева», — и только потом облегчать общий путь. Итог: полный рефреш с дропа ушёл. Чтений задач — ноль. Следующий замер того же Dnd вместе с Ctrl+Z дал 297 вызовов вместо 1203!! — важно понимать что это не чистый дроп, а дроп + отмена. Это будет важно для третьего дела.
Дамп: перенос 46 элементов, до
команда | count | totalMs |
|---|---|---|
| 1027 | 1996 |
| 44 | 1593 |
| 30 | 47 |
| 16 | 36 |
| 2 | 111 |
| 1 | 157 |
Плюс по одному вызову на миграцию названий, историю отмен, конфиг, стили, связи и контекстные ссылки — подпись полного рефреша и undo.
Дело второе: 993 вызова исчезли, а результат?
Новое дело: это уже не DnD, а открытие папки дерева целиком - на глаз заметно как легкий глитч, не тормоза, но неприятно. Но раз уж начал измерять то идем до конца. Два прогона открытия одной и той же папки — до починки и сразу после. И кто виновник? Опять вмешивается чтение задач: 993 вызова из 1293. Свернул обход в пакетный вызов и сделал индекс ленивым — считаем только то, что человек раскрыл. В итоге счётчик показал 288 IPC вместо начального 1293 - минус 78 процентов. Довольный я пошёл заваривать чай.
Новые замеры. Правда была жесткой. Открытие не ускорилось: количество вызовов упало на 78%, а суммарное время возросло.
Черт возьми, но как, Холмс?
Вы, конечно, уже догадались, что я смотрел не туда. Лично я смотрел на число вызовов. А вы на что-то другое? Копаемся в куче вызовов и находим очень подозрительных сэров: “метаданные”, которые отвечают за теги, бейджи, время создания и тд для каждого документа. Из общей работы (всех IPC) выделяем замеры “чтения метаданных” для тех же двух прогонов открытия — до и после уменьшения всех вызовов IPC на 78%. Получаем:
было 164 вызова и 38 секунд суммарно,
стало 190 вызовов и 109 секунд суммарно.
Средний вызов подорожал с 234 до 573 миллисекунд и их стало больше! Мало того, внезапно к сэрам примкнули IPC ребята из клуба “превью ссылок”: их было всего 16 - количество не изменилось, а время среднего выросло с 271 до 622 мсек.
Что происходит?
Здесь нужна оговорка, иначе меня справедливо поправят. Сумма времени всех вызовов — не время открытия: вызовы идут параллельно, и 109 секунд в сумме не значат, что папка открывается две минуты. Но произошло подорожание каждого отдельного вызова, и это ПОСЛЕ того как были убраны ненужные.
Это получается, что дополнительная работа сокращала время выполнения?? Холмс, глаза говорят - возможно Солнце крутится вокруг Земли. Причина таких чудес оказалась дьявольски простой.
Те 993 дешёвых IPC чтения по семь миллисекунд работали турникетом на входе — они растягивали поток запросов во времени.
Я убрал турникет, и все тяжёлые запросы приехали к двери одновременно борясь за желание пройти. Дверь стала открываться каждому в два с половиной раза медленнее. Cтройная, хоть и длинная, очередь крепких ребят и превратилась в давку с дракой.
Лечится это не батчем, а уменьшением запросов. Элемент в папке сначала смотрит в кэш и молчит при попадании, а уже промахи собираются в один пакетный вызов IPC. Сто девяносто отдельных чтений превратились в одно на 87 миллисекунд, это бинго!
метрика | до | после чинки задач | холодный старт | тёплый |
|---|---|---|---|---|
вызовов на открытие | 1293 | 288 | 104 | 60 |
чтения задач | 993 | 0 | 0 | 0 |
чтения метаданных | 164 | 190 | 1 пакетный | 0, кэш |
время в метаданных | ~38 с | ~109 с | ~87 мс | 0 |
И про методику: холодный замер — это сброс кэша или перезапуск приложения. Иначе вы меряете свой собственный прогретый кэш и радуетесь результату. Земля опять крутится вокруг Солнца.
Дело третье: батч, который батч только снаружи
Драгндроп работал, папки со сложными элементами (например, линками на внешние документы) открывались молниеносно. Осталось проверить отмену того самого переноса. Делается это элегантно по-британски Ctrl-Z (Undo). Шерлок, мне кажется, я слишком много гулял голодным на болотах. При отмене линки вальяжно возвращались на моих глазах буквально по одной — около 3х секунд.
Детальные замеры показали что Dnd + undo это 297 вызовов, из них 44 IPC именно undo. Срочно применяем технику оптимизации IPC и видим счетчик undo IPC: 28 вызовов вместо 44. Время отмены улучшилось: 2136 миллисекунд, из них 1836 — диск. Уже лучше, но все еще не достаточно.
Под подозрение сразу попал рояль в кустах - один пакетный вызов внутри работал 1334 миллисекунд, а это уже Rust, это серьезно.
Вот и та “мелкая” лакированная деталь из первого дела. Снаружи это был один вызов и не вызывал подозрения (тк мы меряли количества IPC), а внутри Rust шёл по каждой ссылке и на каждую перечитывал и перезаписывал файл со связями — дважды. Сорок с лишним ссылок примерно и дают те самые полторы секунды.
Я сложил 44 письма в один конверт, курьер съездил один раз, а на почте конверт вскрыли и понесли письма по одному, каждый раз заполняя адрес заново.
Настоящая починка была не на границе IPC, а в глубинах бекенда: одно чтение и одна запись метаданных на пару узлов, пакетное обновление связей и ключей учёта времени и так далее. Это была очень безликая и тягостная работа, которую, впрочем, нужно было обязательно сделать.
Что имеем по факту:
метрика | до | после |
|---|---|---|
пакетный перенос ссылок | 1334 мс | 29 мс |
работа с диском | 1836 мс | 557 мс |
отмена целиком | 2136 мс | 688 мс |
Батч на границе не обещает ничего про то, что происходит за ней. Один вызов может внутри быть тем же N+1, только теперь его не видно в счётчике.
Что из этого следует
Была и четвёртая мелочь, для полноты. Превью читались через границу в base64: файл читается в Rust, кодируется в строку, едет в webview, декодируется обратно — 41 вызов на открытие.
Для PDF на десять мегабайт дорого не число вызовов, а память и кодирование; это как пересылать фотографию, диктуя её по телефону цифрами. Переход на прямой доступ к файлу убрал их все, а время подготовки превью упало с восьми секунд до примерно 240 миллисекунд.
Мой изначальный метод, Холмс, был плох ровно одним: я считал улики вместо того, чтобы их взвешивать. Что я вынес:
Счётчик ставится до оптимизаций, иначе чините не то — виновник почти наверняка окажется не тем, кого вы подозревали.
Число вызовов и время смотрите раздельно: самый частый и самый дорогой — обычно разные вызовы.
Падение числа вызовов может ухудшить тайминги, если вы сняли троттлинг с того, что осталось.
Батч на границе процессов не равен батчу на диске — проверяйте, что внутри команды не тот же цикл.
N+1 ищите в индексах и фоновых пересборках, а не в видимом UI.
Прежде чем резать общий рефреш, выясните, какие побочные эффекты на нём висят: это часто контракт, а не мусор.
Холодный замер — только после сброса кэша или перезапуска.
Нераскрытое дело
Одну улику я так и не объяснил. После всех починок новым лидером стала проверка «существует ли файл»: 143 вызова на один перенос с отменой и 47 на раскрытие папки с пятьюдесятью заметками. Часть понятна — галерея и сверка ссылок спрашивают про каждую строку, — но откуда именно 143 на одну операцию, я не понимаю до конца. Рядом ждут своего часа опрос фокуса каждые полсекунды вместо событий и построение поискового индекса на 600–1000 миллисекунд.
Холмс, если у вас есть версия — она нужна мне в комментариях. Заодно интересно, ловил ли кто-нибудь ещё это расхождение: вызовов стало меньше, а тайминги хуже.
Приложение, на котором всё это мерилось: https://github.com/getintessika/intessika
