Автор занимается оптимизацией производительности Chrome в компании Google — прим. пер.
Летом 2017 года я боролся с проблемой производительности Windows. Завершение процессов происходило медленно, сериализованно и блокировало системную очередь ввода, что приводило к многократным подвисаниям курсора мыши при сборке Chrome. Основная причина заключалась в том, что при завершении процессов Windows тратила много времени на поиск объектов GDI, удерживая при этом критическую секцию system-global user32. Я рассказывал об этом в статье «24-ядерный процессор, а я не могу сдвинуть курсор».
Microsoft исправила баг, и я вернулся к своим делам, но потом оказалось, что баг вернулся. Появились жалобы на медленную работу тестов LLVM, с частыми подвисаниями ввода.
Но на самом деле баг не вернулся. Причина оказалась в изменении нашего кода.
Проблема 2017 года
Каждый процесс Windows содержит несколько стандартных дескрипторов объектов GDI. Для процессов, которые ничего не делают с графикой, эти дескрипторы обычно имеют значение NULL. При завершении процесса Windows вызывает некоторые функции для этих дескрипторов, даже если они NULL. Это не имело значения — функции работали быстро — до выхода Windows 10 Anniversary Edition, в которой некоторые изменения в безопасности сделали эти функции медленными. Во время работы они удерживали ту же блокировку, которая использовалась для событий ввода. При одновременном завершении большого количества процессов каждый делает несколько вызовов медленной функции, которая удерживает эту критическую блокировку, что в итоге приводит к блокировке пользовательского ввода и к подвисанию курсора.
Патч Microsoft заключался в том, чтобы не вызывать эти функции для процессов без объектов GDI. Я не знаю подробностей, но думаю, что исправление Microsoft было примерно таким:
+ if (IsGUIProcess())
+ NtGdiCloseProcess();
– NtGdiCloseProcess();
То есть просто пропустить очистку GDI, если процесс не является процессом GUI/GDI.
Поскольку компиляторы и другие процессы, которые у нас быстро создаются и завершаются, не использовали объекты GDI, этого патча оказалось достаточно, чтобы исправить подвисание UI.
Проблема 2018 года
Оказалось, что процессам очень легко фактически выделяются некоторые стандартные объекты GDI. Если ваш процесс загружает gdi32.dll, то вы автоматически получите объекты GDI (DC, поверхности, регионы, кисти, шрифты и т.д.), нужны они вам или нет (обратите внимание, что эти стандартные объекты GDI не отображаются в Диспетчере задач среди объектов GDI для процесса).
Но это не должно быть проблемой. Я имею в виду, зачем компилятору загружать gdi32.dll? Ну, оказалось, что если загрузить user32.dll, shell32.dll, ole32.dll или многие другие DLL, то вы автоматически получите вдобавок gdi32.dll (с вышеупомянутыми стандартными объектами GDI). И очень легко случайно загрузить одну из этих библиотек.
Тесты LLVM при загрузке каждого процесса вызывали CommandLineToArgvW (shell32.dll), а иногда вызывали SHGetKnownFolderPath (тоже shell32.dll) Этих вызовов оказалось достаточно, чтобы вытянуть gdi32.dll и сгенерировать эти страшные стандартные объекты GDI. Поскольку набор тестов LLVM генерирует очень много процессов, он в конечном итоге сериализуется при завершении процессов, вызывая огромные задержки и зависания ввода, намного хуже, чем те, что были в 2017 году.
Но на этот раз мы знали об основной проблеме с блокировкой, поэтому сразу знали, что делать.
Первым делом мы избавились от вызова CommandLineToArgvW, вручную отпарсив командную строку. После этого набор тестов LLVM редко вызывал какие-либо функции из любой проблемной библиотеки DLL. Но мы заранее знали, что это никак не повлияет на производительность. Причина заключалась в том, что даже оставшегося условного вызова оказалось достаточно, чтобы всегда вытягивать shell32.dll, который в свою очередь вытягивал gdi32.dll, создающий стандартные объекты GDI.
Вторым исправлением стала отложенная загрузка shell32.dll. Отложенная загрузка означает, что библиотека загружается по требованию — при вызове функции — вместо загрузки при запуске процесса. Это означало, что shell32.dll и gdi32.dll будет загружаться редко, а не всегда.
После этого набор тестов LLVM начал выполняться в пять раз быстрее — за одну минуту вместо пяти. И больше никаких подвисаний мыши на машинах разработчиков, так что сотрудники могли нормально работать во время выполнения тестов. Это безумное ускорение для такого скромного изменения, и автор патчей был так благодарен за моё расследование, что выдвинул меня на корпоративный бонус.
Иногда мельчайшие изменения имеют самые большие последствия. Нужно лишь знать, где набрать «ноль».
Путь выполнения не принят
Стоит повторить, что мы обратили внимание на код, который не выполнялся — и это стало ключевым изменением. Если у вас есть инструмент командной строки, который не обращается к gdi32.dll, то добавление кода с условным вызовом функции многократно замедлит завершение процессов, если загружается gdi32.dll. В приведённом ниже примере CommandLineToArgvW никогда не вызывается, но даже простое присутствие в коде (без задержки вызова) негативно отражается на производительности:
int main(int argc, char* argv[]) {
if (argc < 0) {
CommandLineToArgvW(nullptr, nullptr); // shell32.dll, pulls in gdi32.dll
}
}
Так что да, удаления вызова функции, даже если код никогда не выполняется, может быть достаточно, чтобы значительно повысить производительность в некоторых случаях.
Воспроизведение патологии
Когда я исследовал начальную ошибку, я написал программу (ProcessCreateTests), которая создавала 1000 процессов, а затем параллельно их все убивала. Это воспроизвело зависание, и когда Microsoft исправила ошибку, я использовал тестовую программу для проверки патча: см. видео. После реинкарнации бага я изменил свою программу, добавив опцию -user32, которая для каждого из тысячи тестовых процессов загружает user32.dll. Как и ожидалось, время завершения всех тестовых процессов резко возрастает с этой опцией, и легко обнаружить подвисания курсора мыши. Время создания процессов также увеличивается с параметром -user32, но во время создания процессов нет подвисаний курсора. Можете использовать эту программу и посмотреть, насколько ужасной может быть проблема. Здесь показаны некоторые типичные результаты моего четырёхъядерного/восьмипоточного ноутбука после недели аптайма. Опция -user32 увеличивает время для всего, но особенно драматично увеличивается блокировка UserCrit при завершении процессов:
> ProcessCreatetests.exe
Process creation took 2.448 s (2.448 ms per process).
Lock blocked for 0.008 s total, maximum was 0.001 s.
Process destruction took 0.801 s (0.801 ms per process).
Lock blocked for 0.004 s total, maximum was 0.001 s.
> ProcessCreatetests.exe -user32
Testing with 1000 descendant processes with user32.dll loaded.
Process creation took 3.154 s (3.154 ms per process).
Lock blocked for 0.032 s total, maximum was 0.007 s.
Process destruction took 2.240 s (2.240 ms per process).
Lock blocked for 1.991 s total, maximum was 0.864 s.
Копаем глубже, просто для интереса
Я подумал о некоторых методах ETW, которые можно применить для более детального изучения проблемы, и уже начал писать их. Но натолкнулся на такое необъяснимое поведение, которому решил посвятить отдельную статью. Достаточно сказать, что в этом случае Windows ведёт себя ещё более странно.
Другие статьи цикла:
- Замедляем Windows, часть 0: произвольное замедление VirtualAlloc
- Замедляем Windows, часть 1: файловый доступ
- Замедляем Windows, часть 2: создание процессов
- Замедляем Windows, часть 3: эта
Литература
- Первый отчёт о подвисаниях UI: «24-ядерный процессор, а я не могу сдвинуть курсор»
- Следующая статья, которая подводит к пониманию проблемы: «Что *делает* Windows, удерживая эту блокировку»
- Статья о другой блокировке UI из-за взаимодействия между воркерами Gmail, ASLR в v8, политикой выделения памяти CFG и медленным сканированием WMI: «24-ядерный CPU, а я не могу набрать электронное письмо»
- Загрузка компилятором gdi32.dll кажется странной, но ещё более странно, что компилятор загружает mshtml.dll, что раньше делал VC++ в некоторых случаях
- Иногда недели исследований приводят к маленьким, но критическим изменениям, как обсуждалось в статье «Знать, где набрать ноль»
- Видео с демонстрацией использования ProcessCreateTests и ETW для проверки исправления бага
- Первое изменение для LLVM путём мануального парсинга командной строки
- Второе исправление для LLVM с помощью задержки загрузки shell32.dll
Комментарии (41)
qw1
04.12.2018 21:01+1удерживая при этом критический раздел system-global user32
Не стоило так переводить "критическую секцию".
apro
05.12.2018 00:16А что в самом MS происходит раз они выпускают Update с такой проблемой? У них же самих должен быть набор тестов для WIN32 API, и он идее прогон их тестового набора тоже должен был замедлиться в несколько раз, им было на это наплевать?
qw1
05.12.2018 00:41Маловероятно, что у них есть тест, проверяющий завершение 1000 процессов в течении 1 секунды (я бы до такого не додумался). А если и есть тест, он может проверять только стабильность системы (что ничего не упало, все ресурсы освобождены), и выполняется в автоматическом режиме. То есть, тест пройден, но некому отметить, что во время выполнения теста весь UI подвис.
apro
05.12.2018 01:54Маловероятно, что у них есть тест, проверяющий завершение 1000 процессов
Это же одна из самых востребованных функций ОС — запуск новых процессов,
думаете у них теста одновременного запуска максимально возможного количества процессов? А как иначе тестировать обработку максимального количества объектов разного рода от дескрипторов файлов до GDI объектов и как ОС обрабатывает эту ситуацию. Очень странно было бы не автоматизировать проверку обработки этого крайнего случая.
Но я имел ввиду не конкретный тест, а совокупность тестов. Разработчики llvm не делают же чего-то необычного запуская тесты в нескольких процессах одновременно,
точно также прогон тестов WIN32 API и всех компиляторов разработанных MS должен по идее выглядеть.qw1
05.12.2018 12:12Я о том и написал, что тест на завершение 1000 процессов, если и есть, то не проверяет отзывчивость UI в этот момент. Ну да, он завершается успешно через несколько секунд, ничего не упало, утечек памяти нет — тест пройден.
ktod
05.12.2018 08:48Уже лет 20 при первом знакомстве с новой версией вин проверяю, а не исправили ли багу с подвисанием указателя мыши при сворачивании/разворачивании окна. Но, нет, мс чтит традиции.
bvn13
05.12.2018 09:29как воспроизвести?
ktod
05.12.2018 09:44Кликаете на пиктограмму «свернуть окно» и без паузы двигаете указатель мыши. Указатель остается на месте пока проигрывается «анимация» сворачивания.
bvn13
05.12.2018 10:03видимо, SSD и 16Гб ОЗУ делает свое дело…
AngReload
05.12.2018 10:11+1HDD и 8Гб ОЗУ — курсор бегает без проблем. Вообще никогда не слышал о такой проблеме.
SergeyMax
05.12.2018 11:19Такое было на виавских чипсетах времён первого пентиума, когда драйверы Bus Master IDE не были установлены, или чот типа такого
ua30
05.12.2018 12:29Как мало надо для курсора… Помню в детстве ZX Spectrum был с 48 КБ ОЗУ. Не понимал, ну зачем так много памяти делать, кому столько пригодится!?
vassabi
05.12.2018 12:33вы видели — какой нынче есть стандарт иконок для айпада с ретиной?
я когда увидел дизайнеров с просьбой им спаковать 1024х1024, я чуть не упал. А ведь нет — таки иконка!ua30
05.12.2018 12:41Вспомнились ASCII «картинки для взрослых» в монохроме. А как увидел первые, наверное, 256х256 фото на 4-битном CGA (16 цветов, но цветов!) — не спал неделю. Были ведь времена! В такие моменты понимаешь, что ты уже не молод. Хоть еще и не стар!
vanxant
06.12.2018 10:15Наверно всё-же EGA мониторе. CGA не умел в графику 16 цветов (точнее умел в особо диком режиме 160х200, но не каждый).
FreeNickname
06.12.2018 01:251024x1024 Apple всё же использует для featuring в AppStore, не на рабочем столе)
ktod
05.12.2018 12:4132ГБ и nvme. Ничего не решают.
springimport
05.12.2018 18:03На самом деле в таких вещах решает цп. Для полноты картины нужен 9900k 5.0ггц.
roscomtheend
05.12.2018 10:41+1Никогда не слышал о такой баге (использую с версии 3.1), только что проверил (под рукой только Win10 на ноуте) — ничего не фризится (но и анимация сворачивания быстрая).
old_gamer
05.12.2018 19:37О, спасибо, тестируя, случайно обнаружил, что если зажать ЛКМ на кнопке приложения в таскбаре и провести мышью вверх, то вызывается меню, доступное по щелчку ПКМ. Не знаю, зачем мне это знание, но прикольно.
Вин 7.
vassabi
05.12.2018 11:36+1напомнило случай, когда я работал на игроделов, в то вермя у них была проблема со скоростью завершения игры. Обычно с уровня выход идет в меню, а потом уже из программы, поэтому процесс выгрузки объектов был не так заметен. Но в этом случае — они хотели, чтобы было быстро прямо с уровня…
Долго мучались, ускоряли и спрямляли логику, даже нашли и пофиксили 10+ багов в разных деструкторах. Уже второй дедлайн пропущен. Пока не заметили (прямо как в истории про «платье короля», интерн спросил техлида — «смотрите, а почему это так ?»), что при креше программа «завершается практически мгновенно».
После этого прикрутили флажок к глобальному обработчику (все нормально, это мы выходим, не пугай пользователя) и после flush на файл сейва — делили единицу на ноль (или разыменовывали NULL, уже не помню). Еле потом уговорили ПМа, что так на самом деле делать нельзя и в других играх такого делать больше не будем.Gorthauer87
05.12.2018 12:07А разве нет возможности забить на вызов деструкторов статических переменных и выйти так без креша?
qw1
05.12.2018 12:15Например, в каждом деструкторе написать что-то типа
if (g_isFastExiting) return;
Но это сколько надо исходников менять + зависимость всех объектов от модуля с флагом — нехорошо.
ЛибоExitProcess(0)
;
vassabi
05.12.2018 12:24там было много причин, и не только в статических переменных.
Вообще, стремление было сделать выход с «останавливаем все потоки, закрываем все дескрипторы, и помечаем всю выделенную память как пустую».
Так что «выход с крешем» просто был самым быстрым вариантом для реализации (второй дедлайн, рождество через неделю), чтобы не плодить лишних зависимостей никуда, кроме обработчика сигналов.qw1
05.12.2018 12:27Вообще такой подход к завершению приложения очень хорош тем, что можно найти все утечки — памяти, хендлов, и т.п.
vassabi
05.12.2018 12:41когда до дедлайна еще две недели — то игроделы нормально работают: утечки ищут, скорость считают. А когда после запланированной даты сдачи проекта про*ли уже второй срок сдачи проекта, то…
Chaos_Optima
05.12.2018 12:13А почему деление на 0 а не std::abort?
vassabi
05.12.2018 12:31увы, если бы то решение писал я, я бы вам точно ответил. Я в это время работал над другой игрушкой, просто в одном опенспейсе сидели.
Рискну предположить, что abort уже был обвязан своей логикой (т.е. SIGABRT уже что-то делал), в том числе и прочие штатные пути типа onexit/atexit. А хотелось — чтобы никаких деструкторов, никаких раскруток стеков, просто стопануть, всю память освободить (прямо на уровне «железа» — пометить страницы свободными и досвидания, гори дом вместе с тараканами) и все.mayorovp
05.12.2018 13:48Тогда надо вызывать
std::terminate()
. Необработанные исключения вызывают именно его.vassabi
05.12.2018 15:25эээ… а разве не наоборот — хендлер для необработанных исключений вызывает
std::terminate
, который в штатном варианте вызваетstd::abort
, который вызывает сигналSIGABRT
, который обрабатывается его обработчиком? При этом сигналSIGSEGV ( *NULL=0; )
— сразу летит на свой обработчик (да, можно пошаманить с __try… __catch, но смысл ?).
перечитал доки поstd::terminate()
— сейчас я бы наверно пробовал что-то типаstd::_Exit(EXIT_SUCCESS);
, но, как говорится: «был бы я такой умный, как моя жена завтра» :)
UPD: вспомнил еще засаду для всех вариантов с std::zzzz: там был свой набор libc/libcpp/etc, так что вполне могло быть что и std::_Exit был перегруженый, с вызовом внутреннего менеджера памяти, так что сложно сказать — сработало бы или нет…
amarao
05.12.2018 15:30man 2 exit
void _exit(int status);
The function _exit() terminates the calling process «immediately». Any open file descriptors belonging to the process are closed.
Evengard
05.12.2018 12:21А почему нельзя было сделать просто ExitProcess(0)?
vassabi
05.12.2018 12:53наверно потому что он не такой быстрый и не такой замечательный (документация упоминает даже какой-то потенциальный дедлок и разные неопределенные состояния, если им пользоваться как попало), как то, что в итоге ушло в продакшен.
leR12
05.12.2018 18:26вообще без проблем на W7. один раз настроил ( лет 6 назад) мгновенно всё то что надо отрубается. память очищается.
CoolCmd
правильнее перевести как отложенная загрузка
не в первый раз вижу, как сотрудники гугла охотятся за бонусами: скрывают баги, тырят идеи для патентов. предлагаю еще одну креативную схему:
1. в мелкософт засылается соучастник, который под любым предлогом (безопасность, защита от педофилов и т.д.) добавляет в винду патч, который замедляет процессы, если их запускать по несколько тысяч. условия специфические, поэтому никто не замечает подвох.
2. чувак в гугле выявляет и исправляет тормоза.
3.
PROFITбонус.ну это так, на правах шутки.
SergeyMax
unclejocker
Зачем так сложно? Теоретически достаточно сговора троих инженеров и менеджера. Инженеры номинируют друг-друга по кругу (двоих мало, т.к. нельзя номинировать того, кто номинировал тебя), менеджер апрувит.