Оптимизация – ваш злейший враг

Автор: Dr. Joseph M. Newcomer
Перевод: Андрей Лягусский
Источник: "Optimization - your worst enemy"
Материал предоставил: RSDN Magazine #6-2004
Опубликовано: 25.06.2005
Версия текста: 1.0
Оптимизация: Что и Когда
Процветающая жизнь – лучшее отмщение
Когда не нужно оптимизировать
Оптимизация – ваш враг
Выводы

Для начала позвольте привлечь ваше внимание к моей персоне. Я серьезно!

Немного общей информации: моя докторская работа была одной из первых работ по автоматической генерации оптимизирующих компиляторов из формального машинного описания («Машинно-независимая генерация оптимального кода», университет Карнеги-Меллона (в дальнейшем CMU – прим. перев.), кафедра информатики, 1975). После защиты докторской я провел три года в CMU в качестве ведущего исследователя многопроцессорной компьютерной системы Cmmp, использовавшей нашу местную операционную систему Hydra, безопасную и высокопроизводительную. Затем я вернулся к исследованию компиляторов на проекте PQCC (компилятор компиляторов промышленного уровня), который, в конечном счете, привел к основанию лабораторий Tartan (сейчас поглощены Texas Instruments) – компании по разработке компиляторов, в которой я работал в инструментальной группе. Я провел полтора десятка лет за написанием и использованием инструментов измерения производительности.

Это эссе состоит из нескольких частей и представляет по большей части мой собственный опыт. Истории, которые я рассказываю, произошли на самом деле. Имена не изменялись, правда, парочку из них пришлось изъять.

Оптимизация: Что и Когда

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

Итак, вы оптимизируете потому, что у вас проблемы с производительностью. Иногда это оптимизация вычислений: манипуляция картинкой слишком медленна. Иногда это оптимизация доступа к данным: слишком много времени нужно для загрузки данных. А иногда это оптимизация алгоритмов: вы ошиблись в алгоритмической основе. Если вы не понимаете разницу между квадратичной сложностью сортировки, и сложностью n log n, у вас наверняка проблемы, хотя само по себе такое знание совершенно бесполезно.

Пару лет назад я работал над сложной программой, которая должна была выполнять семантические кросс-проверки между «выражениями» в коде программы и «объявлениями». Я обнаружил, что вычисления имеют сложность n3 (вообще-то m*n2, но в большинстве случаев m было сравнимо с n). И здесь у вас три пути:

Единственный верный путь при оптимизации – это инженерный подход. Я исследовал производительность, и на самом большом «реальном» примере, который у нас был, я обнаружил, что практически всегда n равнялось 1, иногда 2, редко 3, и только один раз 4. И это были слишком маленькие значения, чтобы проявлять беспокойство. Конечно, сложность алгоритма равнялась n3, но при этом значения n были столь малы, что необходимости переписывать код не возникло. Переписывание кода являло собой сложную задачу, задержало бы весь проект на пару недель и потребовало бы дополнительно по нескольку указателей в каждом узле дерева в и без того тесном адресном пространстве миникомпьютера.

Также я написал распределитель памяти, который все использовали. И я провел кучу времени, оттачивая его производительность - чтобы это был самый быстрый распределитель в своем классе. Все эти приключения детально описаны в книге «IDL: The Language and its Implementation», в настоящий момент, увы, уже вышедшей из печати (Nestor, Newcomer, Gianinni and Stone, Prentice-Hall, 1990). Одна группа, использовавшая этот распределитель, также использовала специальный инструмент для измерения производительности на Unix. Эта программа определяла, где в данный момент крутится счетчик выполнения (program counter), и через достаточное время предоставляла «гистограмму плотности», показывающую, сколько времени профилированная программа проводила в каждом блоке кода. И этот инструмент показал, что львиную долю своего времени выполнения программа проводила в распределителе памяти. Для меня это не имело значения, но все пальцы указывали в моем направлении.

