Невызванная функция замедляет программу в 5 раз

https://randomascii.wordpress.com/2018/12/03/a-not-called-function-can-cause-a-5x-slowdown/
  • Перевод
Замедляем Windows, часть 3: завершение процессов



Автор занимается оптимизацией производительности 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 ведёт себя ещё более странно.

Другие статьи цикла:


Литература


Поделиться публикацией
Комментарии 48
    +5
    задержка загрузки shell32.dll

    правильнее перевести как отложенная загрузка

    автор патчей был так благодарен за моё расследование, что выдвинул меня на корпоративный бонус

    не в первый раз вижу, как сотрудники гугла охотятся за бонусами: скрывают баги, тырят идеи для патентов. предлагаю еще одну креативную схему:
    1. в мелкософт засылается соучастник, который под любым предлогом (безопасность, защита от педофилов и т.д.) добавляет в винду патч, который замедляет процессы, если их запускать по несколько тысяч. условия специфические, поэтому никто не замечает подвох.
    2. чувак в гугле выявляет и исправляет тормоза.
    3. PROFIT бонус.
    ну это так, на правах шутки.
      +2
      сотрудники гугла
      До гугла точно так же делал Марк Руссинович, в частности я имею в виду его цикл статей "дело о..."
        0
        Зачем так сложно? Теоретически достаточно сговора троих инженеров и менеджера. Инженеры номинируют друг-друга по кругу (двоих мало, т.к. нельзя номинировать того, кто номинировал тебя), менеджер апрувит.
        +7
        удерживая при этом критический раздел system-global user32
        Не стоило так переводить "критическую секцию".
          0

          Ссылка на "часть 2" ведет на часть 1.

            0

            А что в самом MS происходит раз они выпускают Update с такой проблемой? У них же самих должен быть набор тестов для WIN32 API, и он идее прогон их тестового набора тоже должен был замедлиться в несколько раз, им было на это наплевать?

              +2
              Маловероятно, что у них есть тест, проверяющий завершение 1000 процессов в течении 1 секунды (я бы до такого не додумался). А если и есть тест, он может проверять только стабильность системы (что ничего не упало, все ресурсы освобождены), и выполняется в автоматическом режиме. То есть, тест пройден, но некому отметить, что во время выполнения теста весь UI подвис.
                0
                Маловероятно, что у них есть тест, проверяющий завершение 1000 процессов

                Это же одна из самых востребованных функций ОС — запуск новых процессов,
                думаете у них теста одновременного запуска максимально возможного количества процессов? А как иначе тестировать обработку максимального количества объектов разного рода от дескрипторов файлов до GDI объектов и как ОС обрабатывает эту ситуацию. Очень странно было бы не автоматизировать проверку обработки этого крайнего случая.


                Но я имел ввиду не конкретный тест, а совокупность тестов. Разработчики llvm не делают же чего-то необычного запуская тесты в нескольких процессах одновременно,
                точно также прогон тестов WIN32 API и всех компиляторов разработанных MS должен по идее выглядеть.

                  +2
                  Я о том и написал, что тест на завершение 1000 процессов, если и есть, то не проверяет отзывчивость UI в этот момент. Ну да, он завершается успешно через несколько секунд, ничего не упало, утечек памяти нет — тест пройден.
              –2
              Уже лет 20 при первом знакомстве с новой версией вин проверяю, а не исправили ли багу с подвисанием указателя мыши при сворачивании/разворачивании окна. Но, нет, мс чтит традиции.
                +1
                как воспроизвести?
                  –1
                  Кликаете на пиктограмму «свернуть окно» и без паузы двигаете указатель мыши. Указатель остается на месте пока проигрывается «анимация» сворачивания.
                    0
                    видимо, SSD и 16Гб ОЗУ делает свое дело…
                      +6

                      HDD и 8Гб ОЗУ — курсор бегает без проблем. Вообще никогда не слышал о такой проблеме.

                        0
                        Такое было на виавских чипсетах времён первого пентиума, когда драйверы Bus Master IDE не были установлены, или чот типа такого
                          –1
                          Как мало надо для курсора… Помню в детстве ZX Spectrum был с 48 КБ ОЗУ. Не понимал, ну зачем так много памяти делать, кому столько пригодится!?
                            0
                            вы видели — какой нынче есть стандарт иконок для айпада с ретиной?
                            я когда увидел дизайнеров с просьбой им спаковать 1024х1024, я чуть не упал. А ведь нет — таки иконка!
                              +3
                              Вспомнились ASCII «картинки для взрослых» в монохроме. А как увидел первые, наверное, 256х256 фото на 4-битном CGA (16 цветов, но цветов!) — не спал неделю. Были ведь времена! В такие моменты понимаешь, что ты уже не молод. Хоть еще и не стар!
                                0
                                Наверно всё-же EGA мониторе. CGA не умел в графику 16 цветов (точнее умел в особо диком режиме 160х200, но не каждый).
                                0
                                1024x1024 Apple всё же использует для featuring в AppStore, не на рабочем столе)
                            0
                            32ГБ и nvme. Ничего не решают.
                              0
                              На самом деле в таких вещах решает цп. Для полноты картины нужен 9900k 5.0ггц.
                            0
                            8 Гб, обычный жестак — курсор перемещается в процессе отрисовки.
                          +6
                          Никогда не слышал о такой баге (использую с версии 3.1), только что проверил (под рукой только Win10 на ноуте) — ничего не фризится (но и анимация сворачивания быстрая).
                            +2
                            О, спасибо, тестируя, случайно обнаружил, что если зажать ЛКМ на кнопке приложения в таскбаре и провести мышью вверх, то вызывается меню, доступное по щелчку ПКМ. Не знаю, зачем мне это знание, но прикольно.
                            Вин 7.
                              +1
                              Для планшетов, где нет правой клавиши мыши.
                                0
                                Точно, не подумал. Спасибо!
                                +1
                                Только не «вверх», а от края монитора. Два дня я гадал каким образом эта фича у меня оказалась отключена на всех компах кроме игрового — пока не догадался провести мышью вниз :-)
                                  0
                                  У меня именно вверх. Не надо до края, достаточно на 1-2 пиксела буквально. Нажимаю ЛКМ в любом месте кнопки на таскбаре и чуть-чуть вверх.
                                    +1
                                    А таскбар-то у вас где?
                                      0
                                      да, точно, не понял сначала, о чем вы.
                              +3
                              напомнило случай, когда я работал на игроделов, в то вермя у них была проблема со скоростью завершения игры. Обычно с уровня выход идет в меню, а потом уже из программы, поэтому процесс выгрузки объектов был не так заметен. Но в этом случае — они хотели, чтобы было быстро прямо с уровня…
                              Долго мучались, ускоряли и спрямляли логику, даже нашли и пофиксили 10+ багов в разных деструкторах. Уже второй дедлайн пропущен. Пока не заметили (прямо как в истории про «платье короля», интерн спросил техлида — «смотрите, а почему это так ?»), что при креше программа «завершается практически мгновенно».
                              После этого прикрутили флажок к глобальному обработчику (все нормально, это мы выходим, не пугай пользователя) и после flush на файл сейва — делили единицу на ноль (или разыменовывали NULL, уже не помню). Еле потом уговорили ПМа, что так на самом деле делать нельзя и в других играх такого делать больше не будем.
                                0

                                А разве нет возможности забить на вызов деструкторов статических переменных и выйти так без креша?

                                  0
                                  Например, в каждом деструкторе написать что-то типа
                                  if (g_isFastExiting) return;

                                  Но это сколько надо исходников менять + зависимость всех объектов от модуля с флагом — нехорошо.

                                  Либо ExitProcess(0);
                                    0
                                    там было много причин, и не только в статических переменных.
                                    Вообще, стремление было сделать выход с «останавливаем все потоки, закрываем все дескрипторы, и помечаем всю выделенную память как пустую».
                                    Так что «выход с крешем» просто был самым быстрым вариантом для реализации (второй дедлайн, рождество через неделю), чтобы не плодить лишних зависимостей никуда, кроме обработчика сигналов.
                                      +1
                                      Вообще такой подход к завершению приложения очень хорош тем, что можно найти все утечки — памяти, хендлов, и т.п.
                                        0
                                        когда до дедлайна еще две недели — то игроделы нормально работают: утечки ищут, скорость считают. А когда после запланированной даты сдачи проекта про*ли уже второй срок сдачи проекта, то…
                                      –1
                                      -
                                      +1
                                      А почему деление на 0 а не std::abort?
                                        0
                                        увы, если бы то решение писал я, я бы вам точно ответил. Я в это время работал над другой игрушкой, просто в одном опенспейсе сидели.
                                        Рискну предположить, что abort уже был обвязан своей логикой (т.е. SIGABRT уже что-то делал), в том числе и прочие штатные пути типа onexit/atexit. А хотелось — чтобы никаких деструкторов, никаких раскруток стеков, просто стопануть, всю память освободить (прямо на уровне «железа» — пометить страницы свободными и досвидания, гори дом вместе с тараканами) и все.
                                          0

                                          Тогда надо вызывать std::terminate(). Необработанные исключения вызывают именно его.

                                            +2
                                            эээ… а разве не наоборот — хендлер для необработанных исключений вызывает std::terminate, который в штатном варианте вызвает std::abort, который вызывает сигнал SIGABRT, который обрабатывается его обработчиком? При этом сигнал SIGSEGV ( *NULL=0; ) — сразу летит на свой обработчик (да, можно пошаманить с __try… __catch, но смысл ?).

                                            перечитал доки по std::terminate() — сейчас я бы наверно пробовал что-то типа std::_Exit(EXIT_SUCCESS);, но, как говорится: «был бы я такой умный, как моя жена завтра» :)

                                            UPD: вспомнил еще засаду для всех вариантов с std::zzzz: там был свой набор libc/libcpp/etc, так что вполне могло быть что и std::_Exit был перегруженый, с вызовом внутреннего менеджера памяти, так что сложно сказать — сработало бы или нет…
                                              0
                                              Хм, и правда. Тогда тем более не понятно чем std::abort не устроил.
                                            –2
                                            man 2 exit

                                            void _exit(int status);
                                            The function _exit() terminates the calling process «immediately». Any open file descriptors belonging to the process are closed.
                                          0

                                          А почему нельзя было сделать просто ExitProcess(0)?

                                            0
                                            наверно потому что он не такой быстрый и не такой замечательный (документация упоминает даже какой-то потенциальный дедлок и разные неопределенные состояния, если им пользоваться как попало), как то, что в итоге ушло в продакшен.
                                          0
                                          вообще без проблем на W7. один раз настроил ( лет 6 назад) мгновенно всё то что надо отрубается. память очищается.
                                            0
                                            Читал статью в оригинале. Так познакомился с WPA.
                                            Вобщем, проблема была устранена в 1803, в одном из ноябрьских обновлений.
                                            Но, когда я поставил Windows 1809, с декабрьскими обновлениями, оказалось, что проблема никуда не исчезла. Бред какой-то. Регресс как он есть.
                                            И ведь это semi-annual update (targeted). Официальный.
                                            Обидно очень. Хотелось бы как-то связаться с инженерами и ускорить выпуск патча.
                                            И забыть обо всём об этом.
                                            Проблема очень критическая, т.к. если кто знает ARQA QUIK — эта программа НЕЩАДНО тормозит именно из-за бага в win32kfull.sys, куда уходит банальная функция gdi lineTo
                                            win32kfull.sys!NtGdiLineTo и далее знакомый EPATHOBJ::bSimpleStroke
                                            Система настолько тормозит, что даже банальные часы в таскбаре замирают на несколько секунд. При этом 4 ядра, 8 нитей HT, 16 гиг памяти, винт SSD, всё доступно, но…

                                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                            Самое читаемое