7-zip — нет времени спешить

в 8:43, , рубрики: 7-zip, c++, WinAPI, windows, Клиентская оптимизация, разработка под windows, реверс-инжиниринг
7-zip — нет времени спешить - 1

Преамбула

Данной заметки не должно было быть, но мы живем не в идеальном мире.
Есть много пользователей, что предпочли стандартному проводнику Windows альтернативу и наверное еще больше пользователей архиватора 7-zip. Имеет смысл поделиться с ними, подумал я и вот мы здесь.

Опустим лишние подробности, случилось так, что я заметил в используемом файловом менеджере значительную задержку появления окна контекстного меню при выборе большого количества файлов. Даже учитывая что его код меню написан без учета современных рекомендаций MS, лаг был подозрительно большим. И хоть никого не вдохновляет идея потратить личное время на неоплачиваемую работу с непредсказуемым результатом, я решил немного разобраться в причинах.

Что делает обычный эникейщик в подобной ситуации? Конечно же берет в руки первый подвернувшийся профайлер.

Результат
7-zip — нет времени спешить - 2

Итак, локальным виновником оказался метод IContextMenu::QueryContextMenu, а по факту все время съедает... 7-zip.dll
Эта библиотека является расширением оболочки и отвечает за добавление команд архиватора 7-zip в контекстное меню. Не то что ожидаешь увидеть по итогу, но стоит проверить.

Отключаем показ меню архиватора в настройках программы.

7-zip — нет времени спешить - 3

И убеждаемся, что собака порылась именно тут.
Ну раз такое дело, почему бы не проверить WinRar? Ничего похожего не наблюдается, лаг отсутствует...

Присмотримся

Для более чистого эксперимента закинем тестовую директорию на RAMDISK, а файловый менеджер заменим на что-то более очевидное, например Total Commander.

Сразу стоит отметить, что все изложенное не затрагивает проводник Windows, там своя внутренняя API магия, которая не ограничивается двухступенчатой инициализацией (сначала для первых 16 объектов, чтобы быстро отобразить меню, а после выбора команды, Invoke уже для полного набора). Все остальные файловые менеджеры почти поголовно подвержены проблеме, как впрочем и практически любой другой софт.

Что ж, попробуем вызвать меню в TC для файлов в нашей папке. Для наглядности сделаем количество побольше. Тест на 20k файлах выявил замедление более чем в х20 раз, а именно 3 секунды без 7-zip и более минуты! с ним.

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

Скучный спойлер. WinAPI и базовый алгоритм

Как обычно приложения создают контекстные меню.
Приведу упрощенное изложение стандартного алгоритма.

Небольшая отсылка к MSDN
Для большинства не секрет, что ОС для внутренних операций с объектами файловой системы использует не текстовые представления путей к которым мы привыкли, а некие альтернативные идентификаторы которые могут определять и виртуальные объекты.

Идентификатор элемента (функционально эквивалентен имени файлапапки) фактически является структурой SHITEMID. Список таких идентификаторов определяется структурой ITEMIDLIST.

Для API оболочки объекты пространства имен обычно идентифицируются указателем как раз на эту самую структуру - PIDL (Pointer to an ITEMIDLIST).

В общем, изначально, тем или иным образом приложение (например ФМ) должно преобразовать пути в список идентификаторов. Независимо от API (ILCreateFromPath, SHParseDisplayName итп) в конечном счете будет вызван метод интерфейса IShellFolder рабочего стола Desktop.IShellFolder::ParseDisplayName, внутри которого для стандартных путей идентификаторы будут собраны из WIN32_FIND_DATAW, тут (на мой взгляд) можно было бы немного оптимизировать, но разработчикам виднее.
На Win10+ этот метод вызывается из windows.storage.dll (CFSFolder::ParseDisplayName), как и ряд методов ниже.

Затем обычно следует цепочка вызовов (упрощенный вариант):

Desktop.IShellFolder::BindToObject    Получение интерфейса (IShellFolder) родительского объекта (другие подобные API все равно вызовут его)
Parent.IShellFolder::GetUIObjectOf    Получение интерфейса (IContextMenu)
IContextMenu::QueryInterface          Для получения указателя более высокого уровня (IContextMenu3,IContextMenu2)
CreatePopupMenu                       Создается само меню
IContextMenu#::QueryContextMenu       Заполнение меню
TrackPopupMenuEx                      Показываем меню