Тогда я написал небольшую зацепку в распределителе, которая подсчитывала количество его вызовов. И этот счетчик показал, что распределитель вызывался более 4 000 000 раз. Ни один вызов не занимал больше времени, чем минимальный измеримый интервал в 10 микросекунд (приблизительно десять инструкций на нашей 1-MIPS машине), но 40 000 000 микросекунд – это 40 секунд. Конечно, общее время было еще больше, потому что надо учесть 4 000 000 операций освобождения памяти, которые были, конечно, быстрее, но все-таки распределитель занимал более 50% времени выполнения всей программы.

Почему такое происходило? Потому что, по неизвестным для программистов причинам, критическая функция, которую они вызывали во внутреннем цикле своих алгоритмов, выделяла блок памяти в 5-10 байт, делала свою работу и освобождала его. Когда мы изменили это поведение на 10-байтовый локальный стековый буфер, время, занимаемое распределителем по отношению к общему времени выполнения всей программы снизилось до 3%.

Без дополнительной информации мы не смогли бы определить, почему распределитель вызывался так много раз. Профилирующие инструменты, основанные на счетчике выполнения – это очень слабый класс инструментов. Выдаваемые ими результаты часто бывают подозрительными. Вы можете обратиться к моей статье «Профилирование производительности» в Dr.Dobb’s Journal за январь 1993. (только для зарегистрированных пользователей – прим. перев.)

Классический случай грубой ошибки при оптимизации несколько лет назад допустила одна из крупнейших компаний разработки программного обеспечения. Мы имели дело с их первой интерактивной системой, работающей в режиме разделения времени, и эта работа позволила нам набраться нового опыта различными способами. Одна из таких возможностей выпала на долю группы, работавшей с компилятором FORTRAN. Сейчас любой разработчик компиляторов в курсе, что чем большую хэш-таблицу он использует для поиска символов, тем значительнее будет производительность при поиске. Когда вы разрабатываете многопроходный компилятор на мэйнфрейме с 32 килобайтами памяти, в результате вы придете к относительно маленькой таблице символов, но будете использовать очень, очень хороший алгоритм хеширования, так что вероятность коллизии при хешировании уменьшается (в отличие от двоичного поиска, который имеет сложность log n, хорошая хэш-таблица имеет константное время доступа относительно некоторой плотности таблицы, так что пока вы держите плотность ниже этого порога, можно ожидать, что стоимость доступа к символу или его добавления в среднем будет равна 1 или 2. Отличная хэш-таблица (которая обычно вычисляется заранее для константных символов) имеет константное время доступа в районе 1.0 или 1.3; когда достигается значение 1.5, хеширование нужно переработать).

И вот, эта группа, работавшая с компилятором, обнаружила, что у них теперь не 32 килобайта памяти, и не 128, и даже не 512. Вместо этого у них появилось 4 гигабайта виртуального адресного пространства. «Эй, а давайте-ка сделаем действительно большую хэш-таблицу!» - завопили они от радости. «Например, как насчет таблицы в 1 мегабайт?». Сказано – сделано. Однако кроме этого у них еще был чрезвычайно изощренный компилятор, разработанный специально для маленьких, густозаселенных хэш-таблиц. В результате, так как символы были равномерно распределены по 256 4-килобайтовым страницам в этом одном мегабайте, каждая операция обращения к символу из таблицы приводила к ошибке отсутствия страницы в памяти. Компилятор оказался той еще сволочью. Когда же наконец группа решила вернуться к 64-килобайтной таблице, несмотря на то, что алгоритм стал хуже по «абсолютной» производительности с чисто алгоритмической точки зрения (больше машинных инструкций требовалось для поиска символа), он не вызывал так много ошибок при обращении к отсутствующей странице памяти, и поэтому стал работать на порядок быстрее. Так что эффекты третьей стороны имеют значение.

Кроме того, избегайте С. Нет, не скорости света. Когда мы говорим об эффективности, с алгоритмической точки зрения это записывается как C * f(n). Так, квадратичный алгоритм формально будет обозначен как C * n2, что значит «константа умноженная на квадрат количества обрабатываемых элементов». Это сокращается до O(n2), или «квадратичной сложности», а в обиходе принято опускать слово «сложность». Но никогда не забывайте, что у вас еще есть C. Когда-то давно я выполнял проект, который на выходе выдавал набор отчетов, сортированных разными способами. Для начала (а дело было еще до языка С и qsort) я просто сделал обычную пузырьковую сортировку, алгоритм со сложностью O(n2). После первичного тестирования я скормил ему немного реальных данных. Через десять минут после того, как сообщение «Обработка отчетов» появилось на консоли, все еще не было никаких результатов. Парочка простых проверок показала, что все это время программа занималась сортировкой. Ну что же, я был наказан за свою лень. Пришлось откопать доверенный алгоритм сортировки в куче (n log n), и потратить час времени, чтобы реализовать его рабочую версию в моей программе (как вы помните, еще никакого qsort не было и в помине). Закончив с реализацией, я снова запустил тест. Через семь минут после начала фазы обработки отчетов результатов еще не было. Проверки вскрыли кое-что любопытное: теперь программа большую часть времени была занята выполнением эквивалента функции strcmp, сравнивая строки. Решая проблемы с О, я просто проигнорировал С. Поэтому сначала я сделал отдельную сортировку таблицы символов, представляющих имена, и ассоциировал каждую запись с целым числом. Затем, когда нужно было сортировать подструктуры, я уже имел дело с простыми целочисленными идентификаторами. Этот прием уменьшил константу C до того порога, при котором для полной сортировки отчетов требовалось менее 30 секунд. Вторичный эффект, но весьма значимый.

Некоторые инструменты профилирования измеряют только время процессов, проведенное в пользовательском режиме выполнения, а время выполнения в режиме ядра не учитывают. Это может замаскировать ту ударную нагрузку, которую приложение перекладывает на плечи процессов в режиме ядра. К примеру, однажды мне довелось разбираться с программой, производительность которой была просто на нуле. В терминах затраченного времени никаких узких мест c помощью профайлера обнаружено не было. Однако, когда я взглянул на отладочную информацию, то увидел что процедура считывания данных вызывалась около миллиона раз, что не является исключительным при обработке мегабайтов данных, но меня это насторожило. Посмотрев на выполнение кода при отладке, я обнаружил что каждый раз когда процедура вызывалась, она обращалась к ядру чтобы прочитать один байт из файла! Заменив такое поведение на работу с 8-килобайтным буфером, я получил 30-кратное увеличение производительности. Вывод из этого такой: время выполнения в режиме ядра имеет значение. Не случайно графический интерфейс пользователя начиная с NT 4.0 больше не является пользовательским процессом, а интегрирован в ядро. Процессы ядра диктуют уровень производительности.

Поэтому ответ на вопрос «что оптимизировать?» очень прост: оптимизировать надо то, что забирает слишком много времени. В то же время локальные оптимизации, игнорирующие общие проблемы с производительностью абсолютно бесполезны. И эффекты первого порядка (например, время выполнения, занятое распределителем) могут быть побочны. Семь раз отмерь – один раз отрежь.

Процветающая жизнь – лучшее отмщение

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

Когда-то, во времена зарождения языка C, его распределитель памяти был самым слабым из существующих. Это был алгоритм «первый попавшийся», то есть он работал следующим образом: распределитель просматривал все узлы в списке блоков памяти, и первый же попавшийся свободный блок, который был не меньше нужного размера, разбивался на две части – одна возвращалась по запросу, вторая (общий размер блока минус запрошенный размер) возвращалась в список свободных узлов. «Преимущества» этого очевидны – очень низкая скорость работы и дикая фрагментация памяти. В действительности это хуже, чем вы можете себе представить. При выделении памяти приходилось пробегать весь список блоков, игнорируя уже выделенные. Поэтому при увеличении числа блоков производительность падала, а блоки становились все меньше и были непригодны к использованию. Они отнимали время без всякой реальной пользы.