В GetUIObjectOf мы передаем массив указателей на наши относительные идентификаторы и получаем на выходе интерфейс с дефолтным меню.

При, казалось бы, таком простом действии как создание меню, в действительности "за кулисами" происходит просто огромный объем работы.
Описание всего этого "цирка с конями" потянет на цикл отдельных статей, поэтому сфокусируемся на области используемой 7-zip.

Мы уже выяснили, что инициатор проблемной цепочки это QueryContextMenu.
Внутри CDefFolderMenu::QueryContextMenu происходит много разного
В зависимости от флагов поведение различается, однако базово, к пунктам дефолтного меню добавляются новые с объединением.
По типам объектов в списке сканируются соответствующие ветки реестра на наличие статических и динамических обработчиков.

Для динамики вызываются:
CDefFolderMenu::_AddHandlersToDCA
HDXA_QueryContextMenu

Условный CDefFolderMenu:

*IContextMenu                                       ;  0x00  (00)  CDefFolderMenu::vftable{IContextMenu3}
*IServiceProvider                                   ;  0x08  (08)  CDefFolderMenu::vftable{IServiceProvider}
*IObjectProvider                                    ;  0x10  (16)  CDefFolderMenu::vftable{IObjectProvider}
*IShellExtInit                                      ;  0x18  (24)  CDefFolderMenu::vftable{for IShellExtInit}
*IObjectWithSelection                               ;  0x20  (32)  CDefFolderMenu::vftable{IObjectWithSelection}
*IOleWindow                                         ;  0x28  (40)  CDefFolderMenu::vftable{IOleWindow}
*IDefaultFolderMenuInitialize                       ;  0x30  (48)  CDefFolderMenu::vftable{IDefaultFolderMenuInitialize}
*IVerbStateTaskCallBack                             ;  0x38  (56)  CDefFolderMenu::vftable{IVerbStateTaskCallBack}
*IContextMenuBaseInfo                               ;  0x40  (64)  CDefFolderMenu::vftable{IContextMenuBaseInfo}
*IContextMenuForProgInvoke                          ;  0x48  (72)  CDefFolderMenu::vftable{IContextMenuForProgInvoke}
*IDefaultFolderMenuGetStateFromWrapperInstances     ;  0x50  (80)  CDefFolderMenu::vftable{IDefaultFolderMenuGetStateFromWrapperInstances}
*IContextMenuCB                                     ;  0x68  (104) CFSFolder::vftable{IContextMenuCB}
*IDataObject                                        ;  0x70  (112) CFSIDLData::vftable{IDataObject}
*IShellFolder2                                      ;  0x88  (136) CFSFolder::vftable{IShellFolder2}
*hdsaStatics                                        ;  0xB0  (176) HDSA - массив для статики    DSA_Create(sizeof(STATICITEMINFO),  1)
*HDXA                                               ;  0xB8  (184) HDXA - массив для динамики   DSA_Create(sizeof(CONTEXTMENUINFO), 5)
*hdsaCustomInfo                                     ;  0xC0  (192) HDSA - массив SEARCHINFO's   DSA_Create(sizeof(SEARCHINFO),      1)
*pidlFolder.LPITEMIDLIST                            ;  0xC8  (200) PIDL родительской директории
*apidl.LPITEMIDLIST                                 ;  0xD0  (208) Указатель на массив структур ITEMIDLIST
cidl                                                ;  0xD8  (216) Размер массива apidl (количество структур)
*paa.IAssociationArray                              ;  0xE0  (224) CAssocArray::vftable{IQueryAssociations}
*psss.IServiceProvider                              ;  0xE8  (232) CSafeServiceSite::vftable{IServiceProvider}
nKeys                                               ;  0xF0  (240) Количество хэндлов ключей реестра в массиве hkeyClsKeys
hkeyClsKeys[16]                                     ;  0xF8  (248) HKEY - (#DEF_FOLDERMENU_MAXHKEYS = 16) - Массив открытых хэдлов реестра для класса

При сборе инфы реестра заполняются соответствующие пулы данных, в основном это:

  • Ассоциативный массив IAssociationArray (упорядоченный список путей реестра с информацией о типе, обработчиках, псевдонимах команд, иконках итп)

  • Три динамических массива структур - для статических (hdsaStatics), динамических (HDXA) обработчиков и массив SEARCHINFO (hdsaCustomInfo).

Впоследствии при вызове метода CDefFolderMenu::InvokeCommand, если команда (verb) не каноническая (из списка дефолтного меню) и не статика, функция HDXA_LetHandlerProcessCommandEx начнет перебор структур CONTEXTMENUINFO из массива HDXA и будет пробовать вызвать метод IContextMenu::InvokeCommand по указателю в начале структуры. И тут, либо какой-то метод успешно отработает, либо, если массив кончился, CDefFolderMenu::InvokeCommand вернет E_INVALIDARG

На этом этапе нужно подгрузить в адресное пространство процесса библиотеки всех востребованных динамических расширений меню, а в случае 7-zip, это практически всегда. И тут мы подходим к инициализации нашего обработчика.

Стоит держать в уме, что такой обработчик меню для программиста это зона высокой ответственности, ведь данный код будет выполняться при каждом вызове контекстного меню для объектов ФС и писаться он должен с максимальной оптимизацией.

Нужно больше тестов

Проверим сначала на единственном файле.
Создаем пустую папку и в ней пустой текстовый файл, аттачимся к TC и ставим bp 7_zip!DllGetClassObject
Запускаем выполнение процесса и вызываем контекстное меню файла. После остановки делаем трассировку. На данный момент мы увидим лишь портянку со змейкой вызовов много раз повторяющегося кода 7_zip!DllUnregisterServer+offset

Суммарный результат (не полной) трассировки:

Function Name                 Invocations MinInst MaxInst AvgInst
7_zip                                 559       6     696      38
7_zip!DllGetClassObject                 1      44      44      44
7_zip!DllUnregisterServer            1308       3    2741      23
7-zip — нет времени спешить - 4

У меня конечно есть нелюбовь к построению причинно-следственных связей, но даже мне кажется, что в текущем контексте, вызовов для модуля достаточно много. Конечно можно уже пробовать курить исходники и делать какие-то выводы, но мы пока не будем, хотя сырки все равно нужны. Чтобы было красиво и наглядно нам не хватает символов для 7-zip.dll.

Ну, это дело пяти минут.

Забираем с оффсайта архив, в CPP/Build.mak дописываем в CFLAGS /Zi, в LFLAGS /DEBUG и проставим -Od в CFLAGS_OX

Выполняем в командной строке студии (x64 Native)
cd /d X:7z2201-srcCPP7zip
nmake /f makefile CPU=AMD64 (Если сильно спешите, скомпилируйте только dll)

Теперь у нас есть отладочные символы. Добавим путь к ним и сыркам для отладчика.
Также подменим библиотеку в папке архиватора. Повторим трассировку и посмотрим топ.

Результат
Function Name                               Invocations MinInst MaxInst AvgInst
7_zip!UString::Len                                  436       4       4       4
7_zip!operator new                                  380      10      10      10
7_zip!_malloc_base                                  380      19      19      19
7_zip!malloc                                        380       1       1       1
7_zip!operator new[]                                361       1       1       1
7_zip!free                                          287       3       3       3
7_zip!_free_base                                    287       3      13      12
7_zip!operator delete                               287       1       1       1
7_zip!operator delete[]                             287       1       1       1
7_zip!memcpy                                        250      11      46      20
7_zip!wmemcpy                                       234      12      12      12
7_zip!UString::UString                              243      20     128      22
7_zip!UString::SetStartLen                          131      27      27      27

С символами все стало достаточно наглядно. Посмотрим стек на самой часто вызываемой функции.

стек
7_zip!UString::Len:
00007ffb`b3b71690 48894c2408      mov     qword ptr [rsp+8],rcx ss:00000000`01de7cf0=0000000001de7d38
0:000> k
 # Child-SP          RetAddr           Call Site
00 00000000`01de7ce8 00007ffb`b3b71c06 7_zip!UString::Len [X:7z2201-srcCPPCommonMyString.h @ 587] 
01 00000000`01de7cf0 00007ffb`b3b71897 7_zip!CLang::OpenFromString+0x2d6 [X:7z2201-srcCPPCommonLang.cpp @ 71] 
02 00000000`01de7d90 00007ffb`b3b82224 7_zip!CLang::Open+0x1f7 [X:7z2201-srcCPPCommonLang.cpp @ 146] 
03 00000000`01de7e20 00007ffb`b3b82493 7_zip!LangOpen+0x24 [X:7z2201-srcCPP7zipUIFileManagerLangUtils.cpp @ 30] 
04 00000000`01de7e50 00007ffb`b3b82534 7_zip!OpenDefaultLang+0x143 [X:7z2201-srcCPP7zipUIFileManagerLangUtils.cpp @ 257] 
05 00000000`01de7ee0 00007ffb`b3b82339 7_zip!ReloadLang+0x34 [X:7z2201-srcCPP7zipUIFileManagerLangUtils.cpp @ 279] 
06 00000000`01de7f40 00007ffb`b3b7fa30 7_zip!LoadLangOneTime+0x39 [X:7z2201-srcCPP7zipUIFileManagerLangUtils.cpp @ 43] 
07 00000000`01de7f80 00007ffb`b80cfe1f 7_zip!CZipContextMenu::QueryContextMenu+0x20 [X:7z2201-srcCPP7zipUIExplorerContextMenu.cpp @ 535] 
08 00000000`01de8ae0 00007ffb`b80a7b70 SHELL32!HDXA_QueryContextMenu+0x5e7
09 00000000`01de8d80 00000000`005ed2ce SHELL32!CDefFolderMenu::QueryContextMenu+0x5c0
0a 00000000`01de8ef0 00000000`00000001 TOTALCMD64+0x1ed2ce

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

Конечно странное решение.
Почему бы не брать строки по указателям из закешированного шаблона?
Зачем каждый раз самоотверженно парсить один и тот же файл локализации (16Кб для ru) при каждом нажатии ПКМ? Медленные строковые операции стоит минимизировать прежде всего. Хотя что я докопался, это даже "не те дройды" которых мы ищем...

Если указать профайлеру путь к полученным символам, мы увидим больше подробностей и там, но ничего принципиально нового, можно сразу открывать отладчик.

Перейдем в TC в тестовую директорию, где файлов побольше.
После срабатывания bp 7_zip!DllGetClassObject поставим точку остановки на SHELL32!DragQueryFileAorW и глянем стек.
На третьей остановке мы уже попадаем в цикл функции и можем отследить всю проблемную цепочку.

Breakpoint 1 hit (вызов #3+) SHELL32!DragQueryFileAorW:
0:000> k
 # Child-SP          RetAddr           Call Site
00 00000000`01de88d8 00007ffb`b8212f43 SHELL32!DragQueryFileAorW
01 00000000`01de88e0 00007ffb`b3b7a063 SHELL32!DragQueryFileW+0x13
02 00000000`01de8920 00007ffb`b3b7a0e9 7_zip!NWindows::NShell::CDrop::QueryFile+0x33 [X:7z2201-srcCPPWindowsShell.h @ 68] 
03 00000000`01de8950 00007ffb`b3b7a18c 7_zip!NWindows::NShell::CDrop::QueryFileName+0x79 [X:7z2201-srcCPPWindowsShell.cpp @ 114] 
04 00000000`01de89a0 00007ffb`b3b7e16d 7_zip!NWindows::NShell::CDrop::QueryFileNames+0x5c [X:7z2201-srcCPPWindowsShell.cpp @ 130] 
05 00000000`01de89f0 00007ffb`b3b7e7f1 7_zip!CZipContextMenu::GetFileNames+0x11d [X:7z2201-srcCPP7zipUIExplorerContextMenu.cpp @ 146] 
06 00000000`01de8aa0 00007ffb`b80cfc44 7_zip!CZipContextMenu::Initialize+0xd1 [X:7z2201-srcCPP7zipUIExplorerContextMenu.cpp @ 177] 
07 00000000`01de8ae0 00007ffb`b80a7b70 SHELL32!HDXA_QueryContextMenu+0x40c
08 00000000`01de8d80 00000000`005ed2ce SHELL32!CDefFolderMenu::QueryContextMenu+0x5c0
09 00000000`01de8ef0 00000000`00004e20 TOTALCMD64+0x1ed2ce
Вся цепочка вызовов с исходным кодом функций
STDMETHODIMP CZipContextMenu::Initialize(LPCITEMIDLIST pidlFolder, LPDATAOBJECT dataObject, HKEY /* hkeyProgID */)
{
  _dropMode = false;
  _dropPath.Empty();
  if (pidlFolder != 0)
  {
    #ifndef UNDER_CE
    if (NShell::GetPathFromIDList(pidlFolder, _dropPath))
    {
      NName::NormalizeDirPathPrefix(_dropPath);
      _dropMode = !_dropPath.IsEmpty();
    }
    else
    #endif
      _dropPath.Empty();
  }
  return GetFileNames(dataObject, _fileNames); 🡰
}

🡻

HRESULT CZipContextMenu::GetFileNames(LPDATAOBJECT dataObject, UStringVector &fileNames)
{
  fileNames.Clear();
  if (!dataObject)
    return E_INVALIDARG;

  #ifndef UNDER_CE
  FORMATETC fmte = {CF_HDROP, NULL, DVASPECT_CONTENT, -1, TYMED_HGLOBAL};
  NCOM::CStgMedium stgMedium;
  HRESULT result = dataObject->GetData(&fmte, &stgMedium);
  if (result != S_OK)
    return result;
  stgMedium._mustBeReleased = true;

  NShell::CDrop drop(false);
  NMemory::CGlobalLock globalLock(stgMedium->hGlobal);
  drop.Attach((HDROP)globalLock.GetPointer());
  drop.QueryFileNames(fileNames);  🡰
  
  #endif

  return S_OK;
}

🡻

void CDrop::QueryFileNames(UStringVector &fileNames)
{
  UINT numFiles = QueryCountOfFiles();
  fileNames.ClearAndReserve(numFiles);
  for (UINT i = 0; i < numFiles; i++)
  {
    const UString s2 = QueryFileName(i); 🡰
    if (!s2.IsEmpty())
      fileNames.AddInReserved(s2);
  }
}

🡻

UString CDrop::QueryFileName(UINT fileIndex)
{
  UString fileName;
  {
    UINT bufferSize = QueryFile(fileIndex, (LPWSTR)NULL, 0);    🡰  вызов чтобы узнать размер буфера
    const unsigned len = bufferSize + 2;
    QueryFile(fileIndex, fileName.GetBuf(len), bufferSize + 1); 🡰  реальный вызов
    fileName.ReleaseBuf_CalcLen(len);
  }
  return fileName;
}

🡻

UINT QueryFile(UINT fileIndex, LPWSTR fileName, UINT fileNameSize)
    { return ::DragQueryFileW(m_Object, fileIndex, fileName, fileNameSize); }

🡻

SHELL32!DragQueryFileW

🡻

┌─────────────────────────────────────────────────────┐
│    4     0 [  0] SHELL32!DragQueryFileW             │
│   17     0 [  1]   SHELL32!DragQueryFileAorW        │
│   15     0 [  2]     KERNEL32!GlobalLock            │
│   28     0 [  3]       KERNEL32!IsBadReadPtr        │
│   22    28 [  2]     KERNEL32!GlobalLock            │
│   40    50 [  1]   SHELL32!DragQueryFileAorW        │
│    1     0 [  2]     KERNEL32!lstrlenWStub          │
│  654     0 [  2]     KERNELBASE!lstrlenW            │
│   60   705 [  1]   SHELL32!DragQueryFileAorW        │
│ 2181     0 [  2]     SHELL32!StringCchCopyW         │
│   68  2886 [  1]   SHELL32!DragQueryFileAorW        │
│   15     0 [  2]     KERNEL32!GlobalUnlock          │
│   73  2901 [  1]   SHELL32!DragQueryFileAorW        │
│    6     0 [  2]     SHELL32!_security_check_cookie │
│   83  2907 [  1]   SHELL32!DragQueryFileAorW        │
│    6  2990 [  0] SHELL32!DragQueryFileW             │
└─────────────────────────────────────────────────────┘

Коротко по сути - мы кликаем ПКМ на файлах (например хотим посмотреть свойства, или удалить), TC инициализирует процесс создания меню и подгружает расширения, дело доходит до 7-zip...
И тут 7-zip такой:
- Минуточку! Есть мнение, что вы недостаточно страдаете от оверинжиниринга реализации контекстного меню MS и я буду в буфер получать строки файловых путей для каждого выбранного объекта. Мне ведь надо проверить, что вы там выбрали и соответствующие пункты меню показать, чтобы все "по красоте".
- Кто вам сделал удобное меню?
- Кто молодец? А?... вот то-то же!

Безусловно стояла задача сделать пользователю "удобно", но стоило бы потестить и конечную реализацию удобняшек. А по факту мы имеем много бесполезной работы съедающей время.

Также в CDrop::QueryFileName вместо того чтобы один раз выделить буфер достаточного размера, вся цепочка с DragQueryFileW для каждого объекта вызывается дважды, умножая и мою печаль.

В общем, стоит все таки иногда прислушиваться к рекомендациям MS, как это делает WinRar и не пытаться объять "необъятное".

Псевдокод из функции в rarext.dll (расширение WinRar)
ObjCount = DragQueryFileW(hDrop, 0xFFFFFFFF, 0i64, 0);
if ( !ObjCount )
   return E_FAIL;
sub_39D20(a1 + 24);
Count = 16;                                 ✓
if ( ObjCount < 16 )                        ✓
   Count = ObjCount;                        ✓
for ( i = 0; i < Count; ++i )               ✓
{
   szFile[0] = 0;
   DragQueryFileW(hDrop, i, szFile, 0x800u);
   sub_39DC0(a1 + 24, szFile);
}

Причины найдены. Делаем выводы.

Ситуация неоднозначная, поскольку с одной стороны это проблема подавляющего большинства софта, мало кто следует гайдлайнам и мы имеем медленные меню. Но с другой, 7-zip забивший на рекомендации, вносит дополнительные задержки.

Как и где поправить код для 7zip (тем кто захочет это сделать), надеюсь понятно.
Сам я еще тот гуманитарий и поэтому оставлю поиск и разбор прочих недоработок в сырках архиватора тем кто разбирается в вопросе и кому это интересно.

Тестирование производилось на

  • последнем ванильном билде 7-zip v22.01 от 2022-07-15

  • Windows 10 Pro 21H2 19044.1320 x64, CPU 3800МГц

При желании быстро воспроизвести похожий результат (задумайтесь о жизни, если оно возникло), можете воспользоваться командным файлом для создания тестовой директории.

Например, таким
@echo off
cd /d "%~dp0"
set TestDir=%~dp0TEST
md "%TestDir%"
echo "Working..."
for /L %%N in (1,1,20000) do REM. >"%TestDir%%%N.txt"

При запуске создаст рядом с собой папку TEST с 20k текстовыми файлами нулевого размера.
Затем открываете любой ФМ кроме штатного проводника, Ctrl+A и ПКМ.

Многие пользователи TC скорее всего не используют архиватор "классическим" образом, но это совершенно не важно в рамках рассматриваемой проблемы.

Можно было бы обойтись без отладки и сразу смотреть исходники, но часто ситуация бывает не столь однозначна, как выглядит на первый взгляд и в попытке сэкономить десяток минут, можно потратить больше, или что-то упустить.

Кто-то может сказать, что все это ерунда и можно в общем-то игнорировать при современных мощностях железа, а с действительно большими количествами файлов мало кто работает. Это ваш выбор, пусть этой зимой вам будет чуть теплее, чем остальным, просто чаще вызывайте меню 😜

Мораль проста - не все динамические расширения одинаково полезны, а если вы их пишите, надеюсь, умеете "в оптимизацию".
Ну и убирайте всё неиспользуемое из меню (ShellExView в помощь).

Автор: Влад Волков

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js