Я как-то работал в CMU по годичному контракту. И мое первое впечатление от использования среды Unix выражалось в желании пойти к кому-нибудь из окружающих и спросить – «как вы вообще можете жить при таком раскладе вещей?» Технологии ПО в 1990 были в точности теми же, что и за десять лет до этого, когда я заканчивал CMU, за исключением того, что в современном случае компилятор не работал (он генерировал неправильный код даже для простейших конструкций), отладчик не работал, отслеживание вызовов (которое целиком состояло из шестнадцатеричных адресов безо всяких символов) было бесполезным, линковщик не работал, и не было ничего даже отдаленно похожего на подходящую систему документирования. Не принимая во внимание эти мелочи, я ожидал хотя бы нормальной пользовательской среды. Используя до этого Microsoft C вместе с CodeView, и даже ранние версии среды Visual C, я установил для себя достаточно высокие стандарты относительно инструментария, от которых Unix (особенно в то время) отстала очень далеко. На целые мили. И пару раз я чистосердечно выразился на эту тему.

В один из дней мы обсуждали какой-то алгоритм, требовавший распределения памяти. Я был убежден, что это решение неприемлемо, так как распределение памяти обошлось бы слишком дорого. Я произнес что-то вроде «ну конечно, если вы будете использовать этот тупоголовый распределитель памяти из Unix, то вы обречены на проблемы с производительностью. Нормальный распределитель снял бы все вопросы.». Один человек из присутствовавших на обсуждении сразу же набросился на меня: «Мне неприятно слышать, как вы опускаете Unix. И вообще, что вы знаете о распределителях памяти?». На что мой ответ был – «задержитесь на этой мысли, я сейчас вернусь». Я сходил в свой кабинет, где лежала копия книги IDL, принес ее с собой назад, и открыл главу «Распределение памяти». «Видите это?» - «Да». «Как называется эта глава?» - «Распределение памяти». Я закрыл книгу и указал на обложку – «Это имя вам знакомо?» - «Да, это ваше имя». «Отлично. Я написал эту главу, в которой рассказывается о разработке высокопроизводительного, минимально фрагментирующего распределителя памяти. Итак, вы спрашивали, что я знаю о распределителях памяти? Вообще-то, я написал на эту тему книгу».

Больше никто не набрасывался на меня, когда я опускал Unix.

В качестве небольшого замечания. Распределитель памяти в NT работает весьма схожим образом с тем, что я описал в книге IDL, и основан на алгоритме «быстрого совпадения», разработанном Чаком Вейнстоком для его докторской в CMU около 1974 года.

Когда не нужно оптимизировать

Не занимайтесь «продвинутой» оптимизацией, которая в действительности не имеет смысла. Например, есть люди, которые пытаются «оптимизировать» графический интерфейс пользователя. Константы в виде «магических чисел» и супернавороченные алгоритмы. В результате получается нечто, что сложно разрабатывать, еще сложнее отлаживать и абсолютно невозможно поддерживать. В данном случае оптимизация бессмысленна. И вот почему.

Нужно принимать во внимание человеческий фактор. Компьютерная мышь находится приблизительно в 60 сантиметрах от уха. Звук распространяется со скоростью около 330 м/с. Это значит, что звук от щелчка мышью или нажатия клавиши доходит до уха за 2 миллисекунды. Цепочка нервных клеток от мозга до кончиков пальцев в длину составляет около 90 сантиметров. Распространение сигнала по нервным клеткам имеет скорость около 100 м/с, то есть факт нажатия кнопки мыши или клавиатуры принимается мозгом приблизительно через 10 миллисекунд. Плюс, задержка восприятия сигнала в мозгу может составить от 50 до 250 миллисекунд.

Как много инструкций процессор Pentium успеет выполнить за 2, или за 10, или за 100 миллисекунд? За 2 миллисекунды 500 MHz процессор выполняет 1 000 000 тактов, так что за это время вы сможете выполнить много инструкций. Даже на таком хламе как 120 MHz Pentium ощутимой задержки при обработке графических элементов управления нет.

Однако этот факт не помешал Microsoft полностью проигнорировать объектную модель для обработки событий; если вы обращаетесь к процедуре CWnd::OnWhatever(…), вместо того чтобы напрямую вызвать DefWindowProc с нужными параметрами, MFC повторно использует параметры последнего сообщения для вызова ::DefWindowProc. Целью этого было «уменьшение размера библиотеки MFC», как будто пара лишних строк кода в массивной библиотеке может иметь значение! Даже я могу сообразить, как вместо CWnd::OnWhatever(…) можно сделать inline-подстановку для вызова DefWindowProc.

Оптимизация – ваш враг

Когда-то, много лет назад, я работал на большой (16 процессоров) многопроцессорной системе. Использовались специальным образом модифицированные миникомпьютеры PDP-11, в целом относительно медленные. Мы программировали их с помощью Bliss-11, который, как я могу сказать, до сих пор находится в списке мировых лидеров среди сильно оптимизирующих компиляторов (несмотря на то, что я видел действительно впечатляющие оптимизации в Microsoft C/C++). Сделав несколько замеров уровня производительности, мы обнаружили, что алгоритм страничного доступа представляет собой узкое место. Поэтому естественным предположением было проверить алгоритм на наличие изъянов. После анализа кода человек, ответственный за этот алгоритм, переписал его, принимая во внимание наши новые пожелания насчет производительности. Через неделю у нас уже была новая, более быстро работающая версия алгоритма.

Между тем, в MIT (Массачусетский технологический университет), все еще работала операционная система MULTICS. И они указали на серьезную проблему с производительностью, которая упиралась в алгоритм страничного доступа. Из-за того, что реализация алгоритма была выполнена на PL/1-подобном языке, EPL, они предположили неоптимальность реализации ввиду использования языка высокого уровня. Поэтому были приложены усилия для переписывания алгоритма целиком на ассемблере. Через год, когда вся система была готова и вышла в промышленную эксплуатацию, потери производительности составили 5%. После детальной инспекции выяснился факт наличия ошибки в одном из фундаментальных алгоритмов. С использованием языка EPL, замена старой версии алгоритма исправленной была выполнена через пару недель. Вывод: не оптимизируйте что-то, не представляющее собой проблему. Для начала постарайтесь эту проблему обнаружить. И только после этого можно думать об оптимизации. В противном случае вся ваша оптимизация будет пустой тратой времени и может даже ухудшить производительность.

В компиляторе Bliss атрибут переменной register был равнозначен приказу для компилятора – «Ты действительно сохранишь эту переменную в регистре процессора». В языке С такой атрибут значит совсем другое – «Я бы хотел, чтобы ты сохранил эту переменную в регистре процессора». Множество программистов полагают, что они должны размещать свои переменные в регистрах для получения оптимального по производительности кода. Компилятор Bliss действительно очень хорош, и реализует очень сложную схему распределения регистров под переменные, и при отсутствии указаний от программиста обладает свободой выбора при размещении переменной в регистре, если такое действие улучшает производительность. Однако явное указание на сохранение переменной в регистре процессора делала этот регистр недоступным для более общих вычислений, в частности при доступе к структурам данных. После нескольких добросовестных экспериментов было обнаружено, что в подавляющем большинстве случаев добавление атрибута register к переменной порождало значительно худший код, чем если бы компилятор сам занимался переменными и регистрами. Многочасовые усилия при разработке какого-нибудь вложенного цикла могут привести к небольшому улучшению производительности, но в целом было совершенно ясно, что без изучения сгенерированного машинного кода и серии откалиброванных экспериментов любая попытка оптимизации ведет к худшему коду.

Если вы слышали об эталонных тестах производительности группы SPEC, то наверняка имеете представление о том, как эти тесты обыгрываются. В частности, IBM написала программу, которая берет базовую программу на FORTRAN (которая, к примеру может выполнять один из эталонных тестов – вроде умножения матриц), и преобразует ее с учетом оптимизации для архитектуры кэша системы, на которой программа будет работать. Небольшое количество параметров описывает все стратегии кэширования для модельной линии RISC 6000. Исходная программа показывает на какой-то системе результат в 45 очков по классификации SPEC. После соответствующей «оптимизирующей» модификации та же программа показывает результат в 900 очков. Это 20-кратное увеличение производительности основано целиком на сторонних эффектах четвертого порядка – стратегии кэширования для частной архитектуры. Если вы занимаетесь преобразованием изображений, особенно изображений больших размеров, то знание о кэшировании (пусть даже машинно-независимое) может принести вам прирост производительности на порядок.

Наивный подход к оптимизации на уровне строк кода не так эффективен, как высокоуровневая оптимизация. Оптимизация страничного доступа, кэширования и распределения памяти приносит обычно более ощутимые результаты, чем построчная оптимизация кода. Алгоритмическая оптимизация – следующий кандидат на рассмотрение, особенно если ваша проблема не поддается только что перечисленным способам оптимизации. И только после того, как все это было выполнено, вы можете переходить на уровень строк кода. И если ваша предметная область потребует, то можно даже запрограммировать внутренние циклы (особенно это касается алгоритмов свертки и цифровой обработки сигналов) на ассемблере, чтобы использовать все возможности специализированных инструкций вроде MMX или потоковой обработки мультимедиа.

Возможно, самый лучший пример чисто программистской глупости при «оптимизации» я видел, когда занимался переносом большой библиотеки в исследовательском проекте. Представьте себе, что это был перенос с 16-битной платформы на 32-битную (в действительности это было 18-бит на 36-бит портирование, и язык был совсем не С, но это неважно – устрашающий код можно написать на любом языке, и я видел программистов на С, совершающих те же ошибки). В основном все работало, но иногда возникала странная проблема, проявлявшаяся при редком совпадении условий, и приводила эта проблема к краху программы. Я начал разбираться. Память в куче была повреждена. Когда я обнаружил, как это происходило, оказалось, что память в куче повреждалась при использовании неверного указателя, который приводил к затиранию случайного места в этой же куче. О’кей, а как этот указатель стал испорченным? Четыре уровня вложенности вниз при использовании указателей, и через 12 непрерывных часов отладки я нашел источник проблемы. Но почему это произошло? Еще через 5 часов я обнаружил что программист написал конструктор для структуры данных, похожей на struct, в виде { char* p1; char* p2; } где указатели были сначала 16-битными, а потом стали 32-битными. Когда я посмотрел на код инициализации, вместо ожидаемых конструкций вроде something->p1 = NULL; something->p2 = NULL; я увидел код эквивалентный (*(DWORD*)&something.p1) = 0! На очной ставке программист пытался оправдаться тем, что он смог обнулить два указателя одной машинной инструкцией (хотя это был не x86-компьютер, а мэйнфрейм), и преподносил это действие как умную оптимизацию. Конечно, когда указатели стали 32-битными, такая оптимизация приводила к тому, что обнулялся только один из двух указателей, а второй оставался заполненным каким-то случайным значением. Я заметил что такая оптимизация срабатывала только один раз, при создании объекта; и что среднее приложение, использовавшее эту библиотеку создавало в среднем шесть таких объектов; и что я потратил в предшествующий день 17 часов личного времени и 6 часов машинного времени на отладку; и что если бы программа не содержала ошибки и запускалась бы непрерывно сразу после своего завершения в течение 14 часов, то время, сэкономленное этой «умной оптимизацией» просто рассеивается как пыль! Пару лет спустя этот программист все еще выкидывал подобные трюки – есть люди, которые никогда ничему не учатся.

Выводы

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

Тяжело справиться с такими последствиями! Теперь вы понимаете, в чем смысл заголовка статьи?


Эта статья опубликована в журнале RSDN Magazine #6-2004. Информацию о журнале можно найти здесь