Если говорить о себе кратко– "я просто познаю окружающий мир и получаю от этого удовольствие". Компьютерами одержим еще со старших классов средней школы (или еще раньше – уже, увы, не помню).
Основная специализация: разработка оптимизирующих компиляторов и операционных систем реального времени для управления производством.
Из всех языков программирования больше всего люблю ассемблер, но при разработке больших проектов предпочитаю С (реже – C++). Для создания сетевых приложений прибегаю к помощи Perl и Java, ну а макросы в Word'e и Visual Studio пишу на Бейсике. Эпизодически балуюсь Forth'ом и всякими "редкоземельными" языками наподобие Python'a. Впрочем, важен не сам язык, а мысли, которые этим языком выражают.
Но все-таки, компьютеры – не единственное и, вероятно, даже не самое главное увлечение в моей жизни. Помимо возни с железом и блужданий в непроходимых джунглях защитного кода, я не расстаюсь с миром звезд и моих телескопов, много читаю, да и пишу тоже (в последнее время как-то больше пишу, чем читаю).
"Техника оптимизации программ" – уже седьмая по счету моя книга. Предыдущие: "Техника и философия хакерских атак", "Техника сетевых атак", "Образ мышления – дизассемблер IDA", "Укрощение Интернет", "Фундаментальные основы хакерства. Искусство дизассемблирования" и "Предсказание погоды по местным атмосферным признакам" были горячо одобрены читателями и неплохо расходились, несмотря на то, что ориентированы на очень узкий круг читателей (гораздо более узкий, нежели "Техника оптимизации программ").
Помимо этому моему перу принадлежит свыше двухсот статей, опубликованных в журналах "Программист", "Открытые системы", "Инфо - бизнес", "Компьютерра", "LAN", "eCommerce World", "Mobile", "Byte", "Remont & Service", "Astronomy", "Домашний компьютер", "Интерфейс", "Мир Интернет", "Магия ПК", "Мегабайт", "Полный ПК", "Звездочет" и др.
Хакерские мотивы моего творчества не случайны и объясняются по-детски естественным желанием заглянуть "под капот" компьютера и малость потыкать его "ломом" и "молоточком", разумеется, фигурально - а как же иначе понять как эта штука работает? Если людей, одержимых познанием окружающего мира, считать хакерами, то я – хакер.
A VOL D'OISEAU
На этом вводную часть книги можно считать законченной.AMD Code Analyst

Рисунок 4 0x007 Логотип профилировщика AMD Code Analyst
AMD Code Analyst на два порядка уступает своему прямому конкуренту VTune, и я бы ни за что не порекомендовал использовать его в качестве вашего основного профилировщика. Опасаясь быть побитым агрессивными поклонниками AMD, я все же позволю перечислить основные недостатки Code Analyst'a:
Во-первых, он требует обязательно наличия отладочной информации в профилируемой программе. Программу без отладочной информации он просто откажется загружать! Компиляторы же, в подавляющем своем большинстве, никогда не помещают отладочную информацию в оптимизированные программы. Это объясняется тем, что оптимизация, внося значительные изменения в исходный код, уничтожает прямое соответствие между номерами строк программы и сгенерированными машинными инструкциями. Фактически, оптимизированная и не оптимизированная программа – это две разные программы, имеющие различные, пусть и пересекающиеся, подмножества горячих точек. Профилировка не оптимизированного варианта программы принципиально не позволяет найти и устранить все узкие места настоящего приложения. (При отключенной оптимизации узкие места могут быть найдены там, где их нет).
Во-вторых, разрешающая способность его диаграмм ограничивается строками исходного текста программы, но, увы, не машинными командами (как у VTune). И хотя в принципе Code Analyst позволяет засекать время выполнения каждой инструкции, он не предоставляет никаких механизмов выделения "горячих" точек на этом уровне. Всю работу по выявлению "тяжеловесных" машинных команд нам приходится выполнять самостоятельно, "вручную" просматривая столбик цифр колонки CPI (Cycle per Instruction). Надо ли говорить, что даже в относительно небольшом участке программе количество машинных команд может достигать нескольких тысяч и подобный "кустарный" анализ их температур может растянуться черт знает на сколько времени.
В-третьих, Code Analyst не дает никаких советов по ликвидации выявленных узких мест программы, что не очень-то обрадует программистов-новичков (а таковых, как показывает практика, большинство и лишь очень немногие из нас поднаторели в оптимизации).
В-четвертых, Code Analyst просто неудобен в работе. Неразвитая система контекстных меню, крайне не конфигурабельный и вообще аскетичный интерфейс, отсутствие возможности сохранения "хронологии" профилировки… все это придает ему черты незаконченной утилиты, написанной на скорую руку.
Тем не менее, Code Analyst весьма компактен (его последняя на данный момент версия 1.1.0 занимает всего 16 мегабайт, что на порядок меньше VTune), стабилен и устойчив в работе и главное – он содержит полноценный эмулятор процессоров K6-II, Athlon
(с внешним и интегрированным кэшем), Duron, включая и их мобильные реализации. Причем, возможно вручную выбирать частоту шины и ядра. Это полезно хотя бы для оценки влияния частоты шины на производительность, что особенно актуально для приложений интенсивно работающих с основной оперативной памятью (жалко, но VTune лишен этой возможности). Наконец, Code Analyst содержит толковую справку, сжато и внятно описывающую узкие места процессора. И – самое приятное – он, в отличии от VTune, абсолютно бесплатен!
В конечном счете, независимо от степени своих симпатий (антипатий) к этому профилировщику, правило хорошего тона программирования обязывают использовать его для оптимизации ваших приложений под процессор Athlon, который занимает весьма существенную долю рынка и этим фактом нельзя пренебрегать!
Аннотация
Хотите заглянуть внутрь черного ящика подсистемы оперативной памяти? Хотите узнать: что чувствует, чем дышит и какими мыслями живет каждая микросхема вашего компьютера? Хотите научиться минимальными усилиями создавать эффективный программный код, исполняющийся вдвое – втрое быстрее обычного? Хотите использовать возможности современного оборудования на полную мощь? Тогда – вы не ошиблись в выборе книги!Перед вами лежит уникальное практическое пособие по оптимизации программ под платформу IBM PC и операционные системы семейства Windows (UNIX), скрупулезно описывающее архитектуру, философию и принципы функционирования современных микропроцессоров, чипсетов, оперативной памяти, операционных систем, компиляторов и прочих компонентов ПК.
Это одна из тех редких книг, если вообще не уникальная книга, которая описывает переносимую оптимизацию на системном уровне и при этом ухитряется практически не прибегать к ассемблеру.
Здесь вы найдете и оригинальные приемы программирования, и недокументированные секреты, существование которых Intel и Microsoft хотели бы скрыть, и разъяснение туманных пунктов фирменной документации (включая указания на многочисленные ошибки и неточности!), и перечень типовых ошибок программистов, снижающих производительность системы, и вполне готовые к использованию решения, и…
Основной упор сделан на процессоры AMD Athlon, Intel Pentium-III и Intel Pentium-4 и языки программирования Си/Си ++ (впрочем, описываемые техники не привязаны ни к какому конкретному языку, и знание Си требуется лишь для чтения исходных текстов примеров, приведенных в книге).
Аппаратная оптимизация
На мгновение отвлечемся от компьютеров и зададимся вопросом: можно ли с помощью обычной ученической линейки измерить толщину листа принтерной бумаги? На первый взгляд, тут без штангенциркуля ну никак не обойтись… Но, если взять с полсотни листов бумаги и плотно сложить их друг с другом… вы уже поняли куда я клоню? Пусть погрешность измерения толщины образовавшегося "кирпича" составит ±1 мм, тогда – точность определения толщины каждого отдельно взятого листа будет не хуже чем ±0,02 мм, что вполне достаточно для большинства повседневных целей!Почему бы не применить эту технику для измерения времени выполнения машинных команд? В самом деле, время выполнения одной команды так мало, что ничем его не измерять (см. "Неточность измерений"), но если мы возьмем сто или даже сто тысяч таких команд, то… Увы! Машинные команды – ведут себя совсем не так, как листы бумаги. Неоднородность конвейера приводит к тому, что зависимость между количеством и временем выполнения команд носит ярко выраженный нелинейный характер.
К тому же, современные процессоры слишком умы, чтобы воспринимать переданный им на выполнение код буквально. Нет! Они подходят к этому делу весьма творчески. Вот допустим встретится им последовательность команд MOV EAX, 1; MOV EAX, 1; MOV EAX, 1, каждая из которых помещает в регистр EAX значение "1". Думаете, процессор как полный идиот, исполнит все три команды? Да как бы не так! Поскольку, результат двух первых присвоений никак не используется, процессор отбросит эти команды как ненужные, затратив время лишь на их декодирование, и ощутимо сэкономит на выполнении!
Оптимизация программного кода, выполняемая процессором на аппаратном уровне, значительно увеличивает производительность системы, занижая тем самым фактическое время выполнения машинных команд. Да, мы можем точно измерять сколько тактов выполнялся блок из тысячи таких-то команд, но следует с большой осторожностью подходить к оценке времени выполнения одной такой команды.
означает сказать лишь половину правды.
Сказать, что AMD опередила Intel с поддержкой программной предвыборки, – означает сказать лишь половину правды. Предвыборка отнюдь не является оригинальным изобретением AMD и в мире не-PC компьютеров она достаточно широко распространена. Поскольку, непосредственное управление кэшем не может осуществляться без учета характеристик подсистемы памяти с одной стороны, и архитектуры процессора с другой, – оно всегда аппаратно – зависимо. В мире "больших" компьютеров, конфигурации которых более или менее предсказуемы, "заточка" оптимизации программы под конкретное оборудование – явление вполне нормальное.Но вот PC – дело другое. Оптимальная стратегия предвыборки зависит и от типа оперативной памяти, и от времени доступа к ней, и от ее латентности, и от характеристик чипсета, и от разрядности и тактовой частоты системной шины, и от частоты и архитектуры ядра процессора, и от политики кэширования, и от длины кэш-линеек, и от разрядности и частоты внутренней шины, и от латентности кэш-памяти… Многообразие конфигураций персональных компьютеров приводит к тому, что программная предвыборка создает проблем больше, чем их решает.
Создатели P-4 сделали большой шаг вперед, реализовав механизм аппаратной предвыборки, или, иначе говоря, – усовершенствованную стратегию упреждающего считывания. До сих пор кэш-контроллеры всех бытовых микропроцессоров приступали к загрузке кэш-линейки лишь после явного обращения к ней, а предвидеть: какая линейка будет запрошена следующей, они не могли – интеллектуальности не хватало!
Pentium-4 не только осуществляет упреждающую загрузку последующих 256 байт (двух кэш-линеек) в кэш-второго уровня, но и отслеживает регулярные шаблоны обращения к данным, что позволяет предугадывать к каким кэш-линейки в будущем произойдет обращение.
Алгоритм предсказаний недокументирован, но, тем не менее, суть его (по крайней мере, в общих чертах) понять несложно. Пусть, например, процессор фиксирует ряд кэш-промахов при обращении к линейкам N, N+3, N+6, N+9… Не нужно быть ясновидящим, чтобы с высокой степенью достоверности предположить, что следующей на очереди стоит N+12 линейка.
Т.е. P- 4 умеет распознавать арифметическую прогрессию и вычислять ее члены. Насчет же распознавания геометрической прогрессии в документации ничего не сказано, а проверить экспериментально – под рукой процессора нет.
Определить шаг арифметической прогрессии по нескольким ее элементам – не проблема! Вот выделить прогрессию из произвольной последовательности – куда сложнее. Справляется ли с этим P-4? Нет! Его разработки честно признаются в документации, что "Follows only one stream per 4K page (load or store)". Т.е. в пределах одной страницы доступ к данным, обрабатываемых в цикле, должен происходить по одному регулярному шаблону, в противном случае механизм предсказаний "ослепнет" и аппаратная предвыборка осуществляться не будет. Если же такая необходимость все же возникает (а практически всегда она и возникает), обрабатываемые данные следует разбить на несколько блоков (числом не более восьми) и расположить их в различных четырех килобайтовых регионах. Восьми – потому что P-4 умеет одновременно отслеживать не более восьми регулярных шаблонов (в терминологии разработчиков: потоков данных – data stream). Причем, упреждающая загрузка осуществляется только в пределах одного четырех килобайтового блока памяти – при выходе за его пределы механизм предсказаний дезактивируется и отслеживание шаблона обращений начинается сначала. Т.е. процессор вновь дожидается нескольких кэш-промахов, определяет шаг прогрессии и только после этого приступает к очередному сеансу предвыборки. Вследствие этого ячейки памяти, читаемые с большим шагом (порядка 1Кб), никогда не предвыбираются и потому обрабатываются крайне неэффективно.
Таким образом, аппаратная предвыборка не так уж и прозрачна для программистов, как убеждает Intel. Да, в отличие от программной, аппаратная предвыборка ускоряет работу даже ничего не знающих о ней приложений, но максимальная эффективность достигается лишь при соответствующей организации структуры обрабатываемых данных. Причем, далеко не во всех случаях такое структурирование выполнимо! Поэтому, при всем могуществе аппаратной предвыборки, программная предвыборка не сдает своих позиций и на P-4, по-прежнему, оставаясь эффективнейшим средством оптимизации приложений.
Аппаратное непостоянство
Возможно, это покажется удивительным, но на аппаратном уровне время выполнения одних и тех же операций не всегда постоянно и подвержено определенным разбросам, под час очень большим и значительно превосходящим программную погрешность. Но, если последнюю хотя бы теоретически возможно ликвидировать (ну, например, запустить программу в однозадачном режиме), то аппаратное непостоянство неустранимо принципиально.Почему оно – аппаратное непостоянство – вообще возникает? Ну, тут много разных причин. Вот, например, одна из них: если частота системной шины не совпадает с частотой модулей оперативной памяти, чипсету придется каждый раз выжидать случайный промежуток времени до прихода следующего фронта тактового импульса. Исходя из того, что один цикл пакетного обмена в зависимости от типа установленных микросхем памяти занимает от 5 до 9тактов, а синхронизовать приходится и его начало, и его конец, нетрудно подсчитать, что в худшем случае мы получаем неоднозначность в 25% –40%.
Самое интересное, что аппаратный разброс в чрезвычайно высокой степени разниться от системы к системе. Я, к сожалению, так и не смог определить кто именно здесь виноват, но могу сказать, что, к примеру, на P-III 733/133/100/I815EP не смотря на разницу в частотах памяти и системной шины, аппаратный разброс весьма невелик и едва ли превышает 1% – 2%, на что можно вообще закрыть глаза.
Вот AMD Athlon 1050/100/100/VIA KT133 – совсем другое дело! У него наблюдается просто ошеломляюще аппаратное непостоянство, в частности, в операциях с основной памятью доходящее аж до двух раз! Непонятно, как на такой системе вообще можно профилировать программы!!! В, частности, последовательные замеры времени копирования 16-мегабайтного блока памяти после предварительной обработки (т.е. откидывания заведомо пограничных значений) могут выглядеть так:
Прогон № 01: 84445103 тактов
Прогон № 02: 83966665 тактов
Прогон № 03: 73795939 тактов
Прогон № 04: 80323626 тактов
Прогон № 05: 84381967 тактов
Прогон № 06: 85262076 тактов
Прогон № 07: 85151531 тактов
Прогон № 08: 91520360 тактов
Прогон № 09: 92603591 тактов
Прогон № 10: 100651353 тактов
Прогон № 11: 93811801 тактов
Прогон № 12: 84993464 тактов
Прогон № 13: 92927920 тактов
Смотрите, расхождение между минимальным и максимальным времени выполнения составляет не много, не мало – 36%! А это значит, что вы не сможете обнаруживать "горячие" точки меньшей величины. Более того, вы не сможете оценивать степень влияния тех или иных оптимизирующих алгоритмов, если только они не дают по меньшей мере двукратного прироста производительности!
Отсюда правило: I) не всякая система пригодна для профилировки и оптимизации приложений
и II) если последовательные замеры дают значительный временной разброс, просто смените систему. (Под "системой" подразумевается не операционная система, а аппаратное обеспечение).
Архиерей – царство MS-DOS
В те далекие стародавние времена, когда объем жестких дисков (в просторечии – винчестеров) измерялся мегабайтами, - этих мегабайт никогда не хватало, и большинство файлов (особенно редко употребляемых) приходилось хранить в упакованном виде. Перед запуском файл распаковывали, а после завершения работы – упаковывали вновь, чтобы освободить место для распаковки другого.Когда же эти махинации всем окончательно надоели, программисты (вспомнив, что компьютер призван служить человеку, а не наоборот), додумались до автоматической распаковки исполняемых файлов "на лету". Идея заключалась в дописывании к сжатому архиву крохотного распаковщика, распаковывающего файл не на диск, а непосредственно в оперативную память. Конечно, время загрузки ощутимо увеличивалось (особенно на машинах с медленными процессорами), но это с лихвой окупалось простотой запуска и экономией дискового пространства.
Вскоре появился целый "пантеон" упаковщиков (их тогда писали все кому не лень) – AINEXE, DIET, EXEPACK, LZEXE, PKLITE и масса других – всех не перечислись! И не удивительно: процессоры день ото дня становились все производительнее и производительнее – уже на "тройке" распаковка занимала столь незначительное время, что им было можно полностью пренебречь. К тому же приятным побочным эффектом оказалась защита от дизассемблирования.
Действительно, непосредственно дизассемблировать упакованный файл невозможно, - прежде его необходимо распаковать. Конечно, на каждый щит найдется свой меч – из под пера хакеров вышло немало замечательных универсальных распаковщиков (UNP, Intruder, UUP, а вершиной всему стал CPU386 со встроенным эмулятором реального режима 80386 процессора), но качество автоматической распаковки оставляло желать лучшего (порой распакованные файлы зависали при запуске или в процессе работы), а ручной трассировкой владели далеко не все.
Словом, при всех своих достоинствах, упаковка исполняемых файлов не имела никаких недостатков и не собиралась сдавать позиций даже с приходом емких (по тем временам!) одно – двух гигабайтных жестких дисков и лазерных накопителей на CD-ROM.
Архитектура и характеристики кэшей современных микропроцессоров
Перечислять технические характеристики кэшей всех современных микропроцессоров – занятие неблагодарное, однако, крайне необходимое! Ведь код, оптимальный для одного процессора, может оказаться крайне неоптимальным для другого!Важнейшей характеристикой является размер
кэша первого уровня. Наиболее интенсивно используемые структуры данных обязательно должны быть организованы так, чтобы полностью умещаться в нем. При обработке больших массивов данных следует ограничить свой "аппетит", по крайней мере, размером кэша второго уровня
и уж в самом крайнем случае "вылетать" в оперативную память (подробнее см. "Оптимизация обращения к памяти и кэшу. Влияние размера обрабатываемых данных на производительность")
Проблема в том, что размер кэшей в зависимости от модели процессора варьируется в очень широких пределах – попробуй, выбери, на какой из них рассчитывать. Если есть такая возможность, программисту настоятельно рекомендуется оптимизировать свою программу под кэш минимального уровня и "обкатывать" ее на процессоре именно с таким кэшем. С другой стороны, разумно ориентироваться на наиболее распространенные модели процессоров (вот только как узнать – какая модель будет наиболее распространенной к моменту завершения программы?)
Вторая по значимости характеристика – степень ассоциативности и размер банков кэша. Если степень ассоциативности окажется хотя бы на единицу меньшей, чем это вам необходимо, кэш будет работать вхолостую, под час в десятки раз снижая производительность. Чтобы этого не произошло, программист должен следить, чтобы интенсивно используемые данные по возможности не располагались по адресам, кратным размерам банков кэша. А это требование очень трудно обеспечить – размеры банков кэша варьируются в очень широких пределах, да и их ассоциативность тоже. Чтобы быть уверенным в отсутствии коллизий, необходимо тестировать программу на всех доступных процессорах и при необходимости, корректировать размещение структур данных. (подробнее см. "Оптимизация обращения к памяти и кэшу. Учет ограниченной ассоциативности кэша")
Третье – политика записи. От нее зависит: насколько эффективно выполняется операция записи в память. Все кэши современных процессоров поддерживают режим прямой записи с буферизацией и обратную запись, но количество буферов непостоянно и варьируется от процессора к процессору – от двух 64-битных буфера младших моделей Intel Pentium до двенадцати у Pentium?III (подробнее см. "Оптимизация обращения к памяти и кэшу. Особенности буферизации записи").
Четвертое – длина кэш-линий. В последних процессорах от Intel и AMD она расширена до 64 байт. Но больше – еще не значит лучше. Упреждающая загрузка наиболее эффективна именно при последовательной обработке данных, иначе кэш будет работать вхолостую.
Помимо перечисленных существует еще и масса других факторов, но и без того уже ясно: оптимизация кода под все микропроцессоры сразу – занятие не для слабонервных.
В приведенной ниже таблице (см. табл. 2) перечислены основные характеристики кэшей распространенных процессоров.
|
процессор характеристика |
Pentium II CELERON |
Pentium III CELERON |
Pentium 4 |
Athlon |
||||||||||||
|
L1 |
размер (полный) |
32 Кб |
32 Кб |
н/д |
128 Кб |
|||||||||||
|
тип |
раздельный |
раздельный |
раздельный |
раздельный |
||||||||||||
|
К О Д |
размер |
16 Кб |
16 Кб |
12K ops |
64 Кб |
|||||||||||
|
протокол |
SI |
SI |
? |
SI |
||||||||||||
|
ассоциативность |
4-way |
4-way |
4? |
2 |
||||||||||||
|
размер линеек |
32 байта |
32 байта |
6 mOPs |
64 байт |
||||||||||||
|
банков в линии *1 |
1 |
1 |
н/д |
? |
||||||||||||
|
размер банка *2 |
4 Кб |
4 Кб |
н/д |
32 Кб |
||||||||||||
|
кол-во портов |
1 |
1 |
1? |
1? |
||||||||||||
|
алгоритм замещения |
LRU |
LRU |
? |
LRU |
||||||||||||
|
политика записи |
– |
– |
? |
– |
||||||||||||
|
блокировка |
не блок.? |
не блок.? |
не блок.? |
не блок.? |
||||||||||||
|
частота |
1.0 x ядра |
1.0 x ядра |
1.0 x ядра |
1.0 x ядра |
||||||||||||
|
время доступа |
нормальное |
1 такт |
1 такт |
1 такт |
1 такт |
|||||||||||
|
line-splint |
6 – 12 тактов |
6 – 12 тактов |
н/д |
н/д |
||||||||||||
|
Д А Н Н Ы Е |
размер |
16 Кб |
16 Кб |
8 Кб |
64 Кб |
|||||||||||
|
протокол |
MESI |
MESI |
MESI |
MOESI |
||||||||||||
|
ассоциативность |
4-way |
4-way |
4-way |
2-way |
||||||||||||
|
размер линеек |
32 байта |
32 байта |
64 байта |
64 байта |
||||||||||||
|
банков в линии *1 |
8 |
8 |
8? |
8? |
||||||||||||
|
размер банка *2 |
4 Кб |
4 Кб |
2 Кб |
32 Кб |
||||||||||||
|
кол-во портов |
2 |
2 |
2 |
2 |
||||||||||||
|
алгоритм замещения |
LRU |
LRU |
LRU |
LRU |
||||||||||||
|
политика записи |
WA |
WA |
WT |
WA |
||||||||||||
|
блокировка |
не блок. |
не блок. |
не блок. +4 |
не блок. |
||||||||||||
|
частота |
1.0 x ядра |
1.0 x ядра |
1.0 x ядра |
1.0 x ядра |
||||||||||||
|
время доступа |
||||||||||||||||
|
L2 |
размещение |
unified |
on-die |
unified |
on-die |
on-die |
? |
|||||||||
|
размер, Кб |
128, 256, 512, > |
128, 256, 512, > |
128, 256, 512, > |
512,1024,2048 |
||||||||||||
|
тип *3 |
inclusive |
inclusive |
exclusive |
inclusive |
exclusive |
|||||||||||
|
протокол |
MESI |
MESI |
MESI |
MESI |
||||||||||||
|
ассоциативность |
4-way |
4 |
8 |
4 |
8 |
2 |
16 |
|||||||||
|
размер кэш-линий |
32 байта |
32 |
64x2 |
64? |
||||||||||||
|
размер банка, Кб |
32, 64, 128 |
32, 64, 128 |
32, 64, 128 |
32 |
64 |
128 |
||||||||||
|
кол-во портов |
1? |
1? |
1? |
1? |
||||||||||||
|
алгоритм замещения |
LRU *3 |
LRU |
LRU |
LRU |
||||||||||||
|
политика записи |
WB |
WB |
WB |
WB |
||||||||||||
|
блокировка |
не блок. |
не блок. |
не блок. |
не блок.? |
||||||||||||
|
частота |
0.5x |
1.0x |
0.5x |
1.0x |
? |
1.0х |
||||||||||
|
время доступа |
10 тактов? |
4 такта? |
2 такта? |
8? |
||||||||||||
|
формула |
2-1-1-1 |
1-1-1-1 |
1-1-1-1 |
1-1-1-1 |
||||||||||||
|
ROB, входов |
40 |
40 |
? |
? |
||||||||||||
|
RS, входов |
20 |
20 |
? |
? |
||||||||||||
|
Read Buffer |
4x32 байт? |
4x32 байт? |
? |
? |
||||||||||||
|
Write Buffer |
32 байт? |
32 байт? |
6 x 64 байт |
? |
||||||||||||
|
частота системной шины, MHz |
66 |
100 |
66 |
100 |
133 |
100x4 |
133x4 |
100х2 |
||||||||
|
разрядность шины |
L2 ßà L1 |
64 |
256 |
256 |
64 |
|||||||||||
|
L2 ßà DRAM |
64 |
64 |
64 / 128? |
64 |
||||||||||||
Архитектура памяти Windows
Создание самомодифицирующегося кода требует знания некоторых тонкостей архитектуры Windows, не очень-то хорошо освященных в документации. Точнее, совсем не освященных, но от этого отнюдь не приобретающих статус "недокументированных особенностей", поскольку, во-первых, они одинаково реализованы на всех Windows-платформах, а во-вторых, их активно использует компилятор VisualC++ от Microsoft. Отсюда следует, что никаких изменений даже в отдаленном будущем компания не планирует; в противном случае код, сгенерированный этим компилятором, откажет в работе, а на это Microsoft не пойдет (вернее, не должна пойти, если верить здравому смыслу).Для адресации четырех гигабайт виртуальной памяти, выделенной в распоряжение процесса, Windows используют два селектора, один из которых загружается в сегментный регистр CS, а другой – в регистры DS, ES и SS. Оба селектора ссылаются на один и тот же базовый адрес памяти, равный нулю, и имеют идентичные лимиты, равные четырем гигабайтам. (Замечание: помимо перечисленных сегментных регистров, Windows еще использует и регистр FS, в который загружает селектор сегмента, содержащего информационный блок потока – TIB).
Фактически существует всего один
сегмент, вмещающий в себя и код, и данные, и стек процесса. Благодаря этому передача управления коду, расположенному в стеке, осуществляется близким (near) вызовом или переходом, и для доступа к содержимому стека использование префикса "SS" совершенно необязательно. Несмотря на то, что значение регистра CS не равно значению регистров DS, ES и SS, команды MOV dest,CS:[src]; MOV dest,DS:[src] и MOV dest,SS:[src]
в действительности обращаются к одной и той же ячейке памяти.
Отличия между регионами кода, стека и данных заключаются в атрибутах принадлежащих им страниц – страницы кода допускают чтение и исполнение, страницы данных – чтение и запись, а стека – чтение, запись и исполнение
одновременно.
Помимо этого каждая страница имеет специальный флаг, определяющий уровень привилегий, необходимых для доступа к этой странице.
Некоторые страницы, например те, что принадлежат операционной системе, требуют наличия прав супервизора, которыми обладает только код нулевого кольца. Прикладные программы, исполняющиеся в кольце 3, таких прав не имеют, и при попытке обращения к защищенной странице порождают исключение.
Манипулировать атрибутами страниц, равно как и ассоциировать страницы с линейными адресами, может только операционная система или код, исполняющийся в нулевом кольце. В защите Windows 95\Windows 98 имеются люки, позволяющие прикладному коду повысить свои привилегии до супервизора, но выгода от их использования сомнительна, поскольку "привязывает" пользователя к этой операционной системе и не дает возможности проделать тот же трюк на Windows NT\Windows 2000.
Замечание: среди начинающих программистов ходит совершенно нелепая байка о том, что, дескать, если обратится к коду программы командой, предваренной префиксом DS, Windows якобы беспрепятственно позволит его изменить. На самом деле это в корне неверно – обратиться-то она позволит, а вот изменить – нет, каким бы способом ни происходило обращение, т.к., защита работает на уровне физических страниц, а не логических адресов.
Асинхронная статическая память
Асинхронная статическая память работает независимо от контроллера и потому, контроллер не может быть уверен, что окончание цикла обмена совпадет с началом очередного тактового импульса. В результате, цикл обмена удлиняется по крайней мере на один такт, снижая тем самым эффективную производительность. "Благодаря" последнему обстоятельству, в настоящее время асинхронная память практически нигде не применяется (последними компьютерами, на которых она еще использовались в качестве кэша второго уровня, стали "трешки" – машины, построенные на базе процессора Intel80386).Балансировка логического древа
Оператор множественного выбора switch очень популярен среди программистов (особенно, разработчиков Windows-приложений). Число его ветвей может быть очень велико и их линейная обработка крайне непроизводительна.В некоторых (хотя и редких) случаях, операторы множественного выбора содержат сотни (а то и тысячи) наборов значений, и если решать задачу сравнения "в лоб", то высота логического дерева окажется гигантской до неприличия, а его прохождение займет весьма длительное время, что не лучшим образом скажется на производительности программы.
Но, задумайтесь: чем собственно занимается оператор switch? Если отвлечься от устоявшейся идиомы "оператор SWITCH дает специальный способ выбора одного из многих вариантов, который заключается в проверке совпадения значения данного выражения с одной из заданных констант в соответствующем ветвлении", то можно сказать, что switch – оператор поиска соответствующего значения. В таком случае линейное switch - дерево представляет собой тривиальный алгоритм последовательного поиска – самый неэффективный алгоритм из всех.
Пусть, например, исходный текст программы выглядел так:
switch (a)
{
case 98 : …;
case 4 : …;
case 3 : …;
case 9 : …;
case 22 : …;
case 0 : …;
case 11 : …;
case 666: …;
case 096: …;
case 777: …;
case 7 : …;
}
Тогда соответствующее ему не оптимизированное логическое дерево будет достигать в высоту одиннадцати гнезд (см. рис. 1 слева). Причем, на левой ветке корневого гнезда окажется аж десять других гнезд, а на правой – вообще ни одного (только соответствующий ему case - обработчик).
Исправить "перекос" можно разрезав одну ветку на две и "привив" образовавшиеся половинки к новому гнезду, содержащему условие, определяющее: в какой из веток следует искать сравниваемую переменную. Например, левая ветка может содержать гнезда с четными значениями, а правая – с нечетными. Но это плохой критерий: четных и нечетных значений редко бывает поровну и вновь образуется перекос (тем не менее никакого произвола нет и для балансировки можно использовать любой подходящий алгоритм).
Гораздо надежнее поступить так: берем наименьшее из всех значений и бросаем его в кучу А, затем берем наибольшее из всех значений и бросаем его в кучу B. Так повторяем до тех пор, пока не рассортируем все, имеющиеся значения.
Поскольку, оператор множественного выбора требует уникальности каждого значения, т.е. каждое число может встречаться в наборе (диапазоне) значений лишь однажды, легко показать, что: а) в обеих кучах будет содержаться равное количество чисел (в худшем случае – в одной куче окажется на одно число больше); б) все числа кучи A меньше наименьшего из чисел кучи B. Следовательно, достаточно выполнить только одно сравнение, чтобы определить: в какой из двух куч следует искать сравниваемое значения.
Высота нового дерева будет равна , где N – количество гнезд старого дерева. Действительно, мы же делим ветвь дерева надвое и добавляем новое гнездо – отсюда и берется и +1, а (N+1) необходимо для округления результата деления в большую сторону. Т.е. если высота не оптимизированного дерева достигала 100 гнезд, то теперь она уменьшилась до 51. Что? Говорите, 51 все равно много? А что нам мешает разбить каждую из двух ветвей еще на две? Это уменьшит высоту дерева до 27 гнезд! Аналогично, последующее уплотнение даст 16 à
12 à
11 à
9 à
8… и все! Более плотная упаковка дерева невозможна (подумайте почему – на худой конец постройте само дерево). Но, согласитесь, восемь гнезд – это не сто! Полное прохождение оптимизированного дерева потребует менее девяти сравнений!

Рисунок 1 0х001 Логическое дерево до утрамбовки (слева) и после (справа)
Из всех трех рассматриваемых компиляторов "трамбовать" switch-и не умеет один лишь WATCOM, а Visual C++ и Borland C++ сполна справляются с этой задачей.
BEDO (Burst EDO) – пакетная EDO RAM
Двукратное увеличение производительности было достигнуто лишь в BEDO-DRAM (Burst EDO – пакетная EDO RAM). Добавив в микросхему генератор номера столбца, конструкторы ликвидировали задержку CASDelay, сократив время цикла до 15 нс. После обращения к произвольной ячейке микросхема BEDO автоматически, без указаний со стороны контроллера, увеличивает номер столбца на единицу, не требуя его явной передачи. По причине ограниченной разрядности адресного счетчика (конструкторы отвели под него всего лишь два бита) максимальная длина пакета не могла превышать четырех ячеек (22=4).Забегая вперед, отметим, что процессоры Intel 80486 и Pentium в силу пакетного режима обмена с памятью никогда не обрабатывают менее четырех смежных ячеек за раз (подробнее см. "Взаимодействие памяти и процессора"). Поэтому, независимо от порядка обращения к данным, BEDO всегда работает на максимально возможной скорости и для частоты 66 Мгц ее формула выглядит так: 5-1-1-1, что на ~40% быстрее EDO-DRAM!
Все же, несмотря на свои скоростные показатели, BEDO оказалась не конкурентоспособной и не получила практически никакого распространения. Просчет состоял в том, что BEDO, как и все ее предшественники, оставалась асинхронной
памятью. Это накладывало жесткие ограничения на максимально достижимую тактовую частоту, ограниченную 60 – 66 (75) мегагерцами. Действительно, пусть время рабочего цикла составляет 15 нс. (1 такт в 66 MHz системе). Однако поскольку "часы" контроллера памяти и самой микросхемы памяти не синхронизованы, нет никаких гарантий, что начало рабочего цикла микросхемы памяти совпадет с началом такового импульса контроллера, вследствие чего минимальное время ожидания составляет два такта.
Вернее, если быть совсем точным, рабочий цикл никогда не совпадает с началом тактового импульса. Несколько наносекунд уходит на формирование контроллером управляющего сигнала RAS или CAS, за счет чего он уже не совпадет с началом тактирующего импульса. Еще несколько наносекунд требуется для стабилизации сигнала и "осмысления" его микросхемой, причем, сколько именно времени потребуется заранее определить невозможно, т.к. на результат влияет и температура, и длина проводников, и помехи на линии, и… еще миллион факторов!
Блокируемая и не блокируемая кэш память
Существует две основных разновидности сверхоперативной памяти: блокируемая кэш?память и не блокируемая. Странно, но расшифровка этого термина во многих популярных изданиях отсутствует (я, например, впервые обнаружил ее в технической документации по процессору AMD K5), поэтому имеет смыл рассмотреть этот вопрос поподробнее.Блокируемая кэш память, как и следует из ее названия, блокирует доступ к кэшу после всякого кэш-промаха. Независимо от того, присутствуют ли запрашиваемые данные в сверхоперативной памяти или нет, до тех пор, пока кэш-строка, вызвавшая промах не будет целиком загружена (выгружена), кэш не сможет обрабатывать никаких других запросов ("…a read hit or miss after a read miss waits until the prior miss fills the cache", – как говорят англичане). В настоящее время блокируемая кэш память практически не используется, поскольку при частных кэш-промахах, она работает крайне непроизводительно.
Не блокируемая кэш память, напротив, позволяет работать с кэшем параллельно с загрузкой (выгрузкой) кэш-строк. То есть, кэш-промахи не препятствует кэш-попаданиям. И это – хорошо! Несмотря на то, что не блокируемая кэш память имеет значительно большую аппаратную сложность (а, значит, и стоимость), в силу своей привлекательности, она широко используется в старших процессорах семейства x86, как, впрочем, и во многих других современных процессорах.
Brief
Ниже приведен краткий перечень ключевых рекомендаций, в наибольшей степени определяющих скорость обмена с памятью. В соответствующих главах каждый из этих пунктов будет рассмотрен во всех подробностях.a) разворачивайте циклы, читающие память
b) устраняйте зависимости по данным
c) отправляйте контроллеру памяти несколько запросов одновременно
d) запрашивайте данные на чтение с шагом не меньшим 32 байт
e) группируйте операции чтения памяти с операциями записи
f) используйте все страницы к которым обращаетесь целиком
g) обрабатывать данные с шагом, исключающим попадание на ту же самую страницу.
h) виртуализуйте потоки данных
i) обрабатывайте данные двойными словами
j) выравнивание адреса источников данных
k) комбинируйте вычисления с доступом к памяти
l) обращайтесь к памяти только тогда когда это действительно необходимо
m) никогда не оптимизируйте программу на отдельно взятой машине
Буфера записи
Для предотвращения задержки, возникающей при промахах записи, современные процессоры активно используют различные приемы буферизации. Вместо того, чтобы немедленно отправлять записываемые данные по месту назначения, процессор временно помещает их в специальный буфер, откуда, по мере освобождения шины и/или кэш-контроллера они выгружаются в кэш первого (второго) уровня или в основную оперативную память.Такая схема особенно полезна при значительном превышении количества операций чтения над числом операций записи. Если частота возникновения промахов записи не превышает скорости выгрузки буферов, штрафных задержек не возникает вовсе и эффективная скорость выполнения инструкции записи составляет 1 такт.
Часто приходится слышать: чем больше в процессоре буферов, тем выше предельная частота промахов записи, которую без ущерба для производительности он способен выдержать. На самом деле, это неверно. Максимальная частота промахов определяется не количеством буферов, а скоростью (и политикой) их выгрузки.
В частности, процессоры AMD K6 и Athlon всегда выгружают содержимое буферов записи в кэш первого уровня, благодаря чему скорость их опорожнения весьма велика (при благоприятном стечении обстоятельств каждый 32/64? байтовый буфер выгружается за один такт), а выгруженные данные вплоть до вытеснения их из L1-кэша доступны на чтение практически мгновенно!
Процессоры P6 и P-4, напротив, направляют содержимое буферов записи в кэш второго, а не первого уровня. (Конечно, если записываемые данные уже содержаться в L1-кэше они помещаются именно туда). Эффективность такой стратегии не бесспорна. С одной стороны: это приводит к тому, что процессор переносит значительно меньшую интенсивность промахов записи, а с другой: выгрузка записываемых данных в кэш второго уровня не загрязняет кэш первого уровня, в конечном итоге увеличивая его эффективную емкость.
Кстати, нетривиальным следствием такой политики выгрузки буферов, становится искажение политики кэш-записи. Несмотря на то, что кэш первого уровня формально построен по Write Back архитектуре, фактически он работает по схеме Write True, поскольку промах записи приводит к непосредственному обновлению кэш?памяти более высокой иерархии без загрузки модифицируемых данных в кэш.
Забавно, но Intel впервые "проговорилась" об этом факте лишь в документации по процессору Pentium-4, где в графе " политика записи кэша первого уровня" честно указано "Write True", а не "Write Back", как это утверждалось в документации по процессорам предыдущего поколения.
Важно понять, что увеличение количества буферов записи не компенсируют медлительность их выгрузки в память. Практически единственная польза емкого буфера в том, что он безболезненно переносит локальные
перегрузки, когда несколько промахов возникают одновременно (или идут с минимальным отрывом друг от друга), а затем вновь наступает "тишина".
Чтение после записи. Вопреки своему названию, каждый из буферов доступен не только на запись, но и на чтение. Причем, чтение данных из буфера записи осуществляется по крайней мере на один такт быстрее, нежели из кэш-памяти первого уровня. (Подробнее о том, как можно использовать это обстоятельство для оптимизации своих программ, рассказывается в "Оптимизация обращения к памяти и кэшу. Особенности буферизации записи").
Тем не менее, чтение содержимого буферов (особенно на процессорах семейства Pentium) следует осуществлять с большой осторожностью, т.к. только что записанные данные в любой момент могут перекочевать в кэш второго уровня, что резко увеличит время доступа к ним. Помимо того, что буфера самопроизвольно выгружаются во время простоя шины, они незамедлительно опоражниваются в следующих ситуациях:
· при выполнении инструкции с префиксом монопольного захвата шины (LOCK);
· при выполнении инструкции упорядоченного выполнения (например, CPUID);
· при выполнении инструкции выгрузки буферов SFNCE (Pentium III и выше);
· при выполнении инструкции выгрузки буферов MFENCE (Pentium-4)
· при возникновении исключения или вызове прерывания;
· при записи или чтении в/из порта ввода-вывода;
· при выполнении инструкции BINIT;
Упорядочивание записи. Помимо всего прочего, на буфера еще возложена и функция упорядочивания записи. Старшие представители семейства x86 разбивают машинные инструкции на микрооперации, выполняя их в наиболее предпочтительном с точки зрения RISC-ядра процессора, порядке. Но ведь нарушение очередности операций чтения/записи в память (кэш) может запросто нарушить нормальную работу программы!
Задумайтесь, что произойдет если в следующем блоке кода "a = *p; *p = b;" запись ячейки *p завершится раньше ее чтения? Спрашивайте, а почему такое вообще может случиться? Да мало ли почему! Допустим, блок чтения данных занят обработкой предыдущей инструкции и команда a = *p
вынуждена терпеливо дожидаться свой очереди, в то время как команда *p = b
захватывается бездельничающим в этот момент блоком записи.
Конечно, блок записи можно до завершения чтения и притормозить, но производительности такая мера не добавит – это уж точно. Выход?! Выход: временно сохранить записываемые данные не в кэше (основной памяти), а в некотором промежуточном буфере. Чтобы не захламлять архитектуру микроядра и не вводить еще один буфер, конструкторы решили для этих целей использовать все те же буфера записи. Политика выгрузки данных из буфера гарантирует, что данные, помещенные в буфер, покинут его "застенки" не раньше, чем завершаться все, предшествующие им инструкции. Таким образом, с буфера данные сходят уже упорядоченными и потому никаких конфликтов не возникает.
Причем, буферизация записи в определенной степени сокращает количество обращений к памяти, поскольку если одна и та же ячейка записывалась несколько раз в память (кэш) попадает лишь последний результат.
Реализация и характеристики буферов записи.
Младшие модели Pentium имели всего два буфера записи (write buffers) – по одному на каждую U- и V-трубу, но уже в Pentium MMX количество буферов возросло до четырех, а в Pentium II их и вовсе насчитывается двенадцать! Причем, начиная с Pentium II буфера записи переименованы в Складские Буфера (store buffers), однако, во избежании путаницы, здесь и далее мы будем пользоваться исключительно прежним термином. Все равно "store" и "write" в русском переводе – синонимы (во всяком случае, в рамках данного контекста)
Учитывая, что размер каждого из буферов составляет 32 байт, можно безболезненно сохранять до 384 байт (96 двойных слов) за раз, не беспокоясь за кэш-промахи. Но помните, что попытки записи в некэшируемую память при полностью заполненный буферах приводят к неустранимым кэш-промахам и вытекающих отсюда штрафным задержкам.
Поэтому, целесообразно чередовать операции записи с вычислениями – давая буферам время на выгрузку своего содержимого.
Цели и задачи кэш-памяти
…кэш (называемый так же сверхоперативной памятью) представляет собой высокоскоростное запоминающее устройство небольшой емкости для временного хранения данных, значительно более быстродействующее, чем основная память, но, в отличии от оперативной памяти, не адресуемое и непосредственно не "видимое" для программиста.В задачи кэша входит:
а) обеспечение быстрого доступа к интенсивно используемым данным;
b) согласование интерфейсов процессора и контроллера памяти;
с) упреждающая загрузка данных;
d) отложенная запись данных.
Обеспечение быстрого доступа к интенсивно используемым данным. Архитектурно кэш-память расположена между процессором основной оперативной памятью (см. рис. 0x009) и охватывает все (реже часть) адресного пространства. Перехватывая запросы к основной памяти, кэш-контроллер смотрит: есть ли действительная (валидная от английского valid) копия затребованных данных в кэше. Если такая копия там действительно есть, то данные наскоро извлекаются из сверхоперативной памяти и происходит так называемое кэш-попадание (cache hit). В противном случае говорят о промахе
– (cache miss), и тогда запрос данных переадресуется к основной оперативной памяти.

Рисунок 9 0х009 Расположение кэша в иерархии оперативной памяти
Для достижения наивысшей производительности кэш-промахи должны происходить как можно реже (а в идеале – не происходить вообще). Учитывая, что емкость сверхоперативной памяти намного меньше емкости основной оперативной памяти, добиться этого не так-то просто! И в служебные обязанности кэш-контроллера в первую очередь входит накопление в сверхоперативной памяти действительно нужных данных и своевременное удаление оттуда всякого "мусора", – данных, которые более не понадобятся. Поскольку, кэш-контроллер не имеет абсолютно никакого представления о назначении обрабатываемых данных, эта задача требует нехилого искусственного интеллекта. Но, увы, кэш-контроллеры персональных процессоров интеллектом не обременены и слепо действуют по одному из нескольких шаблонов, называемых стратегиями кэширования.
Стратегия помещения данных в кэш- память представляет собой алгоритм, определяющий: стоит ли помещать копию запрошенных данных в сверхоперативную память или нет? Процессоры класса Intel Pentium (и совместимые с ними процессоры AMD) не мудрствуя лукаво, помещают в кэш все данные, к которым хотя бы однократно происходит обращение.
Поскольку, мы не можем сохранить в кэше содержимое всей оперативной памяти и рано или поздно кэш заполняется по самую макушку (а с такой стратегией он заполняется скорее рано, чем поздно) настанет время, когда для помещения новой порции данных, нам придется в спешном порядке выкинуть из кэша что-нибудь ненужное, чтобы освобождать для них место. (Помните, как говорил кот Матрискин: "…чтобы продать что-нибудь ненужное, надо сначала купить что-нибудь ненужное..")
Поиск вот таких наименее нужных данных и называется стратегия замещения. Можно принимать решение, основываясь на количестве обращений к каждой порции данных (частотный анализ), можно – на времени последнего обращения, выбрав ту, к которой дольше всего не обращались (алгоритм LRU – Least Recently Used), можно – на времени загрузки из основной памяти, вытеснив ту, которая была загружена раньше всех (алгоритм FIFO – First Input First Output), а можно просто подкинуть монетку (randomize-алгоритм)– на кого судьба ляжет, – ту и вытеснять (кстати, именно такая стратегия замещения использовалась в процессорах AMD K5).
В современных процессорах семейства x86 встречаются исключительно стратегии FIFO и LRU, частотный же анализ ввиду сложности его реализации в них не используется.
Согласование интерфейсов процессора и контроллера памяти. "Ячейка памяти" в понятии современных процессоров представляет как правило байт или двойное слово. С другой стороны, минимальной порцией обмена с физической оперативной памятью является пакет, состоящий по меньшей мере из четырех 64-разрядных ячеек.
Здесь можно провести аналогию с оптовой торговлей, – производитель не отпускает товар по штукам и если нам, положим, требуется один карандаш, мы все равно вынуждены приобретать целую упаковку.
Естественно, до той поры, пока остальные карандаши не будут реально востребованы (конечно, если они вообще будут востребованы), их необходимо где-то хранить. Решение: извлечь один-единственный карандаш из упаковки и выбросить остатки, – слишком нерационально, поэтому здесь не рассматривается. Тем более, что подходящее хранилище для пакетов данных у нас есть – это кэш. Получив пакет данных со склада, пардон, загрузив их из основной оперативной памяти, кэш позволяет процессору обрабатывать эти данные с любой разрядностью. Именно этим, кстати, объясняется выбранная стратегия загрузки данных (см. "Стратегия помещения данных"). Кэш-контроллер вынужден помещать в сверхоперативную памяти все ячейки, к которым происходит обращение, уже хотя бы потому, что выкидывать их, как карандаши, в приведенном выше примере, было бы крайне нерационально.
Упреждающая загрузка данных. Существует несколько стратегий загрузки данных из основной оперативной памяти в кэш-память. Простейший алгоритм загрузки, называемый загрузкой по требованию (on demand), предписывает обращаться к основной памяти только после того, как затребованных процессором данные не окажется в кэше (то есть, попросту говоря, после возникновения кэш-промаха). В результате, в кэше окажутся действительно именно те данные, которые нам нужны (и это плюс!), однако, при первом обращении к ячейке, процессору придется очень долго ждать – приблизительно 20 тактов системной шины, если не дольше, – а вот это минус!
Стратегия спекулятивной (speculative) загрузки, напротив, предписывает помещать данные в кэш задолго то того, как к ним произойдет реальное обращение. Откуда же кэш-контроллер может знать, какие именно ячейки памяти потребуется процессору в ближайшем будущем? Ну… наверняка знать этого он этого, конечно, не может, но почему бы ему не попробовать просто угадать?
Алгоритмы угадывания делятся на интеллектуальные и неинтеллектуальные. Типичный пример неинтеллектуального алгоритма – опережающая загрузка.
Исходя из предположения, что данные из оперативной памяти обрабатываются последовательно в порядке возрастания адресов, кэш-контроллер, перехватив запрос на чтение первой ячейки, в порядке собственной инициативы загружает некоторое количество ячеек, последующих за ней. Если данные действительно обрабатываются последовательно, то остальные запросы процессора будут выполнены практически мгновенно, ведь запрошенные ячейки уже присутствуют в кэше! Следует заметить, что стратегия опережающей загрузки возникает уже в силу необходимости согласования разрядности оперативной памяти и процессора (см. "Согласование интерфейсов процессора и контроллера памяти").
Серьезный минус опережающей (и вообще неинтеллектуальной) загрузки состоит в том, что алгоритм обработки данных далеко не всегда совпадает с алгоритмом их загрузки и зачастую ячейки памяти востребуются процессором не в том порядке, в котором кэш-контроллер запрашивает их из основной памяти. Как следствие, – мы имеем значительный падеж производительности, поскольку данные были загружены в холостую.
Интеллектуальный кэш-контроллер предсказывает адрес следующей запрашиваемой ячейки не по слепому шаблону, а на основе анализа предыдущих обращений. Исследуя последовательность кэш-промахов, контроллер пытается установить какой именно зависимостью связны ее элементы и, если это ему удается, предвычисляет ее последующие члены. Если обращение к памяти происходит по регулярному шаблону, интеллектуальная стратегия спекулятивной загрузки при благоприятном стечении обстоятельств может полностью ликвидировать задержки, возникающие при ожидании загрузки данных из основной памяти.
До недавнего прошлого интеллектуальные кэш-контроллеры использовались разве что в суперкомпьютерах и высокопроизводительных рабочих станциях, но теперь они реализованы в процессорах P-4 и AMD Athlon XP (см. "Оптимизация обращения к памяти и кэшу. Управление кэшированием в x86 процессорах старших поколений. Аппаратная предвыборка в микропроцессоре P-4")
Стратегии поиска данных. В соответствии с выбранной стратегией загрузка данных из памяти может начинаться либо после фиксации кэш-промаха (стратегия Look Through), либо осуществляться параллельно с проверкой наличия соответствующей копии данных в сверхоперативной памяти и прерываться в случае кэш-попадания (стратегия Look aside). Последнее сокращает накладные расходы на кэш-промахи, уменьшая тем самым латентность загрузки данных, но зато увеличивает энергопотребление, что в ряде случаев оказывается неприемлемо большой платой за в общем-то довольно незначительную прибавку производительности.
Отложенная запись данных. Наличие временного хранилища данных позволяет накапливать записываемые данные и затем, дождавшись освобождения системой шины, выгружать их в оперативную память "одним махом". Это ликвидирует никому не нужные задержки и значительно увеличивает производительность подсистемы памяти (подробнее об этом см. "Организация кэша. Политики записи и поддержка когерентности").
Механизм отложенной записи в x86 процессорах, реализован начиная с Pentium и AMD K6. Более ранние модели были вынужденные непосредственно записывать в основную память каждую модифицируемую ячейку, что серьезно ограничивало их быстродействие. К счастью, сегодня такие процессоры практически не встречаются и об этой проблеме уже можно забыть.
Цели и задачи профилировки
Основная цель профилировки – исследовать характер поведения приложения во всех его точках. Под "точкой" в зависимости от степени детализации может подразумеваться как отдельная машинная команда, так целая конструкция языка высокого уровня (например: функция, цикл или одна-единственная строка исходного текста).Большинство современных профилировщиков поддерживают следующий набор базовых операций:
Часть 0 Профилировка программ
Профилировкойздесь и на протяжении всей книги мы будем называть измерение производительности как всей программы в целом, так и отдельных ее фрагментов, с целью нахождения "горячих" точек
(HotSpots), – тех участков программы, на выполнение которых расходуется наибольше количество времени.
Согласно правилу "10/90", десять процентов кода съедают девяносто процентов производительности системы (равно как и десять процентов людей выпивают девяносто процентов всего пива). Если время, потраченное на выполнение каждой машинной инструкции, изобразить графически в порядке возрастания их линейных адресов, на полученной диаграмме мы обнаружим несколько высоченных пиков, горделиво возвышающихся над практически пустой равниной, усеянной множеством низеньких холмиков (см. рис. 0x001) Вот эти самые пики – "горячие" точки и есть.
Почему "температура" различных участков программы столь неодинакова? Причина в том, что подавляющее большинство вычислительных алгоритмов так или иначе сводятся к циклам, – т.е. многократным повторениям одного фрагмента кода, причем зачастую циклы обрабатываются не последовательно, а образуют более или менее глубокие иерархии, организованные по типу "матрешки". В результате, львиную долю всего времени выполнения, программа проводит в циклах с наибольшим уровнем вложения и именно их оптимизация дает наилучший прирост производительности!
Громоздкие и тормозные, но редко вызываемые функции оптимизировать нет какой нужды, – это практически не увеличит быстродействия приложения (ну разве что они совсем уж криво будет написаны).
Если алгоритм программы прост, а ее исходный текст свободно умещается в сотню-другую строк, – горячие точки не трудно обнаружить и визуальным просмотром листинга. Но с увеличением объема кода это становится все сложнее и сложнее. В программе, состоящей из тысяч сложно взаимодействующих друг с другом функций (часть из которых – функции внешних библиотек и API операционной системы) далеко не так очевидно: какая же именно из них в набольшей степени ответственна за низкую производительность приложения.
Естественный выход – прибегнуть к помощи специализированных программных средств.
Профилировщик
(так же называемый "профайлером") – основной инструмент оптимизатора программ. Оптимизация "в слепую" редко дает хороший результат. Помните пословицу "самый медленный верблюд определяет скорость каравана"? Программный код ведет себя полностью аналогичным образом и производительность приложения определяется самым узким его участком. Бывает, что виновницей оказывается одна – единственная машинная инструкция (например, инструкция деления, многократно выполняющаяся в глубоко вложенном цикле). Программист, затратив воистину титанические усилия на улучшение остального кода, окажется премного удивлен, что производительность приложения едва ли возросла процентов на десять – пятнадцать.
Правило номер один: ликвидация не самых горячих точке программы, практически не увеличивает ее быстродействия.
Действительно, сколько не подгоняй второго сзади верблюда – от этого караван быстрее идти не будет (случай, когда предпоследней верблюд тормозит последнего – это уже тема другого разговора, требующего глубоких знаний техники профилировки, а потому и не рассматриваемая в настоящей книге).
Часть I Оперативная память
Оперативная память персональных компьютеров сегодня, как и десять лет тому назад, строится на базе относительно дешевой динамической памяти – DRAM (Dynamic Random Access Memory). Множество поколений интерфейсной логики, соединяющей ядро памяти с "внешним миром", сменилось за это время. Эволюция носила ярко выраженный преемственный характер – каждое новое поколение памяти практически полностью наследовало архитектуру предыдущего, включая и свойственные ему ограничения. Ядро же памяти (за исключением совершенствования проектных норм таких, например, как степень интеграции) и вовсе не претерпевало никаких принципиальных изменений! Даже "революционный" Rambus DirectRDRAM ничего подлинного революционного в себе не несет и хорошо вписывается в общее "генеалогическое" древо развития памяти.Поэтому, устройство и принципы функционирования оперативной памяти лучше всего изучать, понимаясь от основания ствола дерева (т.е. самых древних моделей памяти) по его веткам вверх – к самым современным разработкам, которые только существуют на момент написания этой книги ### статьи.
Стоит заметить: в этой главе приводятся лишь необходимый минимум сведений об устройстве оперативной памяти, без которых грамотная оптимизация программ просто немыслима. Настоятельно рекомендуется не ограничиваться "необходимым минимумом", а познакомится с микросхемами памяти во всех подробностях, обратившись к спецификациями и технической документации, распространяемой производителями. Наилучшей (на взгляд автора) документацией можно разжиться на серверах www.IBM.com, www.SAMSUNG.com, www.INTEL.com и www.AMD.com. Много полезной информации можно найти и на сайте www.iXBT.com (и других, подобных ему).
Часть II Подсистема кэш-памяти
"Тогда мы поняли настоящую цену учебникам типа "Язык XXX за двадцать один день" или "YYY - это просто!".Подобные тексты (сами по себе, быть может, и неплохо написанные) оставляют за своими рамками настолько обширные области языка, избегают касаться стольких его тонкостей и особенностей, что в голове у читателя-программиста формируется зачастую усеченный и выхолощенный образ инструмента, который он собирается использовать"
"Редкая профессия" Евгений Зуев
Прозрачность кэш-подсистемы современных процессоров сочетается с их капризным и весьма эгоцентричным характерам. Кэш похож на девушку, которая "хочет, но молчит", заставляя окружающих догадываться: что же у нее на уме, и как же ей угодить. И хотя робкие намеки на демократичность уже начали прорезаться (см. "Управление кэшированием в x86 процессорах старших поколений"), в целом кэш-подсистема представляет собой сплошную скопление чудес, сюрпризов и загадок. Это дремучий лес и официальная документация – плохой путеводитель, постоянно ставящий вас в тупик неполнотой, а то и откровенной недостоверностью информации.
Я искренне надеюсь, что вы сочтете настоящее описание кэш-подсистемы лучшим из имеющихся, но даже оно не освещает и доли тайн кэш-памяти! Перед вами – лишь небольшая часть того, что мне удалось нарыть. Увы! Сжатые временные сроки не позволили рассказать обо всем и пришлось ограничится только самой необходимой информацией…
Часть III Машинная оптимизация
"Лучший способ оптимизации - не оптимизировать вообще, а изначально производить хороший код"Джон Спрей
Часть IV Приложение I Программистская копилка
Есть такая шутливая поговорка "программистом не рождаются – им умирают". Если отвлечься от слегка черноватого юмора, с этим трудно не согласиться. Программистский опыт не приходит как божественное откровение, – его приходится буквально по каплям собирать всю жизнь.Данная приложение представляет собой сборник некоторых статей автора, которые прямо или косвенно относятся к подсистеме оперативной памяти, оптимизации, эффективным приемам кодирования и т.д.
Conventional DRAM (Page Mode DRAM) – "обычная" DRAM
Разобравшись с устройством и работой ядра памяти, перейдем к рассмотрению ее интерфейса. Физически микросхема памяти (не путать с модулями памяти, – о них речь еще впереди) представляет собой прямоугольный кусок керамики (или пластика) "ощетинившийся" с двух (реже – с четырех) сторон множеством ножек. Что это за ножки?В первую очередь выделим среди них линии адреса
и линии данных. Линии адреса, как и следует из их названия, служат для выбора конкретной ячейки памяти, а линии данных – для чтения и для записи ее содержимого. Необходимый режим работы определяется состоянием специального вывода Write Enable (Разрешение Записи).
Низкий уровень сигнала WE готовит микросхему к считыванию состояния линий данных и записи полученной информации в соответствующую ячейку, а высокий, наоборот, заставляет считывать содержимое ячейки и "выплевывать" его в линию данных.
Такой трюк значительно сокращает количество выводов микросхемы, что в свою очередь уменьшает ее габариты. А, чем меньше габариты, тем выше предельно допустимая тактовая частота. Почему? О, тут замешен целый ряд физических явлений и эффектов. Во-первых, в силу ограниченной скорости распространения электрических сигналов, длины проводников, подведенных к различным ножкам микросхемы, должны не сильно отличаться друг от друга, иначе сигнал от одного вывода будет опережать сигнал от другого. Во-вторых, эти самые длины не должны быть очень велики – в противном случае задержка распространения сигнала "съест" все быстродействие. В-третьих, любой проводник действует как приемная и как передающая антенна, причем это воздействие резко усиливается с ростом тактовой частоты. Паразитному антенному эффекту можно противостоять множеством способов (например, путем перекашивания сигналов в соседних разрядах), но самой радикальной мерой было и до сих пор остается сокращение длин и количества проводников. Наконец, в-четвертых, всякий проводник обладает электрической емкостью. А емкость и скорость передачи данных – несовместимы! Вот только один пример: "…первый трансатлантический кабель для телеграфа был успешно проложен в 1858 году,… когда напряжение прикладывалось к одному концу кабеля, оно не появлялось немедленно на другом конце и вместо скачкообразного нарастания достигало стабильного значения после некоторого периода времени.
Когда снимали напряжение, напряжение приемного конца не падало резко, а медленно снижалось. Кабель вел себя как губка, накапливая электричество. Это свойство мы теперь называем емкостью".
Таким образом, совмещение выводов микросхемы увеличивает скорость обмена с памятью, но не позволяет осуществлять чтение и запись одновременно. (Забегая вперед, отметим, что, размещенные внутри кристалла процессора микросхемы кэш-памяти, благодаря своим микроскопическим размерам на количество ножек не скупятся и беспрепятственно считывают ячейку во время записи другой).
Столбцы
и строки матрицы памяти тем же самым способом совмещаются в единых адресных линиях. В случае квадратной матрицы количество адресных линий сокращается вдвое, но и выбор конкретной ячейки памяти отнимает вдвое больше тактов, ведь номера столбца и строки приходится передавать последовательно. Причем, возникает неоднозначность, что именно в данный момент находится на адресной линии: номер строки или номер столбца? А, быть может, и вовсе не находится ничего? Решение этой проблемы потребовало двух дополнительных выводов, сигнализирующих о наличии столбца или строки на адресных линиях и окрещенных RAS (от row address strobe – строб адреса строки) и CAS (от column address strobe – строб адреса столбца) соответственно. В спокойном состоянии на обоих выводах поддерживается высокий уровень сигнала, что говорит микросхеме: никакой информации на адресных линиях нет и никаких действий предпринимать не требуется.
Но вот программист хочет прочесть содержимое некоторой ячейки памяти. Контроллер преобразует физический адрес в пару чисел – номер строки и номер столбца, а затем посылает первый из них на адресные линии. Дождавшись, когда сигнал стабилизируется, контроллер сбрасывает сигнал RAS в низкий уровень, сообщая микросхеме памяти о наличии информации на линии. Микросхема считывает этот адрес и подает на соответствующую строку матрицы электрический сигнал. Все транзисторы, подключенные к этой строке, открываются и бурный поток электронов, срываясь с насиженных обкладок конденсатора, устремляется на входы чувствительного усилителя.
Чувствительный усилитель декодирует всю строку, преобразуя ее в последовательность нулей и единиц, и сохраняет полученную информацию в специальном буфере. Все это (в зависимости от конструктивных особенностей и качества изготовления микросхемы) занимает от двадцати до сотни наносекунд, в течение которых контроллер памяти выдерживает терпеливую паузу. Наконец, когда микросхема завершает чтение строки и вновь готова к приему информации, контроллер подает на адресные линии номер колонки и, дав сигналу стабилизироваться, сбрасывает CAS в низкое состояние. "Ага!", говорит микросхема и преобразует номер колонки в смещение ячейки внутри буфера. Остается прочесть ее содержимое и выдать его на линии данных. Это занимает еще какое-то время, в течение которого контроллер ждет запрошенную информацию. На финальной стадии цикла обмена контроллер считывает состояние линий данных, дезактивирует сигналы RAS и CAS, устанавливая их в высокое состояние, а микросхема берет определенный тайм-аут на перезарядку внутренних цепей и восстановительную перезапись строки (если, конечно, восстановительная перезапись выполняется самой микросхемой).
Задержка между подачей номера строки и номера столбца на техническом жаргоне называется "RAS to CAS delay" (на сухом официальном языке – tRCD). Задержка между подачей номера столбца и получением содержимого ячейки на выходе – "CAS delay" (или tCAC), а задержка между чтением последней ячейки и подачей номера новой строки – "RAS precharge" (tRP). Здесь и ниже будут использоваться исключительно жаргонизмы – они более наглядны и к тому же созвучны соответствующим настойкам BIOS, что упрощает восприятие материала.

Рисунок 5 0х12 Схематическое изображение модуля оперативной памяти (1); микросхемы памяти (2); матрицы (3) и отдельной ячейки памяти (4)

Рисунок 6 scm.dram Устройство ячейки современной микросхемы динамической памяти
DDR SDRAM, SDRAM II (Double Data Rate SDRAM) SDRAM с удвоенной скоростью передачи данных
Дальнейшее совершенствование синхронной памяти привело к появлению DDR?SDRAM – Double Data Rate SDRAM (SDRAM удвоенной скорости передачи данных). Удвоение скорости достигается за счет передачи данных и по фронту, и по спаду тактового импульса (в SDRAM передача данных осуществляется только по фронту). Благодаря этому эффективная частота увеличивается в два раза – 100MHz DDR-SDRAM по своей производительности эквивалента 200 MHz SDRAM (на самом деле, это не совсем так, см. "Вычисление полного времени доступа"). Правда, по маркетинговым соображениям, производители DDR-микросхем стали маркировать их не тактовой /* рабочей */ частой, а максимально достижимой пропускной способностью, измеряемой в мегабайтах в секунду. Т.е. DDR-1600 работает вовсе не на 1.6 GHz (что пока является недостижимым идеалом), а всего лишь на 100 MHz. Соответственно, DDR?2100 работает на частоте 133 MHz.Претерпела изменения и конструкция управления матрицами (банками) памяти. Во?первых, количество банков увеличилось с двух до четырех, а, во-вторых, каждый банк обзавелся персональным контроллером (не путать с контроллером памяти!), в результате чего вместо одной микросхемы мы получили как бы четыре, работающих независимо друг от друга. Соответственно, максимальное количество ячеек, обрабатываемых за один такт, возросло с одной до четырех.
Деление
Деление – очень "дорогостоящая" операция, даже на старших моделях процессоров Intel Pentium занимающая до сорока и более тактов. Кошмар! К счастью процесс деления поддается оптимизации.Если делитель кратен степени двойки, то инструкцию деления можно заменить более быстродействующей инструкцией битового сдвига, выполняющейся всего за один такт. А что делать, если делитель отличен от степени двойки (как чаще всего и бывает)? Тогда имеет смысл заменить деление умножением, - ведь операция умножения выполняется намного быстрее, в среднем укладываясь в четыре такта, что на порядок шустрее! Существует множество формул подобных преобразований, вот, например, одна (самая популярная из них): , где N – разрядность числа. Если делить – константа, то операция деления выполняется всего за пять тактов – два в степени N – константное выражение, вычисляемое на этапе компиляции, выражение вычисляется битовым сдвигом за один такт, еще четыре такта расходуется на умножение, итого, в сумме выходит пять.
К сожалению, компиляторы Borland C++ и WATCOM не настолько "умны", чтобы заменять деление умножением – на это способен один лишь Microsoft Visual C++, за что честь ему и хвала! Битовые же сдвиги используют все три рассматриваемых компилятора.
Двухуровневая организация кэша
Предельно достижимая емкость кэш-памяти ограничена не только ее ценой, но и электромагнитной интерференцией (см. "Часть I Оперативная память. Устройство и принципы функционирования оперативной памяти. RDRAM Rambus DRAM – Rambus-память"), налагающей жесткие ограничения на максимально возможное количество адресных линий, а значит – на непосредственно адресуемый объем памяти. В принципе, мы можем прибегнуть к мультиплексированию выводов или последовательной передаче адресов (как, например, поступили разработчики Rambus RDRAM), но это неизбежно снизит производительность и доступ к ячейке кэш-памяти потребует более одного такта, что не есть хорошо.С другой стороны, двух портовая статическая память действительно очень дорога, а одно-портовая не в состоянии обеспечить параллельную обработку нескольких ячеек, что приводит к досадным задержкам.
Естественный выход состоит в создании многоуровневой кэш-иерархии (см. рис. 0х013). Большинство современных систем имеют как минимум два уровня кэш памяти. Первый, наиболее "близкий" к процессору (условно обозначаемый Level 1
или сокращенно L1), обычно реализуется на быстрой двух портовой синхронной статической памяти, работающей на полной частоте ядра процессора. Объем L1-кэша весьма не велик и редко превышает 32 Кб, поэтому он должен хранить только самые-самые необходимые данные. Зато на обработку двух полно разрядных ячеек уходит всего один такт. (Внимание: процессоры x86 оснащаются не истинно двух портовой памятью! Двух портовый у нее лишь интерфейс, а ядро памяти состоит из нескольких независимых банков, – обычно восьми, реализованных на одно-портовый матрицах, и параллельный доступ возможен лишь к ячейкам разных банков. см. "Оптимизация обращения к памяти и кэшу. Стратегия распределения данных по кэш-банкам.")
Между кэшем первого уровня и оперативной памятью расположен кэш второго уровня (условно обозначаемый Level 2
или сокращенно L2). Он реализуется на одно-портовой конвейерной статической памяти (BSRAM) и зачастую работает на пониженной тактовой частоте.
Поскольку одно- портовая память значительно дешевле, объем L2 кэша составляет сотни килобайт, а зачастую достигает и нескольких мегабайт! Между тем скорость доступа к нему относительно невелика (хотя, естественно, многократно превосходит скорость доступа к основной памяти).
Во-первых, минимальной порцией обмена между L1 и L2 кэшем является отнюдь не байт, а целая кэш-линейка, на чтение которой уходит в среднем 5 тактов частоты кэша второго уровня (напоминаем, что формула BSRAM памяти выглядит так: 2-1-1-1). Если L2-кэш работает на половинной частоте процессора, то обращение к одной ячейке займет целых 10 тактов. Разумеется, эту величину можно сократить. В серверах и высокопроизводительных рабочих станциях кэш второго уровня чаще всего работает на полной частоте ядра процессора и зачастую имеет учетверенную разрядность шины данных, благодаря чему пакетный цикл обмена завершается всего за один такт. Однако и стоимость таких систем соответствующая.

Рисунок 12 0х013 Двух уровневая кэш-иерархия
Включающая (inclusive) архитектура.
Кэш второго уровня, построенный по inclusive-архитектуре, всегда дублирует содержимое кэша первого уровня, а потому эффективная емкость сверхоперативной памяти обоих иерархий равна: L2.CACHE_SIZE – L1.CACHE.SIZE + L1.CACHE.SIZE == L2.CACHE.SIZE.
Давайте рассмотрим, что происходит в системе, когда при полностью заполненном кэше второго уровня, процессор пытается загрузить еще одну ячейку. Обнаружив, что все кэш-линейки заняты, кэш второго уровня избавляется от наименее ценной из них, стремясь при этом найти линейку, которая еще не была модифицирована, поскольку в противном случае еще придется выгружать в основную оперативную память, а это – время.
Затем кэш второго уровня передает полученные из памяти данные кэшу первого уровня. Если кэш первого уровня так же заполнен под завязку ему приходится избавляться от одной из строк по сценарию, описанному выше.
Таким образом, загруженная порция данных присутствует и кэш-памяти первого уровня, и в кэш-памяти второго, что не есть хорошо.
Между тем, практически все современные процессоры (AMD K6, P-II, P-III) построенные именно по включающей архитектуре.


Рисунок 13 0х014 0х015 inclusive- (слева) и exclusive- (справа) архитектуры
Исключающая (exclusive) архитектура.
Кэш-подсистема, построенная по exclusive-архитектуре, никогда не хранит избыточных копий данных и потому эффективная емкость сверхоперативной памяти определяется суммой
размеров сверхоперативной памяти всех иерархий.
Кэш-первого уровня никогда не уничтожает кэш-линейки при нехватке места. Даже если они не были модифицированы, – данные в обязательном порядке вытесняются в кэш-второго уровня, помещаясь на то место, где находилась только что переданная кэшу первого уровня линейка. Т.е. кэш первого и кэш второго уровней как бы обмениваются друг с другом своими линейками, а потому сверхоперативная память используется весьма эффективно.
Причем, во избежание падения производительности, процесс обмена должен происходить параллельно, а не через промежуточный буфер, иначе время загрузки данных из кэш-памяти второго уровня увеличиться на несколько тактов, необходимых для выгрузки линейки в промежуточный буфер.
На сегодняшний день эксклюзивная кэш-подсистема реализована в одном лишь процессоре AMD Athlon, да и то не с первых моделей (см. так же. "Оптимизация обращения к памяти и кэшу. Влияние размера обрабатываемых данных на производительность. Особенности кэш-подсистемы процессора AMD Athlon")
Промах кэша первого уровня может стоить многих тактов процессора и даже очень многих, если искомых данных не окажется и в кэше второго уровня. Конвейерные процессоры, к семейству которых принадлежит и Pentium, в той или иной степени компенсируют падение производительности. Благодаря конвейеру промах чтения не вызывает остановки работы процессора – во время загрузки данных могут выполняться и другие команды. При условии, что поток кода не содержит неустранимых зависимостей, производительность программы ничуть не снизится.Правда, на практике такая ситуация наблюдается очень редко. Не мудрено – если мы обращаемся к данным, то явно затем, чтобы использовать их в самом ближайшем будущем.
EDO-DRAM (Extended Data Out) память с усовершенствованным выходом
Изобретение FPM-DRAM не решило проблему производительности, но дало короткую передышку – ведь тактовые частоты микропроцессоров не стояли на месте, а стремительно росли, вплотную приближаясь к рубежу в 200 МГц. Рынок требовал качественного нового решения, а не изнуряющей борьбы за каждую наносекунду. Инженеров вновь отправили к чертежным доскам, где (году эдак в 1996) их осенила очередная гениальная идея. Если оснастить микросхему специальным триггером-защелкой, удерживающим линии данных после исчезновения сигнала CAS, станет возможным дезактивировать CAS до окончания чтения данных, подготавливая в это время микросхему к приему номера следующего столбца.Взгляните на диаграмму рис. 0х23: видите, у FPM низкое состояние CAS удерживается вплоть до окончания считывания данных, затем CAS дезактивируется, выдерживается небольшая пауза на перезарядку внутренних цепей, и только после этого на адресную шину подается номер колонки следующей ячейки. В новом типе памяти, получившем название EDO?DRAM (Extend Data Output – память с усовершенствованным выходом), напротив, CAS дезактивируется в процессе чтения данных параллельно с перезарядкой внутренних цепей, благодаря чему номер следующего столбца может подаваться до завершения считывания линий данных. Продолжительность рабочего цикла EDO-DRAM (в зависимости от качества микросхемы) составляла 30-, 25- и 20 нс., что соответствовало всего двум тактам в 66 МГц системе. Совершенствование производственных технологий сократило и полное время доступа. На частоте 66 МГц формула лучших микросхем выглядела так: 5?2?x?x. Простой расчет позволяет установить, что пиковый прирост производительности составил около 30%, однако, во многих компьютерных журналах тех лет фигурировала совершенно немыслимая цифра 50%, – якобы настолько увеличивалась скорость компьютера при переходе с FPM на EDO. Это могло быть лишь при сравнении худшей FMP- с самой "крутой" EDO-памятью, т.е. фактически сравнивались не технологии, а старые и новые микросхемы.

Рисунок 7 0x23 Временная диаграмма, иллюстрирующая работу некоторых типов памяти
Елей и деготь оптимизирующих компиляторов
Применяя языки высокого уровня для разработки выполняемого в стеке кода, следует учитывать особенности реализаций используемых компиляторов и, прежде чем останавливать свой выбор на каком-то одном из них, - основательно изучить прилагаемую к ним документацию. В большинстве случаев код функции, скопированный в стек, с первой попытки запустить не получится, особенно если включены опции оптимизированной компиляции.Так происходит потому, что на чистом
языке высокого уровня, таком как Си или Паскаль, скопировать код функции в стек (или куда-то еще) принципиально невозможно, поскольку, стандарты языка не оговаривают, каким именно образом должна осуществляется компиляция. Программист может получить указатель на функцию, но стандарт не оговаривает, как следует ее интерпретировать – с точки зрения программиста она представляет "магическое число" в назначение которого посвящен один лишь компилятор.
К счастью, логика кодогенерации большинства компиляторов более или менее одинакова, и это позволяет прикладной программе сделать некоторые предположения об организации откомпилированного кода.
В частности, программа, приведенная в листинге 3, молчаливо полагает, что указатель на функцию совпадает с точкой входа в эту функцию, а все тело функции расположено непосредственно за точкой входа. Именно такой код (наиболее очевидный с точки зрения здравого смысла) и генерирует подавляющее большинство компиляторов. Большинство, но не все! Тот же MicrosoftVisual C++ в режиме отладки вместо функций вставляет "переходники", а сами функции размешает совсем в другом месте. В результате, в стек копируется содержимое "переходника", но не само тело функции! Заставить Microsoft Visual C++ генерировать "правильный" код можно сбросом флажка "Link incrementally". У других компиляторов название этой опции может значительно отличаться, а в худшем случае – вообще отсутствовать. Если это так – придется отказаться либо от самомодифицирующегося кода, либо от данного компилятора.
Естественное (natural) выравнивание данных
Данные размером в байт, очевидно, никогда не пересекают кэш-строки, поэтому, никакого выравнивания для них и не требуется. Данные размером в слово, начинающиеся с четныхадресов, также гарантированно умещаются в одну кэш строку. Наконец, двойные слова, начинающиеся с адресов, делящихся на четыре без остатка, никогда не пересекают границу кэш-строк (см. рис. 0х026). Обобщив сказанное, мы получим следующую таблицу:
| размер данных | степень выравнивания | ||||||
| P-Plain, P MMX | P Pro, P-II, P-III | Athlon | |||||
| 1 байт (8 битов) | Произвольная | Произвольная | Произвольная | ||||
| 2 байта (16 битов) | Кратная 2 байтам | Кратная 2 байтам | Кратная 2 байтам | ||||
| 4 байта (32 бита) | Кратная 4 байтам | Кратная 4 байтам | Кратная 4 байтам | ||||
| 6 байт (48 бит) | Кратная 4 байтам | Кратная 8 байтам | Кратная 8 байтам | ||||
| 8 байтов (64 бита) | Кратная 8 байтам | Кратная 8 байтам | Кратная 8 байтам | ||||
| 10 байтов (80 битов) | Кратная 16 байтам | Кратная 16 байтам | Кратная 16 байтам | ||||
| 16 байтов (128 битов) | Кратная 16 байтам | Кратная 16 байтам | Кратная 16 байтам |
Таблица 4 Предпочтительная степень выравнивания для различных типов данных
Естественное выравнивание данных так же называют "выравниванием [для] перестраховщиков" или "выравниванием [для] бюрократов", поскольку оно исходит из худшего случая, когда выравниваемые данные пересекают обе кэш-линейки, не учитывая действительной ситуации. Платой за это становится увеличение количества потребляемой приложением памяти, что в ряде случает приводит к значительному падению производительности (в общем, за что боролись, на то и напоролись).

Рисунок 24 0х26 Естественное выравнивание данных
FECI QUOD POTUI, FACIANT MELIORA POTENTES
Хочу закончить свою книгу. Вот и все. Я меняю себя на нее. Мне кажется, что она вцепилась в меня, как якорь.Антуан де Сент-Экзюпери. Цитадель
Фоновое копирование памяти
Фактически, фоновое копирование памяти, – есть ни что иное, как прозрачное комбинирование вычислительных операций с загрузкой ячеек из оперативной памяти. Возвращаясь к предыдущей задаче (загрузить N ячеек памяти и k раз вычислить синус угла), – а что если поместить цикл загрузки ячеек в один, а цикл вычисления синуса в другой поток? И пусть процессор сам разбирается в какой концентрации их лучше всего смешивать. Э, нет! Все не так просто! Потоки (в том виде, в котором они поддерживаются операционной системой) для решения этой задачи абсолютно непригодны. В течение кванта времени, выделяемого потоку, процессор успевает загрузить из памяти десятки мегабайт данных, что явно не входит в наши планы, поскольку мы хотим выполнять оба потока параллельно. Что ж, приходится эмулировать много поточность самостоятельно. Самый простой путь решенияФормула памяти
К середине девяностых среднее значение RASto CAS Delay составляло порядка 30 нс., CAS Delay – 40 нс., а RAS precharge – менее 30 нс. (наносекунд). Таким образом, при частоте системной шины в 60 МГц (т.е. ~17 нс.) на открытие и доступ к первой ячейки страницы уходило около 6 тактов, а на доступ к остальным ячейкам открытой страницы – около 3 тактов. Схематически это записывается как 6-3-x-x и называется формулой памяти.Формула памяти упрощает сравнение различных микросхем друг с другом, однако для достоверного сравнения необходимо знать преобладающий тип обращений к памяти: последовательный или хаотичный. Например, как узнать, какая из двух микросхем лучше: 5?4?x?x или 6?3?x?x? В данной постановке вопрос вообще лишен смысла. Лучше для чего? Для потоковых алгоритмов с последовательной обработкой данных, бесспорно, предпочтительнее последний тип памяти, в противном случае сравнение бессмысленно, т.к. чтение двух несмежных ячеек займет не 5-5-х-х и, соответственно, 6?6?х?х тактов, а 5+RAS Precharge?5+RAS Precharge?x?x и 6+RAS prechange-6+RAS prechange?x?x. Поскольку время регенерации обоих микросхем не обязательно должно совпадать, может сложиться так, что микросхема 6?3?x?x окажется быстрее и для последовательного, и для хаотичного доступа. Поэтому, практическое значение имеет сравнение лишь вторых цифр – времени рабочего цикла. Совершенствуя ядро памяти, производители сократили его сначала до 35, а затем и до 30 нс., достигнув практически семикратного превосходства над микросхемами прошлого поколения.
FPM DRAM (Fast Page Mode DRAM) быстрая страничная память
Первой ласточкой стала FPM-DRAM – Fast-Page Mode DRAM (Память быстрого страничного режима), разработанная в 1995 году. Основным отличием от памяти предыдущего поколения стала поддержка сокращенных адресов. Если очередная запрашиваемая ячейка находится в той же самой строке, что и предыдущая, ее адрес однозначно определяется одним лишь номером столбца и передача номера строки уже не требуется. За счет чего это достигается? Обратимся к диаграмме, изображенной на рис 0x23. Смотрите, в то время как при работе с обычной DRAM (верхняя диаграмма) после считывания данных сигнал RAS дезактивируется, подготавливая микросхему к новому циклу обмена, контроллер FPM-DRAM удерживает RAS в низком состоянии, избавляясь от повторной пересылки номера строки.При последовательном чтении ячеек памяти, равно как и обработке компактных одно?двух килобайтовых структур, время доступа сокращается на 40%, а то и больше, ведь обрабатываемая строка находится во внутреннем буфере микросхемы, и обращаться к матрице памяти нет никакой необходимости!
Правда, хаотичное обращение к памяти, равно как и перекрестные запросы ячеек из различных страниц, со всей очевидностью не могут воспользоваться преимуществами передачи сокращенных адресов и работают с FPM-DRAM в режиме обычной DRAM. Если очередная запрашиваемая ячейка лежит вне текущей (так называемой, открытой) строки, контроллер вынужден дезактивировать RAS, выдержать паузу RAS precharge на перезарядку микросхемы, передать номер строки, выдержать паузу RAS to CAS delay и лишь затем он сможет приступить к передаче номера столбца.
Ситуация, когда запрашиваемая ячейка находится в открытой строке, называется "попаданием на страницу" (Page Hit), в противном случае говорят, что произошел промах (Page Miss). Поскольку, на промах налагаются штрафные задержки, критические к быстродействию программные модули приходится разрабатывать с учетом особенностей архитектуры FPM-DRAM и абстрагироваться от ее устройства уже не получается. (Вот он ключевой момент истории, начиная с которого оперативная память утратила свою однородность!)
Возникла и другая проблема: непостоянство времени доступа затрудняет измерение производительности микросхем памяти и их сравнение результатов друг с другом. В худшем случае обращение к ячейке составляет RAS to CAS Delay + CAS Delay + RAS precharge нс., а в лучшем: CAS Delay. Хаотичное, но не слишком интенсивное обращение к памяти (так, чтобы она успевала перезарядиться) требует не более чем RAS to CAS Delay + CAS Delay нс.
Поскольку, величины RAS to CAS Delay, CAS Delay и RAS precharge непосредственно не связаны друг с другом и в принципе могут принимать любые значения, достоверная оценка производительности микросхемы требует для своего выражения как минимум трех чисел. Однако производители микросхем в стремлении приукрасить реальные показатели, обычно проводят только два: RAS to CAS Delay + CAS Delay и CAS Delay. Первое (называемое так же "временем [полного] доступа") характеризует время доступа к произвольной ячейке, а второе (называемое так же "временем рабочего цикла") – время доступа к последующим ячейкам из уже открытой строки. Время, необходимое для регенерации микросхемы (т.е. RAS precharge), из полного времени доступа исключено. (Вообще-то, в технической документации, кстати, свободно доступной по Интернету, приводятся все значения и тайминги, так что никакого произвола в конечном счете нет).
Фундаментальные проблемы профилировки "в большом"
Профилировкой "в большом" мы будем называть измерение времени выполнения структурных единиц программы (функций, многократно выполняемых циклов и т.д.), а то и всей программы целиком.Профилировке "в большом" присущи свои проблемы. Это и непостоянство времени выполнения, и проблема "второго прохода", и необходимость учета наведенных эффектов… Словом, здесь есть над чем поработать!
Фундаментальные проблемы профилировки "в малом"
Профилировкой "в малом" мы будем называть измерение времени выполнения небольших фрагментов программы, а то и отдельных машинных команд. Профилировке в малом присущ ряд серьезных и практически неустранимых проблем, незнание которых зачастую приводит к грубым ошибкам интерпретации результата профилировки и как следствие – впустую потраченному времени и гораздо худшему качеству оптимизации.Функции с аргументами по умолчанию – из Си++ в классический Си
Язык Си++ выгодно отличается от своего предшественника тем, что поддерживает функции с аргументами по умолчанию. Для чего они нужны? Переставим себе такую ситуацию. Пусть у нас имеется активно используемая функция plot(int x, int y, int color), рисующая в заданных координатах точку заданного цвета. Допустим, в один прекрасный момент мы осознаем, что помимо координат и цвета нам жизненно необходим, скажем, атрибут прозрачности. Как быть? Реализовать еще одну функцию, практически повторяющую первую (ау, метод "copy-and-paste")? Не слишком-то удачное решение! Помимо неоправданного увеличения размеров программы возникнет проблема синхронизации обеих функций – при исправлении ошибок в первой функции, их придется "отлавливать" и во второй. (Вообще же, в грамотно спроектированной программе не должно присутствовать хотя бы двух идентичных блоков).Но почему бы просто не добавить еще один аргумент к функции? Дело в том, что это потребует внесения изменений во все остальные функции, использующие данную. А всякое изменение уже отлаженного кода чревато появлением ошибок в самых непредсказуемых местах. Еще хуже если такая функция вынесена в библиотеку – тогда ее модификация "срубит" множество программ, что весьма нежелательно.
А давайте объявим атрибут прозрачности аргументом по умолчанию! Тогда, ранее написанные фрагменты кода без какой либо адоптации смогут вызывать эту функцию по "старинке", даже подозревая об изменении ее прототипа. Благодаря этому, мы сможем наращивать функциональность кода без потери обратной совместимости. К тому же такая методика если не избавляет от необходимости проектирования прототипов функций, то, во всяком случае, значительно упрощает эту задачу, зачастую позволяя решать ее "на лету".
К сожалению, подобная тактика не может быть непосредственно перенесена на Си, поскольку аргументов по умолчанию не он поддерживает. Правда, Си поддерживает функции с переменным числом аргументов, но это совсем не то, поскольку никакого контроля типов при этом не выполняется, что чревато трудноуловимыми ошибками.
Но выход все же есть! Автор предлагает на суд читателей свой любимый способ передачи аргументов функциям, разумеется, без претензий на право первооткрывателя. В общих чертах суть его в том, что гораздо выгоднее передавать не сами аргументы, а указатель на экземпляр структуры, содержащей в себе все необходимое функции данные.
Этот механизм, во-первых, позволяет модифицировать "прототипы" функций без потери обратной совместимости, так как описание структуры содержится лишь в реализации самой функции, но не функций ее вызывающих. Чем, кстати, обеспечивается гораздо большая степень абстракции и гибкости.
Во-вторых, передача указателя на экземпляр структуры выгодно отличается от передачи "простых" аргументов тем, что обеспечивает совместное использование одной области памяти несколькими функциями, – обрабатываемые данные не "перепихиваются" по цепочке переменных, а могут использоваться непосредственно по "месту проживания", за счет чего достигается заметное увеличение быстродействия программы.
В-третьих, структуры позволяют объявлять универсальные "многопрофильные" прототипы общие для "своих" групп функций. "Сверх – прототип" одновременно включает в себя все аргументы выбранной группы функций, а каждая из функций вольна по своему усмотрению использовать любое подмножество из них. Это еще больше абстрагирует нас от деталей конкретных реализаций и в этом свете группу функций можно рассматривать как один "макрообъект". В результате освоение больших библиотек значительно упрощается. К тому же упрощается классификация функций, так как теперь главным отборочным критерием служит не ее функциональность (которая порой слишком функциональна для однозначной классификации), а вполне однозначный набор аргументов, с которым эта функция манипулирует.
Впрочем, при всех достоинствах этого метода, не лишен он и недостатков. Прежде всего, обращение к членам структуры, переданной по указателю, требует дополнительного расхода процессорного времени, что не самым лучшим образом сказывается на быстродействии.
Впрочем, величина снижения производительности на практике не превышает 0,5%-1% и ей вполне можно пренебречь. Другой минус – загромождение листинга структурами, что затрудняет его понимание. Однако к такому стилю очень быстро привыкаешь, и дискомфорт исчезает сам собой.
С практической стороной реализации тоже не все гладко. Поскольку, Си не поддерживает инициализации членов структуры по умолчанию, возникает проблема: как вызываемой функции узнать какие именно аргументы ей передала вызывающая, а какие содержат не инициализированный мусор? Существует несколько путей выхода из этого тупика. В частотности, можно вручную инициализировать выделяемую под структуру память, принудительно прописывая ее "магическим" числом "значение ячейки неопределенно".
Конечно, данный метод, уже хотя бы в силу присущих ему недостатков, не претендует на радикальное изменение идеологии программирования и вовсе не намеривается вытеснять (и даже потеснясь) классический механизм передачи аргументов, но в некоторых случаях он все же бывает чрезвычайно полезен. Сам автор этой статьи использовал его в нескольких крупных проектах и полученным результатам остался доволен. Благодаря нему кардинальные нововведения новых версий и даже перенос из среды MS-DOS в операционную систему Windows обошлись практически без модификации уже отлаженного кода.
Впрочем, о технике создания мобильного кода мы поговорим в следующий раз.
Группировка операций чтения с операциями записи
В некоторых руководствах по оптимизации встречается утверждение о нежелательности перекрытия шинных транзакций чтения с транзакциями на запись. На самом деле, это утверждение неверно. Современные чипсеты, обладая способностью к внеочередной обработки запросов, самостоятельно определяют наиболее предпочтительную стратегию физического обмена с памятью. Поэтому, необходимости избегать смешивания команд чтения памяти с командами записи в действительности нет. Правда, за одним небольшим исключением. Сказанное справедливо исключительно для обработки больших массивов данных, многократно превышающих емкость кэш-памяти всех уровней. В противном случае, падение производительности на перекрывающихся транзакциях будет весьма значительным (см. "Кэш ???").Пример, приведенный ниже, как раз и позволяет установить: как влияет перекрытие транзакций чтения/записи при обработке больших блоков данных.
/* -----------------------------------------------------------------------
перекрытия транзакций не происходит
----------------------------------------------------------------------- */
for (a = 0; a < BLOCK_SIZE; a += 4)
{
*(int *)((int)p1 + a) = x;
}
for (a = 0; a < BLOCK_SIZE; a += 4)
{
x += *(int *)((int)p1 + a);
}
/* -----------------------------------------------------------------------
перекрытия транзакций происходят постоянно
----------------------------------------------------------------------- */
for (a = 0; a < BLOCK_SIZE; a+= 32)
{
x += *(int *)((int)p1 + a);
*(int *)((int)p2 + a) =x;
}
Листинг 28 [Memory/read.write.c] Фрагмент программы, демонстрирующий влияние перекрытия транзакций чтения/записи на производительность
Опля! Вот уж чего мы вряд ли ожидали, – так это увеличения
производительности при перекрытии транзакций. Впрочем, даже поверхностное разбирательство показывает, что перекрытия транзакций тут играют второстепенную роль и изменения быстродействия вызваны ничем иным, как… разворотом цикла.
Действительно, совмещение двух циклов в одном равносильно его развороту на две итерации!
Чтобы компенсировать побочное влияние разворота, давайте развернем два непрерывающихся цикла на N итераций, а "гибридный" цикл – на N/2, причем, N должно быть достаточно велико, чтобы разворот в N/2 итерации был не сильно хуже, чем N (ведь на время прохождения трассы, как мы помним, влияет не только количество поворотов, но и протяженность прямых участков см. "Разворачивание циклов"). Достаточно точный результат достигается уже при N равном 16, вот его-то мы и возьмем (фрагмент программы с развернутыми циклами здесь не приводится, т.к. эту операцию вы должны уметь осуществлять и самостоятельно).
Ага, оказывается, что перекрытие транзакций все же уменьшает производительность. Правда, совсем не на много, всего лишь на ~5% на системе P-III 733/133/100/I815EP. Эта величина настолько мала, что в подавляющем большинстве случаев ей можно абсолютно безболезненно пренебречь. Правда, на AMD Athlon 1050/100/100/VIA KT133 проигрыш достигает аж ~25%, чем будет достаточно большой жертвой, но все-таки на нее можно закрыть глаза ради упрощения реализации вычислительного алгоритма.

Рисунок 42 graph 31 Демонстрация влияния перекрытия транзакций чтения/записи на время обработки больших блоков данных с учета разворота цикла и без. Если на P-III перекрытие транзакций практически не влияет на производительность, то на AMD Athlon проигрыш уже становится ощутим, хотя и не так велик, что бы перечеркивать все выше написанное
Идеология – как средство конкурентной борьбы
Воины-смертники, готовые умереть за идею, не слишком отличаются от приверженцев движения "Open Source" – и те, и другие служат на благо того, против чего борются.Только глупцы верят, что "Open Source" несет в себе свободу. И пока они в это верят, многие используют их в качестве тарана против монополизма Microsoft. Компании IBM и HP поддерживает LINUX отнюдь не потому, что вдавились в старческую филантропию. Под шумком идей "свободы", "открытости" и "братства" они подсаживает миллионы пользователей на LINUX, отрывая жирный кусок рынка от Microsoft. Подавляющее большинство выбирают LINUX не из-за его технических и потребительских достоинств (которым там фактически нету), а потому, что это круто. От кривого (да не запинают меня его поклонники) LINUX к коммерческим AIX- и HP-UNIX всего лишь один шаг. Немного рекламы, скидок, консалтинга и клиент его сделает! Вот истинная
причина поддержки LINUX компаниями HP и IBM.
Самой же Microsoft "открытые исходники" выгодны вот по каким причинам:
а) конкурент в лице LINUX разбивает в пух и прах все обвинения компании в монополизме;
б) позволяет чужими руками забесплатно создавать и обкатывать новые технологии;
в) способствует обучению и профессиональному росту молодых программистов, а это – кадры;
г) и так далее…
Увлеченные борьбой с монополиями, члены движения "Open Source" не заметили, как стали работать на благо этих же монополий, превратившись в мощный инструмент в их руках.
Энергию, охваченных единой идеей людей, всегда можно направить в выгодное для себя русло. Не стоит бороться с рынком. Его ресурсы – неисчерпаемые энергетические возможности. Надо лишь знать, как отобрать их.
Иерархия оперативной памяти
Оперативная память… что это? Объект материального мира или абстракция, не соответствующая никакой физической действительности? Не спешите отвечать. Давайте рассуждать логически. Если, вооружившись отверткой, открутить несколько болтов, удерживающих корпус компьютера, то, среди прочего семейства микросхем, на материнской плате обнаружатся один, два или три вертикально установленных прямоугольных модуля. Это и есть оперативная память или, говоря по-английски, RAM – Random Access Memory (Память с Произвольным Доступом). Значит, оперативная память – это некоторое физическое устройство (какое – уже неважно). Так?А вот попробуйте подойти с этим вопросом к любому программисту. Если наш программист не будет пьян или вусмерть укурен, то, скорее всего, он определит память как совокупность ячеек, причем не хаотично разбросанных точно пшено на асфальте, а объединенных адресным пространством с наперед заданными свойствами. Слышите? Ни слова об устройстве! Причем, это был явно системный программист, который, если захочет, может без труда положить значение 0x666 в ячейку под "номером" 0x999, а спустя некоторое время извлечь его оттуда. Прикладные же программисты имеют крайне смутное представление о том как нумеруются ячейки (и нумеруются ли они вообще). Языки высокого уровня приучают оперировать не ячейками, а переменными. Считается, что переменные располагаются в памяти, вернее каждое переменной соответствует одна или более ячеек памяти. На самом же деле это не совсем так (или даже совсем не так)! Компилятору может взбрести в голову разместить переменных в регистрах или же вовсе не размещать их нигде, если он обнаружит, что значение переменной в программе не используется или же его можно вычислить еще на этапе компиляции.
Так что же все-таки представляет собой оперативная память? С точки зрения сборщика компьютеров, память – это микросхема. С точки зрения программиста – абстрактное хранилище данных. И бессмысленно спрашивать кто из них прав! Они оба… не правы.
Оперативная память – не микросхема и не абстракция. Это целая подсистема
компьютера, состоящая из множества взаимодействующих друг другом физических компонентов, образующих сложную иерархию логических абстракций.
>>>>> врезка
Давным-давно, когда компьютеры обходились без операционной системы, а о многозадачности никто и не слышал, загружать программы приходилось вручную, зато каждая из них монопольно владела всеми ресурсами компьютера, в том числе и оперативной памятью.
С появлением многозадачных систем встала проблема разделения ресурсов (в первую очередь – оперативной памяти) между несколькими приложениями и, что тоже немаловажно, защиты "владений" одного приложения от случайных или преднамеренных воздействий других. Были и другие трудности. В частности, требовалось обеспечить независимость работы приложений от адреса загрузки и каким-то образом исхитрится втиснуть множество одновременно выполняющихся программ в ограниченный объем оперативной памяти.
Решение этих задач потребовало создание многоуровневой иерархии организации памяти, в результате чего непосредственный доступ к ней оказался утрачен.
<<<<<
Абстракции хороши тем, что уменьшают минимально необходимое количество знаний для работы с аппаратурой, ликвидируя целый пласт второстепенных архитектурных особенностей. Так, знаете ли вы, что чтение ячейки динамической памяти разрушает ее содержимое и этого никто не замечает лишь только потому, что система автоматически восстанавливает информацию, самостоятельно выполняя запись данных после завершения операции чтения.
Второе полезное свойство абстракций: без них все программы были бы непереносимы. Абстракции реализуют (или, если так угодно, – эмулируют) виртуальную машину, устраняя (или по крайней мере ослабляя) влияние конструктивных особенностей физического оборудования на алгоритм реализации программы.
Стоп! Мы слишком увлеклись! Если мы не будет протирать звезды, пардон, учитывать особенности физического оборудования, то кто же тогда их будет учитывать?! Навряд ли этим займутся уровни абстракций и уж тем более – не само оборудование! Эмуляция и производительность – понятия несовместимые…
Абстрактная оперативная память однородна, физическая память – нет. Абстрактная модель оперативной памяти не имеет абсолютно никаких представлений о тех многочисленных ловушках и конструктивных ограничениях, которые словно терьеры вцепляются в программиста, препятствуя достижению максимального быстродействия. Чтобы победить в этой борьбе, нам придется последовательно "обезвредить" все слои абстракции и выйти на уровень "голого" физического оборудования.
Маленькая деталь. Сказанное вовсе не означает, что вам придется программировать на ассемблере и взаимодействовать с "железом" напрямую. Достаточно лишь планировать запросы к памяти с учетом характера этого железа, а эффективно программировать можно на любом языке. Если Вам направиться Си/С++, Паскаль или даже Perl, – я не буду ни в чем вас ограничивать.
Можно провести такую аналогию. Допустим, выемка писем из почтового ящика осуществляется каждое утро ровно в девять часов. Следовательно, для ускорения переписки мы должны обязательно отправлять наши письма до девяти. Даже незначительное опоздание увеличит срок доставки письма на целые сутки! Надеюсь, это вас убедит, что снять крышку с черного ящика с надписью "подсистема оперативной памяти IBM PC" все таки полезно. ОК, снимаем… (тяжелая зараза!) Еще одна секунда и мы узнаем, что же находится там…
На вершине иерархии (см. рис. sx1) находятся прикладные библиотеки управления памятью, реализующие унифицированный интерфейс к сервисам менеджера куч (heap manager) операционной системы. (Вообще-то, некоторые операционные системы, в частности MS-DOS, не имеют полноценного менеджера куч, и в этом случае его обязанности целиком ложатся на плечи прикладной библиотеки, но о таких "операционных системах" мы говорить не будем, поскольку это совершенно другая тема).
Менеджер куч (или, иначе говоря, менеджер динамической памяти), обеспечивает поддержку базовых операции с блоками памяти (выделение блока памяти, освобождение блока памяти, изменение размеров выделенного блока и т.д.)
Уровнем ниже лежит менеджер виртуальной памяти (virtual memory manager), который в тесной координации с процессором реализует:
a) виртуальные адресные пространства, т.е. абстрагируется от реальных физических адресов и позволяет назначать ячейкам памяти произвольные адреса. Благодаря этому, множество одновременно запущенных приложений могут быть загружены по одним и тем же логическим (виртуальным) адресам, причем адресные пространства этих приложений не будет пересекаться! Приложения, словно жители параллельных миров, при всем желании не "увидят" друг друга, конечно, если не воспользуются специально созданными для этой цели разделяемыми регионами памяти или другими средствами межпроцессорного взаимодействия, но это – разговор особый.
b) виртуальную память, представляющую собой дальнейшее развитие идей виртуальных адресных пространств. Любая ячейка виртуальной памяти может находиться как в оперативной памяти, так и на дисковом накопителе. Это позволяет выделять практически неограниченные объемы памяти, вплоть до десятков гигабайт (на самом деле, ОС семейства Windows предоставляют в распоряжение каждого процесса максимум два гигабайта, разве что Windows NT Server Enterprise не накладывает на аппетит программиста никаких ограничений, см. описание "Very Large Memory" в Platform SDK).
Функции менеджера виртуальной памяти требуют для своей работы определенных привилегий, поскольку непосредственно взаимодействуют с центральным процессором и драйвером диска.
Процессор же, в свою очередь, общается с памятью и диском не напрямую, а через контроллер памяти и дисковый контроллер соответственно. Оба в современных компьютерах интегрированы в так называемый чипсет (Chipset – набор микросхем, так же называемый "набором системной логики"), от интеллектуальности которого во многом зависит производительность всей системы.
Основная оперативная память персональных компьютеров обычно реализуется на весьма медленных (по сегодняшним меркам) микросхемах динамической памяти.
Поэтому, внутри процессора размешается небольшое количество быстродействующей сверхоперативной памяти, ускоряющей доступ к интенсивно используемым данным.
Сверхоперативная память, так же именуемая кэш-памятью, формально прозрачна для программиста: она не входит в адресное пространство, ее содержимое не может быть непосредственно прочитано или изменено.
Управление сверхоперативной памятью осуществляется не процессором, а кэш-контроллером
(впрочем, на современных процессорах кэш-контроллер интегрирован в сам процессор, но сути дела это не меняет). В служебные обязанности кэш-контроллера в первую очередь входит накопление в сверхоперативной памяти действительно нужных данных и удаление оттуда всякого "мусора", – данных, которые более не понадобятся.
Итого, иерархия подсистемы оперативной памяти имеет как минимум семь уровней абстракций, каждый из которых реализуется "своей" аппаратурой. Каждый уровень иерархии в свою очередь делится на несколько подуровней. Так, например, чипсет включает в себя блок интерфейса с шиной, арбитр запросов, несколько очередей запросов, контроллер памяти, буфер обмена и т.д.
Поэтому, воспринимайте схему sx1 не более чем наброском, грубо очерчивающим наш дальнейший маршрут путешествия по подсистеме памяти…

Рисунок 3 sx1.doc 0х22 Иерархия памяти в операционных системах Windows 9x/NT/2000 и UNIX (грубо)
Игры не для всех
– А пыли, пыли-то, сколько батюшки! – изумился Сергей и неуверенно чихнул. Он наполовину высунулся из разобранного сервера и, словно ожидая увидеть хотя бы на одном лице раскаяние, примирительно пробурчал "Ладно, тащите пылесос"."Вот уже и электроника стала считаться грязной работой", – подумал он, – "прогресс, мать его! Нет, надо драть отсюда когти по добру, по здорову. Наблюдал бы он по-прежнему звезды. Точная оптика, хромированные маховички, белые халатики. Так, нет же, потянуло к компьютерам. То ли интересно стало, а может, просто заработать хотел. А вышло, что ни там, ни там не платят. То есть прожить, конечно, можно.… Но стоит ли ради этого жить? Что бы вот так копаться в современных, супернавороченных компьютерах, которые сегодня обрастают пылью, а уже завтра оказываются на свалке. И кто вспомнит трудягу-техника, лелеющего всю эту груду микросхем?"
Кто-то сзади осторожно тронул плечо, протягивая свободно умещающийся на ладони агрегат. Работать с ним было одно удовольствие. Пыль тихим шуршанием снималась с насиженных мест и исчезала в мурлыкавших недрах пылесоса. Через пять минут вся материнская плата и большая часть контроллеров периферийных устройств блестели медными дорожками словно новенькие.
Сергей, удовлетворенный результатами своей работы, слегка изменился в лице. Электроника всегда вызывала в нем чувство благоговения, если, конечно, не была увешена паутиной и усыпана пылью.
Неуверенно потянувшись к рубильнику, он включил питание. Сервер тут же ожил, замигал огоньками и затарахтел всеми своими вентиляторами. А на экране монитора под надрывные хрипы жесткого диска вспыхнул логотип "Microsoft Windows NT Server"…
"Уххх…" пронесся вздох облегчения "работает".
"Еще бы ему не работать", – подумал Сергей, – "Вот, черти до чего технику довели. И стоило из-за этого в пять утра вылезать из постели?".
Возвращаться домой никакого желания не было и Сергей, скомкав честно заработанную десятидолларовую бумажку, направился в ближайшее кафе, в надежде скоротать немного времени.
Дул пронизывающий ветер, быстро неслись разлохмаченные облака, подсвечиваемые огнями большого города. Иногда сквозь разрывы удавалось заметить тускло светящие в задымлено – белом небе съежившиеся звезды. Видимость была настолько плохой, что различить контуры созвездий не мог даже тренированный глаз Сергея.
Да и тренированный ли теперь? Столько же воды утекло с тех пор, как он ушел с обсерватории? Три года? Пять лет? Или целая вечность? Но ничего, он к ним еще вернется. Вот только закончатся все намеченные проекты, истечет срок контрактов… И тогда – назад, в горы, к звездам! А к компьютерам – больше ни пальцем, ни ногой.
А как все хорошо начиналось! Быстрый успех, феноменальный взлет, заказы со всего мира.… Потом восторг заказчиков немного поутих, навалились конкуренты, и теперь чтобы удержаться на гребне популярности приходилось прикладывать титанические усилия, порой работая по двадцать и больше часов в день, создавая программы, которые устаревали порой быстрее, чем была поставлена последняя точка в листинге. Этот бешенный тем бессмысленной круговерти затягивал, словно омут, лишая всякой ориентации, цели и смысла жизни.
"И зачем я вообще живу?", – подумал Сергей, усаживаясь за свой любимый столик, в глубине "Трех поросят". "Что бы работать как проклятый, так что даже не остается времени поваляться на диване с любимой книжкой? Или просто поваляться, не думая ни о чем. Оставлю ли за собой след в истории, или так и рассеюсь в ее песке, среди тысяч таких же, молодых преуспевающих программистов, строящих окружающий нас кибернетический мир, струящийся и ускользающий из рук?"
Неслышно подошел официант, выкладывая на стол сигареты "Кэмл", бутылку пива "Усталый мельник" и жареную картошку, прямо в пугающей своей чернотой здоровенной сковородке. В меню такого блюда не значилось, но Сергей любил его, и кафе приходилось терпеть прихоти своего постоянного клиента.
"…так, двадцать семь множим, десять на ум кладем", – считал Сергей свои расходы "итого выходит недостаток в пятьдесят семь долларов, которые нужно найти к завтрему на квартиру.
То есть уже к сегодняшнему", – поправился он, глядя на светлеющее небо, на котором сквозь узкую щель между двумя небоскребами светила такая далекая сейчас Венера.
Нет, деньги не были проблемой, – и домовладельцы могли подождать, и ничего не стоило подписать новый контракт, вытребовав ощутимый процент предоплаты. Включится в очередной проект хотя бы ради того, чтобы разогнать накатившую депрессию, вновь отложить решение вопросов "Зачем?", "Для чего?" на неопределенный срок.
Сергей, глубоко затянувшись, задумчиво уставился на потухшие светильники под потолком. Утро вошло в свои права, но утомленные бессонной ночью чувства пытались убедить, что это вечер.
"...А вечером надо спать", – едва не обронил вслух Сергей, от чего внезапно смутился и без видимой для окружающих причины заспешил к выходу. "Спать, спать", – повторил про себя он, – "а то точно крыша поедет".
На улице семенил мелкий противный дождь, больно ударяющий порывами ветра в лицо. Ждать общественного транспорта по такой погоде ужасно не хотелось, но денег на такси уже не оставалось и приходилось покоряться судьбе.
"Стоило ли ехать в Москву только ради того, что бы оказаться под дождем, и пересчитывать мятые бумажки, которых как ни считай, все равно не хватит на такси?", – выругался Серей. Впрочем, тут же успокоил себя: "Раскис он. Деньги, говорит, у него кончились. Так работать надо, а не заниматься черт знает чем… Жизнью он не доволен. Квартира собственная есть? Есть! Интернет у него есть? Есть! Любимый компьютер? А как же! Возможность заниматься любимым делом? Ррразумется!"
"Но все что у тебя есть – лишь средства работы, ну и существования", – донесся внутренний голос. – "Что впрочем, для тебя одно и то же. Когда другие работают, что бы жить, ты живешь что бы работать…"
Сергей, зябко поежился. И впрямь, у него не было ничего, кроме работы. Ни семьи, ни даже любимой девушки. Друзья? Конечно, были и поклонники, и деловые партнеры и даже приятели, но ни с одним из них он ни разу просто так беспричинно не встретился, просто, что бы поболтать или тяпнуть пивка… Даже окружающий мир перестал замечать.
А ведь когда- то часами мог любоваться красотой заката, радоваться дождю, булыжнику, что валяется под ногой. Компьютеры подмяли собой весь остальной мир. Цветной монитор окрасил жизнь в тона оттенков серого. Собственно, жизнь вне компьютера стала для Сергея чужим, агрессивным, непонятным пространством. Слишком скучным и неинтересным, чтобы его можно было воспринимать всерьез. Компьютер – это не только целая Вселенная, это возможность видеть мир таким, как ты хочешь. Сергея ничуть не смущало, что мир этот виртуален. Какая, в сущности, разница? Реальная Вселенная – ничто иное, как совокупность сильных, слабых, гравитационных и электромагнитных излучений. Восприятие мира – это уже привилегия мозга, а если он не в состоянии отличить грань призрачного от реального, стоит ли искать различия?
"Брр", – пробормотал Сергей, нахохливаясь, словно воробей, под потоками усиливающегося дождя. – "Вот и вся вам разница между мирами, – идет дождь, и никакое мое восприятие действительности не в силах прекратить его, или хотя бы вызвать трамвай, или что там может прийти мне на помощь? Еще минута и насморк мне обеспечен"
От нечего делать он стал изучать содержимое своих карманов. Три метрошных карточки, начатая пачка сигарет, пара дискет неизвестного происхождения, купленная вчера авторучка. А зажигалка куда-то запропастилась. Выронил он ее что ли? Или забыл в сервере? Похоже, что забыл… "Вот будет для кого-то сюрприз", – усмехнулся Сергей, представляя себе, наладчика, открывающего сервер, в недрах которого среди прочих потрохов, лежит новенькая, почти не использованная газовая зажигалка. "Нет", – вздохнул Сергей. – "Юмор вряд ли поймут, если еще короткого замыкания, не будет", – подумал он.
Это значило, что предстояло возвращаться к заказчику в отталкивающем виде мокрой курицы, мучительно подбирая предлог для вскрытия сервера, на который уже начали ломиться первые толпы юзеров. А потом исхитряться незаметно вытащить зажигалку.
"За ногу", – простонал Сергей.
– "Кажется, начинается сезон неприятностей. Хорошо, хоть вовремя успел спохватиться" Теперь оставалось только придумать благовидный повод, требующий немедленного вскрытия сервера. Но, чтобы он ни сказал, ярости шефа не избежать. Попробуй, объясни ему, почему это нельзя было сделать вчера ночью, когда нагрузка на сервер минимальна, а именно сегодня утром, когда каждая минута простоя сервера это прямые убытки для компании, продающей через Интернет свой товар.
"Впрочем", – подумал Сергей, – "зачем придумывать повод, если его можно сделать?" Вытащив из кармана пару долларов, он направился к ближайшему Интернет-кафе. Идея была до безобразия проста. Вешаем сервер и.… Хм, и если это раскроется, то мало не покажется. Впрочем, зачем усложнять себе жизнь? Зачем атаковать сервер, если это можно придумать? Создать видимость атаки!
* * *
Оторвавшись от терминала, Сергей направился к охраннику, сидевшему у входа и, старясь вложить в свой тон максимум уверенности, произнес: "Мне от вас требуется позвонить. Как я могу это сделать?"
"А по какому поводу?", – хмыкнул охранник, явно не настроенный предоставлять телефон первому ощипанному проходимцу.
"С терминала, за которым я только что сидел, был атакован и блокирован сервер одной коммерческой компании, которую необходимо срочно известить об инциденте"
"А, ну раз такое дело, то, конечно, звоните", – смилостивился охранник, доставая из-за спины телефон.
"Евгений Петрович? Доброе утро! Сергей Потапов беспокоит. Знаете ли, я задержался в Интернет кафе. Так вот, к делу. Решил проверить, работает ли наш серверочек. Да, тот, что вчера настраивал. И представьте мое удивление, когда я попал на сайт порнографического содержания. Что? Так я только и хотел сказать, что это значит, что атаковали вас, - изменили таблицу роуминга… Что? Совершенно с Вами согласен, черт побери этих хакеров и мать их за ногу так! Что? Технические аспекты вас не интересуют? Ах, что бы работало через пять минут? Пожалуйста, еще раз загляните в контракт и уточните мои обязанности! Конечно же, за доплату дело другое.
Ну да, выписывайте пропуск, я буду через пять- десять минут. Нет, ремонт займет минут десять от силы тридцать…. Конечно, черт побери!. О кей, о кей. Еду"
"Неплохо", – подумал Сергей, окунаясь в сплошную стену дождя. – "Удивительно, как невыгодное положение порой оборачивается на пользу тебе. Конечно, это нечестно, но что такое честность? Кому дано судить о добре и зле? О пороке и добродетели? Как правило, все эти ярлыки навешиваются не лучшими представителями человечества, которые едва ли сами верят в них. Да, собственно, это не вопрос веры. Мораль – всего лишь сумма взглядов на мир, точнее один из вариантов представления мира. В чистом виде никаких понятий нет, - есть лишь субъективные оценки и проблемы выживания. Умереть голодным, но честным, означает лишь признать поражение честности – ее неспособность сопротивляться реалиям окружающей действительности. Ложь всегда побеждает. Потому, что предоставляет большую свободу действий. Да, собственно, что мы понимаем под ложью? Противоположность истине? Конечно же, нет. Тогда бы все было слишком просто и неинтересно. Ложь – это некоторая сложная производная функция от аргумента, который мы называем истиной. Хм, но есть и такая функция, что f(x)==x. То есть, правда – лишь частный случай лжи. Выходит, что лжи достаточно для описания всех человеческих отношений, а вот правды бы, увы, оказалось не достаточно.… Впрочем, в дискретике…."
За размышлениями Сергей не заметил, как добрался до конечной цели своего путешествия.
"Серж, закурить не найдется?", – окликнул его сидевший в машине водитель. – "Каким ветром занесло к нам? Вот твоя зажигалка, кстати. Ты ее вчера на сервере забыл"
"Зажигалка?" – нервно усмехнулся Сергей. – "Вправду моя! Ну что ж, давай, сядем, покурим, раз погода на то располагает!"
Тепло салона приятно разливалось по телу, насквозь пропитанному холодным дождем и, казалось, нечеловеческие усилия были нужны, чтобы заниматься, имитацией работы по устранению последствий злобной хакерской атаки на сервер, который уже было ни к чему разбирать.
И Сергей решился на очередную наглость.
"Слушай, шеф, у тебя телефона не будет? Позвонить надобно", – попросил он.
"Да как же не будет", – доброжелательно отозвался тот, – "в такой машине все есть! Сотовый Вас устоит?" лукаво улыбнулся он, но тут же поспешно добавил: "только не долго, ок?"
"Ну, о чем разговор", – согласился Сергей. – "Евгений Петрович?.. Переключайте, переключайте барышня. Добрый день Евгений Петрович!… Конечно же, я в курсе, что прошло уже… да, тридцать четыре минуты. Так я звоню, сообщить, что таблица роуминга уже как десять минуть назад восстановлена и сейчас тестируется… Нетехническим языком? Нетехническим языком – все в ажуре… Да, прямо справился не выходя из кафе… Нет, приятное с полезным совместить не удалось, Вы же сами просили работать как загнанная лошадь… Нет, я еще не готов выставить счет… Да, пятьсот долларов превосходно устроят. Всегда готов помочь. Всего доброго, до свидания"
"Серж, а ты где живешь-то? Может тебя довезти?", – предложил свои услуги водитель.
"Спасибо большое, но я вряд ли смогу расплатится", – честно признался Сергей, доставая из карманов последние и уже порядком промокшие бумажки.
"Да, что там! Жизнь длинная – сочтемся", – философски изрек шеф. "Ну так куда ехать?"
* * *
"…Хороший человек этот шофер", – размышлял Сергей, не отрывавший взгляд от дворников, тщетно пытающихся отвоевать хотя бы кусочек пространства у дождя. "А, впрочем, скорее расчетливый, чем хороший. Что помогает нам выжить в этой жизни? Связи, контакты, другими словами, друзья. Чем больше у тебя друзей, тем спокойнее в бушующем море жизни. Ничто ни страшно, если в трудную минуту кто-то успевает протянуть руку"
Пелена за окном замедляла движение транспорта до скорости табуна черепах, ползущих вверх по кромке дюны. Такими темпами до дому можно было добираться и час, и два, что явно не входило в планы, Сергея, мечтающего в этот момент только об одном – доползти до своей кровати и упасть в нее без задних ног.
" Находятся же глупцы, что ищут наркотики", – усмехнулся про себя Сергей. "Зачем? Внутри нас целая фабрика по их производству, - достаточно лишь систематически не досыпать, и мозг сам начет вырабатывать их. Впрочем, о чем это я? Там же совсем другой механизм, правда, с идентичными проявлениями. И с худшими последствиями", – зло усмехнулся он. "Однажды заставив работать уставший от бессонницы мозг, спустя какое-то время с ужасом обнаруживаешь, что ни в какое другое время, кроме бессонницы он наотрез отказывается работать. Но стоит только смириться, как мозг ставит новый барьер. – Порой Сергею приходилось не спасть по пять ночей кряду, чтобы довести свое создание до того волшебного состояния, когда окружающая действительность теряет очертания и, словно проваливаясь в какую-то пустоту, оставляет перед тобой лишь поставленную накануне задачу. Не важно формула это, или не поддающаяся отладке злостная процедура в программе, - и то и другое материализуется, становится осязаемым. К нему можно подойти, пнуть ногой. Попробовать на вкус, растереть на ладони… Конечно же, это всего лишь искаженная работа неправильно функционирующего мозга, и удачные решения – всего лишь следствие данной мозгу возможности получить нетривиальный результат. Беспорядочные возбуждения и торможения нейронов могут что угодно решить методом тривиального перебора. Но есть ли смысл в таких решениях? В некотором смысле – это действительно, ответы упавшие "свыше", но не дающие никаких навыков для решения аналогичных задач"
Горящая буква "М" едва просматривалась сквозь заплывшее стекло. – "Останови, шеф, я выйду", – попросил Сергей. – "На метро, оно быстрее будет", – объяснился он, выходя из машины, и бросил напоследок, уже закрывая дверь: "Да, нервные клетки не восстанавливаются. Они отмирают. Но их место сменяют новые, - это естественный процесс, протекающий всю жизнь".
"Вот так часть истины становится ложью", – подумал Сергей. "Ведь что такое ложь, как не искусство умолчания?".
"Нет, меня определенно мучает совесть", – подумал он, спускаясь вниз по эскалатору. "Все эти рассуждения – лишь попытки убедить себя в собственной же правоте. Странное существо человек. Мы не находим себе места от мелких прегрешений, не замечая при этом, что само наше существование уже является преступлением".
Если на эскалаторе еще удавалось отдаться собственным мыслям, то в переполненном вагоне метро ни о чем другом, кроме как поскорее доехать думать было невозможно. Москвичи ехали на работу. "Зеркальный мир", – усмехнулся Сергей, только и думающий о том, как сейчас завалится спать. "Я смотрю на мир сквозь зеркало, но почему я не вижу себя? Может быть, я в зазеркалье? И смотрю на мир оттуда? Как же тогда направлен ход лучей?", – понимая, что он дремлет наяву, Сергей машинально продолжал рассуждать: "наверно свойства фотона быть одновременно в двух точках как раз и объясняются тем, что одна из них – отражение. А может быть и вторая тоже? И весь наш мир это лишь отражение себя в себе самом?" Внезапно Сергей вспомнил, что перед тем как сесть в поезд, он даже не обратил внимания, куда тот едет. Или обратил? Судя по карте, он ехал верным направлением, и через остановку ожидалась его станция. "Только если я не засну эту остановку", – пробурчал про себя Сергей, протискиваясь сквозь толпу поближе к выходу.
* * *
Квартира Сергея представляла некий гибрид офиса с сорочьим гнездом. Строгие стилизованные под грубо обработанный бетон, обои только усиливали впечатление нежилого, заброшенного помещения с беспорядочно разбросанными по полу коробками компьютерных комплектующих. Сиротливо уткнувшийся в угол стола системный блок с монитором, утешал свое одиночество обществом элегантного кожаного кресла, да потрепанного дивана, погруженного в полутьму плотно занавешенных окон.
Отсутствие пыли и мусора создавало впечатление особого, рабочего порядка, которого не касалась женская рука.
Обстановка являлось как бы продолжением внутреннего мира Сергея, мира который замыкался в этих четырех стенах. Здесь, отрезанный от остальной цивилизации, он и проводил большую часть своей жизни. Шелест дождя, мурлыкавший сквозь стекло, неудержимо клонил ко сну, и спустя минуту – другую Сергей тихонечко посапывал, утомленный ночной вылазкой в город.
Долго спать ему, впрочем, не пришлось. В дверь звонили протяжно и настойчиво. "Мммать", – пробурчал Сергей, на ходу натягивая штаны, – "давно я этот звонок снести хотел! Нет, ну кто это так звонит?"
Щелкнув замком, Сергей недовольно высунулся за дверь, стараясь придать своему лицу по возможности зверское выражение. На пороге стоял Шурик собственной персоной, небрежно держа бутылку водки в одной руке, и тиская звонок второй.
"Заходи", – только и бросил Сергей, исчезая в темноте глубины комнаты. Шурик был одним из тех его приятелей, которых и шугнуть, вроде бы, повода нет, но и встречаться не хочется.
"У тебя закусь, надеюсь найдется?", – затараторил Шурик, едва только вошел. "Я посмотрю, в холодильнике ладно?", – не дожидаясь ответа он полез в холодильник. "Я, как-то неожиданно к тебе зашел. Ехал мимо и подумал, а почему бы мне зайти к моему другу Сергею?"
"Ну и ехал бы себе мимо", – буркнул Сергей вполголоса. Он знал, что Шурик на такие высказывания не обижается.
"Нет, в самом деле", – воскликнул Шурик, – "у тебя есть закуска или нет? Одни консервы и полуфабрикаты. Хоть бы хлеба корочку что-ли…"
"Хлеба ему…", – проворчал, едва сдерживая зевоту, Сергей, – "кто ж так пьет?" фыркнул он уже громче, – "в чем весь смысл? Бухнуть в рот не весть чего, а потом сразу тянуться к закуске. Нет! Пить надо так, что бы почувствовать вкус того, что пьешь, ощутить аромат, почувствовать терпкость на языке…". Широко зевнув, он продолжил, – "но какой у водки аромат? Так что выкинь ты ее, или с собой забери. Если хочешь пить, там, в холодильнике, вино, коньяк"
"Ну, коньяк, так коньяк", – согласился Шурик.
"Я вот, что хотел спросить", – сказал он, расставляя на полу перед диваном бокалы. "Могу я вернуть свой долг не деньгами, а один очень интересным заказом?"
"Заказом!", – недовольно хмыкнул Сергей. "Хватит с меня этих заказов! И так живу как в берлоге, что некогда личной жизнью заняться…"
"Личная жизнь – это неплохо", – согласился Шурик, – "но, прежде чем ее заняться, нужно иметь, на что жить. А ты вот перебиваешься от случая к случаю. Сутками напролет работаешь, а все что имеешь – так это полный холодильник концентратов…"
"Чья бы корова…", – лениво отозвался Сергей, не желая включаться в спор. "Уговорил, я не правильно живу. Ну а смысл? Ты не задавался вопросом, что все эти наши рассуждения о правильности жизни, как и поиски в ней смысла – просто бред собачий. Человек живет не для, а потому что. Биохимические реакции в его теле протекают – не зависимо от воли и сознания. Точно так идет дождь не для того, что бы оросить траву в жаркий полдень, а потому что вода испаряется, конденсируется и падает". Сергей кивнул в сторону зашторенного окна, по которому барабанил дождь.
"Все поиски смысла жизни заранее обречены на провал", – после некоторой паузы добавил он. "Оглянись назад. За тобой остались тысячелетия этих самых поисков, рассветы и падения цивилизаций. Какими же далекими и незначительными они нам кажутся сейчас. Величайшие мудрецы истории едва ли изменили ее ход. В лучшем случае мы помним их имена, и изучаем чудом уцелевшие труды. В чем смысл их жизни? У них была цель, это верно, но смысла не было".
"Но общая цель каждого из нас выжить", – заметил Шурик, важно поднимая бокал. "Так выпьем же за то, что бы мы умели выживать в этом мире".
Сергей, кивнув головой, отпил небольшой глоток. "Только не надо путать Божий дар с яичницей", – заметил он. "Выживание – это инстинкт, иногда возведенный в ранг искусства"
"Это ничего не меняет", – возразил Шурик. "Деньги, все равно никто не отменял, и залогом выживания в наше время становятся именно они".
"Ну, так к делу", – зевнув, оборвал его Сергей.
"К делу", – согласился Шурик, делая большой глоток. "Надобно одну программу взломать…"
"Взломать, это можно", – серьезным видом согласился Сергей. – "Тюк топориком – и она готова".
"Приколись", – фыркнул Шурик
"Приколюсь", – согласился Сергей. "Это так понимать, ты меня нанимаешь на сомнительного запаха дело, отрабатывать деньги, которые по любому и так уже мои"
"Какие же они теперь твои?", – ухмыльнулся Шурик, "когда ты своими руками мне их отдал…"
"Дохлый номер", – парировал Сергей. "Не хочешь отдавать – так и скажи. Только нечего меня во всякие темные дела впутывать".
"Ситуация следующая", – протянул Шурик. "С деньгами у меня кранты. В том, смысле, что сухо, как в Сахаре. Отдать долг я смогу не раньше, чем хоть что-то заработаю. А по этому поводу расклад следующий. Взялся я одну программку подломать, да уперся в одну хитрую функцию, что никак не могу обратить".
"Понятно…", – зевнул еще раз Сергей. "Обращу, если обратима. Во всяком случае, сведу к системе линейных уравнений, а дальше уж – по твоему усмотрению".
"Это же надо обмыть!", – воскликнул Шурик.
"Идея хорошая, но не правильная", – буркнул Сергей.
"Тогда я пойду, наверное", – замялся Шурик.
"Ну, заходи еще", – машинально отозвался Сергей, поднимаясь с пола, что бы проводить гостя.
"Я тебе функцию мылом кину", – бросил напоследок Шурик, скрываясь за дверью.
"Мылом, – это хорошо", – подумал Сергей, размышляя, чем бы сейчас заняться в первую очередь. После принятого натощак конька ужасно хотелось есть. Вытащив из холодильника свой любимый пакетный суп от "Галина Бланка", куриный суп с рисом, Сергей в ожидании пока закипит чайник, наводил порядок в своей комнате.
Смести пыль, поставить на место диски, собрать рассыпанные книжки, протереть пыль со стола – стало своеобразным утренним ритуалом, вошедшим в жизнь Сергея еще с детства, когда он терпеть не мог учить уроки в беспорядке. Если каждый день уделять уборке по пять минут, то надобность в генеральных уборках отпадает.
Наручные часы, с которыми Сергей никогда не расставался пропикали двенадцать. "У нормальных людей в это время обед", – с тоской подумал Сергей. "А едва сажусь завтракать".
Готовить пакетный суп было не сложно, - разорвал пакетик, высыпал содержимое в бурлящую воду, тщательно перемещал, только успел отвернуться – как густая, аппетитная смесь уже готова. Не выливая ее в тарелку, Сергей поставил дымящийся полу-ковшик, полу-чайник на стол и, отхлебывая маленькими глотками, чтобы не обжечься, составлял план действий на ближайшие десять-пятнадцать часов.
Никакой конкретной работы не было. Так, больше по мелочам. Исправить найденные ошибки в своей последней программе и выложить ее на сайт, откуда нетерпеливые юзеры смогли бы легко утянуть, перевести документацию на английский, о чем давно и настойчиво просит его менеджер…
Информация о пенальти
Уже сам факт существования "горячей" точки говорит о том, что в программе что-то неправильно. Хорошо, если это чисто алгоритмическая ошибка, которую видно невооруженным глазом (например, наиболее узким местом приложения оказалась пузырьковая сортировка). Хуже, если процессорное время исчезает буквально на пустом месте без всяких видимых на то причин. Еще хуже, если "хищения" таков происходят не систематически, а совершаются при строго определенном стечении каких-то неизвестных нам обстоятельств.Возвращаясь к предыдущему вопросу: почему указатель pswd загружается так долго? И при каких именно обстоятельствах загрузка y "подскакивает" со своих обычных семи до восьмидесяти тактов? Судя по всему, процессору что-то не понравилось и он обложил эти две машинные команды штрафом (penalty), на время притормозив их исполнение. Можем ли мы узнать когда и за какой проступок это произошло? Не прибегая к полной эмуляции процессора– навряд ли (хотя современные x86 с некоторыми ограничениями позволяют получить эту информацию и так).
Гранды компьютерной индустрии – Intel и AMD уже давно выпустили свои профилировщики, содержащие полноценные эмуляторы выпускаемых ими процессоров, позволяющие визуализировать нюансы выполнения каждой машинной инструкции.
Просто щелкните по строке "mov ecx, DWORD PTR [ebp-28]" и VTune выдаст всю, имеющуюся у него информацию:
Decoder Minimum Clocks
= 1 ; Минимальное время декодирования – 1 такт
Decoder Average Clocks
= 8.7 ; Эффективное время декодирования – 8,7 тактов
Decoder Maximum Clocks
= 86 ; Максимальное время декодирования – 86 тактов
Retirement Minimum Clocks
= 0, ; Минимальное время завершения – 0 тактов
Retirement Average Clocks
= 7.3 ; Эффективное время завершения – 7,3 такта
Retirement Maximum Clocks
= 80 ; Максимальное время завершения – 80 тактов
Total Cycles
= 80 (00,65%) ; Всего времени исполнения – 80 тактов
Micro-Ops for this instruction = 1 ; Кол-во микроопераций в инструкции – 1
The instruction had to wait 0 cycles for it's sources to be ready
("Инструкция ждала 0 тактов пока подготавливались ее операнды, т.е. попросту она их не ждала совсем")
Dynamic Penalty: IC_miss
The instruction was not in the instruction cache, so the processor loads the instruction from the L2 cache or main memory.
("Инструкция отсутствовала в кодовом кэше, и процессор был вынужден загружать ее из кэша второго уровня или основной оперативной памяти")
Occurances
= 1 ; Такое случалось 1 раз
Dynamic Penalty: L2instr_miss
The instruction was not in the L2 cache, so the processor loads the instruction from main memory.
("Инструкция отсутствовала в кэше второго уровня и процессор был вынужден загружать ее из основной оперативной памяти")
Occurances
= 1 ; Такое случалось 1 раз
Dynamic Penalty: Store_addr_unknown
The load instruction stalls due to the address calculation of the previous store instruction.
("Загружающая инструкция простаивала по той причине, что адрес источника рассчитывался предыдущей инструкцией записи")
Occurances
= 10 ; Такое случалось 10 раз
Так, кажется, наше расследование превращается в самый настоящий детектив, еще более запутанный, чем у Агаты Кристи. Если приложить к полученному результату даже самый скромный арифметических аппарат, получится полная чепуха и полная расхождение дебита с кредитом. Судите сами. Полное время выполнения инструкции – 80 тактов, причем, известно, что она выполнялась 11 раз (см. колонку count в листинге 1). А наихудшее время выполнения инструкции составило… 80 тактов! А наихудшее время декодирования и вовсе – 86! То есть, худшее время декодирования инструкции превышает общее время ее исполнения и при этом в десяти итерациях она еще ухитряется простаивать как минимум один такт за каждую итерацию по причине занятости блока расчета адресов.
Да… тут есть от чего поехать крышей!
Причина такого несоответствия заключается в относительности самого понятия времени. Вы думаете время относительно только у Эйнштейна? Отнюдь! В конвейерных процессорах (в частности процессорах Pentium и AMD K6/Athlon) понятие "времени выполнения инструкции" вообще не существует в принципе (см. "Фундаментальные проблемы профилировки в малом. Конвейеризация или пропускная способность vs латентность"). В силу того, что несколько инструкций могут выполняться параллельно, простое алгебраическое суммирование времени их исполнения даст значительно больший результат, нежели исполнение занимает в действительности.
Ладно, оставим разборки с относительностью до более поздних времен, а пока обратим внимание на тот факт, что в силу отсутствия нашей инструкции в кэше (она как раз находится на границе двух кэш линеек) и вытекающей отсюда необходимости загружать ее из основной памяти, в первой итерации цикла она выполняется значительно медленнее, чем во всех последующих. Отсюда, собственно, и берутся эти пресловутые восемьдесят тактов. При большом количестве итераций (а при "живом" исполнении программы оно и впрямь велико) временем начальной загрузки можно и пренебречь, но… Стоп! Ведь профилировщик исполнил тело данного цикла всего 11 раз, в результате чего среднее время выполнения этой инструкции составило 7,6 тактов, что совершенно не соответствует реальной действительности! Ой! Оказывается, это вовсе не горячая точка! И тут совершенного нечего оптимизировать. Если мы увеличим количество прогонов профилировщика хотя бы в четыре раза, среднее время выполнения нашей инструкции понизится до 1,8 тактов и она окажется одним из самых холодных мест программы! Точнее – это вообще абсолютный ноль, поскольку эффективное время исполнения данной инструкции – ноль тактов (т.е. она завершается одновременно с предыдущей машинной командой). Словом, мы навернулись по полной программе.
Отсюда правило: прежде чем приступать к оптимизации, убедитесь, что количество прогонов программы достаточно велико для маскировки накладных расходов первоначальной загрузки.
Короткое лирическое отступление на тему: почему же все так произошло. По умолчанию VTune прогоняет профилируемый фрагмент 1.000 раз. Много? Не спешите с ответом. Наш хитрый цикл устроен так, что его тело получает управление лишь каждые 'z' ? '!' == 0x59 итераций. Таким образом, за все время анализа тело цикла будет исполнено всего лишь 1.000/89 == 11 раз!!! Причем, ни в коем случае нельзя сказать, что это надуманный пример. Напротив! В программном коде такое встречается сплошь и рядом.
while((++pswd[p])>'z') // ß
данный цикл прогоняется профилировщиком 1.000 раз
{
pswd[p] = '!'; // ß
данная инструкция прогоняется всего 11 раз
…
}
Листинг 4 Демонстрация кода, некоторые участки которого прогоняются профилировщиком относительно небольшое количество раз, что искажает результат профилировки.
Поэтому, обнаружив горячу точку в первую очередь убедитесь, что количество ее прогонов достаточно велико. В противном случае полученный результат с большой степенью вероятности окажется недостоверным. И тут мы плавно переходим к обсуждению подсчета числа вызовов каждой точки программы.
Впрочем нет, постойте. Нам еще предстоит разобраться со второй "горячей" точкой и на удивление тормозной скоростью загрузки указателя pswd. Опытные программисты, вероятно, уже догадались в чем тут дело. Действительно, – строка pswd[p] = '!'
– это первая строка тела цикла, получающая управление каждые 0x59 итераций, что намного превосходит "проницательность" динамического алгоритма предсказания ветвлений, используемого процессором для предотвращения остановки вычислительного конвейера. Следовательно, данное ветвление всегда предсказывается ошибочно и выполнение это инструкции процессору приходится начинать с нуля. А процессорный конвейер – длинный. Пока он заполниться… Собственно, тут виновата вовсе не команда "mov edx, DWORD PTR [ebp+0ch]"
– любая другая команда на ее месте исполнялась бы столь же непроизводительно! "Паяльная грелка", до красна нагревающая эту точку программы, находится совсем в другом месте!
Поднимем курсор чуть выше, на инструкцию условного перехода предшествующую этой команде, и дважды щелкнем мышкой. Профилировщик VTune выдаст следующую информацию:
Decoder Minimum Clocks
= 0 ; Минимальное время декодирования – 0 тактов
Decoder Average Clocks
= 0 ; Эффективное время декодирования – 0 тактов
Decoder Maximum Clocks
= 4 ; Максимальное время декодирования – 4 такта
Retirement Average Clocks
= 1 ; Эффективное время завершения – 1 такт
Total Cycles
= 1011 (08,20%) ; Всего времени исполнения – 1010 тактов (8,2%)
Micro-Ops for this instruction = 1 ; Кол-во микроопераций в инструкции – 1
The instruction had to wait (8,11.1,113) cycles for it's sources to be ready
("Эта инструкция ждала минимально 8, максимально 113, а в основном 11,1 тактов пока ее операнды не были готовы")
Dynamic Penalty: BTB_Miss_Penalty ; Штраф типа BTB_Miss_Penalty
This instruction stalls because the branch was mispredicted.
("Эта инструкция простаивала потому что ветвление не было предсказано")
Occurances = 13 ; Такое случалось 13 раз
Наша гипотеза полностью подтверждается. Это ветвление тринадцать
раз предсказывалась неправильно, о чем VTune и свидетельствует! Постой, как тринадцать?! Ведь тело цикла исполняется только одиннадцать! Да, правильно, одиннадцать. Но ведь процессор наперед этого не знал, и дважды пытался передать на него управление, и лишь затем, увидев, что ни одно из двух предсказаний не сбылось, плюнул и поклялся, что никогда – никогда не поставит свои фишки на эту ветку.
ОК. Когда загадки разрешаются – это приятно. Но главный вопрос несколько в другом: как именно их разрешать? Хорошо, что в нашем случае непредсказуемый условный переход находился так близко к "горячей" точке, но ведь в некоторых (и не таких уж редких) случаях "виновник" бывает расположен даже в других модулях программы! Ну что на это сказать… Подходите к профилировке комплексно и всегда думайте головой.Пожалуй, ничего более действенного я не смогу посоветовать…
Intel VTune

Рисунок 3 0x006 Логотип профилировщика VTune
Бесспорно, что VTune – самый мощный из когда-либо существовавших профилировщиков (ну, во всяком случае, на IBM PC). Собственно, его и профилировщиком язык называть не поворачивается. VTune – это высокоинтеллектуальный инструмент, не только выявляющий "горячие" точки, но еще и дающий вполне конкретные советы по их устранению. В дополнении к этому, VTune содержит весьма не хилый оптимизатор кода, увеличивающий скорость программ, откомпилированных Microsoft Visual C++ 6.0 в среднем на 20%, – согласительно, такая прибавка производительности никогда не бывает лишней!
В общем, у VTune столько достоинств, что писать о них становится даже как-то и не интересно. Просто воспринимайте его как безальтернативный профилировщик и все! А в настоящем обзоре мы лучше поговорим о его недостатках (ну какая же программа обходится без недостатков?):
Основной недостаток VTune его чрезмерная "тяжелость" (дистрибьютив шестой версии – последней на момент написания этих строк – весит аж 150 мегабайт) и весьма впечатляющая стоимость, помноженная на тот факт, что даже имея деньги, VTune не так-то просто приобрести из России. Правда, Intel предлагает бесплатную полнофункциональную версию, которая ни чем не уступает коммерческой, но работает всего лишь 30 дней. Качать такую тяжесть ради какого-то месяца работы? Извините, это несерьезно! (Особенно, если у вас dial-up).
Другой минус – VTune не очень стабилен в работе и частенько вешает систему (например, у меня при попытке активизации некоторых счетчиков производительности он загоняет операционную систему в синий экран смерти с надписью "IRQL_NOT_LESS_OR_EQUAL" и хорошо если при этом еще не грохает активный Рабочий Стол!). Впрочем, если не лезть "куда не надо" и вообще перед выполнением каждого действия думать головой, то ужиться с VTune все-таки можно (а что делать – ведь достойной альтернативы все равно нет).
Еще VTune получает много нареканий за свою ужасающую сложность. Кажется, что вообще не возможно освоить его и досконально во всем разобраться. Один встроенный "хелп", занимающий свыше трех тысяч страниц формата A4 чего стоит! Попробуйте его прочесть (только прочесть) – даже если вы хорошо владеете английским это у вас отнимет по меньшей мере целый месяц! Но давайте рассмотрим проблему под другим углом. Вам нужен Инструмент или бирюлька? Чем мощнее и гибче инструмент, – тем он сложнее по определению. С моей точки зрения VTune ничуть не сложнее чем тот же Visual C++ или DELPHI и проблема заключается не в самой сложности, а в отсутствии литературы по профилировке вообще и данному продукту в частности. Поэтому, в данную книгу включен короткий самоучитель по VTune, который вы найдете в главе "Практический сеанс работы с VTune", – надеюсь это вам поможет.
Исходные тексты
void __cdecl c_cpy(int *src, int *dst, int n){
int a; int t;
if (n<1) return; // нечего копировать!
// поэлементное копирование массива
for (a=0;a
}
Листинг 1 Реализация алгоритма копирования памяти на языке Си
_asm_cpy proc
PUSH ESI ; / *
PUSH EDI ; сохраняем регистры
PUSH ECX ; */
MOV ESI,[ESP+4+3*4] ; src
MOV EDI,[ESP+8+3*4] ; dst
MOV ECX,[ESP+8+4+3*4] ; n
REP MVSD ; копируем одной командой!
POP ECX ; /*
POP EDI ; восстанавливаем регистры
POP ESI ; */
ret ; выходим
_asm_cpy endp
Листинг 2 Ассемблерная реализация алгоритма копирования памяти
int __cdecl c_min(int *src, int n)
{
int a; int t;
if (n<2) return -1; // Не среди чего искать минимум!
// Присваиваем первому элементу массива статус
// "условно наименьшего"
t=src[0];
// Если такой, кто будет меньше нашего "меньшего"?
// есть да, то предать статус ему.
for(a=1;a
return t;
}
Листинг 3 Реализация алгоритма поиска минимума на языке Си
_asm_min proc
PUSH ESI ; сохраняем
PUSH EDI ; регистры
MOV ESI,[ESP+8+4] ; src
MOV EDX,[ESP+8+8] ; n
CMP EDX,2 ; есть среди чего искать?
JB @exit ; нет элементов для поиска
MOV EAX, [ESI] ; присваиваем первому элементу
; статус "условно наименьшего"
@for: ; начало цикла
MOV EDI, [ESI] ; в EDI – очередной элемент
CMP EAX, EDI ; если кто еще меньше?
JB @next ; если нет, - следующий элемент
MOV EAX, EDI ; передать статус
@next:
ADD ESI, 4 ; перейти к следующему элементу
DEC EDX ; уменьшить счетчик цикла на 1
JNZ @for ; повторять цикл пока EDX > 0
@exit:
POP EDI ; восстанавливаем
POP ESI регистры
ret
_asm_min endp
Листинг 4 Ассемблерная реализация алгоритма поиска минимума
void __cdecl c_sort(int *src, int n)
{
int a; int t; int f;
if (n<2)
return; // Меньше двух элементов сортировать нельзя!
do{
f=0; // устанавливаем флаг сортировки в нуль
// Перебираем все элементы один за другим
for (a=1; a
// если следующий элемент меньше предыдущего
// меняем их местами и устанавливаем флаг
// сортировки в единицу
if (src[a-1]>src[a])
{
t=src[a-1];
src[a-1]=src[a];
src[a]=t;
f=1;
}
// повторять сортировку до тех пор, пока не дождемся
// первого "чистого" прохода, т.е. прохода без изменений
} while(f);
}
Листинг 5 Реализация алгоритма пузырьковой сортировки на языке Си
_asm_sort proc
MOV EDX,[ESP+8] ; n
CMP EDX,2 ; есть что сортировать?
JB @exit ; сортировать нечего, на выход
PUSH ESI ; /*
PUSH EBP ; сохраняем регистры
PUSH EBX ; */
@while: ; основной цикл сортировки
MOV ESI,[ESP+4+4*3] ; src
MOV EDX,[ESP+8+4*3] ; n
XOR EBP,EBP ; f := 0
@for: ; цикл перебора элементов
MOV EAX, [ESI] ; EAX := src
MOV EBX, [ESI+4] ; EBX := src+1
CMP EAX, EBX ; Сравнить EAX и EBX
JAE @next_for ; Если EAX > EBX перейти к
; следующему элементу
; иначе обменять элементы местами
MOV EBP, EBX ; взвести флаг изменений
MOV [ESI+4], EAX ; "не чистый" проход
MOV [ESI],EBX
@next_for:
ADD ESI, 4 ; src+=1;
DEC EDX ; уменьшить счетчик цикла
JNZ @for ; перебирать элементы, пока
; счетчик не равен нулю
OR EBP,EBP ; dirty-флаг взведен?
JNZ @while : сортировать пока не будет чисто
POP EBX ; /*
POP EBP ; Восстановить регистры
POP ESI ; */
@exit:
ret ; Выход
_asm_sort endp
Листинг 6 Ассемблерная реализация алгоритма пузырьковой сортировки
[1] некоторые процессоры (в частности Alpha) вообще не поддерживают констант, заставляя их ложить в память
[2] такое удаление удаляет и побочные эффекты типа деления на ноль
Использование кучи для создания массивов
От использования статических массивов рекомендуется вообще отказаться (за исключением тех случаев, когда их переполнение заведомо невозможно). Вместо этот следует выделять память из кучи (heap), преобразуя указатель, возвращенный функцией malloc к указателю на соответствующий тип данных (char, int), после чего с ним можно обращаться точно так, как с указателем на обычный массив.Вернее почти "точно так" за двумя небольшими исключениями: во-первых, получившая такой указатель функция может с помощью вызова msize узнать истинный размер буфера, не требуя от программиста явного указания данной величины. А, во-вторых, если в ходе работы выяснится, что этого размера недостаточно, она может динамически увеличить длину буфера, обращаясь к realloc всякий раз, как только в этом возникнет потребность.
В этом случае передавая функции, читающей строку с клавиатуры, указатель на буфер, не придется мучительно соображать: какой именно величиной следует ограничить его размер, – об этом позаботиться сама вызываемая функция, и программисту не придется добавлять еще одну константу в свою программу!
Использование преимуществ синхронного чтения
Использование собственных тактовых генераторов в микросхемах памяти типа SDRAM позволяет осуществлять обмен с памятью без участия процессора – действительно, если процессор точно знает: когда закончится цикл чтения (записи) зачем ему постоянно опрашивать контроллер памяти?Поэтому, при загрузке 32-байтной строки из оперативной памяти ее первые 64-бита (четверное слово) доступны сразу же в момент их получения! Отсюда следует, что чтение массива по колонкам должно происходить заметно быстрее его чтения по столбцам. Достаточно лишь выровнять массив по адресу, кратному 32 и позаботится, чтобы он полностью умещался в кэш второго уровня. Тогда – пока ожидается поступление первых 64-битов ячейки очередной линейки столбца, три оставшихся 64-битных ячейки предыдущей линейки загружаются в "фоновом" режиме! Благодаря этому скорость обработки массива возрастает практически вдвое. Единственное условие – массив должен целиком вмещаться в кэш второго уровня.
Использование WriteProcessMemory
Если требуется изменить некоторое количество байт своего (или чужого) процесса, самый простой способ сделать это – вызвать функцию WriteProcessMemory. Она позволяет модифицировать существующие страницы памяти, чей флаг супервизора не взведен, т.е., все страницы, доступные из кольца 3, в котором выполняются прикладные приложения. Совершенно бесполезно с помощью WriteProcessMemory пытаться изменить критические структуры данных операционной системы (например, page directory или page table) – они доступны лишь из нулевого кольца. Поэтому, эта функция не представляет никакой угрозы для безопасности системы и успешно вызывается независимо от уровня привилегий пользователя (автору этих строк доводилось слышать утверждение, дескать, WriteProcessMemory требует прав отладки приложений, но это не так).Процесс, в память которого происходит запись, должен быть предварительно открыт функцией OpenProcess с атрибутами доступа "PROCESS_VM_OPERATION" и "PROCESS_VM_WRITE". Часто программисты, ленивые от природы, идут более коротким путем, устанавливая все атрибуты – "PROCESS_ALL_ACCESS". И это вполне законно, хотя справедливо считается дурным стилем программирования.
Простейший пример использования функции WriteProcessMemory для создания самомодифицирующегося кода, приведен в листинге 1. Она заменяет инструкцию бесконечного цикла "JMP short $-2" на условный переход "JZ $-2", который продолжает нормальное выполнение программы. Неплохой способ затруднить взломщику изучение программы, не правда ли? (Особенно, если вызов WriteMe расположен не возле изменяемого кода, а помещен в отдельный поток; будет еще лучше, если модифицируемый код вполне естественен сам по себе и внешне не вызывает никаких подозрений – в этом случае хакер может долго блуждать в той ветке кода, которая при выполнении программы вообще не получает управления).
int WriteMe(void *addr, int wb)
{
HANDLE h=OpenProcess(PROCESS_VM_OPERATION|PROCESS_VM_WRITE,
true,GetCurrentProcessId());
return WriteProcessMemory(h, addr,&wb,1,NULL);
}
int main(int argc, char* argv[])
{
_asm {
push 0x74 ; JMP --> > JZ
push offset Here
call WriteMe
add esp,8
Here: JMP short here
}
printf("#JMP SHORT $-2 was changed to JZ $-2\n");
return
0;
}
Листинг 2 Пример, иллюстрирующий использования функции WriteProcessMemory для создания самомодифицирующегося кода
Поскольку Windows для экономии оперативной памяти разделяет код между процессами, возникает вопрос: а что произойдет, если запустить вторую копию самомодифицирующейся программы? Создаст ли операционная система новые страницы или отошлет приложение к уже модифицируемому коду? В документации на Windows NT и Windows 2000 сказано, что они поддерживают копирование при записи (copy on write), т.е. автоматически дублируют страницы кода при попытке их модификации. Напротив, Windows 95 и Windows 98 не поддерживают такую возможность. Означает ли это то, что все копии самомодифицирующегося приложения будут вынуждены работать с одними и теми же страницами кода, что неизбежно приведет к конфликтам и сбоям?
Нет, и вот почему – несмотря на то, что копирование при записи в Windows 95 и Windows 98 не реализовано, эту заботу берет на себя сама функция WriteProcessMemory, создавая копии всех модифицируемых страниц, распределенных между процессами. Благодаря этому, самомодифицирующийся код одинаково хорошо работает как под Windows 95\Windows 98\Windows Me, так и под Windows NT\Windows 2000. Однако следует учитывать, что все копии приложения, модифицируемые любым иным путем (например, командой mov нулевого кольца) будучи запущенными под Windows 95\Windows 98 будут разделять одни и те же страницы кода со всеми вытекающими отсюда последствиями.
Теперь об ограничениях. Во-первых, использовать WriteProcessMemory разумно только в компиляторах, компилирующих в память или распаковщиках исполняемых файлов, а в защитах – несколько наивно. Мало-мальски опытный взломщик быстро обнаружит подвох, обнаружив эту функцию в таблице импорта. Затем он установит точку останова на вызов WriteProcessMemory, и будет контролировать каждую операцию записи в память. А это никак не входит в планы разработчика защиты!
Другое ограничение WriteProcessMemory заключается в невозможности создания новых страниц – ей доступны лишь уже существующие страницы. А как быть в том случае, если требуется выделить некоторое количество памяти, например, для кода, динамически генерируемого "на лету"? Вызов функций, управления кучей, таких как malloc, не поможет, поскольку в куче выполнение кода запрещено. И вот тогда-то на помощь приходит возможность выполнения кода в стеке…
Истоки
Итак, динамическая оперативная память относительно дешева, но по сегодняшним меркам недостаточно производительна. Статическая оперативная память на всех, достигнутых ныне частотах, имеет скорость доступа в один такт, но стоит чрезвычайно дорого и потому не может использоваться в качестве основной оперативной памяти ПК.Так может, хотя бы часть памяти реализовать на SRAM? Знаете, а это мысль! Ведь что по сути представляет собой оперативная память? Правильно, – временное хранилище данных, загруженных с внешней, так называемой, дисковой памяти. Диски слишком медленны и интенсивная работа с ними крайне непроизводительна. Поэтому, разместив многократно используемые данные в оперативной памяти, мы резко сокращаем время доступа к ним, а, значит,– и время их обработки.
На первый взгляд, выигрыш в производительности достигается в тех, и только в тех случаях, когда загруженные данные используются многократно. А вот и нет! Допустим, потребовалось нам перекодировать содержимое некоторого файла. Поскольку, к каждому байту обращение происходит лишь однократно, – какой смысл загружать его в оперативную память? Тем не менее смысл все-таки есть, – дисковод в силу своих конструктивных особенностей просто "не хочет" считывать один-единственный байт и как минимум требует обработать весь сектор целиком. А раз так, – прочитанный сектор надо где-то хранить. К тому же, обмен данными можно значительно ускорить, есть обрабатывать не один, а сразу несколько секторов за раз. В этом случае, дисководу не придется тратить время на позиционирование головки при обращении к каждому сектору. Наконец, хранение данных в оперативной памяти позволяет отложить их немедленную запись до тех времен, пока это не будет "удобно" дисководу.
Таким образом, вовсе не обязательно всю оперативную память реализовывать на дорогостоящих микросхемах SRAM. Даже небольшое (в процентном отношении) количество статической памяти при грамотном с ней обращении значительно увеличивает производительность системы.
ОК. С памятью мы разобрались. Теперь поговорим о способах ее адресации. Если бы этот кусочек "быстрой" памяти адресовался бы непосредственно, т.е. был доступен программисту как и все остальные ресурсы компьютера, проектирование программ ощутимо усложнилось и, что еще хуже, привело к полной потере переносимость, поскольку такая тактика привязывает программиста к особенностям реализации конкретной аппаратной архитектуры. Поэтому, конструкторы решили сделать "быструю" память невидимой и прозрачной для программиста.
Так родился кэш…
История
История создания статической памяти уходит своими корнями в глубину веков. Память первых релейных компьютеров по своей природе была статической и долгое время не претерпевала практически никаких изменений (во всяком случае – концептуальных), – менялась лишь элементарная база: на смену реле пришли электронные лампы, впоследствии вытесненные сначала транзисторами, а затем TTL- и CMOS-микросхемами… но идея, лежащая в основе статической памяти, была и остается прежней…Динамическая память, изобретенная, кстати, значительно позднее, в силу фундаментальных физических ограничений, так и не смогла сравняться со статической памятью в скорости.
Итоги и прогнозы
Теперь самое время подвести итоги. Если откинуть первый неудачный вариант программы с постоянным вызовом функции printf, можно сказать, что мы увеличили скорость оптимизируемой программы с полутора до восьмидесяти четырех миллионов паролей в секунду, то есть без малого на два порядка! И у нас есть все основания этим гордиться! Пускай, мы далеко от теоретического предела и до исчерпания резерва производительности еще далеко (вот скажем, можно перебирать несколько паролей одновременно, используемые векторные MMX-команды) профилировка приложений несомненно лучшее средство избежать неоправданного снижения производительности!Любопытно, но каждый шаг оптимизации приводил к экспоненциальному росту производительности (см. рис. graph 0x001). Конечно, экспоненциальный рост наблюдается далеко не во всех случаях (можно сказать, что тут нам просто повезло), но тем не менее общая тенденция профилировки такова, что самые крупные "камни преткновения" по обыкновению находится на глубине и разглядеть их, не "окунувшись в воду" (в смысле в дебри кода) в общем-то невозможно.
В этой главе ### статье мы рассмотрели лишь самые базовые средства профилировщика VTune (да и то мельком), но его возможности этим отнюдь не исчерпаются! Собственно это целый мир, содержащий помимо прочего собственный язык программирования и даже имеющий собственное API, позволяющие вызывать функции VTune из своих программ (читай: клепать к профилировщику собственные плагины)…
Но, как бы то ни было, первый шаг в изучении VTune уже сделан и в дальнейшем вы постепенно сможете осваивать его и самостоятельно. А напоследок рискну дать вам один совет. Пользоваться контекстовой помощью VTune крайне неудобно и множество разделов при этом так и остаются неохваченными. Поэтому, лучше воспользоваться любым help-декомпилятором и перегнать hlp-файл в rtf, который затем можно открыть с помощью того же Ворда и распечатать. Или – читать с экрана, ибо помощь занимает свыше трех тысяч страниц – бумаги не напасешься!

Рисунок 11 graph 0x001
Измерения падения производительности от сжатия программ (DLL)
В заключении поговорим об измерении падения производительности от упаковки файлов. Казалось бы, что тут сложного – берем неупакованный файл, запускаем его, замеряв время загрузки, записываем результат на бумажке, упаковываем, запускаем еще раз, и… Первый камень преткновения – что понимать под "временем загрузки"? Если проецирование, - так оно выполняется практически мгновенно, и им можно вообще пренебречь. Моментом времени, начиная с которого с приложением можно полноценно работать? Так это от самого приложения зависит больше, чем от его упаковки. К тому же на время загрузки упакованных файлов очень сильно влияет количество свободной на момент запуска физической оперативной памяти (не путать с общим объемом памяти, установленной на машине). Если перед запуском упакованного файла мы завершим одно-два "монстроузных" приложения, то занятая ими память теперь окажется свободна, и сможет беспрепятственно использоваться распаковщиком. Напротив, если свободной памяти нет, ее придется по крохам отрывать от остальных приложений…Хорошо, даже если мы оценим изменение времени загрузки (что, кстати, сделать весьма проблематично – серия замеров на одной и той же машине, с одним и тем же набором приложений дает разброс результатов более чем на порядок!), как измерять падение производительности остальных приложений? Ведь, при нехватке памяти Windows в первую очередь избавляется от не модифицированных страниц, которые незачем сохранять на диске! В результате упаковка файла несколько повышает производительность самого этого файла, но значительного ухудшает положение остальных, неупакованных приложений!
Поэтому, никаких конкретных цифр здесь не приводится. Приблизительные оценки, выполненные "на глаз", показывают, что при наличии практически неограниченного количества оперативной памяти потери производительности составляют менее 10%, но при ее нехватке, скорость всех приложений падает от двух до десяти раз! (Для справки: в эксперименте участвовали исполняемые файлы Microsoft Word 2000, Visual Studio 6.0, Free Pascal 1.04, IDA Pro 4.17, Adobe Acrobat Reader ¾, машина с процессором CLERION-300A, оснащенная 256 мегабайтами ОЗУ, для имитации нехватки памяти ее объем уменьшался до 64 мегабайт; использовались операционные системы –Windows 2000 и Windows 98).
Эффективность предвыборки в многозадачных системах
Процессы, исполняющиеся в многозадачных системах, владеют кэш-памятью не единолично, а вынуждены делить ее между собой. Снижает ли это эффективность предвыборки? Эффективность предвыборки в кэш первого уровня – однозначно нет. Промежуток времени между переключением задач – это целая вечность для процессора, соответствующая, по меньшей мере, миллионам тактов. В любом случае, независимо от того, будет ли вытеснено содержимое L1-кэша или нет, – предвыборка позволяет конвейеризовать загрузку данных из памяти, предотвращая тем самым возможное падение производительности.С L2-кэшем ситуация не так однозначна. Если оптимизируемый алгоритм позволяет распараллелить загрузку данных с их обработкой, то состояние L2-кэша вообще не играет никакой роли, поскольку быстродействие программы ограничивается именно скоростью вычислений, но пропускной способностью подсистемы памяти (подробнее см. "Практическое использование предвыборки. Планирование дистанции предвыборки"). Однако если время обработки данных меньше времени их загрузки из основной памяти, падения производительности никак не избежать. Предвыборка, конечно, увеличит производительность программы и в этом случае, но – увы – ненамного, максимум в два-три раза.
С другой стороны, одновременное выполнение двух или более приложений, интенсивно обменивающихся с памятью, на рабочих станциях случается очень редко (для серверов, правда, это – норма жизни). В большинстве случаев пользователь активно работает лишь с одним приложениям, другие же находятся в фоне и довольствуются минимальным количеством памяти, а порой и вовсе "спят", не трогая L2-кэш и практически не снижая эффективности предвыборки.
Эволюция динамической памяти.
В микросхемах памяти, выпускаемых вплоть до середины девяностых, все три задержки (RASto CAS Delay, CAS Delay и RAS precharge) в сумме составляли порядка 200 нс., что соответствовало двум тактам в 10 мегагерцовой системе и, соответственно, двенадцати – в 60 мегагерцовой. С появлением Intel Pentium 60 (1993 год) и Intel 486DX4 100 (1994 год) возникла потребность в совершенствовании динамической памяти – прежнее быстродействие уже никого не устраивало.Как компиляторы выравнивают данные
Большинство компиляторов все заботы о выравнивании данных берут на себя – прикладной программист об этих проблемах может даже не задумываться. Однако, стратегия, используемая компиляторами, не всегда эффективна, и в критических ситуациях без живой головы не обойтись!Стратегия выравнивания для каждого типа переменных своя: статические (static) и глобальные переменные большинство компиляторов насильно выравнивают в соответствии с их размером, игнорируя при этом кратность выравнивая, заданную прагмой pack или соответствующим ключом компилятора! Во всяком случае, именно так поступают Microsoft Visual C++ и Borland C++. Причем оба этих компилятора (как, впрочем, и подавляющее большинство других) не дают себе труда организовать переменные оптимальным образом, и размещают их в памяти в строгом соответствии с порядком объявления в программе. Что отсюда следует? Рассмотрим следующий пример:
// ….
static int a;
static char b;
static int c;
static char d;
// …
Компилятор, выравнивая переменную 'c' по адресу, кратному четырем, пропускает три байта, следующих за переменной 'b', образуя незанятую "дырку", по напрасно отъедающую память. А вот если объявить переменные так:
// ….
static int a;
static int c;
static char b;
static char d;
// …
…компилятор расположит их вплотную друг другу, не оставляя никаких дыр! Обязательно возьмите этот трюк на вооружение! (Однако, помните, что в этом случае цикл наподобие for(;;) b = d
будет выполняться достаточно неэффективно – т.к. переменные b и d попадут в один банк, это делает невозможной их синхронную обработку – подробнее см. "Стратегия распределения данных по кэш-банкам").
Автоматические переменные (т.е. обыкновенные локальные переменные) независимо от своего размера большинством компиляторов выравниваются по адресам кратным четырем. Это связано с тем, что машинные команды закидывания и стягивания данных со стека работают с один типом данных – 4-байтным двойными словами, поэтому переменные типа char
занимают в стеке ровно столько же места, как и int. И никакой перегруппировкой переменных при их объявлении от "пустот" избавиться не удастся. Локальные массивы так же всегда расширяются до размеров, кратным четырем, т.е. char a[11] и char b[12]
занимают в памяти одинаковое количество места. (Впрочем, это утверждение не относятся к массивам переменных типа int, т.к. поскольку размер каждого элемента массива равен четырем байт – длина массива всегда кратна четырем).
Другой тонкий момент – поскольку локальные переменные адресуются относительно вершины стека, на этапе компиляции адрес их размещения еще неизвестен! Он определяется значительно позже – в ходе исполнения программы и зависит и от операционной системы, и от потребностей в стеке всех ранее вызванных функций, и… да мало ли еще от чего! Как же в этом ситуации осуществить выравнивание?
Компилятор Borland C++, не мудрствуя лукаво, при входе в функцию просто обнуляет четыре младших бита регистра-указателя вершины стека, округляя его тем самым до адреса, кратного шестнадцати. Компилятор же Microsoft Visual C++ пускает все на самотек, рассуждая так: поскольку размеры всех локальных переменных насильно округляются до величин, кратным четырем, а начальное значение регистра-указателя стека так же гарантированно кратно четырем (за это ручается операционная система), то в любой точке программы значение регистра указателя вершины стека всегда кратно четырем и никакого дополнительного выравнивания ему не требуется.
Динамические переменные размешаются в куче – области памяти выделенной специальной функцией наподобие malloc. Степень выравнивания (если таковая имеется) у каждой функции может быть своя. Например, malloc выравнивает выделяемые ей регионы памяти по адресам кратным 16.
Структуры. По умолчанию каждый элемент структуры выравнивается на величину, равную его размеру (т.е. char – вообще не выравниваются, short int – выравниваются по четным адресам, int и float – адресам, кратным четырем и double c __int64 – восьми).
Как уже отмечалось выше, это не самая лучшая стратегия, к тому же выравнивание структур, работающих с сетевыми протоколами, оборудованием, типизированными фалами категорически недопустимо! Как же запретить компилятору самовольничать? Универсальных решений нет – управление выравниванием не стандартизовано и специфично для каждого компилятора. Например, компиляторы Microsoft Visual C++ и Borland C++ поддерживают прагрму pack, задающую требую степень выравнивания. Например, "#pragma pack(1)" заставляет компилятор выравнивать элементы всех последующих структур по адресам, кратным единице – т.е. не выравнивать их вообще. Аналогичную роль играют ключи командной строки: /Zn? (Microsoft Visual C++) и –a? (Borland C++), где – "?" степень выравнивания.
Однако было бы неразумно полностью отказываться от выравнивания структур – если внутреннее представление структуры некритично, выравнивание ее элементов может значительно ускорить работу программы. К счастью, действие прагмы pack, в отличие от соответствующих ей ключей командой строки, - локально. Т.е. в тексте программы pack
может встречаться неоднократно и с различными степенями выравнивания. Более того, прагма pack
может сохранить предыдущее значение степени выравнивания, а затем восстановить его – pack(push)
запоминает, а pack(pop) вытаивает значение выравнивания из внутреннего стека компилятора. Разумеется, вызовы push/pop
могут быть вложенными. Пример их использования приведен ниже.
#pragma pack(push)
#pragma pack(1)
struct IP_HEADER{
// …
}
#pragma pack(pop)
Если программу не предполагается использовать на процессорах ниже чем P-II, достаточно выровнять начало структуры по адресу, кратному 32 и, если размер структуры не превышает 32 байт, о выравнивании каждого элемента можно вообще забыть.
Как опубликовать рекламную статью бесплатно?
Редакторы – народ строгий. На рекламу у них глаз наметан. Только появится рекламный подтекст, как сразу – публикация на правах рекламы, см. расценки. Недостаточно опытных редакторов легко обмануть, написав откровенно-рекламную статью в критической форме. Например, о программе с интуитивно-понятным интерфейсом, можно высказываться как о программе "для дурака", не забыв, конечно, привести все копии экрана. Клиент пропустит "дурака" мимо ушей, а вот описание интерфейса ему может понравиться!Вообще же, основной признак рекламных статей – отсутствие информации о предмете рекламы. Зато отношения к этому предмету – хоть отбавляй. Например, "неотразимый дизайн". Простите, какой-какой? Угловатый, обтекаемый, тошнотворно-прозрачный? Оставьте же человеку права на собственный вкус! А "неотразимый" лучше вообще заменить на какое ни будь другое не броское прилагательное с частицей "не". Скажем: "недурной, выполненный в классическом угловатом стиле".
Никогда не стоит в рекламе бояться говорить о недостатках продукта – это не отвернет покупателя, зато крепче уверит его в беспристрастность описания достоинств. Ну, как такое горячее доверие не обмануть?! Так, где тут у нас лапша, пока он растопырил уши…
Как привлечь к себе внимание?
О! Привлечь к себе внимание очень просто – стоит ляпнуть какую-нибудь глупость или затронуть национальное или какое-нибудь другое достоинство. Ну, например, объявить, что в будущем при регистрации вашей копии программы потребуется указывать номер паспорта или, скажем, расписываться в лицензионном соглашении, заверять его у нотариуса, отсылать ценным письмом на фирму и только после этого ожидать регистрационного ключа, "отпирающего" программу.Поднимется буря! Все журналисты встанут на уши и начнут смаковать, дескать, какой же идиот-клиент на это пойдет?! Да в новой программе нет никаких сногсшибательных возможностей, так, косметическая доводка по мелочам – стоит ли переходить на нее ценой такой головной боли?
Но, нет, фирма не потеряет своих клиентов – она их приобретет! Спустя некоторое время будет объявлено: мол, идя на встречу общественности, отменяем все эти нововведения, возвращая все на круги своя. Но, пока длится скандал, потребитель узнает и о самой программе, и о новых возможностях и, вполне вероятно, что приобретет!
Такую тактику применяют практически все "отцы-основатели" компьютерной индустрии: и Intel, и Microsoft, и многие другие копании.
Несколько видоизмененный вариант на ту же тему – публично заявить, что конкуренты украли такую-то "фенечку" и подать на них в суд. Исход разбирательства не важен. Главное – привлечение внимания общественности к собственной персоне. Пример: компания Sun, в стремлении донести до потребителя для чего вообще нужна эта Java (что это за серверный олень такой?), затеяла долгое препирательство с Microsoft. И это притом, что реализация VM Java от Microsoft была едва ли не самой лучшей, а Sun и сама не знала на какую ногу встать – то ли вопить о несовместимости с Java, то ли обвинять в незаконном распространении фрагментов исходных текстов в составе SDK.
Мотив этого и многих других судебных разборок один – широкомасштабная бесплатная реклама. И только потом – щелчок по носу конкурента.
Как с ними борются?
Было бы по меньшей мере удивительно, если бы с ошибками переполнения никто не пытался бы бороться. Такие попытки предпринимались неоднократно, но конечный результат во всех случаях оставлял желать лучшего.Очевидное "лобовое" решение проблемы заключается в синтаксической проверке выхода за границы массива при каждом обращении к нему. Такие проверки опционально реализованы в некоторых компиляторах Си, например, в компиляторе "Compaq C" для Tru64 Unix и Alpha Linux. Они не предотвращают возможности переполнения вообще и обеспечивают лишь контроль непосредственных ссылок на элементы массивов, но бессильны предсказать значение указателей.
Проверка корректности указателей вообще не может быть реализована синтаксически, а осуществима только на машинном уровне. "Bounds Checker" – специальное дополнение для компилятора gcc – именно так и поступает, гарантированно исключая всякую возможность переполнения. Платой за надежность становится значительное, доходящее до тридцати
(!) и более раз падение производительности программы. В большинстве случаев это не приемлемо, поэтому, такой подход не сыскал популярности и практически никем не применяется. "Bounds Checker" хорошо подходит (и широко используется!) для облегчения отладки приложений, но вовсе не факт, что все допущенные ошибки проявят себя еще на стадии отладки и будут замечены beta-тестерами.
В рамках проекта Synthetix удалось найти несколько простых и надежных решений, не спасающих от ошибок переполнения, но затрудняющих их использование злоумышленниками для несанкционированного вторжения в систему. "StackGuard” – еще одно расширение к компилятору gcc, дополняет пролог и эпилог каждой функции особым кодом, контролирующим целостность адреса возврата.
Алгоритм в общих чертах следующий: в стек вместе с адресом возврата заносится, так называемый, “Canary Word”, расположенный до адреса возврата. Искажение адреса возврата обычно сопровождается и искажением Canary Word, что легко проконтролировать.
Соль в том, что Canary Word содержит символы “\0”, CR, LF, EOF, которые не могут быть обычным путем введены с клавиатуры. А для усиления защиты добавляется случайная привязка, генерируемая при каждом запуске программы.
Компилятор Microsoft Visual C++ так же способен контролировать сбалансированность стека на выходе из функции: сразу после входа в функцию он копирует содержимое регистра-указателя вершины стека в один из регистров общего назначения, а затем сверяет их перед выходом из функции. Недостаток: впустую расходуется один из семи регистров и совсем не проверяется целостность стека, а лишь его сбалансированность.
"Bounds Checker", выпущенный фирмой NuMega для операционной системы Microsoft Windows 9x\NT, довольно неплохо отлавливает ошибки переполнения, но, поскольку, он выполнен не в виде расширения к какому-нибудь компилятору, а представляет собой отдельное приложение, к тому же требующее для своей работы исходных текстов "подопытной" программы, он может быть использован лишь для отладки, и не пригоден для распространения.
Таким образом, никаких готовых "волшебных" решений проблемы переполнения не существует и сомнительно, чтобы они появилось в обозримом будущем. Да и так ли это необходимо при наличие поддержки структурных исключений со стороны операционной системы и современных компиляторов? Такая технология при правильном применении обгоняет в легкости применения, надежности и производительности все остальные, по крайней мере, существующие на сегодняшний день контролирующие алгоритмы.
Как сделать свои программы надежнее?
Просматривая популярную рассылку по информационной безопасности BUGTRAQ (или любую другую), легко убедиться, что подавляющее большинство уязвимостей приложений и операционных систем связано с ошибками переполнения буфера (buffers overfull). Ошибки этого типа настолько распространены, что вряд ли существует хотя бы один полностью свободный от них программный продукт.Переполнение приводит не только к некорректной работе программы, но и возможности удаленного вторжения в систему с наследованием всех привилегий уязвимой программы. Это обстоятельство широко используется злоумышленниками для атак на телекоммуникационные службы.
Проблема настольно серьезна, что попытки ее решения предпринимаются как на уровне самих языков программирования, так и на уровне компиляторов. К сожалению, достигнутый результат до сих пор оставляет желать лучшего, и ошибки переполнения продолжают появляться даже в современных приложениях – ярким примером могут служить: Internet Information Service 5.0 (Microsoft Security Bulletin MS01-016), Outlook Express 5.5 (Microsoft Security Bulletin MS01-012), Netscape Directory Server 4.1x (L0PHT A030701-1), APPLE: QuickTime Player 4.1 (SPSadvisory#41), ISC BIND 8 (CERT: Advisory CA-2001-02, Lotus Domino 5.0 (Security Research Team, Security Bulletin 010123.EXP.1.10) – список можно было бы продолжать до бесконечности. А ведь это серьезные продукты солидных производителей, не скупящихся на тестирование!
Ниже вы найдете описание приемов программирования, следование которым значительно уменьшает вероятность появления ошибок переполнения, в то же время не требуя от разработчика никаких дополнительных усилий.
Как создать иллюзию устойчивости, когда делать идут хуже некуда?
Отличительная черта крупных, устойчиво стоящих на ногах компаний, – потребность в критике. Объективная критика позволяет выявить свои слабые стороны и приносит неоценимую пользу (это не аналитиков нанимать, которые требуют много денег, но все равно ничего путного не делают). Необъективная критика – лучшая похвала. Раз оппонент ни за что конструктивное ухватиться не может – значит, и ухватываться-то не за что (ну, во всяком случае, на его, - критика, - умственном уровне развития). Похвала, напротив, внушает чувство неуверенности и ее информационная ценность равна нулю. Мало ли, что кому понравилось? А, может быть, 99% остальным – это как раз и не нравится?Мелкие, готовые вот-вот развалиться, компании ведут себя диаметрально противоположно. Они очень болезненно реагируют на критику, хватают критикующих их журналистов за грудки и с воплями "клевета, клевета", устраивают чуть ли не судебные разборки, дескать, сейчас же напечатайте опровержение!
Народ же обиженных любит! Товар фирмы-великомученика могут брать охотнее только за ее "мученичества" ("поможем братьям нашим китайцам завалить паразитов Янок!"). Словом, обильная порция грязи, критики и клеветы никогда не повредит. Напротив, чем больше критики, тем сильнее уверенность потребителя, что истинная причина критики не в действительных недостатках товара, а опасениях конкурентов, что этот самый товар может пошатнуть их позицию на рынке.
Чем плоха идея лить грязь на самих себя? Причем грязь откровенно сумасбродную, типа "эту программу писали сатанисты". Клиенту до того, кто ее писал, никакого дела нет. Но, не зная кем инсценирована эта критика, он может склониться к такому продукту, здраво рассуждая – если никаких других недостатков у него нет – он идеален.
Как учатся оптимизации
Было бы слишком наивно ожидать от книги, что она научитвас писать эффективный код. Нет! По этому поводу уместно вспоминать детский анекдот, когда верзила-двоечник говорит своему "очкаристому" однокласснику "Дай мне твою самую толстую книгу – я ее прочитаю и буду таким же умным как ты". Книгу – извольте, но
Как удержать клиента в своих руках?
Клиента мало завоевать, его необходимо еще и удержать. Самая прочная сеть – закрытые и постоянно меняющиеся стандарты. Взять, к примеру, ICQ или Microsoft Office. Чтобы написать своего ICQ-клиента или свой редактор документов Office необходимо знать их формат. А он – секрет фирмы. Выяснить его можно только утомительным и трудоемким дизассемблированием, но весь труд полетит впустую, если этот формат будет хотя бы незначительно изменен в новой версии программы.В результате – монополизация рынка: хочешь работать с нашими файлами? – Пользуйся нашими приложениями! Любопытно, что Билл Гейтс, во всю критикуя этот прием в своей книге "Дорога в будущее", сам является его горячим поклонником!
Как заставить клиента купить лицензионную копию ПО?
Законов, по которым было бы можно привлечь к ответственности физическое лицо, использующее нелегальное программное обеспечение в нашей стране не существует. С юридическими лицами в этом плане несколько легче, но удовлетворенные судебные иски все же очень редки. Разработчикам остается полагаться лишь на стойкость защитных механизмов, препятствующих несанкционированному копированию, да совесть пользователей, понимающих, что пиратство, в конечном счете, оборачивается против них. Но нет такой защиты, которую нельзя было бы взломать, а своя рубашка всегда ближе к телу – программное обеспечение воровали, воруют и будут воровать!Придется идти на хитрость. Защита должна быть полностью интегрирована с программой, как бы "размазана" по ней и иметь, по крайней мере, два уровня обороны: на первом – блокирование работы, а на втором – искажение результата, выдаваемого программой. Взломщик легко нейтрализует блокировку, но вот дальше его будет ждать сюрприз – придется тщательно проанализировать весь код программы, полностью
разобраться с логикой ее работы, но даже тогда нельзя быть уверенным, что в какой-то момент программа не сделает из чисел "винегрет". В результате, у пользователя взломанная программа будет работать нестабильно, например, выводить на экран один цифры, а на принтер – другие. Вероятнее всего, большинство не будут искать хороший "кряк", а приобретут лицензионную копию.
На всякий случай можно затруднить поиски хорошего "кряка", наводнив хакерские сайты специально изготовленными низкокачественными "ломками", написанными самим же автором программы. Такая тактика особенно характерна для разработчиков бухгалтерских пакетов, т.к. рядовой хакер не слишком осведомлен в тонкостях дебита и кредита, а, потому, проверить корректность работы такой программы не может.
Как заставить клиента купить новую версию ПО?
Не секрет: чаще всего свежие версии ПО устанавливаются не ради новых функциональных особенностей, а в надежде, что большинство (или хотя бы основные) ошибки старых версий были устранены.Так сделаем же превосходный маркетинговый ход! Допускаем (не обязательно умышленно) в каждой версии некоторое количество не смертельных ошибок; исправляем их в следующей версии, попутно внося свежие ляпы, и… потребитель приобретет такой продукт без конца.
Эту тактику, даже не стараясь замаскировать, взяли на вооружение практически все крупные и мелкие компании. И не безуспешно! Например, скажите: какое основное качество Windows 98? Правильно, не считая красивых бирюлек и мелких несущественных доделок ядра (о которых не всякий рядовой пользователь и знает), основное отличие от Windows 95 – надежность. А какое основное качество Windows Me? Вот, вот, - аналогично!
Наивно думать, что ошибки в ПО приносят убыток компании. Напротив, они оборачиваются выгодой, порой весьма значительной. Тщательное же "вылизывание" продукта чрезвычайно невыгодно. Это не только лишние затраты на beta-тестеров, но и отсрочка выхода программы на рынок – прямая угроза его захвата конкурентами.
Попутно – пусть новая версия сохраняет документы в формате, не поддерживаемом прежними версиями программы. Если хотя бы один – два процента пользователей установят новый продукт на свои компьютеры, начнется цепная реакция – всем остальным придется переходить на нее не в силу каких-то особенных достоинств, а из-за необходимости читать документы, созданные другими пользователями.
Именно так и поступила Microsoft со своим Word 7.0, документы которого не читались младшими версиями. Официальная тому причина – переход на UNICODE-кодировку с целью обеспечения многоязыковой поддержки. Но неужто ради этого стоило отказываться от совместимости? Разработчики предлагали встраивать в документ макрос, который, будучи запущен на Word 6.0, автоматически бы конвертировал текст. Так ведь нет! Выпустили отдельный конвертор, о существовании которого до сих пор знают не все пользователи. Сдается, что причина такого решения ни в чем ином, как в принудительном навязывании пользователям нового Word 7.0
Кэш-подсистема современных процессоров
Кэш подсистема процессоров P-II, P-III, P-4 и AMDAthlon представляет собой многоуровневую иерархию, состоящую из следующих компонентов: кэша данных первого уровня, кэша команд первого уровня, общего кэша второго уровня, TLB-кэша страниц данных, TLB-кэша страниц кода, буфера упорядочивая записи и буферов записи (см. рис. 0х016).MOB: Данные, сходящие с вычислительного конвейера, первым делом попадают на MOB (Memory Order Buffer – буфер упорядочивая записи к памяти) где они, постепенно накаливаясь, ожидают своей очереди выгрузки в паять. Грубо говоря, буфер упорядоченной записи играет тут же самую роль, что и зал ожидания в аэропорту. Пассажиры прибывают туда в более или менее случайном порядке, но улетают в строгом соответствии со временем, указанном в билете, да и то при условии, что к этому моменту выдастся летная погода и самолету предоставят "коридор" (кто летал – тот поймет).
Данные, находящиеся в MOB, всегда доступы процессору, даже если они еще не выгружены в память, однако емкость буфера упорядоченной записи довольно невелика (40 входов на P6) и при его переполнении вычислительный конвейер блокируется. Поэтому, содержимое MOB должно при всякой возможности незамедлительно выгружаться оттуда. Это происходит по крайней мере тремя путями:
а) если модифицируемая ячейка уже присутствует в кэш-памяти первого уровня, то она прямиком направляется в соответствующую ей кэш-строку, на что уходит всего один такт, в течении которого в кэш может быть записана одна или даже две любых несмежные ячейки (максимальное количество одновременно записываемых ячеек определяется архитектурой кэш-подсистемы конкретного процессора, –см "Оптимизация обращения к памяти и кэшу. Влияние размера обрабатываемых данных на производительность. В кэше первого уровня");
б) если модифицируемая ячейка отсутствует в кэш-памяти первого уровня, она, при наличии хотя бы одного свободного буфера записи, попадает туда. Это так же занимает всего один такт, причем, максимальное количество параллельно записываемых ячеек определяется количеством портов, имеющихся в "распоряжении" у буферов записи (например, процессоры AMD K5 и Athlon содержат только один такой порт);
с) если модифицируемая ячейка отсутствует в кэш-памяти первого уровня и ни одного свободного буфера записи нет, – процессор самостоятельно загружает соответствующую копию данных в кэш-первого уровня, после чего переходит к пункту а). В зависимости от ряда обстоятельств, загрузка данных занимает от десятков до сотен (а то и десятков тысяч!) тактов процессора, поэтому, таких ситуаций по возможности следует избегать.
L1 CAHE. Кэш первого уровня размещается непосредственно на кристалле и реализуется на базе двух портовой статической памяти. Он состоит из двух независимых банков сверхоперативной памяти, каждый из которых управляется "своим" кэш-контроллером. Один кэширует машинные инструкции, другой – обрабатываемые ими данные. В кратной технической спецификации процессора обычно указывается суммарный объем кэш-памяти первого уровня, что приводит к некоторой неопределенности, т.к. емкости кэша инструкций и кэша данных не обязательно должны быть равны (а на последних процессорах они и не равны).
Каждый банк кэш-первого уровня помимо собственно данных и инструкций содержит и буфера ассоциативной трансляции (TLB) страниц данных и страниц кода соответственно. Под буфера ассоциативной трансляции отводятся фиксированные линейки кэша и занимаемое ими пространство "официально" исключено из емкости кэш-памяти. Т.е. если в спецификации сказано, что на процессоре установлен 8 Кб кэш данных, – все эти 8 Кб непосредственно доступны для кэширования данных, а реальная емкость кэш-памяти в действительности же превосходит 8 Кб.
Буфера Записи. Если честно, то у автора нет полной ясности где конкретно в кэш-иерархии расположены буфера записи. На блок-диаграммах процессоров Intel Pentium и AMD Athlon, приведенных в документации, они вообще отсутствуют, а в § 9.1 "INTERNAL CACHES, TLBS, AND BUFFERS" главы "MEMORY CACHE CONTROL" руководства по системному программирования от Intel, буфера записи изображены чисто условно и явно не в том месте, где им положено быть (сам Intel пишет, что "буфера записи связны с исполнительным блоком процессора", а на рисунке подсоединяет их к блоку интерфейсов с шиной – с каких это пор последний стал "вычислительным устройством"?!).
Проанализировав всю документированную информацию, так или иначе касающуюся буферов и основываясь на результат собственных экспериментов, автор склоняется к мысли, что буфера записи напрямую связаны как минимум с Буфером Упорядоченной Записи (ROB Wb), Блоком Интерфейса с Памятью (MIU) и Блоком Интерфейсов с Шиной (BIU). А на K5 (K6/Athlon) Буфера Записи связаны еще с кэш-памятью первого уровня.
Но, так или иначе, Буфера Записи позволяют на некоторое время откладывать фактическую запись в кэш и/или основную память, осуществляя эту операцию по мере освобождения кэш-контроллера, внутренней или системной шины, что ликвидирует целый ряд задержек и тем самым увеличивает производительность процессора.
Блок Интерфейсов с Памятью (MIU). Блок Интерфейсов с Памятью представляет собой одно из исполнительных устройств процессора и функционально состоит из двух компонентов: устройства чтения памяти и устройства записи памяти. Устройство чтения соединено с буферами записи и кэшем первого уровня. Если требуемая ячейка памяти присутствует хотя бы в одном из этих устройств, на ее чтение расходуется всего один такт. Причем независимо от типа обрабатываемых данных, вся кэш-линейка загружается целиком. Хотя Intel и AMD умалчивают об этой детали, она легко обнаруживается экспериментально. Действительно, имея всего одно устройство для работы с памятью, процессоры Pentium и AMD Athlon ухитряются спускать несколько инструкций чтения памяти за каждый такт, правда при условии, что данные выровнены по границе четырех байт и находятся в одной кэш-линейке. Отсюда следует, что шина, связывающая MIU и L1-Cache должна быть как минимум 256?битной, что (учитывая близость кэш-памяти первого уровня к ядру процессора) реализовать без особых затрат и труда.
Устройство записи памяти соединено с Блоком Упорядоченной Записи (ROB Wb), уже рассмотренным выше.
Блок Интерфейсов с Шиной Блок Интерфейсов с Шиной (BIU) является единственным звеном, связующим процессор с внешним миром, эдакое своеобразное "окно в Европу".
Сюда стекается все информация, вытесняемая из Буферов Записи и кэш-памяти первого уровня, сюда же поступают запросы за загрузку данных и машинных команд от кэша данных и кэша команд соответственно. Со стороны "Европы" к Блоку Интерфейсов с Шиной примыкает кэш-память второго уровня и основная оперативная память. Понятно, что от поворотливости BIU зависит быстродействие всей системы в целом.
Кэш второго уровня. В зависимости от конструктивных особенностей процессора кэш второго уровня может размещаться либо непосредственно на самом кристалле, либо монтироваться на отдельной плате вне его.
Однокристальная
(On Die) реализация обладает практически неограниченным быстродействием, – поскольку длины проводников, соединяющих кэш второго уровня с Блоком Интерфейсов с Шиной относительно невелики, кэш свободно работает на полной процессорной частоте, а разрядность его шины в процессорах P-III и P-4 достигает 256-бит. С другой стороны, такое решение значительно увеличивает площадь кристалла, а значит и его себестоимость (процент брака с увеличением площади кристалла растет экспоненциально). Тем не менее, благодаря совершенству производственных технологий (и не в последнюю очередь – жесточайшей конкурентной борьбе), – интегрированных кэшем второго уровня обладают все современные процессы.
Процессоры P-II и первые модели процессоров P-III и AMD Athlon имели
Двойная независимая шина (DIB – Dual Independent Bus). Для увеличения производительности системы, кэш второго уровня "общается" с BIU через свою собственную локальную шину, что значительно сокращает нагрузку, выпадающую на долю FSB.
В силу геометрической близости кэша второго уровня к процессорному ядру, длина локальной шины относительно невелика, а потому она может работать на значительно более высоких тактовых частотах, чем системная шина. Разрядность локальной шины долгое время оставалась равной разрядности системной шины и составляла 64 бита.
Впервые эта традиция нарушилась лишь с выходом Pentium-III Copper mine, оснащенным 256 битной локальной шиной, позволяющей загружать целую 32 байтную кэш-линейку всего за один такт! Это фактически уравняло кэш первого и кэш второго уровня в правах! (см. "Оптимизация обращения к памяти и кэшу. Влияние размера обрабатываемых данных на производительность. Особенности кэш-подсистемы процессоров P-II и P-III") К сожалению, процессоры AMD Athlon не могут похвастаться шириной своей шины…
Архитектура двойной независимой шины значительно снижает нагрузку на FSB, т.к. большая часть запросов к памяти обрабатывается локально. По статистике, коэффициент загрузки системной шины в однопроцессорных рабочих станциях, составляет порядка 10% от ее максимальной пропускной способности, а остальные 90% запросов ложатся на локальную шину. Даже в четырех процессорном сервере нагрузка на системную шину не превышает 60%, создавая тем самым обманчивую видимость, что производительность системной шины перестает быть самым узким местом системы, ограничивающим ее производительность.
Несмотря на то, что статистика не лжет, интерпретация казалось бы самоочевидных фактов, мягко говоря не совсем соответствует действительности. Низкая загрузка системной шины объясняется высокой латентностью основной оперативной памяти, приводящей к тому, что по меньшей мере половину времени шина тратит не на передачу, а на ожидание выполнения запроса. Помните как в анекдоте, – почему у вас нет черной икры? Да потому что спроса нет! К счастью, в старших моделях процессоров появились команды предвыборки, позволяющие предотвратить латентность и разогнать шину на всю мощь (см. так же "Оптимизация обращения к памяти и кэшу. Практическое использование предвыборки. Планирование дистанции предвыборки").

Рисунок 14 0х016 Кэш-подсистема современных процессоров (кэш кода не показан)

Рисунок 15 0x024 Блок-схема подсистемы кэш-памяти процессоров семейства Intel P6

Рисунок 16 0x023 Физическое воплощение подсистемы кэш-памяти на примере процессора Intel Pentium-III Coppermain
Кэш – принципы функционирования
Краткое описание архитектуры кэш-подсистемы, включенное в настоящую книгу, не претендует на исчерпывающее изложение материала и ориентировано в первую очередь на неподготовленных программистов, помогая им разобраться с устройством кэш-памяти хотя бы в общих чертах. Тем не менее, определенную ценность эта глава представляет и для профессионалов, позволяя им освежить свои знания и, возможно, даже восполнить некоторые пробелы и/или распутать темные места документации.Несмотря на то, что приведенного здесь материла для грамотной работы с кэш-памятью более чем достаточно, автор настоятельно рекомендует не пренебрегать и чтением технической документации по процессорам, распространяемой их производителями, поскольку там содержится множество интереснейших сведений, не включенных в данную книгу по тем или иным соображениям (ну хотя бы уже потому, что нельзя объять необъятное и это все-таки руководство по оптимизации, но не энциклопедия компьютерного железа).
Комбинирование операций чтения с операциями записи
___Разнесение инструкций записи/чтения (см. Ител P-4)Комбинирование операция записи с вычислительными операциями
Буферизация записи позволяет откалывать физическую выгрузку данных в сверхоперативную и/или основную оперативную память до того момента, когда процессору это будет наиболее "удобно". До тех пор, пока в наличии имеется по крайней мере один свободный буфер, такой прием существенно увеличивает производительность. В противном случае, процессор вынужден простаивать в ожидании выгрузки содержимого буферов и тогда запись с буферизацией ведет себя как обычная запись.Другая не менее значимая функциональная обязанность буферов – упорядочивание записи. Данные, поступающие в буфера, неупорядочены и попадают в них по мере завершения команд микрокода. Здесь они сортируются и вытесняются с буферов уже в порядке следования оригинальных машинных команд программы.
Отсюда: переполнение буферов автоматически влечет за собой блокировку кэш-памяти,– до тех пор, пока не завершится выгрузка данных, вызвавших кэш-промах, все остальные попытки записи в память (независимо завершаются ли они кэш-попаданием или нет) будут временно заблокированы. Не очень-то хорошая перспектива, не правда ли?
Этой ситуации можно избежать, если планировать обработку данных так, чтобы скорость заполнения буферов не превышала скорости их выгрузки в кэш и/или оперативную память. Процессоры P-II и P-III содержат двенадцать 256-битных буферов, каждый из которых может соответствовать любой 32-байтовой области памяти, выровненной по границе 32 байт. Другими словами, буфера записи представляют собой 384-байтовый полностью ассоциативный кэш, состоящий из 12 кэш-линеек по 32 байта каждая. Так, надеюсь, будет понятно?
Процессор P-4 содержат аж 24 буфера записи, каждый из которых, по всей видимости, вмещает 512 бит данных.
Процессор AMD Athlon, так же, как и P-II/P-III содержит 12 буферов записи, совмещенных с буферами чтения. Об их длине в документации ничего не говориться (или просто я не нашел?), но можно предположить, что вместимость каждого из буферов составляет 64 байта.
Конечно, это вдовое меньше, чем у P-4, но AMD Athlon в отличии от своего конкурента, всегда выгружает содержимое буферов в кэш-первого уровня, в то время как процессоры P6 и P-4 при отсутствии записываемых данных в кэше первого уровня, выгружают их в кэш второго.
Трудно сказать, чья политика лучше. С одной стороны, при записи большого количества данных, обращений к которым в обозримом будущем не планируется, Intel заметно выигрывает, поскольку не "засоряет" кэш-первого уровня. С другой: выгрузка буферов на AMD Athlon происходит несколько быстрее, что уменьшает вероятность возникновения "заторов". Но, в то же время, 256-битная шина P-III и P-4, соединяющая кэш первого с кэшем второго уровня, практически стирает разницу в их производительности и буфера выгружаются даже с большей скоростью, чем на Athlon. Правда, Athlon, умеющий вытеснять содержимое кэша первого уровня параллельно с записью в него новых данных, от такого расклада вещей ничуть не страдает и… В общем, этот поединок детей двух гигантов процессорной индустрии можно продолжать бесконечно. Да и есть ли в этом смысл? Ведь при оптимизации программы все равно приходится закладываться на худшую конфигурацию, т.е. в данном случае на двенадцать 32-байтовых буферов записи.
Посмотрим (см. "Intel® Architecture Optimization Reference Manual Order Number: 245127-001"), что говорит Intel по этому поводу. "Write hits cannot pass write misses, so performance of critical loops can be improved by scheduling the writes to memory. When you expect to see write misses, schedule the write instructions in groups no larger than twelve, and schedule other instructions before scheduling further write instructions"
("Попадания /* hit */ записи не преодолевают промахи записи, поэтому, производительность критических циклов может быть увеличена планированием записей в память. Когда вы ожидаете промах записи, объединяйте инструкции записи в группы не более чем по двенадцать
и разделяйте их группами других инструкций").
Эта рекомендация содержит по крайней мере три неточности. Первая и наименее существенная из них: на самом деле, количество членов группы может быть и более двенадцати, ведь выгрузка буферов осуществляется параллельно с их заполнением. Объединение 14 команд записи не несет никакого риска, во всяком случае при выполнении программы под P-II/P-III/AMD Athlon и уже тем более P-4.
Другой момент. Под словом "двенадцать" авторы документации подразумевали "двенадцать команд записи с различными установочными адресами". На P-II/P-III в установочный адрес входят биты с 4 по 31 линейного адреса, а на P-4/AMD Athlon – с 5 по 31. Т.е. при последовательной записи 32-разрядных значений, "штатная" численность каждого группы команд составляет не двенадцать, а девяносто шесть "душ".
Наконец, третье и последнее. Поскольку, все записываемые данные в обязательном порядке проходят через буфера, предыдущее замечание не в меньшей степени справедливо и для модификации данных, уже находящихся в кэш-памяти перового уровня. С другой стороны, к данным, еще не вытесненным из буфера, можно обращаться многократно и "записью" это не считается.
Боюсь, что своими уточнениями я вас окончательно запутал… Но блуждание в трущобах непонимания, все таки лучше столбовой дороги грубого заблуждения. Давайте рассмотрим следующий пример Как вы думайте, можно ли назвать его оптимальным?
/*------------------------------------------------------------------------
*
* НЕОПТИМИЗИРОВАННЫЙ ВАРИАНТ
*
------------------------------------------------------------------------*/
// 192 ячейки типа DWORD дают в сумме 776 байт памяти,
// что вдвое превышает емкость буфером записи на
// процессорах PII/PIII. Где-то на половине исполнения
// возникнет затор и дальнейшие операции записи будут
// исполняться гораздо медленнее, т.е. им придется
// всякий раз дожидаться выгрузки буферов
for(a = 0; a < 192; a += 8)
{
// для устранения накладных расходов цикл
// должен быть развернут, иначе его
// расщепление снизит производительность
p[a + 0] = (a + 0);
p[a + 1] = (a + 1);
p[a + 2] = (a + 2);
p[a + 3] = (a + 3);
p[a + 4] = (a + 4);
p[a + 5] = (a + 5);
p[a + 6] = (a + 6);
p[a + 7] = (a + 7);
}
// делаем некоторые вычисления
for(b = 0; b < 66; b++)
x += x/cos(x);
Листинг 17 [Cache/write_sin.c] Кандидат на оптимизацию посредством совмещения команд записи с вычислительными операциями
Конечно же, цикл for a
на P-II/P-III исполняется весьма не оптимально. Даже если предположить, что на момент его начала все буфера девственно чисты (что вовсе не факт!), не успеет он перевалить за половину, как свободные буфера действительно кончатся и начнутся сильные тормоза, поскольку все последующие операции записи будут вынуждены ждать освобождения буферов. Ввиду постоянной занятости шины ждать придется довольно долго. Зато потом, в вычислительном цикле, она (шина) будет простаивать. Экий не рационализм!
Давайте реорганизуем наш цикл так, разбив его на несколько под–циклов, каждый из которых заполнял бы не более двенадцати-четырнадцати буферов. В данном случае, как нетрудно рассчитать, потребуется всего два цикла (192/96 == 2). Между циклами записи памяти мы разместим вычислительный цикл. Желательно спланировать его так, чтобы за время вычислений все буфера гарантированно успевали бы освободиться. Попутно отметим, если время вычислений значительно превосходит время выгрузки буферов, такую перегруппировку выполнять бессмысленно, т.к. в этом случае производительность в основном определяется скоростью вычислений, но не записью памяти.
Кстати, вычислительный цикл так же целесообразно разбить на две половинки, поместив первую из них перед циклом записи, а вторую позади него. Это гарантирует, что независимо от состояния буферов записи на момент начала выполнения записывающего цикла, все буфера будут действительно пусты. (Между прочим, этот факт не отмечен ни в одном известной мне инструкции по оптимизации, включая оригинальные руководства от Intel и AMD).
/*------------------------------------------------------------------------
*
* ОПТИМИЗИРОВАННЫЙ ВАРИАНТ
*
------------------------------------------------------------------------*/
// выполняем часть запланированных вычислений,
// с таким расчетом, что бы буферам хватило времени
// на сброс их текущего содержимого, ведь отнюдь
// не факт, что на момент начала выполнения цикла
// буфера пусты
for(b = 0; b < 33; b++)
x += x/cos(x);
// выполняем 96 записей ячеек типа DWORD, что
// соответствует емкости буферов записи; к моменту
// завершения цикла практически все буфера будут
// забиты. "Практически" -потому что какая-то часть
// из них уже успеет выгрузится
for(a = 0; a < 96; a += 8)
{
p[a + 0] = (a + 0);
p[a + 1] = (a + 1);
p[a + 2] = (a + 2);
p[a + 3] = (a + 3);
p[a + 4] = (a + 4);
p[a + 5] = (a + 5);
p[a + 6] = (a + 6);
p[a + 7] = (a + 7);
}
// выполняем оставшуюся часть вычислений. Будет
// просто замечательно, если за это время все буфера
// успеют аккурат выгрузится, - тогда мы достигнет
// полного параллелизма!
for(b = 0; b < 33; b++)
x += x/cos(x);
// выполняем оставшиеся 96 записей. Поскольку они
// к этому моменту уже освободились, запись
// протекает так быстро, как это только возможно
for(a = 96; a < 192; a += 8)
{
p[a + 0] = (a+0);
p[a + 1] = (a+1);
p[a + 2] = (a+2);
p[a + 3] = (a+3);
p[a + 4] = (a+4);
p[a + 5] = (a+5);
p[a + 6] = (a+6);
p[a + 7] = (a+7);
}
Листинг 18 [Cache/write_sin.c] Оптимизированный вариант, комбинирующий запись в память с вычислительными операциями
Результаты прогона оптимизированного варианта программы не то, чтобы очень впечатляющие, но и скромными их никак не назовешь (см. рис. graph 0x012). И на P-III, и на AMD Athlon достигается приблизительно двукратный прирост производительности вне зависимости от того: находятся ли ячейки записываемой памяти в кэше первого уровня или нет.
Более пристальный осмотр показывает, что в каждой группы составляет по кр
Как мы помним, кэш-память процессоров P6 и AMD K6 не блокируемая, – т.е. один промах не препятствует другим попаданиям. записываемые в буфера, попадают в порядке завершения команд микрокода, но в
Процессоры P-II/P-III имеют 12 32х-байтовых буферов,
то операции записи в некэшируемую память следует группировать количеством не более двенадцати записи с шагом более 32х байт и девяносто шести при записи с шагом равным четырем (12х32/4).
Рассмотрим следующий пример:
// Инициализируем 20 некэшируемых элементов массива
for (a=0;a<20;a++)
p[a*32]=0x666;
// Выполняем некоторые вычисления
for (b=0;b<10;b++)
tmp=tmp*13;
Казалось бы разумно расщепить цикл инициализации на два, разделенных вычислительным циклом. Тогда, первые двенадцать итераций инициализационного цикла выполняются без задержек (записываемые данные направляются в буфера), а пока процессор обрабатывает вычислительный цикл, буфера выгружаются в память, и оставшиеся десять операций присвоения вновь выполняются без задержек!
for (a=0;a<12;a++)
p[a*32]=0x666;
for (b=0;b<10;b++)
tmp=tmp*13;
for (a=12;a<20;a++)
p[a*32]=0x666;
Теоретически оно, может быть, и так, но на практике сколь ни будь значительного выигрыша добиться не получается. Взгляните на рисунок ???, – видите, оптимизация увеличила производительность на единичные проценты. Это объясняется тем, что, во-первых, расщепление циклов увеличивает количество ветвлений, "съедая" тем самым львиную долю выигрыша (см. "Декодер - Ветвления"). А, во-вторых, пока процессор вычисляет новое значение параметра цикла, проверяет условие продолжения цикла, наконец, выполняет условный переход – буфера выгружаются в память.
Поэтому, расщеплять циклы подобные этому не надо – значительного прироста производительности это все равно не даст, а вот отлаживать программу станет труднее.

Рисунок 38 graph 0x012 Демонстрация эффективности комбинирования операций записи памяти с вычислительными операциями
Напоследок – две пары расщепленных циклов можно, безо всякого ущерба для производительности, объединить в один – super-цикл. Особенно это актуально для тех случаев, когда цикл записи памяти разбивается не на два, а на множество под – циклов. Тогда можно поступить так:
/*------------------------------------------------------------------------
*
* ОПТИМИЗИРОВАННЫЙ СВЕРНУТЫЙ ВАРИАНТ
*
------------------------------------------------------------------------*/
for(d = 0;d<192;d += 96)
{
for(b = 0; b < 33; b++)
x += x/cos(x);
for(a = d; a < d+96; a += 8)
{
p[a + 0] = (a + 0);
p[a + 1] = (a + 1);
p[a + 2] = (a + 2);
p[a + 3] = (a + 3);
p[a + 4] = (a + 4);
p[a + 5] = (a + 5);
p[a + 6] = (a + 6);
p[a + 7] = (a + 7);
}
}
Листинг 19 Объединение двух расщепленных циклов в один
А как быть, если алгоритмом предусматриваются одни лишь операции записи, и кроме них нет никаких других вычислений? Стоит ли "искусственно" внедрять какую-нибудь команду для задержки? Нет! В лучшем случае – при правильном планировании – код выполнится за то же самое время – не медленнее, но и не быстрее. И неудивительно – ведь во время выгрузки буферов процессор занят бесполезными вычислениями!
Комбинирование вычислений с доступом к памяти
В современных процессорах обращение к оперативной памяти уже не приводит к остановке вычислительного конвейера. Отослав чипсету запрос на загрузку ячейки процессор временно приостанавливает выполнение текущей машинной инструкции и переходит к следующей. Это обстоятельство, в частности, позволяет посылать очередной запрос чипсету до завершения предыдущего (см. "Параллельная обработка данных"). Однако параллелизм чипсета весьма невысок и при обработке больших объемов данных процессор все равно вынужден простаивать, ожидая загрузки ячеек из оперативной памяти. Так почему бы не избавить процессор от скуки, и не занять его вычислениями?Предположим, нам необходимо загрузить N ячеек памяти и k раз вычислить синус угла. Предпочтительнее выполнять эти действия не последовательно, а объединить их в одном цикле. Тогда вычисления синуса будут происходить параллельно с загрузкой ячеек из памяти, сокращая тем самым время выполнения задачи.
Сразу же возникает вопрос: в какой именно пропорции целесообразнее всего смешивать вычисления и обращения к памяти? Достаточно очевидно, что в идеале время выполнения вычислительных инструкций должно быть равно времени загрузки ячеек из памяти, – в этом случае достигается наивысшая степень параллелизм. Проблема в том, что соотношение скорости доступа к памяти и скорости выполнения машинных инструкций на различных системах варьируется в очень широких пределах, и потому универсальных решений поставленной задачи нет. (см. подробное обсуждение эта проблемы в главе "Кэш Предвыборка"). С другой стороны, полного параллелизма добиваться вовсе не обязательно. Да и зачастую он вообще невозможен в принципе, – так, например, количества вычислений может оказаться просто недостаточно для покрытия всех задержек загрузки данных из памяти.
Попробуем оценить выигрыш, достигаемый при полном и частичном параллелизме. Для этого напишем следующую несложную тестовую программу, исследующую различные "концентрации" вычислительных операций на ячейку памяти.
Ниже для экономии места приведен лишь ее фрагмент. Обратите внимание: цикл, обращающийся к памяти, в оптимизированном варианте должен быть развернут. В противном случае, мы не получим никакого выигрыша в производительности, и хорошо если еще не окажемся в убытке. Почему? Так ведь свернутый цикл нельзя исполнять параллельно. Это, во-первых. А, во-вторых, циклы с небольшим количеством итераций крайне невыгодны хотя бы уже потому, что исполняются на одну итерацию больше, чем следует. (Стратегия предсказания ветвлений такова, что в последней итерации цикла процессор, не догадываясь о том, что цикл уже завершен, действует "как раньше", передавая управление на первую команду цикла; потом, конечно, он распознает свою ошибку и выполняет откат, но время выполнения от этого не уменьшается). Необходимость разворота циклов приводит к колоссальному увеличению размеров тестовой программы, – поскольку язык Си не поддерживает циклические макросы, весь код приходится набивать на клавиатуре вручную. Вот, кстати, хороший пример задачи которую невозможно элегантно решить штатными средствами языка Си (макроассемблер с этим бы справился на ура). Но довольно лирики, перейдем к исследованиям…
/* ---------------------------------------------------------------------------
не оптимизированный вариант
--------------------------------------------------------------------------- */
per=16; // 16 загружаемых байт на одно вычисление синуса
// цикл загрузки ячеек из памяти
// этот цикл исполняется крайне не оптимально, поскольку неповоротливая
// подсистема памяти просто не успевает за процессором и тому приходится скучать
for(a = 0; a < BLOCK_SIZE; a+=4)
z += *(int*)((int)p1 + a);
// цикл вычисления синусов
for(a=0; a < (BLOCK_SIZE/per); a++)
x+=cos(x);
/* ---------------------------------------------------------------------------
не оптимизированный вариант
--------------------------------------------------------------------------- */
// общий цикл, комбинирующий обращение к памяти с вычислениями синуса
for(a = 0; a < BLOCK_SIZE; a += per)
{
// внимание: цикл обращения к памяти должен быть развернут,
// иначе оптимизация обернется проигрышем в производительности
z += *(int*)((int)p1 + a);
z += *(int*)((int)p1 + a + 4);
z += *(int*)((int)p1 + a + 8);
z += *(int*)((int)p1 + a + 12);
// вычисление синуса будет происходить параллельно с загрузкой предыдущих
// ячеек за счет чего достигается увеличение производительности
x+=cos(x);
}
Листинг 27 [Memory/mem.mix.c] Фрагмент программы, демонстрирующей эффективность комбинирования обращения к памяти с выполнением вычислений
Прогон данной программы под системой AMD Athlon 1050/100/100/VIA KT133 (см. рис. graph 22) позволил установить, что наивысший параллелизм достигается, когда на каждые тридцать два загруженных байта приходится одна операция вычисления синуса. В этом случае оптимизированный вариант исполняется практически на треть быстрее. Еще больший выигрыш наблюдается на P-III/733/133/100/I815EP: при концентрации 256 байт на один синус, мы добиваемся чуть ли не двукратного увеличения производительности!
Правда, платой за это становится размер программы. Развернутый цикл из 64 команд загрузки данных смотрится, прямо скажем, диковато. Попытка же его свернуть съедает весь выигрыш производительности подчистую. Крайняя правая точка графика graph 22 (на диаграмме она отмечена символом звездочки) как раз и иллюстрирует такую ситуацию. Легко подсчитать, что на свертке цикла мы теряем не много, ни мало – 40% производительности, в результате чего оптимизированный вариант обгоняет не оптимизированный всего на 5%.
Впрочем, в подавляющем большинстве случаев быстродействие более критично, чем размер приложения, поэтому, никаких поводов для расстройства у нас нет.

Рисунок 41 graph 22 Демонстрация эффективности комбинирования вычислительных операций с командами, обращающихся к памяти
Конвейеризация или пропускная способность vs латентность
Начнем с того, что в конвейерных системах такого понятия как "время выполнения одной команды" просто нет. Уместно провести такую аналогию. Допустим, некоторый приборостроительный завод выпускает шестьсот микросхем памяти в час. Ответьте: сколько времени занимает производство одной микросхемы? Шесть секунд? Ну конечно же нет! Полный технологический цикл составляет не секунды, и даже не дни, а месяцы! Мы не замечаем этого лишь благодаря конвейеризациипроизводства, т.е. разбиении его на отдельные стадии, через которые в каждый момент времени проходит по крайней мере одна микросхема.
Количество продукции, сходящей с конвейера в единицу времени, называют его пропускной способностью. Легко показать, что пропускная способность в общем случае обратно пропорциональна длительности одной стадии, – действительно, чем короче каждая стадия, тем чаще продукция сходит с конвейера. При этом количество самых стадий (попросту говоря длина конвейера) не играет абсолютно никакой роли. Кстати, обратите внимание, что практически на всех заводах каждая стадия представляет собой элементарную операцию, – вроде "накинуть ключ на гайку" или "стукнуть молотком". И не только потому, что человек лучше приспосабливается к однообразной монотонной работе (наоборот, он, в отличии от автоматов, ее терпеть не может!). Элементарные операции, занимая чрезвычайно короткое время, обеспечиваю конвейеру максимальную пропускную способность.
Той же самой тактики придерживаются и производители процессоров, причем заметна ярко выраженная тенденция увеличения длины конвейера в каждой новой модели. Так, если в первых Pentium длина конвейера составляла всего пять стадий, то в уже Pentium-II она была увеличена до четырнадцати стадий, а в Pentium-4– и вовсе до двадцати. Такая мера была вызвана чрезмерным наращиванием тактовой частоты ядра процессора и вытекающей отсюда необходимости как-то заставить конвейер на этой частоте работать.
С одной стороны и хорошо, – конвейер крутится как угорелый, спуская до 6 микроинструкций за такт и какое нам собственно дело до его длины? А вот какое! Вернемся к нашей аналоги с приборостроительным заводом.
Допустим, захотелось нам запустить в производство новую модель. Вопрос: через какое время она сойдет с конвейера? (Бюрократическими проволочками можно пренебречь). Ни через шесть секунд, ни через час новая модель готова не будет и придется ждать пока весь технологический цикл не завершится целиком.
Латентность – т.е. полное время прохождения продукции по конвейеру, может быть и не критична для неповоротливого технологического процесса производства (действительно, новые модели микросхем не возникают каждый день), но весьма ощутимо влияет на быстродействие динамичного процессора, обрабатывающего неоднородный по своей природе код. Продвижение машинных инструкций по конвейеру сопряжено с рядом принципиальных трудностей, – то не готовы операнды, то занято исполнительное устройство, то встретился условный переход (что равносильно переориентации нашего приборостроительного завода на производство новой модели) и… вместо безостановочного вращения конвейера мы наблюдаем его длительные простои, лишь изредка прерываемые короткими рывками, а затем вновь покой и тишина.
В лучшем случае время выполнения одной инструкции определяется пропускной способностью конвейера, а в худшем – его латентностью. Поскольку пропускная способность и латентность различаются где-то на порядок или около того, бессмысленно говорить о среднем времени выполнения инструкции. Оно не будет соответствовать никакой физической действительности.
Малоприятным следствием становится невозможность определения реального времени исполнения компактного участка кода (если, конечно, не прибегать к эмуляции процессора). До тех пор, пока время выполнения участка кода не превысит латентность конвейера (30 тактов на P6), мы вообще ничего не сможем сказать ни о коде, ни о времени, ни о конвейере!
Конвейерная статическая память
Конвейерная статическая память представляет собой синхронную статическую память, оснащенную специальными "защелками", удерживающими линии данных, что позволяет читать (записывать) содержимое одной ячейки параллельно с передачей адреса другой.Так же, конвейерная память может обрабатывать несколько смежных ячеек за один рабочий цикл. Достаточно передать лишь адрес первой ячейки пакета, а адреса остальных микросхема вычислит самостоятельно, – только успевай подавать (забирать) записывание (считанные) данные!
За счет большей аппаратной сложности конвейерной памяти, время доступа к первой ячейке пакета увеличивается на один такт, однако, это практически не снижает производительности, т.к. все последующие ячейки пакета обрабатываются без задержек.
Конвейерная статическая память используется в частности в кэше второго уровня микропроцессоров Pentium-II и ее формула (см. "ЧастьI. Оперативная память. Устройство и принципы функционирования оперативной памяти Формула памяти ") выглядит так: 2 ? 1 ? 1 ? 1.
Краткая история создания данной книги
Настоящая книга задумывалась отнюдь не ради коммерческого успеха (который вообще сомнителен – ну много ли людей сегодня занимаются оптимизацией?), а писалась исключительно ради собственного удовольствия (как говорится: UTILE DULCI MISCERE – соединять приятно с полезным).История ее создания вкратце такова: после сдачи моих первых шести книг, обеспечивших мне более или менее сносное существование, у меня появилось возможность неторопливо, без лишней спешки заняться теми исследованиями, которые меня уже давно волновали.
Вплотную столкнувшись с необходимостью оптимизации еще во времена древних и ныне скоропостижно почивших 8-рязрядных машин, я скорее по привычке, чем по насущной необходимости, сохранил преданность эффективному коду и настоящих дней. А потому, не без оснований считал, что "собаку съел" на оптимизации и искренне надеялся, что писать на эту тему мне будет очень легко. Вот только бы выяснить несколько "темных" мест и объяснить некоторые странности поведения процессора, до анализа которых раньше просто не доходили руки...
…уже к концу первого месяца своих изысканий я понял, что если и "съел собаку", то тухлую как мамонт, ибо за последние несколько лет техника оптимизации кардинально поменялась, а сами процессоры так усложнились, что особенности их поведения судя по всему перестали понимать и сами отцы-разработчики. В общем, вместо трех изначально запланированных на книгу месяцев, работа над ней растянулась более чем на год, причем за это время не было выполнено и десятой части запланированного проекта.
Поверьте! Я вовсе не мячик пинал и каждый день проводил за компьютером как минимум по 12– 15 часов, благодаря чему большая часть прежде темных мест выступила из мрака и теперь ярко освещена! Быть может, это и не очень производительный труд (и вообще крайний низкий выход в пересчете на символ – в – час), тем не менее проделанной рабой в целом я остался доволен. И не удивительно! Изучать "железки" – это до жути интересно. Перед вами – черный ящик и все, что вы можете – планировать и осуществлять различные эксперименты, пытаясь описать их результаты некоторой математической моделью, проверяя и уточняя ее последующими экспериментами. И вот так из черного ящика постепенно начинают проступать его внутренности и Вы уже буквально "чувствуете" как он работает и "дышит"! А какое вселенское удовлетворение наступает, когда бессмысленная и совершенно нелогичная паутина результатов замеров наконец-то ложится в стройную картину! Чувство, охватывающее вас при этом, можно сравнить разве что с оргазмом!
Краткий экскурс с историю или ассемблер – это всегда весна
До середины девяностых борьба ассемблера с компиляторами шла с переменным успехом. Верх одерживала то одна, то другая сторона. После выхода новых, более быстродействующих микропроцессоров, интерес к ассемблеру на какое-то время угасал – всем казалось, что наконец-то настала та счастливая пора, когда для решения подавляющего большинства задач достаточно одних лишь языков высокого уровня и нет нужды корпеть над кодом, сражаясь за каждый такт. Впрочем, эйфория никогда не длилась долго, – вслед за возросшими процессорными мощностями усложнялись и возлагаемые на них задачи. Возможностей языков высокого уровня вновь катастрофически не хватало, и интерес к ассемблеру незамедлительно вспыхивал с новой силой.Практически ни один, сколь ни будь заметный проект тех лет, не обошелся без ассемблера. Ассемблерный код в изобилии присутствовал и в MS-DOS, и в Windows, и в Quake, и в Microsoft Office, и во многих других продуктах. Короче, слона шапками не закидать – плохой компилятор остается плохим и на быстрых процессорах, а потому рынок его сбыта будет сильно ограничен.
Жесточайшая конкуренция разработчиков компиляторов, в конечном счете, обернулась благом (и в первую очередь – для прикладных программистов), ибо привела к интенсивному совершенствованию алгоритмов машинной оптимизации. Уже к концу девяностых качество оптимизирующих компиляторов практически достигло теоретического идеала, сравнявшись в решении штатных задач с программистами средней квалификации.
Сегодня, когда редкий программист обходится без визуальных средств разработки и даже "чистые" высокоуровневые языки выходят из моды, ассемблер и вовсе выглядит архаичным рудиментом, задвинутым на одну полку с перфоратором и ламповой ЭВМ. Между тем, слухи о его скорой смерти сильно преувеличены. Ассемблер жив! И лучшее подтверждение этому – тот факт, что основным языком разработки драйверов Microsoft объявляет именно его, ассемблер, а вовсе не Си\Си++ или, скажем, Паскаль. Без ассемблера (или специализированных компиляторов) невозможно использовать преимущества новых мультимедийных команд параллельной обработки данных. Наконец, ассемблер по-прежнему незаменим в создании высокопроизводительных математических и графических библиотек. (Кстати, загляните в каталог CRT\SRC\Intel дистибьютива Microsoft Visual С++ – и здесь не обошлось без ассемблера, практически все функции, работающий со строками, реализованы именно на нем).
В общем, ситуация с вживлением ассемблера стабилизировалась, и на оставшуюся у него вотчину языки высокого уровня более не претендуют.
Краткий обзор современных профилировщиков
Существует не так уж и много профилировщиков, поэтому особой проблемы выбора у программистов и нет.Если оптимизация – не ваш основной "хлеб", и эффективность для вас вообще не критична – вам подойдет практически любой профилировщик, например тот, что уже включен в ваш компилятор. Более сложные профилировщики для вас окажутся лишь обузой и вы все равно не разглядите заложенный в них потенциал, поскольку это требует глубоких знаний архитектуры процессора и всего компьютера в целом.
Если же вы всерьез озабоченны производительность и качеством оптимизации ваших программ и планируете посвятить профилировке значительное время, то кроме IntelVTune и AMD Code Analyst вам ничто не поможет. Обратите внимание: не "Intel VTune или AMD Code Analyst", а именно "Intel VTune и AMD Code Analyst". Оба этих профилировщика поддерживают процессоры исключительно "своих" фирм и потому использование лишь одного из них – не позволит оптимизировать программу более, чем наполовину.
Тем не менее, далеко не все читатели знакомы с этими продуктами и потому автор не может удержаться от соблазна, чтобы хотя бы кратко не описать их основные возможности.
Краткое описание профилировщика DoCPUClock
Подробное описание профилировщика DoCPU Clock содержится в Приложении ???, здесь же мы рассмотрим лишь его базовые функции да и то кратко.Сердцем профилировщика являются всего две функции A1 и A2, которые, используя команду RDTSC, с высокой точностью определяют время выполнения исследуемого фрагмента программы. Функция A1 принимает единственный аргумент – указатель на переменную типа unsigned int, в которую и записывает текущее значение регистра времени. Функция A2 возвращает разницу значений переданного ей аргумента и регистра времени, при этом сам аргумент остается в полной неприкосновенности.
Рассмотрим простейший пример их использования:
Критерии оценки качества машинной оптимизации
Основные критерии качества кода это: быстродействие, компактностьи время, затраченное на его разработку. Причем, оптимизация программы по всем трем критериям невозможна в принципе. В частности, при выравнивании кода и структур данных по кратным адресам, производительность программы возрастает, но вместе с нею увеличивается и ее размер.
Считается, что скорость – более приоритетная характеристика, нежели объем. Сегодня, когда количество оперативной памяти измеряется сотнями мегабайт, а емкость диска – сотнями гигабайт, компактность программного кода, действительно, уже не столь критична, однако, конечному пользователю отнюдь не все равно: сколько мегабайт занимает программа – один или миллион. Поэтому, практически все современные компиляторы поддерживают как минимум два режима оптимизации: maximum speed и minimum space.
Однако скрупулезное тестирование не входит в наши планы. Оставим это на откуп читателям, а сами ограничимся компромиссным режимом максимальной оптимизации, пытающимся достичь наивысшей скорости при наименьшем объеме. Именно такой режим и используется в подавляющем большинстве случаев, а потому он наиболее интересен.
Отметим также, что манипулирование ключами тонкой настойки оптимизатора может значительно изменить результаты тестирования, причем, как в худшую, так и в лучшую сторону. Но в этом случае будут уже сравниваться не сами оптимизаторы, искусство их настройки, а это – тема совершенно другого разговора.
Важно понять: точно оценить общее
качество оптимизации на частных
случаях невозможно. Вот, например, MicrosoftVisual C++ умеет подменять константное деление умножением, (что в десятки раз быстрее!) а Borland C++ – нет. В зависимости от того, встречается ли константное деление в оптимизируемой программе или нет, соответствующим образом будет варьироваться и разница в быстродействии кода, сгенерированного обоими компиляторами.
Поэтому, в отсутствии конкретного примера, можно говорить лишь о приблизительной, прикидочной оценке качества компиляторов. Позднее (см. статью "Сравнительный анализ оптимизирующих компиляторов языка Си\Си++", опубликованную в N… журнала "Программист") мы рассмотрим этот вопрос во всех подробностях, а сейчас же нас в первую очередь будет интересовать усредненное качество машинной оптимизации на примере типовых алгоритмов.
Конечно, понятие "типовой алгоритм" очень относительное и субъективное. Для одних программистов, например, показательно Фурье-преобразование, другие же в своей практике могут и вовсе не сталкиваются с вещественной арифметикой. Нижеследующий выбор заведомо нерепрезентативен, но… определенную пищу для размышлений он все-таки дает.
Методики оценки качества машинной оптимизации
Задача оценки качества кодогенерации намного сложнее, чем может показаться на первый взгляд. Прежде всего, следует разделять, собственно, сам компилятор и его окружение (среду, библиотеки и т.д.). В частности совершенно некорректно сравнивать размер откомпилированного примера "Hello, World" с его ассемблерной реализацией. Вызов 'printf("xxx");' компилятор транслирует приблизительно в следующий код: "push offset xxx\call printf\pop eax" – круче уже не оптимизируешь!Обратите внимание на размер объективного файла, сформированного компилятором. Не правда ли, он мало в чем не уступает объективному файлу ассемблерной реализации? Конечно, после подключения всех необходимых библиотек, размер откомпилированного файла увеличивается в десятки раз, в то время как объем ассемблерного модуля практически не изменяется. Да, это так, но при чем здесь компилятор?! Ему встретился вызов printf, – он и включил его в объективный файл. Если бы программист захотел вывести строку напрямую, – через соответствующую API функцию операционной системы (как он поступил в ассемблерной реализации), исполняемый файл сразу бы похудел на десяток-другой килобайт. Что еще остается? Ах да, среда, называемая так же RTL (Run Time Library
– библиотека времени исполнения), – служебные функции, вызываемые самим компилятором. Несмотря на то, что библиотеки времени исполнения являются неотъемлемым компонентом компилятора, к качеству кодогенерации они не имеют никакого отношения, т.к. с точки зрения компилятора функции RTL ничем не отличаются от обычных библиотечных функций.
Избыточность штатных библиотечных функций и библиотеки времени исполнения на крохотных проектах очевидна, – вывод строки "Hello, World" не использует и сотой доли возможностей функции printf, но в программе, состоящей из нескольких тысяч строк, соотношение между полезными и служебными функциями нормализуется и коэффициент полезного действия библиотек практически вплотную приближается к единице.
Таким образом, сравнивать эффективность компилятора и ассемблера на примере библиотечных функций – это вопиющая некорректность. Следует рассматривать лишь чистые реализации, не обращающиеся к внешнему коду. В противном случае, будет сравниваться не качество машинной и ручной оптимизации, а совершенство библиотек (написанных, кстати, в большинстве своем на ассемблере) с ручной оптимизацией. Совершенно бесполезно сравнивать и размеры объективных файлов, – помимо кода они содержат массу посторонней информации, причем в рассматриваемых ниже примерах ее объем превышает размер машинного кода в десятки раз!
Истинную картину вещей дает лишь дизассемблер, – загружаем в него объективный или исполняемый – без разницы – файл и от адреса конца функции вычитаем адрес ее начала. Полученная разность и будет подлинным размером исследуемого кода.
Измерять производительность еще проще – достаточно засечь время выполнения функции и… Правда, тут есть одно "но". Если уж мы взялись оценивать именно качество кодогенерации, а не быстродействие компьютера, следует учесть, и по возможности свести к нулю, все посторонние эффекты. Во-первых, к моменту вызова функции все, обрабатываемые ей данные, должны целиком находиться в кэше первого уровня, иначе неповоротливость памяти сотрет все различия в производительности тестируемого кода. Во-вторых, размер обрабатываемых данных должен быть достаточно велик для того, чтобы замаскировать накладные расходы на вызов функции, передачу аргументов, снятие показаний со счетчика производительности и т.д. Все нижеследующие примеры обрабатывают 4.000 элементов типа int, – это дает стабильный и хорошо воспроизводимый результат, т.к. "насыщение" наступает уже на 1.000 элементах, после чего накладные расходы уже не играют сколь ни будь заметной роли.
Microsoft Profile.exe

Рисунок 5 0x008 "Визитная карточка" профилировщика Microsoft Profile.exe
Профилировщик Microsoft Profile.exe настолько прост и незатейлив, что даже не имеет собственного имени и нам, на протяжении всей книги, придется называет его по имени исполняемого файла.
Profile.exe – чрезвычайно простой и минимально функциональный профилировщик, попадающий в этот обзор лишь потому, что он входит в штатную поставку компилятора Microsoft Visual C++ (редакции: Professional и Enterprise), а потому достается большинству из нас практически даров, в то время как остальные профилировщики приходится где-то искать/скачивать/покупать.
Основные его возможности перечислены в таблице ???, приведенной ниже.
Наглядная демонстрация качества машинной оптимизации
Разговор о качестве машинной оптимизации был бы неполным без конкретных примеров. Показатели производительности – слишком абстрактные величины. Они дают пищу для размышлений, но не объясняют: почему откомпилированный код оказался хуже. Как говорится, пока не увижу своими глазами, пока не пощупаю своими руками – не поверю!Что ж, такая возможность у нас есть! Давайте изучим непосредственно сам ассемблерный код, сгенерированный компилятором. Для экономии бумажного пространства ниже приведен лишь один пример – результат компиляции программы пузырьковой сортировки компилятором MicrosoftVisual C++. Пример довольно показательный, ибо ощутимо улучшить его качество, не вывихнув при этом себе мозги, практически невозможно. Да вы смотрите сами. Если не владеете ассемблером – не расстраивайтесь, в листинге присутствуют подробные комментарии (надеюсь, вы понимаете, что их вставил не компилятор?).
.text:004013E0 ; ---------- S U B
R
O
U
T
I
N
E
----------
.text:004013E0
.text:004013E0
.text:004013E0 с_sort proc near ; CODE XREF: sub_401420+DAp
.text:004013E0
.text:004013E0 arg_0 = dword ptr 8
.text:004013E0 arg_4 = dword ptr 0Ch
.text:004013E0
.text:004013E0 push ebx
.text:004013E0 ; сохраняем регистр EBX, т.к. функция обязана сохранять
.text:004013E0 ; модифицируемые регистры, иначе программа рухнет
.text:004013E0 ;
.text:004013E1 mov ebx, [esp+arg_4]
.text:004013E1 ; загружаем в ebx самый правый аргумент – количество элементов
.text:004013E1 ; точно так поступил бы и человек
.text:004013E1 ; ага, локальные переменные адресуются непосредственно через ESP
.text:004013E1 ; это экономит один регистр (EBP), высвобождая его для других нужд
.text:004013E1 ; человек так не умеет… вернее, не то, что бы совсем не умеет,
.text:004013E1 ; но адресация через ESP
заставляет заново вычислять
.text:004013E1 ; местоположение локальных переменных при всяком перемещении
.text:004013E1 ; верхушки стека (в частности при передаче функции аргументов),
.text:004013E1 ; что ну очень утомительно…
.text:004013E1 ;
.text:004013E5 push ebp
.text:004013E6 push esi
.text:004013E6 ; сохраняем еще два регистра
.text:004013E6 ; вообще-то, человек мог воспользоваться и командой PUSHA,
.text:004013E6 ; сохраняющей все регистры общего назначения, что было бы
.text:004013E6 ; намного короче, но в то же время, увеличило бы потребности
.text:004013E6 ; программы в стековом пространстве и несколько снизило бы
.text:004013E6 ; ее скорость
.text:004013E6 ;
.text:004013E7 cmp ebx, 2
.text:004013E7 ; сравниваем значение аргумента n
с константой 2
.text:004013E7 ;
.text:004013EA push edi
.text:004013EB jl short loc_40141B
.text:004013EB ; а вот здесь пошла оптимизация кода под ранние процессоры P-P MMX
.text:004013EB ; сравнение содержимого EBX
и анализ результата сравнения разделен
.text:004013EB ; командой сохранения регистра EDI. Дело в том, что P-P MMX
.text:004013EB ; могли спаривать команды, если они имели зависимость по данным
.text:004013EB ; впрочем, в данном конкретном случае такая оптимизация излишня
.text:004013EB ; т.к. процессор пытается предсказать направление перехода еще до
.text:004013EB ; его реального исполнения. Впрочем, перестановка команд –
.text:004013EB ; карман не тянет и никому не мешает
.text:004013EB ;
.text:004013ED mov ebp, [esp+0Ch+arg_0]
.text:004013ED ; загружаем в EBP значение кране левого аргумента –
.text:004013ED ; указателя на сортируемый массив. Как уже сказано, так
.text:004013ED ; адресовать аргументы умеют только компиляторы,
.text:004013ED ; человеку это не под силу
.text:004013ED ;
.text:004013F1
.text:004013F1 loc_4013F1: ; CODE XREF: C_Sort+39j
.text:004013F1 ; цикл начинается с нечетного адреса. Плохо!
.text:004013F1 ; это весьма пагубно сказывается на производительность
.text:004013F1 ;
.text:004013F1 xor esi, esi
.text:004013F1 ; очищаем регистр ESI, выполняя логического XOR
над ним самим
.text:004013F1 ; вот на это человек – способен!
.text:004013F1 ;
.text:004013F3 cmp ebx, 1
.text:004013F6 jle short loc_40141B
.text:004013F6 ; эти две команды лишние.
.text:004013F6 ; Для человека очевидно, если EBX
>= 2, то он всегда больше одного
.text:004013F6 ; А вот для компилятора это – вовсе не факт! (Ну темный он)
.text:004013F6 ; Дело в том, что он превратил возрастающий цикл for
.text:004013F6 ; в убывающий цикл do/while с пост условием
.text:004013F6 ; /* убывающие циклы с постусловием реализуются на
.text:004013F6 ; х86-процессорах намного более эффективно */
.text:004013F6 ; но для этого компилятор должен быть уверен, что цикл
.text:004013F6 ; исполняется хотя бы раз – вот он и помещает
.text:004013F6 ; в код дополнительную и абсолютно лишнюю в данном случае
.text:004013F6 ; проверку. Впрочем, она не отнимает много времени
.text:004013F6 ;
.text:004013F8 lea eax, [ebp+4]
.text:004013F8 ; быстрое сложить EBP
с 4 и записать результат в EAX
.text:004013F8 ; об этом трюке знают далеко не все программисты,
.text:004013F8 ; и обычно выполняют данную задачу в два этапа
.text:004013F8 ; MOV EAX, EBX\ADD EAX, 4
.text:004013F8 ;
.text:004013FB lea edi, [ebx-1]
.text:004013FB ; быстро вычесть из EBX
единицу и записать результат в EDI
.text:004013FB ;
.text:004013FE
.text:004013FE loc_4013FE: ; CODE XREF: C_Sort+35j
.text:004013FE mov ecx, [eax-4]
.text:004013FE ; Оп
с! Команда, расположенная в начале цикла пересекает
.text:004013FE ; границу 0x10 байт, что приводит к ощутимым задержка исполнения
.text:004013FE ; Да, забыл сказать, эта инструкция загружает ячейку src[a-1]
.text:004013FE ; в регистр ECX
.text:004013FE ;
.text:00401401 mov edx, [eax]
.text:00401401 ; загружаем ячейку src[a] в регистр EDX
.text:00401401 ;
.text:00401403 cmp ecx, edx
.text:00401403 ; сравниваем ECX (src[a-1]) и
EDX (src[a])
.text:00401403 ; вообще-то, это можно было реализовать и короче
.text:00401403 ; CMP ECX, [EAX], избавлюсь от команды MOV EDX, [EAX]
.text:00401403 ; однако, это ни к чему, т.к. значение [EAX] нам потребуется ниже
.text:00401403 ; при обмене переменными и эта команда все равно бы вылезла там.
.text:00401403 ;
.text:00401405 jle short loc_401411
.text:00401405 ; переход на ветку loc_401411, если ECX
<= EDX
.text:00401405 ; в противном случае – выполнить обмен ячеек
.text:00401405 ;
.text:00401407 mov [eax-4], edx
.text:0040140A mov [eax], ecx
.text:0040140A ; обмен
ячейками. Вообще-то, можно было бы реализовать это и
.text:0040140A ; через XCHG, что было бы на несколько байт короче, но у этой
.text:0040140A ; инструкции свои проблемы – не на всех процессорах она работает
.text:0040140A ; быстрее…
.text:0040140A ;
.text:0040140C mov esi, 1
.text:0040140C ; устанавливаем ESI (флаг f) в
.text:0040140C ; человек мог бы сократить это на несколько байт, записав это так:
.text:0040140C ; MOV ESI, ECX. Поскольку ECX
> EDX, то ECX
!=0,
.text:0040140C ; при условии, конечно, что EDX
>= 0.
.text:0040140C ; разумеется, компиляторам такое не по зубам, однако, это
.text:0040140C ; алгоритмическая оптимизация и к качеству кодогенерации она
.text:0040140C ; не имеет никакого отношения
.text:0040140C ;
.text:00401411 loc_401411: ; CODE XREF: C_Sort+25j
.text:00401411 add eax, 4
.text:00401411 ; увеличиваем EAX (a) на
4 (sizeof(int))
.text:00401411 ;
.text:00401414 dec edi
.text:00401414 ; уменьшаем счетчик цикла на единицу
.text:00401414 ; (напоминаю, компилятор превратил наш for
в убывающий цикл)
.text:00401414 ;
.text:00401415 jnz short loc_4013FE
.text:00401415 ; переход к началу цикла, пока EDI
не равен нулю
.text:00401415 ; некоторые человеки здесь лепят LOOP, который хоть и компактнее
.text:00401415 ; но исполняется значительно медленнее
.text:00401415 ;
.text:00401417 test esi, esi
.text:00401417 ; проверка флага f на равенство нулю
.text:00401417 ;
.text:00401419 jnz short loc_4013F1
.text:00401419 ; повторять цикл, пока f
равен нулю
.text:0040141B
.text:0040141B loc_40141B:
.text:0040141B pop edi
.text:0040141C pop esi
.text:0040141D pop ebp
.text:0040141E pop ebx
.text:0040141E ; восстанавливаем все измененные регистры
.text:0040141E ;
.text:0040141F retn
.text:0040141F ; выходим из функции
.text:0040141F C_Sort endp
.text:0040141F
Итак, какие у противников компиляторов есть претензии к качеству кода? Смогли бы они реализовать эту же процедуру хотя бы процентов на десять эффективнее? Согласитесь, здесь есть чему поучиться даже весьма квалифицированным программистам!
Непостоянства времени выполнения
Если вы профилировали приложения и раньше, то наверняка сталкивались с тем, что результаты измерений времени выполнения варьируются от прогона к прогону, порой отличаясь один от другого более, чем значительно.Причин такого непостоянства существует по меньшей мере две: программное непостоянство, связанное с тем, что в многозадачных операционных системах (в частности в Windows) профилируемая программа попадает под влияние чрезвычайно изменчивой окружающей среды, и аппаратное непостоянство, вызванное внутренней "многозадачностью" самого железа.
В силу огромной их значимости для результатов профилировке, обе этих причины ниже будет рассмотрены во всех подробностях.
Несколько советов по измерению производительности
1) "выкидывание" неиспользуемых переменных2) размещение сравниваемых фрагментов в "своих" функциях
Неточность измерений
Одно из фундаментальных отличий цифровой от аналоговой техники заключается в том, что верхняя граница точности цифровых измерений определяется точностью измерительного инструмента (точность аналоговых измерительных инструментов, напротив, растет с увеличением количества замеров).А чем можно измерять время работы отдельных участков программы? В персональных компьютерах семейства IBM PC AT имеются как минимум два таких механизма: системный таймер (штатная скорость: 18,2 тика в секунду, т.е. 55 мсек., максимальная скорость – 1.193.180 тиков в секунду или 0,84 мсек.), часы реального времени (скорость 1024 тика в секунду т.е. 0,98 мсек.). В дополнении к этому в процессорах семейства Pentium появился так называемый регистр счетчик - времени (Time Stamp Counter), увеличивающийся на единицу каждый такт процессорного ядра.
Теперь – разберемся со всем этим хозяйством подробнее. Системный таймер (с учетом времени, расходующего на считывание показаний) обеспечивает точность порядка 5 мсек., что составляет более двух десятков тысяч тактов даже в 500 MHz системе! Это – целая вечность для процессора. За это время он успевает перемолотить море данных, скажем, отсортировать сотни полторы чисел. Поэтому, системный таймер для профилирования отдельных функций непригоден. В частности, нечего и надеяться с его помощью найти узкие места функции quick sort! Да что там узкие места – при небольшом количестве обрабатываемых чисел он и общее время сортировки определяет весьма неуверенно.
Причем, прямого доступа к системному таймеру под нормальной операционной системой никто не даст, а минимальный временной интервал, еще засекаемый штатной Си-функций clock(), составляет всего лишь 1/100 сек, – а это годиться разве что для измерения времени выполнения всей программы целиком.
Точность часов реального времени так же вообще не идет ни в какое сравнение с точность системного таймера (перепрограммированного, разумеется).
Остается надеяться лишь на Time Stamp Counter.
Первое знакомство с ним вызывает просто бурю восторга и восхищения "ну наконец-то Intel подарила нам то, о чем мы так долго мечтали!". Судите сами: во-первых, операционные системы семейства Windows (в том числе и "драконическая" в этом плане NT) предоставляют беспрепятственный доступ к машинной команде RDTSC, читающий содержимое данного регистра. Во-вторых, поскольку он инкрементируется каждый такт, создается обманчивое впечатление, что с его помощью возможно точно определять время выполнения каждой команды процессора. Увы! На самом же деле это не далеко так!
Начнем с того, что в конвейерных системах (как мы уже помним) вообще нет такого понятия как время выполнения команды и следует говорить либо о пропускной способности, либо латентности. Сразу же возникает вопрос: а что же все-таки RDTSC меряет? Документация Intel не дает прямого ответа, но судя по всему, RDTSC считывает содержимое регистра счетчика-времени в момент прохождения данной инструкции через соответствующее исполнительное устройство. Причем, RDTSC – это неупорядоченная команда, т.е. она может завешаться даже раньше предшествующих ей команд. Именно так и произойдет если предшествующая команда простаивает в ожидании операндов.
Рассмотрим крайний случай, когда измеряется время выполнения минимальной порции кода (одна машинная команда уходит на то, чтобы сохранить считанное значение в первой итерации):
RDTSC ; читаем значение регистра времени
; и помещаем его в регистры EDX и EAX
MOV [clock], EAX ; сохраняем младшее двойное слово
; регистра времени в переменную clock
RDTSC ; читаем регистр времени еще раз
SUB EAX, [clock] ; вычисляем разницу замеров между
; первым и вторым замером
Листинг 5 Попытка замера времени выполнения одной машинной команды
При прогоне этого примера на P-III он выдаст 32 тактов, вызывая тем самым риторический вопрос: "а почему, собственно, так много?" Хорошо, оставим выяснение обстоятельств "похищения процессорных тактов до лучших времени", а сейчас попробуем измерять время выполнения какой-нибудь машинной команды, ну скажем, INC EAX, увеличивающий значение регистра EAX на единицу.
Поместим ее между инструкциями RDTSC и перекомпилируем программу.
Вот это да! Прогон показывает все те же 32 такта. Странно! Добавим-ка мы еще одну INC EAX. Как это так – снова 32 такта?! Зато при добавлении сразу трех
инструкций INC EAX контрольное время исполнения скачкообразно увеличивается на единицу, что составляет 33 такта. Четыре и пять инструкций INC EAX дадут аналогичный результат, а вот при добавлении шестой, результат изменений вновь подскакивает на один такт.
Но ведь процессор Pentium, имея в наличии всего лишь одно арифметическое устройство, никак не может выполнять более одного сложения за такт одновременно! Полученное нами значение в три команды за такт – это скорость их декодирования, но отнюдь не исполнения! Чтобы убедиться в этом запустим на выполнение следующий цикл:
MOV ECX,1000 ; поместить в регистр ECX значение 1000
@for: ; метка начала цикла
INC EAX ;\
INC EAX ; +- одна группа профилируемых инструкций
INC EAX ;/
INC EAX ;\
INC EAX ; +- вторая группа профилируемых инструкций
INC EAX ;/
DEC ECX ; уменьшить значение регистра ECX на 1
; (здесь он используется в качестве
; счетчика цикла)
JNZ @xxx ; до тех пор, пока ECX не обратится в ноль
; перепрыгивать на метку @for
Листинг 6 Измерение времени выполнения 6х1000 машинных команд INC
На P-III выполнение данного цикла займет отнюдь не ~2.000, а целых 6.781 тактов, что соответствует по меньшей мере одному такту, приходящемуся на каждую математическую инструкцию! Следовательно, в предыдущем случае, при измерении времени выполнения нескольких машинных команд, инструкция RDTSC "вперед батьки пролезла в пекло", сообщив нам совсем не тот результат, которого мы от нее ожидали!
Вот если бы существовал способ "притормозить" выполнение RDTSC до тех пор, пока полностью не будут завершены все предшествующие ей машинные инструкции… И такой способ есть! Достаточно поместить перед RDTSC одну из команд упорядоченного выполнения. Команды упорядоченного выполнения начинают обрабатываться только после схода с конвейера последней предшествующей ей неупорядоченной команды и, покуда команда упорядоченного выполнения не завершится, следующие за ней команды мирно дожидаются своей очереди, а не лезут как толпа дикарей вперед на конвейер.
Подавляющее большинство команд упорядоченного выполнения – это привилегированные
команды (например, инструкции чтения/записи портов ввода-вывода) и лишь очень немногие из них доступны с прикладного уровня. В частности, к таковым принадлежит инструкция идентификации процессора CPUID.
Многие руководства (в частности и Ангер Фог в своем руководстве "How to optimize for the Pentium family of microprocessors" и технический документ "Using the RDTSC Instruction for Performance Monitoring" от корпорации Intel) предлагают использовать приблизительно такой код:
XOR EAX,EAX ; вызываем машинную команду CPUID,
CPUID ; после выполнения которой все
; предыдущие машинные инструкции
; гарантированно сошли к конвейера
; и потому никак не могут повлиять
; на результаты наших замеров
RDTSC ; вызываем инструкцию RDTSC, которая
; возвращает в регистре EAX младшее
; двойное слово текущего значения
; Time Stamp Counter 'a
MOV [clock],EAX ; сохраняем полученное только что
; значение в переменной clock
// … ;\
// профилируемый код ; +-здесь исполняется профилируемый код
// … ;/
XOR EAX,EAX ; еще раз выполняем команду CPUID,
CPUID ; чтобы все предыдущие инструкции
; гарантированно успели покинуть
; конвейер
RDTSC ; вызываем инструкцию RDTSC для чтения
; нового значение Time Stamp Count 'a
SUB EAX,[clock] ; вычисляем разность второго и первого
; замеров, тем самым определяя реальное
; время выполнения профилируемого
; фрагмента кода
Листинг 7 Официально рекомендованный способ вызова RDTSC для измерения времени выполнения
К сожалению, даже этот, официально рекомендованный, код все равно не годится для измерения времени выполнения отдельных инструкций, поскольку полученный с его помощью результат представляет собой полное время выполнения инструкции, т.е. ее латентность, а отнюдь не пропускную способность, которая нас интересует больше всего. (Кстати, и у Intel, и Фрога есть одна грубая ошибка – в их варианте программы перед инструкцией CPUID отсутствует явное задание аргумента, который CPUID ожидает увидеть в регистре EAX. А, поскольку, время ее выполнения зависит от аргумента, то и время выполнения профилируемого фрагмента не постоянно, а зависит от состояния регистров на входе и выходе. В предлагаемом много варианте, инициализация EAX осуществляется явно, что страхует профилировщик от всяких там наведенных эффектов).
Имеется и другая проблема, еще более серьезная, чем первая. Вы помните постулат квантовой физики, сводящийся к тому, что всякое измерение свойств объекта неизбежно вносит в этот объект изменения, искажающие результат измерений? Причем, эти искажения невозможно устранить простой калибровкой, поскольку изменения могут носить не только количественный, но и качественный характер.
Если профилируемый код задействует те же самые узлы процессора, что и команды RDTSC/CPUID, время его выполнения окажется совсем иным нежели в "живой" действительности! Никаким ухищрениями нам не удастся достигнуть точности измерений до одного–двух тактов!
Поэтому, минимальный промежуток времени, которому еще можно верить, составляет, как показывает практика и личный опыт автора, по меньше мере пятьдесят – сто тактов.
Отсюда следствие: штатными средствами процессора измерять время выполнения отдельных команд невозможно.
Низкая "разрешающая способность"
Учитывая, что пропускная способность большинства инструкций составляет всего один такт, а минимальный промежуток времени, который еще можно измерять, находится в районе пятидесяти – ста тактов, предельная разрешающая способность не эмулирующих профилировщиков не превышает полста команд.Под "разрешающей способностью" здесь и далее понимается протяженность "горячей" точки более или менее уверенно распознаваемой профилировщиком.
Строго говоря, не эмулирующие профилировщики показывают не саму горячую точку, а некоторую протяжную область к которой эта "горячая" точка принадлежит.
О чем и для кого предназначена эта книга
Настоящая книга описывает устройство и механизмы взаимодействия различных компонентов компьютера и рассказывает об эффективных приемах программирования и технике оптимизации программ, как на уровне машинного кода, так и на уровне структур данных.Она ориентирована на прикладных программистов, владеющих (хотя бы в минимальном объеме) языком Си, а так же на системных программистов, знающих ассемблер. Описываемые техники не привязаны ни к какому языку и знание Си требуется лишь для чтения исходных текстов примеров, приведенных в книге.
В не меньшей степени "Техника оптимизации" будет интересна и лицам, занимающимся сборкой и настройкой компьютеров, поскольку подробно описывает устройство "железа" и разбирает "узкие места" распространенных моделей комплектующих.
В основу данной книги положена уникальная информация и методики, разработанные лично автором. Информация, почерпнутая из технической документации производителей комплектующих, операционных систем и компиляторов, тщательно проверена, в результате чего обнаружено большое количество ошибок, на которые и обращается внимание читателя (тем не менее, автор не гарантирует отсутствие вторичных и "наведенных" ошибок в самой книге).
Материал книги в основном ориентирован на микропроцессоры AMDAthlon и Intel Pentium-II, Pentium-III и Pentium-4, но местами описываются и более ранние процессоры.
Хотите заглянуть внутрь черного ящика подсистемы оперативной памяти? Хотите узнать, что чувствует, как "дышит" и какими мыслями живет каждая микросхема вашего компьютера? Тогда, вы не ошиблись в выборе книги!
Перед вами лежит уникальное практическое пособие по оптимизации программ под платформу IBM PC и операционные системы семейства Windows и UNIX. Его уникальность заключается в том, что оно показывает как эффективно реализовать на языке высокого уровня те трюки и приемы, которые всеми остальными руководствами осуществляются на ассемблере.
Это не только пособие по оптимизации программ, но и введение в философию компьютерного "железа".
О серии книг "Оптимизация"
Ну вот, – воскликнет, иной читатель, – опять серия! Да сколько же можно этому Касперски объявлять серий?! И что интересно: все серии разные! Выпущен первый том "Техники сетевых атак", но вот уже года три как нет второго. Объявлен трехтомник "Образ мышления – IDA", но до сих пор вышли только первый и третий тома книги, а второго по?прежнему нет и в обозримом будущем даже и не предвидится. Наконец, "Фундаментальные Основы Хакерства. Искусство Дизассемблирования"– это вновь всего лишь первый том! Так не лучше ли автору сконцентрироваться на чем ни будь одном, а не метаться по всей предметной области?Кто-то даже сравнил меня с Кнутом – мол обещал семь томов, а выпустил только три. Но если Кнут выпустил три тома одной книги, то Касперски – по одному тому трех разных. Ситуация осложняется тем, что мой издатель по маркетинговым соображением наотрез отказался называть настоящую книгу томом. Хорошо, пусть это будет не "том", пусть это будет "первая книга серия". (Суть дела от этого все равно меняется).
Предвидя встречный вопрос читателя о причинах неполноты охвата информации (действительно, настоящий том ограничивается исключительно одной оперативной памятью и ни слова не обмолвливается о таких важных аспектах оптимизации как, например, планирование потока команд или векторизации вычислений), считаю своим долгом заявить, что в одной-единственной книге просто невозможно изложить все аспекты техники оптимизации и уж лучше подробно освещение узкого круга вопросов, чем поверхностный рассказ обо всем.
К тому же, с выпуском данной книги, работа над "Оптимизацией" не прекращается и на будущее запланировано как минимум пять томов. Ниже все они перечислены в предполагаемом хронологическом порядке.
Об одном подходе к решению задач…
Все школьные приемы решения задач, так или иначе, сводятся к тому, что сначала каллиграфическим почерком ученик выписывает «Дано», потом «Найти», затем «Решение». Последнее обычно сопровождается ожесточенным покусываем авторучки, в поисках оптимального разбиения задачи на более мелкие, не представляющие по отдельности никакой трудности. То есть, говоря математическим языком, - решения с помощью декомпозиции.Такой подход оптимален для школьных задач потому, что большинство из них составлялось композиционным образом, - то есть, автором учебника бралась за отправную точку некая идея (формула, теорема), а поверх насыпался добрый слой щебенки, который учащийся и должен был разобрать, проделывая обратный путь.
В жизни же подобной искусственности нет, и наиболее трудным моментом в решении задачи является (ну кто бы об этом мог подумать в школе!) именно определение что же именно нам дано, и что требуется найти.
Грубо говоря, часто бывает легче приложить усилия по получению дополнительных исходных данных, чем решать возникшую проблему в существующем виде. Точно так же можно изменить даже искомую величину, и выбрать другую, связанную с требуемой простой и легко решаемой зависимостью.
Например, как быстро в уме вычислить, сколько нечетных дней каждого месяца существует в году? Но только не делите 365 на два, потому что это очевидно неверное решение. Ведь нумерация дней в году идет не последовательно от одного до 365, а разбита на списки, в каждом из которых может находиться 28, 29, 30 или 31 дней. То есть, в одном случае за 30 днем месяца, наступает 1 число следующего, а в другом два нечетных числа «слипаются» если после 31 числа идет 1.
Хм, очевидно, что нечетных дней будет ощутимо больше. Но насколько больше? Давайте посчитаем! Так, «длинные» месяцы – Январь, Март, Май, Июль, Август, Октябрь, Декабрь. Итого, выходит нечетных чисел должно быть на семь больше.
Составим простое уравнение x+x-7==365, отсюда 2х==372, x=186. Гм, но нет ли более короткого решения (мы все же пытаемся это считать в уме!).
А почему бы и нет? Необходимо только чуть-чуть ( на время) изменить условия задачи. Попробуем найти количество четных
дней. В самом деле, оно (за исключением февраля) всегда постоянно и в каждом месяце равно пятнадцати. А в феврале, стало быть, четырнадцати. Умножим пятнадцать на десять и добавим еще двадцать девять, - получается 179. Не правда ли просто? А теперь несложно догадаться, что оставшиеся дни в году и будут искомыми нечетными!
Но в чем преимущество такого решения? Вспомним, что в условии не было оговорено, какой нас интересует год – простой или високосный? А, в самом деле, если ли разница? В первом решении да, ибо тогда формула должна была бы принять вид x+x-8==366. Но… посмотрите, что получается во втором случае – x==366-179! Или, если это записать в другом виде, x==Число_дней_в_году
– Константа_179.
Вряд ли можно усомниться, что последнее решение элегантнее. А первое и вовсе не верно. Почему? А разве нам кто-то оговаривал, какой именно требуется год? Нет же, верно? Следовательно, продолжительность его равна Y, а вовсе не 365 дней. Тогда… тогда первым уравнением задача не решается.
Кстати, говорят, что программист отличается от простого смертного тем, что пытается проверить задачу на всех, в том числе и бессмысленных, входных значениях. В нашем случае, оговаривается, что Y может быть принимать только два значения – либо 365, либо 366. Любое другое приведет к бессмысленному результату.
Выходит, что задача решена не в общем, а в частом виде? А каково количество нечетных дней на N-ый день произвольного месяца? То есть, сколько их будет, скажем от первого января 1990 года до 27 октября 2001?
Решая задачу де композиционным способом, пришлось бы для начала найти способ вычисления прошедших дней между двумя произвольными датами, но… может быть, существует иной способ решения? Попробуйте отыскать его. Уверяю, что это доставит Вам истинное удовольствие…
Обработка памяти байтами, двойными и четвертными словами
В своей повседневной практике программисты сталкиваются с самыми различными типами данных: байтами, двойными и четвертными словами… Какие же из них наиболее эффективны? Среди программистов нет единого мнения на этот счет. Одни руководства рекомендуют обрабатывать большие блоки памяти двойными словами и советуют навсегда забыть, что такое "байт". Другие же соблазняют командами мультимедийной обработки данных, способными "заглатывать" по крайней мере 64 бита (целое четвертное слово!) за один раз. Ближе всего к истине подобралось первое утверждение, да и то с некоторыми оговорками.Несложная тестовая программа (см. [Memory/DWORD.c] – исходный текст который здесь не приводится ввиду ее простоты) убедительно доказывает, что чтение памяти двойными словами действительно, происходит на ~30% – ~40% быстрее побайтового чтения (см. рис. graph 25). А вот чтение памяти четвертными словами (с использованием команды MOVQ) на P?III 733/133/100/I815EP отстает от двойных слов на добрых 25%! Правда, на AMD Athlon 1050/100/100/VIA KT133 разрыв в производительности составляет всего ~5%, но это никак не меняет сути вещей. Чтение больших блоков памяти четвертными словами крайне нецелесообразно. (Вот компактные блоки памяти, – другое дело, но об этом мы поговорим позже, - см. "Кэш").
Интересная ситуация складывается с записью памяти. И байты, и двойные слова в этом случае оказывается одинаково эффективны! Поэтому, при записи данных смело выбирайте любой тип данных, – какой вам больше приходится по душе (читай: какой лучше подходит для описания конкретного алгоритма). Запись памяти четверными словами, как вы уже наверняка догадались, менее выгодна. И это действительно так! Забавно, но теперь наибольший разрыв в производительности наблюдается не на P?III (как это было при чтении памяти), а на AMD Athlon, который в 1,6 раз проигрывает двойными словам по скорости обработки.

Рисунок 34 graph 25 Сравнительная эффективность чтения/записи больших блоков памяти байтами, двойными и четверными словами.
Как видно: чтение памяти лучше всего осуществлять двойными словами, а запись – либо байтами, либо двойными словами. Обработка памяти четвертными словами всегда осуществляется наименее эффективно
Обработка байтовых потоков двойными словами.
В некоторых случаях байтовые потоки данных могут (не без ухищрений конечно) обрабатываться непосредственно двойными словами, что значительно (см. рис. graph 26) увеличивает производительность обрабатывающего их приложения.
Рассмотрим следующий пример, шифрующий байты тривиальной операцией XOR по постоянной маске:
simple_crypt(char *src, int mask, int n)
{
int a;
for (a = 0; a < n; a++)
src[a]^=mask;
}
Листинг 22 Пример обработки байтового потока
Поскольку, все байты обрабатываются однородно, – почему бы ни попробовать обрабатывать четыре байта одной командой? В данном случае для этого достаточно лишь "размножить" маску шифрования, скопировав ее в остальные три байта двойного слова.
Правда, еще потребуется предусмотреть возможность обработки блоков, размер которых не кратен четырем (а что, может же сложиться такая ситуация?). Кстати, это очень просто! Достаточно, получив остаток от деления размера блока на четыре, зашифровать оставшиеся "хвост" (если он есть) "вручную", – т.е. по байтам.
Это может выглядеть, например, так:
optimized_simple_crypt(char *src, int mask, int n)
{
int a;
// размножаем байтовую маску для получения двойного слова
supra_mask = mask+(mask<<8)+(mask<<16)+(mask<<24);
// обрабатываем байты двойными словами
for (a = 0; a < n; a += 4)
{
*(int *)src ^= supra_mask; src+=4;
}
// обрабатываем оставшийся "хвост" (если он есть)
for (a = (n & ~3); a < n; a++)
{
*src ^= mask; src += 1;
}
}
Листинг 23 Оптимизированный пример обработки байтового потока двойными словами
Разумеется, такой трюк применим не только к побайтовой шифровке! Оптимизации поддаются также алгоритмы копирования, инициализации, сравнения, поиска, обмена… словом все однородные способы обработки данных.
На обоих процессорах запись расщепленных
На обоих процессорах запись расщепленных данных вызывает блокировку опережающей записи (store forwarding), что зачастую оборачивается весьма внушительным падением производительности (см "Особенности буферизации записи. Волчьи ямы опережающей записи").Внимание: некоторые машинные инструкции (в частности MOVAPS) требуют обязательного выравнивания своих операндов. Попытка "скармливания" не выровненных данных (независимо от того, пересекают они границу кэш-линейки или нет) заканчивается выбрасываем исключения. В этом случае, необходимость выравнивания оговаривается в описании данной машинной инструкции.
Взять, например, описание уже упомянутой MOVAPS "…When the source or destination operand is a memory operand, the operand must be aligned on a 16-byte boundary or a general-protection exception (#GP) is generated" ("Когда операнд-источник или операнд-приемник представляет собой "операнд – в – памяти", он должен быть выровнен по 16-байтной границы, в противном случае сработает исключение общей защиты
– general-protection exception #GP").

Рисунок 23 0х020 Если читаемые данные начинаются в одной, а заканчиваются в другой строке кэша, при их обработке возникает задержка.
Обработка "расщепленных" (line-splint) данных
Чтение данных размером в байт, слово и двойное слово, целиком находящихся в одной кэш-линейке сверхоперативной памяти первого уровня, процессоры семейства P6 осуществляют за 1 такт. Если же данные выходят за границу 32- (64-/128-) байтной кэш-линейки и своим "хвостом" попадают в следующую кэш-линейку (см. рис. 0х020), – эта операция отнимает уже от 6 до 12 тактов, а сами данные называются "расщепленными" (от английского line-splint).Так происходит потому, что: во-первых, процессор не может за один такт считать сразу две кэш-линейки, – хотя кэш-память и двух портовая (т.е. поддерживает параллельную обработку двух кэш-линеек), блок взаимодействия с памятью (Load Unit) способен загружать только одну ячейку за каждый такт. Точнее, полное время загрузки отнимает как минимум три такта, но при благоприятном стечении обстоятельств конвейер спускает по одной ячейка за каждый так, ликвидируя тем самым ее трех тактовую латентность.
Расщепленные данные блокируют конвейер и хвостовые ячейки начинают обрабатываться только после завершения обработки головы. Отсюда: минимальное время загрузки расщепленных данных составляет 2*Load.Unit.Latency == 2*3 == 6 тактов. Во-вторых, обрабатывая расщепленные данные, кэш-контроллер вынужден производить дополнительные расчеты, прикидывая как две половинки объединить в одну, что тоже обходится не бесплатно.
Гораздо лучше справляется с чтением расщепленных данных процессор AMD Athlon, теряющий при этом всего один такт, а то и вовсе не теряющий ничего (хотя, последнее утверждение и вступает в противоречие с документацией, оно подтверждается экспериментально). Это объясняется тем, что Athlon имеет две очереди запросов на загрузку данных из кэш-памяти первого уровня и потому может считывать сразу обе кэш-линейки. А вот с записью расщепленных данных Athlon справляется несравнимо хуже, поскольку очередь на запись у него всего одна. Тем не менее, за счет эффективного и грамотно спроектированной механизма буферизации записи, возникающая при этом задержка приблизительно в полтора раза меньше, чем на P-II/P-III.
Обработка результатов измерений
Непосредственные результаты замеров времени исполнения программы в "сыром" виде, как было показано выше, ни на что ни годны. Очевидно, перед использованием их следует обработать. Как минимум откинуть пограничные значения, вызванные нерегулярными наведенными эффектами (ну, например, в самый ответственный для профилировки момент, операционная система принялась что-то сбрасывать на диск), а затем… Затем перед нами встает Буриданова проблема. Мы будет должны либо выбрать результат с минимальным временем исполнения – как наименее подвергнувшийся пагубному влиянию многозадачности, либо же вычислить наиболее типичное время выполнения – как время выполнения в реальных, а не идеализированных "лабораторных" условиях.Мной опробован и успешно применяется компромиссный вариант, комбинирующий оба этих метода. Фактически я предлагаю вам отталкиваться от среде минимального времени исполнения. Сам алгоритм в общих чертах выглядит так: мы делаем N прогонов программы, а затем отбрасываем N/3 максимальных и N/3 минимальных результатов замеров. Для оставшиеся N/3 замеров мы находит среднее арифметическое, которое и берем за основной результат. Величина N варьируется в зависимости от конкретной ситуации, но обычно с лихвой хватает 9-12 прогонов – большее количество уже не увеличивает точности результатов.
Одна из возможных реализаций данного алгоритма приведена ниже:
unsigned int cycle_mid(unsigned int *buff, int nbuff)
{
int a,xa=0;
if (!nbuff) nbuff=A_NITER;
buff=buff+1; nbuff--; // Исключаем первый элемент
if (getargv("$NoSort",0)==-1)
qsort(buff,nbuff,sizeof(int), \
(int (*)(const void *,const void*))(_compare));
for (a=nbuff/3;a<(2*nbuff/3);a++)
xa+=buff[a];
xa/=(nbuff/3);
return xa;
}
Листинг 8 Нахождение средне типичного времени выполнения
Обработка структурных исключений
Приемы, описанные выше, реализуются с без особых усилий и излишних накладных расходов. Единственным серьезным недостатком является их несовместимость со стандартными библиотеками, т.к. они интенсивно используют завершающий символ нуля и не умеют по указателю на начало буфера определять его размер. Частично эта проблема может быть решена написанием "оберток" – слоя переходного кода, "посредничающего" между стандартными библиотеками и вашей программой.Но следует помнить, что описанные подходы сам по себе еще не защищает от ошибок переполнения, а только уменьшают вероятность их появления. Они исправно работают в том, и только в том случае, когда разработчик всегда помнит необходимости постоянного контроля за границами массивов.
Практически гарантировать выполнение такого требования невозможно и в любой "полновесной" программе, состоящей из сотен и более тысяч строк, ошибки всегда есть. Это – аксиома, не требующая доказательств.
К тому же, чем больше проверок делает программа, тем "тяжелее" и медлительнее получается откомпилированный код и тем вероятнее, что хотя бы одна из проверок реализована неправильно или по забывчивости не реализована вообще!
Можно ли, избежав нудных проверок, в то же время получить высокопроизводительный код, гарантированно
защищенный от ошибок переполнения?
Несмотря на смелость вопроса, ответ положительный, да – можно! И поможет в этом обработка структурных исключений (SEH). В общих чертах смысл идеи следующий – выделяется некий буфер, с обоих сторон "окольцованный" несуществующими страницами памяти и устанавливается обработчик исключений, "отлавливающий" прерывания, вызываемые процессором при попытке доступа к несуществующей странице (вне зависимости от того, был ли запрос на запись или чтение).
Необходимость постоянного контроля границ массива при каждом к нему обращении отпадает! Точнее, теперь она ложится на плечи процессора, а от программиста требуется всего лишь написать несколько строк кода, возвращающего ошибку или увеличивающего размер буфера при его переполнении.
Единственным незакрытым лазом останется возможность прыгнув далеко-далеко за конец буфера случайно попасть на не имеющую к нему никакого отношения, но все-таки существующую страницу. В этом случае прерывание вызвано не будет и обработчик исключений ничего не узнает о факте нарушения. Однако, такая ситуация достаточно маловероятна, т.к. чаще всего буфера читаются и пишутся последовательно, а не в разброс, поэтому, ей можно пренебречь.
Преимущество от использования технологии обработки структурных исключений заключаются в надежности, компактности и ясности, использующего его программного кода, не отягощенного беспорядочно разбросанными проверками, затрудняющими его понимание.
Основной недостаток – плохая переносимость и системно - зависимость. Не всякие операционные системы позволяют прикладному коду манипулировать на низком уровне со страницами памяти, а те, что позволяют – реализуют это по-своему. Операционные системы семейства Windows такую возможность к счастью поддерживают, причем на довольно продвинутом уровне.
Функция VirtualAlloc обеспечивает выделение региона виртуальной памяти, (с которым можно обращаться в точности как и с обычным динамическим буфером), а вызов VirtualProtect позволят изменить его атрибуты защиты. Можно задавать любой требуемый тип доступа, например, разрешить только чтение памяти, но не запись или исполнение. Это позволяет защищать критически важные структуры данных от их разрушения некорректно работающими функциями. А запрет на исполнение кода в буфере даже при наличие ошибок переполнения не дает злоумышленнику никаких шансов запустить собственноручно переданный им код.
Использование функций, непосредственно работающих с виртуальной памятью, воистину позволяет творить настоящие чудеса, на которые принципиально не способны функции стандартной библиотеки Си/Cи ++.
Единственный их недостаток заключается в непереносимости. Однако, эта проблема может быть решена написанием собственной реализации функций VirtualAlloc, VirtualProtect и некоторых других, пускай в некоторых случаях на уровне компонентов ядра, а обработка структурных исключений изначально заложена в С++.
Таким образом, затраты на портирование приложений, построенных с учетом описанных выше технологий программирования, в принципе возможны, хотя и требует значительных усилий. Но эти усилия не настолько чрезмерны, что бы не окупить полученный результат.
Обращайтесь к памяти только когда это действительно необходимо
Наиболее эффективный способ оптимизации обмена с памятью заключается… в отказе от использования памяти. Нет, это не шутка! Большинство приложений используют память крайне нерационально, и грамотная алгоритмизация позволяет значительно умерять их аппетит.Возьмем, к примеру, такой случай. Пусть у нас имеется текстовой или графический редактор, умеющий, среди прочего осуществлять копирование фрагментов текста (изображения) и их вставку. Традиционно эта задача сводится к вызову memmove (или memcpy), между тем существует масса более элегантных и производительных решений. Задумаемся: а зачем, собственно, вообще дублировать копируемый блок? До тех пор, пока скопированный фрагмент не будет изменен, мы вправе пользоваться ссылкой на оригинальный блок. Это может быть, не очень актуально для текстового редактора, но при обработке графических файлов высокого разрешения порой экономит миллиарды
обращений к памяти.
Более того, если пользователю захотелось изменить скопированный фрагмент, нет нужды дублировать его целиком! Достаточно "расщепить" непосредственно модифицированную часть, соответствующим образом скорректировав ссылки. Конечно, все это значительно "утяжеляет" алгоритм и затрудняет его отладку, но выигрыш стоит того!
Поскольку, данная проблема больше относится к алгоритмизации как таковой, чем к подсистеме памяти вообще, этот вопрос не будет здесь подробно рассматриваться.
Общее время исполнения
Сведения о времени, которое приложение тратит на выполнение каждой точки программы, позволяют выявить его наиболее горячие участки. Правда, здесь необходимо сделать одно уточнение. Непосредственный замер покажет, что по крайней мере 99,99% всего времени выполнения профилируемая программа проводит внутри функции main, но ведь очевидно, что "горячей" является отнюдь не main, а вызываемые ею функции! Чтобы не вызывать у программистов недоумения, профилировщики обычно вычитают время, потраченное на выполнение дочерних функций, из общего времени выполнения каждой функции программы.Рассмотрим, например, результат профилировки некоторого приложения профилировщиком profile.exe, входящего в комплект поставки компилятора Microsoft Visual C++.
Func Func+Child Hit
Time % Time % Count Function
---------------------------------------------------------
350,192 95,9 360,982 98,9 10000 _do_pswd (pswd_x.obj)
5,700 1,6 5,700 1,6 10000 _CalculateCRC (pswd_x.obj)
5,090 1,4 10,790 3,0 10000 _CheckCRC (pswd_x.obj)
2,841 0,8 363,824 99,6 1 _gen_pswd (pswd_x.obj)
1,226 0,3 365,148 100,0 1 _main (pswd_x.obj)
0,098 0,0 0,098 0,0 1 _print_dot (pswd_x.obj)
В средней колонке (Func + Child Time) приводится полное время исполнения каждой функции, львиная доля которого принадлежит функции main (ну этого следовало ожидать), за ней с минимальным отрывом следует gen_pswd со своими 99,5%, далее идет do_pswd – 98,9% и, сильно отставая от нее, где-то там на отшибе плетется CheckCRC, оттягивая на себя всего лишь 3,0%. А функцией Calculate CRC, робко откусывающей 1,6%, на первый взгляд можно и вовсе пренебречь! Итак, судя по всему, мы имеем три горячих точки: main, gen_pswd и do_pswd (см. рис. graph 0x002).

Рисунок 1 graph 0x002 Диаграмма, иллюстрирующая общее время выполнения каждой из функций.
Кажется мы имеем три горячих точки, но на самом деле это не так.
Впрочем, main можно откинуть сразу. Она – понятное дело – ни в чем не "виновата". Остаются gen_pswd и do_pswd. Если бы это были абсолютно независимые функции, то горячих точек было бы и впрямь две, но в нашем случае это не так. И, если из полного времени выполнения функции gen_pswd, вычесть время выполнения ее дочерней функции do_pswd у матери останется всего лишь… 0,8%. Да! Меньше процента времени выполнения!
Обратимся к крайней левой колонке таблицы профилировщика (funct time), чтобы подтвердить наши предположения. Действительно, в программе присутствует всего лишь одна горячая точка – do_pswd, и только ее оптимизация способна существенно увеличить быстродействие приложения.

Рисунок 2 graph 0x003 Диаграмма, иллюстрирующие чистое время работы каждой из функций (т.е. с вычетом времени дочерних функций). Как видно, в программе есть одна, но чрезвычайно горячая точка.
Хорошо, будем считать, что наиболее горячая функция определена и теперь мы горим желанием ее оптимизировать. А для этого недурно бы узнать картину распределения температуры внутри самой функции. К сожалению, профилировщик profile.exe (и другие подобные ему) не сможет ничем нам помочь, поскольку его разрешающая способность ограничивается именно функциями.
Но, на наше счастье существуют и более продвинутые профилировщики, уверенно различающие отдельные строки и даже машинные команды! К таким профилировщикам в частности относится VTune от Intel. Давайте запустим его и заглянем внутрь функции do_pswd (подробнее о технике работы с VTune см. "Практический сеанс профилировки с VTune").
Line Clock ticks Source temperature
105 729 while((++pswd[p])>'z'){ **************************>>>
106 14 pswd[p] = '!'; **************
107 1 y = y | y << 8; *
108 2 x -= k; **
109 k = k << 8; *
110 3 k += 0x59; ***
111 2 p++; **
112 1 } *
Листинг 1 Карта распределения "температуры" внутри функции do_pswd, полученная с помощью профилировщика VTune.
Вот теперь совсем другое дело – сразу видно, что целесообразно оптимизировать, а что и без того уже вылизано по самые помидоры. Горячие точки главным образом сосредоточены вокруг конструкции pswd[p], – она очень медленно выполняется. Почему? Исходный текст не дает непосредственного ответа на поставленный вопрос и потому совсем не ясно: что конкретно следует сделать для понижения температуры горячих точек.
Приходится спускаться на уровень "голых" машинных команд (благо VTune это позволяет). Вот, например, во что компилятор превратил безобидный на вид оператор присвоения pswd[p] = '!'
Line Instructions Cycles Count temperature
107 mov edx, DWORD PTR [ebp+0ch] 143 11 *****************************
107 ^ загрузить в регистр EDX указатель pswd
107 add edx, DWORD PTR [ebp-4] 22 11 *****
107 ^ сложить EDX с переменной p
107 mov BYTE PTR [edx], 021h 33 11 *******
107 ^ по полученному смещению записать значение 0х21 ('!')
Листинг 2 Исследование температуры машинных команд внутри конструкции pswd[p]='!'
Смотрите! В одной строке исходного текста происходит целых три обращения к памяти! Сначала указатель pswd загружается в регистр EDX, затем он суммируется с переменной p, которая так же расположена в памяти, и лишь затем по рассчитанному смещению в память благополучно записывается константа '!' (021h). Тем не менее, все равно остается не ясно почему загрузка указателя pswd занимает столько времени. Может быть, кто-то постоянно вытесняет pswd из кэша, заставляя процессор обращаться к медленной оперативной памяти? Так ведь нет! Программа работает с небольшим количеством переменных, заведомо умещающихся в кэше второго уровня.
Обсуждение результатов тестирования
Итак, тестирование началось… Прогон "подопытных" примеров на процессорах Intel Pentium-III 733 и AMD Athlon 1.400 (см. рис. 1, рис. 2) говорит о достаточно высоком качестве кодогенерации современных компиляторов. В среднем (за вычетом особо оговариваемых исключений) производительность откомпилированных программ лишь на 20%?30% уступает вручную оптимизированному ассемблеру. Конечно, это весьма внушительная величина (особенно, если вспомнить, что эталонная ассемблерная программа достаточно далека от идеала). Эй, кто там говорил, что машинная оптимизация уступает человеку не более одно-двух процентов?! А ну подать сюда этого человека!С другой стороны, разрыв производительности (за редкими исключениями) все же не настолько велик, чтобы перенос программы на ассемблер приводил к качественным изменениям.
А теперь обо всем этом подробнее. Как и следовало ожидать, наибольший разрыв в производительности наблюдается на копировании памяти. Впрочем, этот разрыв значительно сокращается с ростом тактовой частоты процессора. Если на P-III 733 наименьшее отставание составило целых 25%, то на Athlon 1.400 – всего 9%! Едва ли последняя цифра нуждается в комментариях – Microsoft рулит и жизнь прекрасна. Быстрота современных процессоров, помноженная на мощь современных компиляторов – и никаких ассемблерных вставок! Конечно, не все компиляторы одинаково хороши. Так, WATCOM – вообще в осадке; Borland уверенно держит позиции на Intel, но генерирует несколько неоптимальный код с точки зрения AMD.
С поиском минимума все компиляторы справились одинаково хорошо, а Microsoft Visual C++ вообще построил идеальный по своей красоте код, лишь из-за досадной случайности не дотянувшийся до 100% результата, – начало цикла пришлось на наихудший с точки зрения микропроцессора адрес: 0х4013FF. "Благодаря" этому каждая итерация облагается несколькими штрафными тактами, что, в конечном счете, выливается во вполне весомые потери. Чаще всего, впрочем, судьба оказывается не столь жестока, и код, сгенерированный компилятором, исполняется достаточно эффективно.
Однако нет никаких гарантий, что даже малейшее изменение программы, (да, да, – в том числе и выкидывание лишнего кода!), не ухудшит ее производительности (причем, под час весьма значительно). Увы, в этом смысле компиляторы все еще тупы до безобразия. Они либо вовсе не выравнивают переходы, либо выравнивают все
переходы, что неоправданно увеличивает размер программы и нередко дает обратный эффект, многократно снижая ее производительность (если программа в результате такого распухания не поместится в кэш). Между тем, правильное решение – выравнивать лишь часто выполняемые переходы, в частности циклы, но – увы – ни в одном известном мне компиляторе это не реализовано.
Заметно лучше сложилась ситуация с сортировкой. Компилятор Microsoft Visual C++ отстает от ассемблерного кода всего лишь на 13%-14%. За ним с минимальным отрывом идет Borland C++ со своими 15% и 24% для Athlon 1.400 и P-III 733 соответственно. Последнее место занимает WATCOM, ни в чем не уступающий Borland'у на Pentium'e, но безапелляционно сдающий свои позиции на Athlon'е. Ну не виноват он, что создавался в ту далекую эпоху, когда и процессоры, и техника оптимизации были совсем другими! В целом, WATCOM неплохой, но безнадежно устаревший оптимизатор, и любовь к нему (у тех, у кого она имеется) не должна слепить глаза, сегодня WATCOM'ом – уже не самый лучший выбор.

Рисунок 3 0х001 Сравнение качества машинной кодогенерации по скорости на Intel P-III 733

Рисунок 4 0х002 Сравнение качества машинной кодогенрации по скорости на AMD Athlon-1.400
Перейдем теперь к сравнению размера откомпилированного и ассемблерного кода. Первое, что бросается в глаза (см. рис. 3), – весьма внушительный отрыв Microsoft Visual C++ от своих конкурентов. Однако и он отстает от "ручного" кода, не дотягивая по меньшей мере 23%. Причем, по мере упрощения задач этот разрыв резко увеличивается, достигая в случае примера с копированием памяти целых 76%! Ух, ты! Ассемблерная реализация оказалась практически вдвое короче!
У конкурентов же ситуация еще хуже. Значительно хуже. Грубо говоря, можно утверждать, что перенос программы на ассемблер как минимум сокращает ее размер раза в полтора-два. Тем не менее, два раза – это не триста и с этим вполне можно жить.

Рисунок 5 0x003 Сравнение качества машинной кодогенерации по размеру
Операции с сентенциями
"Горячая" клавиша <Shift-Alt-L> удаляет весь текст, расположенный справа от курсора до следующей сентенции (пустой строки). Если пустые строки вставлены в листинг "с умом", – эта операция может оказаться весьма полезной.Команды "SentenceLeft" и "SentenceRight" перемещают курсор на следующую и предыдущую сентенцию соответственно.
Операции со строками и словами
Клавишная комбинация <Shift-Alt-T> меняет местами текущую и предыдущую строку, что в некоторых ситуациях оказывается весьма сподручно. Ее ближайшая родственница <Shift-Ctrl-T> отличается лишь тем, что меняет местами не строки, а слова.Пара команд "LineIndent" и "LineUnindent" сдвигают текущую строку на одну позицию табуляции назад и вперед соответственно. Какая от этого выгода? Не легче ли нажать
Команда "IndentToPrev" выравнивает текущую строку по образу и подобию предыдущей, что значительно упрощает форматирование листинга. Ее ближайшая родственница – "IndentSelectionToPrev", как и следует из ее названия, выравнивает сразу весь выделенный блок, что еще удобнее!
Определение количества вызовов
Как мы только что показали, определение количества вызовов профилируемой точки необходимо уже хотя бы для того, чтобы мы могли убедиться в достоверности изменений. К тому же, оценивать температуру точки можно не только по времени ее выполнения, но и частоте вызова.Например, пусть у нас есть две "горячие" точки, в которых процессор проводит одинаковое время, но первая из них вызывается сто раз, а вторая – сто тысяч раз. Нетрудно догадаться, что оптимизировав последнюю хотя бы на 1% мы получим колоссальный выигрыш в производительности, в то время как сократив время выполнение первой из них вдвое, мы ускорим нашу программу всего лишь на четверть.
Часто вызываемые функции в большинстве случаев имеет смысл инлайнить
(от английского in-line), т.е. непосредственно вставить их код в тело вызываемых функций, что сэкономит какое-то количество времени.
Определять количество вызовов на умеют практически все профилировщики, и тут нет никаких проблем, заслуживающих нашего внимания.
Определение предпочтительной кэш-иерархии
Народная мудрость "положишь подальше – возьмешь поближе" в отношении предвыборки данных практически всегда неверна. Чем ближе к процессору в кэш-иерархии расположены данные, тем быстрее они могут быть получены. Т.е. если уж и осуществлять предвыборку – то предпочтительнее всего в кэш первого уровня (редкие исключения из этого правила будут рассмотрены ниже).Если данные используются многократно, их предвыборку желательно осуществлять в кэш-уровни всех иерархий, – тогда при вытеснении из кэша первого уровня, данные за короткое время будут получены из кэша второго уровня и процессору не придется обращаться к медленной оперативной памяти. Напротив, однократно используемые данные (равно как и данные, гарантированно не вытесняемые из кэша первого уровня), загружать в кэш второго уровня нецелесообразно, особенно если в нем в это время хранится нечто полезное.
Сказанное в высшей степени справедливо для P-III, но не совсем верно в отношении P-4 (вернее, не верно совсем). Поскольку, вместо загрузки данных в кэш первого уровня, P-4 помещает их в первый банк кэша второго уровня, особенной свободы выбора у программистов и нет. И единственное отличие между командами prefetchnta и prefetchtx
заключается в том, что prefetchnta не может вытеснить из кэша второго уровня более одной восьмой объема его данных. (А по закону бутерброда вытесняются именно те данные, которые вам нужнее всего).
На K6 (VIA C3) никаких проблем с определение предпочтительной кэш-иерархии нет, поскольку нет и самой возможности ее выбора – данные всегда загружаются в кэш-уровни всех иерархий, вытесняя содержимое L2-кэша еще интенсивнее, чем на P-4! Поэтому, разработчики, оптимизирующие свои программы под K6\C3 не найдут в этой главе для себя ничего интересного.
Но довольно теории, перейдем к конкретным примерам. Вернемся к листингу N???2. Достаточно очевидно, что совершенно все равно: какой командой предвыборки пользоваться – prefetchnta или prefetcht0, поскольку к каждой ячейке обращение происходит лишь однократно, а в кэше второго уровня не хранится никаких ценных данных, которые было бы жалко вытеснять. (Впрочем, не стоит забывать, что в многозадачных операционных системах кэш приходится делить между несколькими приложениями и без острой надобности затирать его содержимое, право же, не стоит).
Достаточно лишь воспользоваться командой предвыборки не временных данных, убивая одним выстрелом двух зайцев наповал. Во-первых, предвыборка избавляет нас от ожидания загрузки ячеек блока BLOCK2, а, во-вторых, она позволяет подгружать блок BLOCK2 напрямую в кэш первого уровня (на P-4 в первый банк кэша второго уровня), не затирая содержимого блока BLOCK1, хранящегося в L2-кэше. (На P-4, – увы, – блок BLOCK1 все же будет частично затираться).
Следовательно, в данном случае выгоднее всего воспользоваться инструкцией prefetchnta, а не prefetchtx, поскольку она не затирает (на P-4 минимально затирает) кэш второго уровня:
for(...)
for(c=0;c
{
// Обрабатываем блок BLOCK1 (находящийся в L2-кэше).
// Предвыборка в L1 кэш не нужна, т.к. это все равно
// не увеличит производительности, ввиду того, что на
// P-III время доступа к кэшу второго уровня
// пренебрежительно мало, а P-4 и вовсе не позволяет
// грузить данные в кэш первого уровня
b+=p1[d]; if ((d+=32) > BLOCK1_SIZE) d=0;
// Перед тем как заняться вычислениями отдаем команду на
// предвыборку данных блока BLICK2 в L1-кэш (в L2 на P-4)
// Во-первых, избавляясь тем самым от ожидания загрузки
// данных из медленной оперативной памяти, а во-вторых,
// предотвращая вытеснение данных блока BLOCK1 из L2-кэша
_prefetchnta(p2+c+STEP_SIZE);
// Обратите внимание, что загружаются данные, обращение
// к которым произойдет только в следующей итерации.
// Почему именно так? Дело в том, что время загрузки
// превышает время вычисления "b+=b % (c+1)" и...
// Загружая данные следующей итерации, мы теряем лишь
// первую итерацию цикла, а не через одну,
// как может показаться в начале, т.к. этот прием вполне
// законен и обеспечивает максимальный прирост
// быстродействия.
b+=b % (c+1);
// Теперь данные загружаются из L1-кэша! (из L2 на P-4)
b+=p2[c];
}
Листинг 23 Оптимизированный вариант с использованием предвыборки не временных данных
На P-III использование prefetchnta на 40% увеличивает производительность, в то время как prefetcht0 – на 20%, на prefetcht1 на 50% уменьшает ее, что и не удивительно, т.к. предвыборка временных данных приводит к вытеснению из кэша второго уровня содержимого блока BLOCK1, ничего не давая взамен. (см. рис. 0х016)

Рисунок 41 graph 0x016 Влияние различных типов предвыборки на производительность различных приложений
Определение ситуаций предпочтительного использования ассемблера
Наконец-то, мы вплотную подошли к ответу на главный вопрос: в каких именно случаях обращение к ассемблеру целесообразно, а в каких – нет. Часто программист (даже высококвалифицированный!) обнаружив профилировщиком "узкие" места в программе, автоматически принимает решение о переносе соответствующих функций на ассемблер. А напрасно! Как мы уже убедились, разница в производительности между ручной и машинной оптимизацией в подавляющем большинстве случаев очень невелика. Очень может статься так, что улучшать уже нечего, – за исключением мелких, "косметических" огрехов, результат работы компилятора идеален и никакие старания не увеличат производительность, более чем на 3%–5%. Печально, если это обстоятельство выясняется лишь послепереноса одной или нескольких таких функций на ассемблер. Потрачено время, затрачены силы… и все это впустую. Обидно, да?
Прежде, чем приступать к ручной оптимизации не мешало бы выяснить: насколько не оптимален код, сгенерированный компилятором, и оценить имеющийся резерв производительности. Но не стоит бросаться в другую крайность и полагать, что компилятор всегда генерирует оптимальный или близкий к тому код. Отнюдь! Все зависит от того, насколько хорошо вычислительный алгоритм ложиться в контекст языка высокого уровня. Некоторые задачи решаются одной машинной инструкцией, но целой группой команд на языках Си и Паскаль. Наивно надеяться, что компилятор поймет физический смысл компилируемой программы и догадается заменить эту группу инструкций одной машинной командой. Нет! Он будет тупо транслировать каждую инструкцию в одну или (чаще всего) несколько машинных команд, со всеми вытекающими отсюда последствиями…
Итак, правило номер один. Прежде, чем оптимизировать код, обязательно следует иметь надежно работающий не оптимизированный вариант. Создание оптимизированного кода "на ходу", по мере написания программы, невозможно! Такова уж специфика планирования команд – внесение даже малейших изменений в алгоритм практически всегда оборачивается кардинальными переделками кода.
Потому, приступайте к оптимизации только после тренировки на "кошках", – языке высокого уровня. Это поможет пояснить все неясности и темные места алгоритма. К тому же, при появлении ошибок в программе подозрение всегда падает именно на оптимизированные участки кода (оптимизированный код за редкими исключениями крайне ненагляден и чрезвычайно трудно читаем, потому его отладка – дело непростое), – вот тут-то и спасает "отлаженная кошка". Если после замены оптимизированного кода на неоптимизированный ошибки исчезнут, значит, и в самом деле виноват оптимизированный код. Ну, а нет, – ищите их где-нибудь в другом месте.
Правило номер два. Не путайте оптимизацию кода и ассемблерную реализацию. Обнаружив профилировщиком узкие места в программе, не торопитесь переписывать их на ассемблер. Сначала убедитесь, что все возможное для увеличения быстродействия кода в рамках языка высокого уровня уже сделано. В частности, следует избавиться от прожорливых арифметических операций (особенно обращая внимание на целочисленное деление и взятие остатка), свести к минимуму ветвления, развернуть циклы с малым количеством итераций… в крайнем случае, попробуйте сменить компилятор (как было показано выше – качество компиляторов очень разниться друг к другу). Если же все равно останетесь недовольны результатом тогда…
Правило номер три. Прежде, чем переписывать программу на ассемблер, изучите ассемблерный листинг компилятора на предмет оценки его совершенства.
Возможно, в неудовлетворительной производительности кода виноват не компилятор, а непосредственно сам процессор или подсистема памяти, например. Особенно это касается наукоемких приложений, жадных до математических расчетов и графических пакетов, нуждающихся в больших объемах памяти. Наивно думать, что перенос программы на ассемблер увеличит пропускную способность памяти или, скажем, заставит процессор вычислять синус угла быстрее. Получив ассемблерный листинг откомпилированной программы (для Microsoft Visual C++, например, это осуществляется ключом "/FA"), бегло просмотрите его глазами на предмет поиска явных ляпов и откровенно глупых конструкций наподобие: "MOV EAX,[EBX]\MOV [EBX],EAX".
Обычно гораздо проще не писать ассемблерную реализацию с чистого листа, а вычищать уже сгенерированный компилятором код. Это требует гораздо меньше времени, а результат дает ничуть не худший.
Правило номер четыре. Если ассемблерный листинг, выданный компилятором, идеален, но программа без видимых причин все равно исполняется медленно, не отчаивайтесь, а загрузите ее в дизассемблер. Как уже отмечалось выше, оптимизаторы крайне неаккуратно подходят к выравниванию переходов и кладут их куда глюк на душу положит. Наибольшая производительность достигается при выравнивании переходов по адресам, кратным шестнадцати, и будет уж совсем хорошо, если все тело цикла целиком поместиться в одну кэш-линейку (т.е. 32 байта). Впрочем, мы отвлеклись. Техника оптимизации машинного кода – тема совершенно другого разговора. Обратитесь к документации, распространяемой производителями процессоров – Intel и AMD.
Правило номер пять. Если существующие команды процессора позволяют реализовать ваш алгоритм проще и эффективнее, – вот тогда действительно, тяпнув для храбрости пивка, забросьте компилятор на полку и приступайте к ассемблерной реализации с чистого листа. Однако с такой ситуацией приходится встречаться крайне редко, и к тому же не стоит забывать, что вы – не на одиноком острове. Вокруг вас – огромное количество высокопроизводительных, тщательно отлаженных и великолепно оптимизированных библиотек. Так зачем же изобретать велосипед, если можно купить готовый?
И, наконец, последнее правило номер шесть. Если уж взялись писать на ассемблере, пишите максимально "красиво" и без излишнего трюкачества. Да, недокументированные возможности, нетрадиционные стили программирования, "черная магия", – все это безумно интересно и увлекательно, но… плохо переносимо, непонятно окружающим (в том числе и себе самому после возращения к исходнику десятилетней давности) и вообще несет в себе массу проблем. Автор этих строк неоднократно обжигался на своих же собственных трюках, причем самое обидное, что трюки эти были вызваны отнюдь не "производственной необходимостью", а… ну, скажем так, "любовью к искусству".За любовь же, как известно, всегда приходится платить. Не повторяете чужих ошибок! Не брезгуйте комментариями и непременно помещайте все ассемблерные функции в отдельный модуль. Никаких ассемблерных вставок – они практически непереносимы и создают очень много проблем при портировании приложений на другие платформы или даже при переходе на другой компилятор.
Единственная предметная область, не только оправдывающая, но, прямо скажем, провоцирующая ассемблерные извращения, это – защита программ. О чем мы и поговорим ниже…
Определение степени покрытия
Вообще-то говоря, определение степени покрытия не имеет никакого отношения к оптимизации приложений и это – побочная функция профилировщиков. Но, поскольку она все-таки есть, мораль обязывает автора рассмотреть ее – пускай и кратко.Итак, покрытие – это процент реально выполненного кода программы в процессе его профилировки. Кому нужна такая информация? Ну, в первую очередь, тестерам, – должны же они убедиться, что весь код приложения протестирован целиком и в нем не осталось никаких "темных" мест.
С другой стороны, оптимизируя программу очень важно знать какие именно ее части были профилированы, а какие нет. В противном случае многих "горячих" точек можно просто не заметить только потому, что соответствующие им ветки программы вообще ни разу не получили управления!
Рассмотрим, например, как может выглядеть протокол покрытия функций, сгенерированный профилировщиком profile.exe для нашего тестового примера pswd.exe (о самом тестовом примере см. "Практический сеанс профилировки с VTune")
Program Statistics ; Статистика по программе
------------------
Command line at 2002 Aug 20 03:36: pswd ; командная
строка
Call depth: 2 ; глубина вызовов: 2
Total functions: 5 ; всего
функций: 5
Function coverage: 60,0% ; покрыто
функций: 60%
Module Statistics for pswd.exe ; статистика
по модулю pswd
------------------------------
Functions in module: 5 ; функций
в модуле: 5
Module function coverage: 60,0% ; функций
прокрыто: 60%
Covered Function ; порытые функции
----------------
. _DeCrypt (pswd.obj)
. __real@4@4008fa00000000000000 (pswd.obj)
* _gen_pswd (pswd.obj)
* _main (pswd.obj)
* _print_dot (pswd.obj)
Тут на чистейшем английском языке написано, что лишь 60% функций получили управление, а остальные 40% не были вызваны ни разу! Разумно убедиться: а вызываются ли эти функции когда ни будь вообще или представляют собой "мертвый" код, который можно безболезненно удалить из программы, практически на половину уменьшив ее в размерах?
Если же эти функции при каких-то определенных обстоятельствах все же получают управление, нам необходимо проанализировать исходный код, чтобы разобраться: что же это за обстоятельства и воссоздать их, чтобы профилировщик смог прогнать и остальные участки программы. Имена покрытых и непокрытых функций перечислены в секции Cover Function. Покрытые отмечаются знаком "*", а непокрытые – "."
Вообще же, для определения степени покрытия существует множество узкоспециализированных приложений (например, NuMega Code Coverage), изначально заточенных именно под эту задачу и со своей работой они справляются намного лучше любого профилировщика.
Оптимизация блочных алгоритмов
Если с потоковыми алгоритмами все было предельно просто (хотя и не без тонкостей), то оптимизация блочных алгоритмов – вещь сама по себе нетривиальная. Если потоковая обработка данных предполагает, что данные запрашиваются последовательно и ничего не стоит организовать оптимальную (с точки зрения подсистемы памяти) трансляцию виртуальных адресов, то блочные алгоритмы могут в хаотичном порядке запрашивать любые ячейки в границах отведенного им блока. До тех пор, пока размер блока не превосходит эти пресловутые (N? 1)c – т.е. составляет порядка 4х-6ти килобайт, – никаких проблем не возникает, особенно если разрядность обрабатываемых данных сопоставима с величиной пакетного цикла обмена с память. В противном случае, возникают штрафные задержки при попадании в регенерирующиеся DRAM-банки, плюс издержки от упреждающего чтения данных к которым никто не обращается.В идеале следовало бы посоветовать сократить размер блоков до нескольких килобайт, но – увы – такое решение не всегда возможно. Что ж, тогда придется прибегать к механизму виртуализации адресов. Пусть слово "виртуальный" не вводит вас в заблуждение относительно механизма реализации такого приема. Для этого вовсе не обязательно иметь наивысшие привилегии и доступ к системным таблицам. Виртуальную трансляцию вполне можно организовать и программным способом, один из которых мы уже рассмотрели выше при оптимизации потоковых алгоритмов.
К сожалению, эта тема не имеет прямого отношения к подсистеме оперативной памяти, и не может быть подробно рассмотрена в рамках этой книги. Давайте отложим этот вопрос по крайней мере до третьего тома.
Спекулятивная загрузка данных Алгоритм спекулятивной загрузки данных родился в те незапамятные времена, когда вычислительными залами владели "динозавры", а роль внешней памяти выполняла до ужаса тормозная магнитная лента. Первые программы работали приблизительно так: ЗАГРУЗИТЬ БЛОК ДАННЫХ С ЛЕНТЫ, ОБРАБОТАТЬ ДАННЫЕ, ПОВТОРИТЬ. Во время загрузки данных бобины с лентой бешено вращались, а на время вычислений покорно останавливались.
На обывателя это зрелище, действительно, действовало неотразимо, но для достижения наибольшей производительности ленточный накопитель должен работать не урывками, а непрерывно.
История не сохранила имя первого человека, которого осенила блестящая мысль: совместить обработку данных с загрузкой следующего блока. Если время выполнения не оптимизированной программы в общем случае равно: T = N*(Ttype + Tdp), где Type – время загрузки блока данных с ленты, Tdp
– время обработки блока, а N – количество блоков, то оптимизированный алгоритм может быть выполнен за: Toptimize = N*(max((Ttype + Tdp))).
Максимальный выигрыш достигается в том случае, когда время загрузки данных с ленты в точности равно времени вычислений. Как нетрудно подсчитать: T/Toptimize = N*(Ttype + Tdp)/ N*Ttype
== 2, т.е. время обработки сокращается в два раза.
И хотя бобины магнитной ленты уже давно вышли из употребления, – они до сих пор готовы преподнести всем нам хороший урок. Задумайтесь: как работает подавляющее большинство современных блочных алгоритмов? Какую программу ни возьми, всюду встречаешь сценарий: "загрузить – обработать – повторить". Пускай, быстродействие оперативной памяти не идет ни в какое сравнение со скоростью магнитной ленты, но ведь и тактовая частота процессоров не стоит на месте!
Простое смешивание команд загрузки содержимого ячеек памяти с вычислительными инструкциями (подробнее см. "Группировка вычислений с доступом к памяти") еще не обеспечивает их параллельного выполнения, – ведь вычислительные инструкции начинают выполняться только после окончания загрузки обрабатываемых данных!
Вспоминая сценарий работы с лентой, давайте усовершенствуем стратегию загрузки данных, обращаясь к следующему обрабатываемому блоку задолго до того, как он будет реально востребован. Этим мы устраним зависимость между командами загрузки и обработки данных, благодаря чему они смогут выполнятся параллельно, и, если время обработки данных не превышает времени их загрузки (как часто и бывает), обмен с памятью станет происходить непрерывно, вплотную приближаясь к ее максимальной пропускной способности. (см.
так же "Параллельная загрузка данных").
Рассмотрим следующий пример:
/*--------------------------------------------------------------------
не оптимизированный вариант
------------------------------------------------------------------- */
for(a = 0; a < BLOCK_SIZE; a += 32)
{
// загружаем ячейки
x += (*(int *)((int ) p + a)); // ß эта команда блокирует все остальные
x += (*(int *)((int ) p + a + 4));
x += (*(int *)((int ) p + a + 8));
x += (*(int *)((int ) p + a + 12));
x += (*(int *)((int ) p + a + 16));
x += (*(int *)((int ) p + a + 20));
x += (*(int *)((int ) p + a + 24));
x += (*(int *)((int ) p + a + 28));
// выполняем некоторые вычисления
x += a/x/666;
}
Листинг 42 Типовой не оптимизированный алгоритм обработки данных
Основной его недостаток заключается в том, что вычислительная процедура "x += a/x/666" вынуждена дожидаться выполнения всех предыдущих команд, а они в свою очередь вынуждены дожидаться окончания загрузки требуемых данных из памяти. Т.е. первая строка цикла "x+=*(int*)((int)p + a)" блокирует все остальные (а еще говорят, что семеро одного не ждут).
Можно ли устранить такую зависимость по данным? Да, можно. Достаточно, например, загружать данные со сдвигом на одну или несколько итераций (подробнее о вычислении предпочтительной величины сдвига см. "Кэш. Предвыборка"). Образно говоря мы как бы смещаем команды загрузки данных относительно инструкций их обработки. (см. рис. 43).

Рисунок 53 0х43 Устранение зависимости по данным путем упреждающей загрузки следующего обрабатываемого блока
В результате этого мы получаем приблизительно следующий код (обратите внимание, исходный текст программы практически не изменен!):
/*--------------------------------------------------------------------
оптимизированный вариант
со спекулятивной загрузкой
------------------------------------------------------------------- */
// загружаем первую порцию данных
x += (*(int *)((int) p + a));
for(a = 0; a < BLOCK_SIZE; a += 32)
{
// упреждающая загрузка очередной порции данных
y = (*(int *)((int) p + a + 32)); // ß ***
// обрабатываем ранее загруженные ячейки
x += (*(int *)((int) p + a + 4));
x += (*(int *)((int) p + a + 8));
x += (*(int *)((int) p + a + 12));
x += (*(int *)((int) p + a + 16));
x += (*(int *)((int) p + a + 20));
x += (*(int *)((int) p + a + 24));
x += (*(int *)((int) p + a + 28));
// выполняем некоторые вычисления
x += a/x/666;
// востребуем упреждено - загруженные данные
x += y;
}
Листинг 43 [Memory/Speculative.read.c] Фрагмент программы, демонстрирующий эффективность использования спекулятивной загрузки данных
На P-III/133/100/I815EP такой несложный трюк уменьшает время обработки данного цикла приблизительно на ~25%.
Почему всего лишь на четверть, а не на половину, как это следует из рис. 43? Причина в том, что команда "x += a/x/666" выполняется вдвое быстрее, чем загрузка следующего блока данных, потому и

Рисунок 54 Univac Компьютер UNIVAC с ленточным накопителем (слева) и женщиной-оператором (справа)
Оптимизация инициализации строк
Компилятор Microsoft Visual C++ выгодно отличается от своих конкурентов – Borland С++ и WATCOM тем, что константные строки он инициирует, "заглатывая" их не байтами, а двойными словами. К тому же, только Microsoft Visual C++ умеет хранить короткие строки в регистрах.Поэтому, программы, интересно манипулирующие со строками, выгоднее компилировать компилятором Microsoft Visual C++.
Оптимизация константных условий
Константные условия в изобилии встречаются во множестве программ. Например, бесконечный цикл (или цикл с условием в середине) подавляющим большинством программистов объявляется так:while(1)
{
// тело цикла
}
Логично, что проверка 1 == 1 бессмысленна, и ее можно опустить. Компиляторы Microsoft Visual C++ и WATCOM именно так и поступают, но вот Borland C++ аккуратно проверяет: а равен ли один одному (ну мало ли…).
Оптимизация константных выражений
"Священные войны" вокруг констант и переменных ведутся уже давно[1]. Одни утверждают, что везде, где только можно, следует заменять переменные константами, другие же советуют поступать наоборот – использовать переменные вместо непосредственных значений. Давайте попробуем разобраться с этим вопросом.Операндом машинной инструкции может быть либо регистр, либо непосредственное значение. В свою очередь, каждый из них может содержать либо готовое значение, либо указатель на ячейку памяти, из которой это значение следует извлечь. Причем, в силу архитектурных ограничений, только один из двух операндов инструкции может обращаться к памяти. Отсюда следует, что непосредственное присвоение одной переменной другой переменной – невозможно.
Приходится сначала загружать содержимое одной переменной в регистр общего назначения, а затем "перебрасывать" его в другую переменную. Платой за такое решение становится увеличение размера кода и снижение его быстродействия, не говоря уже о том, что для этой операции требуется один регистр, а регистры, как известно, нужно беречь, ибо на платформе Intel 80x86 их всего семь.
Замена переменной ее фактическим значением позволяет избавиться от промежуточной пересылки, увеличивая тем самым компактность кода. Однако если к данной переменной обращение происходит неоднократно – гораздо выгоднее поместить ее в регистр. В 32?разрядном режиме это сэкономит в среднем четыре байта на каждое обращение и увеличит скорость выполнения инструкции с двух тактов процессора до одного.
Таким образом, оптимальнее всего использовать следующую стратегию: наиболее интенсивно используемые константные переменные помещать в регистры, и только если свободных регистров нет, заменять переменные их непосредственными значениями.
Оптимизация копирования памяти
Для копирования памяти чаще всего используется штатная функция memcpy, входящая в стандартную библиотеку языка Си. Это очень быстрая функция, подавляющим большинством своих реализаций опирающаяся на команду циклической пересылки "REPMOVSD", копирующую по четыре байта за каждую итерацию. Но в некоторых ситуациях (например, при работе с блоками большого размера) производительности memcpy начинает катастрофически не хватать и у программистов появляется неудержимое стремление ну хоть немного оптимизировать ее.Попробовать переписать memcpy на ассемблер? – Напрасный труд! Даже удалив весь обвязочный код, обеспечивающий копирование блоков с размером не кратным четырем, вам не удастся выиграть больше чем один-два процента! А вот правильный выбор адреса начала копируемого блока действительно ощутимо улучшает результат. Подтверждение тому – диаграмма, приведенная на рис. 0х017 и иллюстрирующая зависимость скорости копирования блоков памяти (вертикальная ось) от величины смещения относительно начала блока памяти, выделенного функцией malloc
(в данном случае возвращенный ею адрес заведомо кратен 0x10).
Смотрите – большие блоки памяти (т.е. такие, размер которых значительно превосходит кэш второго уровня), начинающиеся с адресов кратным четырем, обрабатываются практически в полтора раза быстрее, а блоки умеренного размера (целиком умещающиеся в кэше второго уровня) – практически втрое быстрее, чем блоки, начинающиеся с адресов, не кратным четырем!
И неудивительно – отсутствие выравнивания приводит к тому, что в каждом октете скопированных двойных слов, неизбежно будет присутствовать двойное слово, начинающиеся в одной кэш-линейке, а заканчивающиеся в другой. Процессоры Pentium шибко не любят такие "не правильные" слова и взыскивают за их обработку штрафные такты задержки.

Рисунок 48 graph 0x017 Зависимость относительной скорости копирования от адреса начала блока на процессоре Pentium-III 733/1333/100. За 100% принято время копирования блоков памяти, начинающихся с адреса, кратного 0х10.
Поэтому, при интенсивном копировании строк (структур, объектов, массивов) желательно добиться, чтобы они располагались в памяти по адресам, кратным четырем. (Подробнее об этом см. "Оптимизация обращения к памяти и кэшу. Выравнивание данных").
Однако в некоторых случаях выполнить выравнивание принципиально невозможно. Это бывает в частности тогда, когда адрес копируемого объекта поступает извне, например, возвращается операционной системой. Впрочем, это не повод для расстройства – увеличив начальный адрес до величины кратной четырем, мы избежим падения производительности. А пропущенный хвост можно скопировать и отдельно. Даже если это и вызовет штрафную задержку, потери времени будут ничтожно малы, т.к. максимальная протяженность "хвоста" никогда не повышает трех байт.
В копировании больших блоков памяти есть еще один фокус. – Если память копируется слева направо (т.е. с увеличением адресов), а скопированный блок обрабатывается от начала к концу (как чаще всего и бывает), то потребуются дополнительные такты ожидания на время пока начало блока не будет загружено в кэш. Если же схитрить и копировать память справа налево (т.е. от больших адресов к меньшим), то по завершению копирования начало блока окажется в кэше и может быть моментально использовано!
Но не спешите набрасываться на инструкцию "STD\REP MOVSD", копирующую двойные слова в обратном направлении! Подсистема памяти IBM PC оптимизирована именно под прямое копирование, а на обратном теряется до десяти-пятнадцати процентов производительности. Поэтому, приходится изощряться, обрабатывая блок справа налево небольшими "кусочками", копируя каждый из них в прямом направлении. Это можно сделать, например, следующим образом:
my_memcpy(char *dst, char *src, int len)
{
int a=STEP_SIZE; // Размер копируемых "кусочков"
while(len) // Копировать пока не скопируем все
{
if (len
< a) a=len; // Если оставшийся хвост меньше размера
// копируемого "кусочка", коррекрируем
// размер последнего
dst-=a;src-=a; // Уменьшаем указатели на
// требуемую величину
memcpy(dst,src,a); // Копируем очередной кусочек
len-=a; // Уменьшаем длину оставшегося блока
}
}
Листинг 35 Оптимизированное копирование памяти с помещением начала копируемого блока в кэш (Пригодно для любых процессоров)
Единственная проблема – правильно выбрать размер копируемых кусочков. С одной стороны: чем кусочки больше – тем лучше, т.к. это уменьшает накладные расходы на "обвязочный" код. А с другой – слишком большой блок может попросту не поместиться в кэш первого уровня (на P-4 его всего лишь восемь килобайт).
Учитывая, что в кэше находится и копируемый, и скопированный блок, размер кусочков не должен превышать половины размера самого маленького их кэшей, т.е. четырех килобайт. Но эта стратегия будет не самой оптимальной для P-III, а для P-II (CELERON) и вовсе окажется проигрышной, т.к. накладные расходы на выполнение обвязочного кода пересилят выигрыш от попадания в кэш.
По мнению автора, память выгоднее всего копировать 64-128 килобайтными кусками. В среднем это дает 10%-15% ускорение по сравнению со штатной функцией memcpy, но в отдельных случаях выигрыш может быть намного большим. Впрочем, размер кусочков нетрудно задавать и опционально – через файл настроек программы, конфигурируемый конечными пользователями (или определять тип и параметры процессора автоматически).
Вот, пожалуй, и все способны оптимизации копирования памяти на младших моделях процессоров Pentium. Гораздо больше существует популярных способов "пессимизации" копирования памяти. В первую очередь хотелось бы обратить внимание на широко распространенное заблуждение, связанное с машинной инструкцией MOVSQ. Эта MMX'овая команда оперирует 64-битными (8 байтовыми) операндами, что делает ее очень привлекательной в глазах множества программистов, наивно полагающих: чем больше размер операнда, тем быстрее происходит его копирование.
На самом же деле это не совсем так. При обработке больших блоков памяти (т.е. таких, размер которых намного превышает размер L2 кэша) на быстрых процессорах (в частности P-III), вы получите не худшую производительность, а на P-II скорость даже снизиться от 1.3 до 1.5 раз! Действительно, производительность MOVSD в первую очередь определяется не быстродействием процессора, а временем доступа к памяти, поэтому при копировании больших блоков памяти MOVSQ с учетом "обвязочного" кода быстрее никак не будет.
Обработка блоков памяти умеренного размера (целиком умещающихся в L2-кэше) посредством команды MOVSQ на P-II на 10%, а на P-III даже на 40% обгоняет MOVSD! Но при дальнейшем уменьшении размера копируемых блоков ситуация меняется на диаметрально противоположную. Блоки, не вылезающие за пределы L1-кэша, с помощью MOVSQ
на P-II копируется на 40%-80% медленнее (накладные расходы на обвязочный код дают о себе знать!), а на P-III лишь на несколько процентов быстрее, чем копируемые MOVSD. (см. рис. 0х21)
Таким образом, функцию копирования, базирующуюся на команде MOVSQ, в качестве основной функции переноса памяти использовать нерационально. Она оправдает себя лишь при многократной обработке блоков порядка 0x40-0x80 килобайт на момент копирования уже находящихся в кэше. В противном случае будет намного лучше воспользоваться шатанной функцией memcpy. (К тому же следует учитывать, что MOVSQ отсутствует на ранних процессорах Pentium без MMX, парк которых до сих пор еще достаточно широк, так стоит ли отсекать большое количество потенциальных пользователей ради незначительного увеличения скорости работы вашей программы?)

Рисунок 49 graph 0x021 Результаты тестирования функции копирования памяти четвертными словами на блоках различного размера на процессорах CELERON-300A/66/66 и Pentium-III 7333/133/100. Скорость копирования измеряется в относительных единицах в сравнении со штатной функцией memcpy, производительность которой принята на 100%.
Другое распространенное заблуждение гласит, что MOVSD вовсе не предел производительности и яко бы вручную можно создать супер-оптимизированный цикл, выполняющийся намного быстрее ее.
Действительно, линейки кэша первого уровня состоят из восьми независимых банков, которые могут обрабатываться параллельно (в частности, процессоры P-II способны за один такт обрабатывать два таких банка) и в правильно организованном цикле за вычетом обвязочного кода теоретически возможно копировать по 8 байт за каждые два такта. Инструкция REP MOVSD за это же время копирует только 4 байта.
На практике, однако, двукратного выигрыша достичь не удается – львиную долю прироста производительности "съедает" обвязочный код. Во всяком случае, автору так и не удалось создать цикл, который бы на всех моделях процессоров обгонял REP MOVSD
хотя бы на десяток-другой процентов. В лучшем случае код выполнялся не хуже, а зачастую в полтора-два раза медленнее штатной функции memcpy!
А, может быть, стоит обратиться к контроллеру DMA (про то, что современные операционные системы прикладным приложениям прав доступа к контроллеру просто не дадут, мы скромно промолчим)? Легенда об использовании DMA для копирования памяти давно уже будоражит умы программистов, но на самом деле она безосновательна. Тип передачи "память à память" ни в оригинальной IBM AT, ни в современных клонах оной не реализован. Возьмем, например, описание чипсета Intel 82801 и откроем его на странице 8-25, где содержится описание режимов передачи. Там, среди прочей полезной информации, мы найдем такие слова:
|
Bit |
Description |
|
3:2 |
DMA Transfer Type. These bits represent the direction of the DMA transfer. When the channel is programmed for cascade mode, (bits[7:6] = "11") the transfer type is irrelevant. 00 = Verify-No I/O or memory strobes generated 01 = Write-Data transferred from the I/O devices to memory 10 = Read-Data transferred from memory to the I/O device 11 = Illegal |
память". А раз его нет, то и говорить не о чем!
Оптимизация копирования в старших моделях процессоров Pentium. Команды управления кэшированием, впервые появившиеся в процессоре P-III, это, выражаясь словами известного юмориста, "не только ценный мех", но и превосходное средство ускорить копирование компактных блоков памяти вчетверо, а умеренных и больших – по крайней мере втрое!
Этот замечательный результат достигается, как ни странно, использованием всего двух команд: инструкции предвыборки данных в кэш первого (второго на P-4) уровня – prefetchnta и инструкции некэшируемой записи восьмерных (!) слов – movntps, выгружающей 128-битные операнды из SIMD-регистра в память. (Внимание: копируемые данные должны быть выровнены по 16-битной границе, в противном случае процессор сгенерирует исключение).
Поскольку, prefetchnta
– не блокируемая инструкция (т.е. молниеносно возвращающая управление задолго до своего фактического завершения), процессор может загружать очередную порцию данных из буфера-источника параллельно с переносом предыдущей порции в буфер-приемник. К тому же такая схема копирования не "загаживает" кэш второго уровня, оставляя скэшированные ранее данные в целости-сохранности, что немаловажно для большинства алгоритмов.
Но, как бы там все ни было хорошо, обязательно помните, что эти команды работают только на P-III+, а на более ранних процессорах (в т.ч. и AMD Athlon) генерируют исключение "неверный опкод".
Поскольку, компьютеры на базе P-II и P-MMX все еще продолжают использоваться (причем, достаточно широко), следует либо создавать отдельные версии программ для новых и старых процессоров (но это увеличит трудности по их тестированию и сопровождению), либо перехватывать исключение "неверный опкод" и программно эмулировать отсутствующие команды (что приведет к очень большим тормозам), либо автоматически определять процессор при старте программы и использовать соответствующую функцию копирования. (Хорошая идея поместить различные версии функций копирования в различные DLL, избавляясь от условных переходов внутри функции).
Последний способ, похоже, наиболее предпочтителен, хотя, все же и он не лишен недостатков.
Новые команды – это, конечно, хорошо, но есть ли от них хоть какая-то польза, пока на рынке не появятся компиляторы, обеспечивающие их поддержку? Ну, во-первых, такие компиляторы уже есть и распространяются самой Intel, а, во-вторых, ничто не мешает программисту использовать ассемблерные вставки в своем любимом компиляторе (правда, не все умеют программировать на ассемблере).
Будет большим заблуждением считать, что поддержка компилятором новых команд автоматически увеличит производительность приложения и вся оптимизация сведется к тривиальной перекомпиляции. Новые команды предлагают новые концепции программирования, что требует кардинального пересмотра алгоритмов программы. А решать алгоритмические задачи до сих пор способен лишь человек – компиляторам это не "по зубам".
Писать программы на ассемблере – тоже не лучший выход. Решение, предложенное Intel, носит компромиссный характер и заключается во введении встроенных операторов (intrinsic), функционально эквивалентных командам процессора, но обладающих высокоуровневым интерфейсом с "человеческим лицом". Например, конструкция "void _mm_prefetch(char *a, int sel)" на самом деле является не функцией, а завуалированным вызовом команды prefetchx. Встретив ее в тексте программы, компилятор отнюдь не станет вызывать подпрограмму, а непосредственно вставит соответствующую инструкцию в код, минимилизируя накладные расходы. (Некоторые операторы заменяются не на одну, а на целую группу совместно употребляемых машинных команд, но сути дела это не меняет).
Подробное описание intrinsic'сов и соответствующих им машинных инструкций можно найти в "Интел Инстракшен Сет" (Instruction Set Reference) и справочном руководстве, прилагаемом к компилятору (Intel C/C++ Compiler User's Guide With Support for the Streaming SIMD Extensions 2). Поясняющие примеры, приведенные в руководстве по оптимизации (Intel Architecture Optimization Reference Manual), в подавляющем большинстве написаны на Си с intrinsic'ами, и для их понимания необходимо знать: какой intrinsic какой команде процессора соответствует (их мнемоники очень часто не совпадают).
Поэтому, обращаться к таблице соответствий придется не зависимо от того: программируете ли вы на чистом ассемблере или Си (Фортране).
Хорошо, Intel справилась с проблемой, но как быть пользователям других компиляторов? Не отказываться же от своих любимых продуктов в угоду прогрессу (тем более, что Intel предоставляет компиляторы всего лишь двух языков – Си\Си++ и Фортрана, к тому же распространяет их отнюдь не бесплатно)!
Выход состоит в использовании ассемблерных вставок, а точнее даже не ассемблерных, а машинно-кодовых, поскольку сомнительно, чтобы ваш любимый транслятор понимал мнемоники, придуманные после его создания. В ассемблерах MASM и TASM ручной ввод кода обычно осуществляется через директиву "DB", а в компиляторах Microsoft Visual C++ и Borland C++ для той же цели служит директива "emit". К сожалению, синтаксис ее вызова различен для каждого из компиляторов, что приводит к проблемам переносимости.
Так, Microsoft Visual C++ предваряет emit одиночным символом прочерка и требует обязательного его помещения в ассемблерный блок. Например:
main()
{
__asm
{
; "рукотворное" создание инструкции INT 0x66 (опокд – CD 66)
_emit 0xCD
_emit 0x66
}
}
Компилятор Borland C++, напротив, предписывает окантовывать emit двойным символом прочерка с обеих сторон и ожидает его появления вне ассемблерного блока. Например:
main()
{
; "рукотворное" создание инструкции INT 0x66 (опокд – CD 66)
__emit__(0xCD, 0x66);
}
Разумеется, ручной ввод машинных кодов команд – утомительное и требующее определенной квалификации занятие. Проблема в том, что "Интел инстракшен сет" содержит лишь базовые опкоды инструкций, без перечисления всех возможных способов адресации. Например, опкод инструкции prefetchnta
выглядит так: 0F 18 /0. Если попытаться задать его с помощью emit как "__emit__(0xF, 0x18, 0x0)", то ничего не получится! (Кстати, это частая ошибка начинающих).
Ведь prefetchnta
ожидает операнда- указателя на адрес памяти, по которому следует произвести предвыборку. Где же он тут? А вот где: обратите внимание, что последний байт опкода инструкции предварен косой чертой – это обозначает, что приведено не все содержимое байта, а лишь те биты, которые хранят опкод инструкции. Остальные же определяют тип адресации, указывая процессору: где именно следует искать ее операнды (операнд).
В рамках данной книги вряд ли было бы целесообразно подробно рассказывать о формате машинных команд, поэтому автору ничего не остается, как отослать всех интересующихся к первым восьми страницам "Интел Инстракшен Сет", где это подробно описано. Во всяком случае, реализации двух необходимых для оптимизации копирования памяти команд, приведены ниже.
// Функция предвыбирает 32-байтовую строку в L1-кэш на P-III
// и 128-байтовую строку в L2-кэш на P-4
// Аналог _mm_prefetch((char*)mem, _MM_HINT_NTA)
__forceinline void __fastcall __prefetchnta(char *x)
{
__asm
{
mov eax,[x]
; prefetchnta [eax]
_emit 0xF
_emit 0x18
_emit 0x0
}
}
// Функция копирует 128-бит (16-байт) из src
в dst.
// Оба указателя должны быть выровнены по 16-байтной границе
// Аналог _mm_stream_ps((float*)dst,_mm_load_ps((float*)&src))
void __forceinline __fastcall __stream_cpy(char *dst,char *src)
{
__asm
{
mov eax, [src]
mov edx, [dst]
; movaps xmm0, oword ptr [eax]
_emit 0xF
_emit 0x28
_emit 0x0
; movntps oword ptr [edx], xmm0
_emit 0xF
_emit 0x2B
_emit 0x2
}
}
Листинг 36 Пример реализации команд процессора P-III+ ассемблерными вставками на Microsoft Visual C++.
Пару слов о способах вызова "рукотворных" команд. Это только кажется, что все просто, а на самом же деле, на этом пути сплошное нагромождение ловушек, трудностей и проблем. Вот только некоторые из них…
Достаточно очевидно, что оформлять одиночные процессорные команды в виде cdecl- или stdcall-функций невыгодно – зачем разбрасываться командами процессора? (Хотя, к чести P-III/P-4 стоит сказать, что при его скорости накладными расходами на вызовы функцией можно безболезненно пренебречь).
Отсюда и появляется квалификатор __forceinline, предписывающий компилятору встраивать вызываемую функцию непосредственно в тело вызывающей.
Правда, компилятор – животное упрямое и душа его – потемки. Встраиванию функции препятствует целый ряд противопоказаний (см. описание квалификатора inline
в прилагаемой к компилятору документации). Так, например, встраиваемыми не могут быть голые (naked) функции – т.е. функции без пролога и эпилога, часто используемые программистами, не полагающимися на удаление избыточного кода оптимизатором.
Кстати, об оптимизаторе. Соглашение Microsoft о быстрых вызовах (см. ключевое слово __fastcall) предписывает передавать первый аргумент функции в регистре ECX, а второй – в EDX. Так почему же в выше приведенных примерах автор перепихивает содержимое первого регистра в EAX, если можно преспокойно обратиться к ECX? Увы, коварство оптимизатора этого не позволяет. Обнаружив, что на аргументы функции явных ссылок нет (и, конечно же, благополучно забыв о регистрах), оптимизатор думает – а на кой передавать эти аргументы, если они не используются? И не только думает, но и не передает! В результате, в регистрах оказывается неинициализированный мусор, и функция, естественно, не работает! К сожалению, любое обращение к аргументам из ассемблерного блока приводит к автоматическому созданию фрейма, адресуемого через регистр EBP, т.е., аргументы функции передаются не через регистры, а через локальные стековые переменные!
Словом, избежать накладных расходов таким путем, увы, не удается… Впрочем, эти расходы не слишком велики и ими, скрепя сердце, можно пренебречь.
С алгоритмом оптимизированного копирования тоже не все безоблачно. Пример реализации, приведенный в "Intel® Architecture Optimization Reference Manual" – руководству по оптимизации под P-II и P-III (Order number 245127-001), содержит множество ошибок (вообще такое впечатление, что это руководство готовили в страшной спешке, – вот что значит первая ревизия!). К тому же, он не оптимален под P-4, а пример, приведенный в руководстве оптимизации по P-4, не оптимален под P-III! Поэтому, просто скопировать код программы в буфер обмена и откомпилировать – не получится.
Хочешь – не хочешь, а приходится доставать мозги с полки и мыслить самостоятельно. Значит, так…
На первом месте, конечно же, должен быть цикл предвыборки, дающий процессору задание на загрузку данных из основной памяти в кэш первого уровня (второго на P-4). Затем, загруженные в кэш данные могут быть мгновенно (в течении одного такта) прочитаны и занесены в SIMD-регистр, по эстафете передаваемый инструкции некэшируемой записи для выгрузки в память. (Использование промежуточного регистра объясняется тем, что адресация типа "память à
память" в микропроцессорах Intel 80x86 отродясь отсутствует).
Остается найти оптимальную стратегию предвыборки и записи. Решение, первым приходящее на ум, – перемежевать операции переноса данных с их предвыборкой, неверно. Правда, объяснить: почему оно неверно "на пальцах", не углубляясь в тонкости реализации шинных транзакций, невозможно. В общих чертах дело обстоит так: системная шина у процессора одна, но запросы на чтение/запись памяти делятся на множество фаз, которые могут перекрывать друг друга. Полного параллелизма в такой схеме, естественно, не достигается и неупорядоченная обработка запросов несет свои издержки. Уменьшение количества транзакций между чтением и записью данных позволяет значительно ускорить доступ к памяти, что, в свою очередь, существенно увеличивает производительность системы.
Следовательно, целесообразнее выполнять предвыборку в одном, а копировать память в другом цикле. Но, вот вопрос, – какими кусками "нарезать" память? В приведенном ею примере Intel рекомендует выбрать размер блока равным размеру кэша первого уровня для P-III и половине кэша второго для P-4 (т.к. у него команда предвыборки умеет загружать данные только в этот кэш). В то же время, описывая команду предвыборки в руководстве по оптимизации под P-4, Intel остерегает пересекать 4-килобайтовую границу страниц при предвыборке. (Такое впечатление, что авторы технической документации из Intel понимают в выпускаемых их фирмой процессорах меньше, чем сторонние разработчики).
Итак, у нас имеются два противоречащих друг другу утверждения, – какому же из них верить? Но зачем, собственно, верить? Наука – она тем от религии и отличается, что в первую очередь полагается не на авторитеты и логические умозаключения, а на эксперимент. Эксперимент же недвусмысленно скажет, что наилучшая производительность достигается при предвыборке блоков размером в 4 килобайта – т.е. одной страницы, а при увеличении размера начинает стремительно падать.
Так, с этим мы разобрались. Идем дальше. Реализация цикла предвыборки (между нами говоря) представляет собой достойный увековечивания пример как не надо программировать. Смотрите сами: "for (j=kk+4; j
должно стоять PAGESIZE, мы уже выяснили, но вот величина шага приращения цикла очень интересна. Теоретически она должен быть равна размеру предвыбираемой строки – 32 байта на P-III и 128 байт на P-4. Откуда же здесь взялась четверка? Дело в том, что данный пример копирует массив, состоящий из элементов типа double, каждый из которых имеет размер 8 байт (про sizeof парни из Intel, конечно же, забыли), а 8 x 4 == 32! А если придется копировать данные другого типа? Так не лучше ли перейти от частностей к бестиповым указателям void? К тому же, данная реализация процессорно-зависима и не оптимальна на P-4. Правильным решением будет определять тип процессора при старте программы и выбирать соответствующий ему шаг цикла – 32 байта на P-III и 128 байт на P-4.
Следующий на очереди цикл копирования памяти. В Intel'ом примере допущена еще одна грубая ошибка (не иначе, как документацию писали под пивом) – "for (j=kk; j
фигурирует NUMPERPAGE
– количество элементов на страницу. Это уже лучше, но все же остается непонятным стремление авторов документации любой ценой избежать обращения к sizeof.
Тело цикла состоит из нескольких подряд идущих команд переноса 128-битного (16-байтового) блока памяти – в примере из руководства по P-III их два, а P-4 – аж восемь! Вакх! Зачем нам столько, когда достаточно и одной? А затем – чем больше операций копирования выполняется в каждой итерации цикла, тем меньше накладные расходы на организацию этого цикла. Различие же в количестве операций в обоих процессорах объясняется тем, что на P-III размер кэш-линий равен 32 байтам, а на P-4 – 128.
Поскольку, каждая операция копирования переносит 16 байт памяти, легко видеть, что в обоих случаях за одну итерацию цикла кэш-линия обрабатывается целиком, а задержки, связанные с поддержкой цикла, приурочиваются к переходу на следующую кэш–линию, что обеспечивает максимально возможную производительность. Любопытно, что цикл переноса памяти, оптимизированный под P-4, ничуть не хуже чувствует себя и на P-III, а вот пример, оптимизированный под P-III, попав на P-4, показывает далеко не лучший результат. Универсальным решением будет перенос 128–байтного блока памяти за каждую итерацию – это оптимально для обоих процессоров.
Теперь остается решить лишь технические проблемы. Во-первых, рассмотреть случай копирования блока памяти, размер которого не кратен размеру страницы – оставшийся хвост последней страницы придется переносить отдельно. Во-вторых, команда некэшируемой записи требует (а команда чтения рекомендует) чтобы данные были выровнены по 16-байтовой границе. В противном случае генерируется исключение. Вообще-то, требование выравнивания можно просто оговорить в спецификации функции, но будет лучше, если функция автоматически выровняет переданные ей указатели, не забыв, конечно, скопировать остаток обычным способом.
И, наконец, последнее: в фирменном примере присутствует бессмысленная на первый взгляд конструкция: "temp = a[kk+NUMPERPAGE]; // TLB priming" – для чего она предназначена? Программистам, знакомым с защищенным режимом, должно быть известно, что для трансляции виртуальных адресов в физические, процессор обращается к специальной структуре данных – страничному каталогу.
Страничный каталог формируется операционной системой и хранится в оперативной памяти. А оперативная память – это тормоза. Поэтому, данные страниц, к которым обращались в последнее время, автоматически запоминаются в специальном кэше – буфере ассоциативной трансляции (TLB - Transaction Look aside Buffer). Разумеется, данных о станицах, к которым еще не обращались, в TLB нет. Конструкция "temp = a[kk+NUMPERPAGE];"
как раз и загружает данные следующей копируемой страницы в TLB. Правда, остается непонятным, почему Intel выполняет упреждающую загрузку в TLB с одного лишь буфера-источника, забывая о приемнике. К тому же, загрузка данных страницы в TLB чтением ячейки памяти – блокируемая инструкция. Так какая разница: когда она произойдет сейчас или позже в ходе реального обращения к странице? (Правда, можно предположить, что с учетом внеочередного исполнения команд на P-III+ данная конструкция все же будет не блокируемой, но экспериментально подтвердить эту гипотезу не удается). К сожалению, это рождает проблему выхода за границы копируемого массива (ведь в последней итерации цикла мы обращаемся к возможно не существующей странице за его концом и если не предпринять дополнительных мер – операционная система может выбросить исключение, а дополнительные меры – это лишние накладные расходы). Ко всему прочему, оптимизирующий компилятор просто уничтожит бессмысленное (с его точки зрения!) присвоение – ведь переменная temp никак не используется в программе! Автором было экспериментально установлено, что удаление этой конструкции в не ухудшает производительности, поэтому, в своих разработках он рискнул ее не использовать.
Приведенный ниже пример реализации функции турбо-копирования для оптимизации под конкретный процессор использует два определения: _PAGE_SIZE, в обоих случаях равное 4 Кб, и _PREFETCH_SIZE – равное 32 байтам для P-III и 128 для P-4. Адреса источника и приемника должны быть выровнены по 16-байтовой границе, а размер копируемого блока – кратен размеру страницы.
Эти ограничения объясняются стремлением максимально упростить листинг для облегчения его понимания.
_turbo_memcpy(char *dst, char *src, int len)
{
int a, b, temp;
for (a=0; a < len; a += _PAGE_SIZE)
{
// Предвыбираем
temp = *(int *)((int) src + a + _page_size);
for (b = a; b < a + _PAGE_SIZE; a += _PREFETCH_SIZE)
__prefetchnta(src+b);
for (b = a; b < a + _PAGE_SIZE; b += 16 * 8)
{
__stream_cpy(dst + b + 16*0, src + b + 16*0);
__stream_cpy(dst + b + 16*1, src + b + 16*1);
__stream_cpy(dst + b + 16*2, src + b + 16*2);
__stream_cpy(dst + b + 16*3, src + b + 16*3);
__stream_cpy(dst + b + 16*4, src + b + 16*4);
__stream_cpy(dst + b + 16*5, src + b + 16*5);
__stream_cpy(dst + b + 16*6, src + b + 16*6);
__stream_cpy(dst + b + 16*7, src + b + 16*7);
}
}
return temp;
}
Листинг 37 [Cache/_turbo_memcpy.size.c] Функция турбо-копирования памяти, использующая новые команды управления кэшированием процессора P-III+.
Результаты тестирования функции турбо-копирования на блоках памяти различного размера приведены на рис. 0x02 (К сожалению, процессора P-4 в данный момент не оказалось под рукой и автору пришлось ограничится одним лишь P-III). Впечатляющее зрелище, не так ли? (Особенно в свете того, что функция копирования написана на чистом Си). Но и это еще не предел!
Во-первых, переписав код на ассемблер, можно на несколько процентов увеличить его производительность, добившись, по крайней мере, пяти кратного превосходства над штатной memcpy при копировании небольших блоков.
Во-вторых, представляет интерес рассмотреть частные случаи оптимизации. Например, если копируемые данные уже находятся к кэше (что очень часто и происходит при обработке блоков данных небольших размеров), цикл предвыборки можно исключить. (Особенно это актуально для программ, выполняющихся под P-4, команда предвыборки которого загружает данные в кэш второго, а не первого уровня).
При копировании блоков большого размера, обрабатываемых с начала, напротив, целесообразно использовать дополнительный цикл предвыборки, загружающий скопированные данные в кэш.
Впрочем, оптимальная стратегия копирования очень сильно зависит от конкретной ситуации и универсальных решений не существует – каждая программа требует своего подхода. Поэтому, не будем давать готовых решений, оставляя читателям полную свободу творчества.

Рисунок 50 graph 0x020 Результаты тестирования производительности функции турбо-копирования на блоках памяти различного размера под процессором Pentium-III 733/133/100. Скорость копирования измеряется в относительных единицах в сравнении со штатной функцией memcpy, производительность которой принята за 100%.

Рисунок 51 graph 0x022 График зависимости производительности функции турбо-копирования от размера предвыбираемого блока. [Pentium-III 733/133/100]
Заключение
…полученными результатами можно по праву гордиться: пятикратное ускорение – это что-то! Однако, копирование – не единственная базовая операция с памятью. Две другие – инициализация (заполнение) и сканирование (поиск) ничуть не в меньшей степени влияют на производительность, но требуют к себе совсем иных подходов, нежели копирование.
Об этом (и многом другом) мы и поговорим в следующей статье…
P.S. Да не будет сочтены небольшие придирки к Intel'овской документации за наезд на компанию. Автор, являясь горячим поклонником Intel, далек от мысли бросать в нее камни, но вот пару острых шуточек в ее адрес, все же себе позволяет.
Оптимизация "мертвого" кода
"Мертвым" называют код, никогда не получающий управления. Например, объявит программист функцию, но ни разу не использует, - ситуация знакомая, правда? Зачем же тогда этой функции впустую расходовать память? Увы, ни один из трех рассматриваемых компиляторов не удаляет "мертвые" функции оставляя эту работу на откуп линкеру. Умные линкеры действительно удаляют функции, на которые отсутствуют ссылки, но все же лучше, если это заблаговременно сделает компилятор.Возьмем другой пример – пусть в программе присутствует следующий код, выводящий отладочное сообщение на экран, если макро DEBUG определен как TRUE:
if (DEBUG) printf("Некоторое отладочное сообщение");
При компиляции финальной версии DEBUG объявляется как FALSE и отладочный код никогда не выполняется, поэтому, имеет смысл его удалить. Компилятор Microsoft Visual C++ удаляет и проверку условия, и тело условного оператора (в данном случае – вызов функции printf), но забывает "вычистить" константную строку. Правда, есть надежда, что ее удалит продвинутый линкер, не обнаружив на нее ни одной ссылки. Вот компилятор WATCOM начисто удаляет весь мертвый код – и проверку условия, и тело условного оператора, и константную строку. А компилятор Borland C++ вообще не удаляет мертвый код, послушно выполняя проверку константы (константы!) FALSE на равенство TRUE.
Оптимизация передачи аргументов
Механизмы вызова и передачи аргументов функциями стандартных типов (как-то cdecl, stdcall, PASCAL) жестко декларированы, и никакой самодеятельности оптимизатор позволить себе не может. Стандарт предписывает передавать аргументы через стек, т.е. через жутко тормозную оперативную память, и, чем больше аргументов принимает функция, тем значительнее накладные расходы на каждый ее вызов.Если же тип функции не задан явно, компилятор имеет право передавать ей аргументы так, как он сочтет нужным. Эффективнее всего передавать аргументы через регистры, чтение/запись которых укладывается в один такт процессора (обращение к памяти может потребовать не одного десятка тактов в зависимости от ряда факторов – быстродействия микросхем и контроллера памяти, частоты шины, наличия или отсутствия данных ячеек в кэше и т.д.). Беда в том, что даже у старших моделей микропроцессоров Pentium регистров общего назначения всего семь и их приходится делить между аргументами и возвращаемыми значениями функций, регистровыми и промежуточными переменными. Выделить все семь регистров под аргументы – было бы слишком глупым решением, ибо в этом случае переменные, содержавшие передаваемые аргументы пришлось бы размещать в оперативной памяти, сводя весь выигрыш на нет.
Компилятор Microsoft Visual C++ отводит для передачи аргументов два регистра, Borland C++ - три, а WATCOM – четыре. Вопрос: "чья стратегия лучше?" остается открыт и единого мнения нет. Автор этой статьи склоняется к мысли, что два регистра – действительно, наилучший компромисс. К слову сказать, Microsoft C 7.0 – "прародитель" Microsoft Visual C++ – использовал для передачи аргументов три регистра, но после серии испытаний, осуждений, споров и дебатов, его разработчики пришли к выводу, что два регистра обеспечивают лучшую производительность, нежели три.
Другой важный момент – тип вызова функции по умолчанию. Компилятор Microsoft Visual C++ задействует регистровую передачу аргументов только в том случае, если перед функцией указан квалификатор __fastcall (исключение составляет неявный аргумент this по умолчанию передаваемый через регистр). Компиляторы же Borland C++ и WATCOM по умолчанию используют регистровую передачу аргументов. Поэтому, если вы используете Microsoft Visual C++, вставляйте квалификатор __fastcall самостоятельно.
Оптимизация подвыражений
Если выражение содержит два или более идентичных подвыражения, то вполне достаточно вычислять значение лишь одного из них.Рассмотрим следующий пример:
if ((a*b)>0x666 && (a*b)<0xDDD) …
Присвоив результат вычисления (a*b) промежуточной переменной, мы сможем избавиться от одной операции умножения, смотрите:
tmp=a*b;
if (tmp>0x666 && tmp<0xDDD) …
Оптимизировать выражения умеют все три рассматриваемых компилятора, но не каждый из них способен распознавать идентичность выражений при их перегруппировке. Вот, например:
if ((a*b)>0x666 && (b*a)<0xDDD) …
Очевидно, что от перестановки множителей произведение не меняется и (a*b) равно (b*a). Компиляторы Microsoft Visual C++ и WATCOM вычислят значение (a*b) лишь однажды, а Borland C++ примет (a*b) и (b*a) за разные
выражения со всеми вытекающими отсюда последствиями.
Оптимизация пролога/эпилога функций
Ранние Си-компиляторы использовали для адресации локальных переменных базовый указатель стека – регистр BP (EBP в 32-разрядном режиме), помещая в начало каждой функции специальный код, называемый прологом. Пролог сохранял текущее содержимое регистра BP (EBP) в стеке и копировал в него указатель вершины стека, хранимый в регистре SP (ESP). Затем, уменьшением значения регистра SP (ESP), выделялась память локальным переменным функции (стек, как известно, растет снизу вверх).По завершению функции код эпилога вновь "опускал" регистр-указатель вершины стека, освобождая память, занятую локальными переменными, и восстанавливал значение базового указателя стека – регистра BP (EBP).
Ну, резервирование/освобождение памяти – это понятно, но вот регистр BP (EBP) зачем? А вот зачем: он хранит указатель кадра стека – региона памяти, отведенного под локальные переменные.
Все три рассматриваемых компилятора адресуют локальные переменные иначе – непосредственно через ESP. Это значительно усложняет реализацию компилятора, т.к. указатель вершины стека меняется в ходе выполнения программы и адресация выходит "плавающей", зато такая техника высвобождает один регистр для регистровых переменных и избавляется от двух операций обращения к памяти (сохранения/восстановления EBP), что заметно повышает производительность.
__встраиваемые функции
__отложенное выталалкивание аргументов из стека
Оптимизация работы с памятью
Эта глава посвящена вопросам оптимизации обработки больших массивов данных и потоковых алгоритмов, – всем тем ситуациям, когда интенсивный обмен с памятью неизбежен. (Обработка компактных структур данных с многократным обращением к каждой ячейки – тема отдельного разговора, подробно рассмотренная в главе "Оптимизация обращения к памяти и кэшу").Несмотря на стремительный рост своей пропускной способности и значительное сокращение времени доступа,– оперативная память по-прежнему остается одним из узких мест, сдерживающих производительность всей системы. Тем более обидно, что в силу архитектурных особенностей платформы IBM PC, теоретическая (она же – заявленная) пропускная способность практически никогда не достигается.
Типовые алгоритмы обработки данных задействуют быстродействие оперативной памяти едва ли на треть, а зачастую намного менее того! Удивительно, но большинство программистов даже не подозревают об этой проблеме! Одно из возможных объяснений этого феномена заключается в том, что мало кто измеряет производительность своих программ в мегабайтах обработанной памяти в секунду (а если и измеряет, то списывает низкую пропускную способность на громоздкость вычислений, хотя время, потраченное на вычисления, в данном случае играет второстепенную роль).
Грамотно организованный обмен данными выполняется как правило в три-четыре раза быстрее, причем (и это замечательно!) эффективное взаимодействие с памятью достижимо на любом языке (в том числе и интерпретируемом!), а не ограничено одним лишь ассемблером.
Вопреки возможным опасением читателей, предложенные автором приемы оптимизации аппаратно независимы и успешно работают на любой платформе под любой операционной системой. Вообще-то, в каком-то высшем смысле, все обстоит не совсем так и выигрыш в производительности достигается исключительно за счет учета конкретных конструктивных особенностей конкретной аппаратуры, ? бесплатного хлеба, увы, не бывает. Тем не менее, на счет переносимости автор не так уж и соврал, – подавляющее большинство современных систем построено на базе DRAM и принципы работы с различными моделями динамической памяти достаточно схожи. Во всяком случае, в ближайшие несколько лет никаких революций в этой области ожидать не приходится.
Что касается же DDR- и Rambus DRAM памяти, – техника оптимизации под нее придерживается полной преемственности, и дает весьма значительный прирост производительности, намного больший, чем в случае с "обычной" SDAM. Нужно ли лучшее подтверждение переносимости предложенных алгоритмов?
Оптимизация распределения переменных
В языках Си/Си++ существует ключевое слово "register", предназначенное для принудительного размещения переменных в регистрах. И все бы было хорошо, да подавляющее большинство компиляторов втихую игнорируют предписания программистов, размещая переменные там, где, по мнению компилятора, им будет "удобно". Разработчики компиляторов объясняют это тем, что компилятор лучше "знает" как построить наиболее эффективный код. "Не надо", – говорят они, "пытаться помочь ему". Напрашивается следующая аналогия: пассажир говорит – "мне надо в аэропорт", а таксист без возражений едет "куда удобнее".Ну, не должна работа на компиляторе превращаться в войну с ним, ну никак не должна! Отказ разместить переменную в регистре вполне законен, но в таком случае компиляция должна быть прекращена с выдачей сообщения об ошибке, типа "убери register, а то компилить не буду!", или на худой конец – выводе предупреждения.
Впрочем, ладно, все это лирика. Гораздо интереснее вопрос – какую именно стратегию распределения переменных по регистрам использует каждый компилятор.
Компиляторы Borland C++ и WATCOM при нехватке регистров помещают в них наиболее интенсивно используемые перемеренные, а все остальные "поселяют" в медленной оперативной памяти. Компилятор же Microsoft Visual C++ не учитывает частоты использования переменных и размещает их в регистрах в порядке объявления.
__освобождение переменных
Оптимизация штатных Си-функций для работы с памятью
Штатные библиотеки языка Си включают в себя большое количество функций, ориентированных на работу с блоками памяти. К ним, в частности, относятся: memcpy, memmove, memcmp, memset и др. В подавляющем большинстве случаях эти функции реализованы на ассемблере и достаточно качественно оптимизированы. Тем не менее, резерв производительности еще есть и путем определенных ухищрений можно сократить время обработки больших блоков памяти чуть ли не в несколько раз! Начнем?..Оптимизация memcpy. Большинство реализаций функции memcpy выглядят приблизительно так: "while (count--) *dst++ = *src++". Этот код имеет по крайней мере три проблемы: перекрытие транзакций чтения/записи, невысокую степень параллелизма обработки ячеек и возможность пересечения обоих потоках в одном и том же DRAM-банке.
Перекрытие транзакций чтения/записи устраняется блочным копированием памяти через кэш-буфер: пусть один цикл считывает несколько килобайт источника в кэш, а другой цикл записывает содержимое буфера в приемник. В результате вместо чередования транзакций чтение – запись – чтение – запись… мы получаем две раздельных серии транзакций: чтение ? чтение ? чтение… и запись – запись – запись... Некоторое перекрытие транзакций на границах циклов все же останется, но если размер буфера составляет хотя бы 1 Кб этими издержками можно полностью пренебречь.
Параллелизм загрузки данных легко усилить, если обращаться к ячейкам с шагом равным размеру пакетного цикла чтения (см. "Параллельная обработка данных"). Для простоты можно остановится на шаге в 32 байта, но в критичных к быстродействию приложениях, оптимизируемых под процессоры старшего поколения (AMD Athlon, Pentium?4), эту величину рекомендуется определять автоматически или задавать опционально.
Пагубное влияние возможного пересечения потоков данных в одном DRAM-банке в данной схеме исчезает само собой, поскольку потоки обрабатываются последовательно, а не параллельно.
Образно выражаясь, можно сказать, что одним выстрелом мы убиваем сразу трех (!) зайцев, – копирование памяти через промежуточный буфер ликвидирует все слабые стороны алгоритма штатной функции memcpy, значительно увеличивая тем самым ее производительность.
Улучшенный вариант реализации memcpy может выглядеть, например, следующим образом:
for (a = 0; a < count; a += subBLOCK_SIZE)
{
for(b = 0; b < subBLOCK_SIZE; b += BRUST_LEN)
tmp += *(int *)((int)src + a + b);
memcpy((int*)((int)dst + a ),(int*)((int)src + a), subBLOCK_SIZE);
}
Листинг
29 [Memory/memcpy.optimize.c] Оптимизированная реализация memcpy
На AMD Athlon 1050/100/100/VIA KT133 оптимизированный вариант memcpy выполняется практически на треть быстрее и это очень хорошо! Правда, на P?III/733/133/100/I815EP прирост производительности намного меньше и составляет всего лишь ~10%. Увы, устраняя одни проблемы, мы неизбежно создаем другие. Предложенный способ оптимизации memcpy имеет как минимум два серьезных недостатка. Во-первых, увеличение количества циклов с одного до трех несет значительные накладные расходы, которых никакими ухищрениями невозможно избежать. Во-вторых, цикл, загружающий данные из оперативной памяти в кэш, фактически работает вхолостую, – запихивая полученные ячейки в неиспользуемую переменную, в то время как цикл, записывающий данные в память, вынужден повторно
обращаться к уже загруженным ячейкам. Т.е. count/BRUST_LEN ячеек копируются как бы дважды. К сожалению, первый цикл не может непосредственно записывать полученные ячейки в память, поскольку это неизбежно вызовет перекрытие шинных транзакций и лишь ухудшит производительность.
Ассемблерная реализация данного алгоритма, конечно, увеличит его быстродействие, но не намного. Гораздо лучший результат дает использование предвыборки (см. "Кэш. Предвыборка"), но это уже тем другого разговора.

Рисунок 43 graph 0x021 Демонстрация эффективности параллельного копирования памяти.
Выигрыш особенно ощутим на процессорах Athlon – целых ~30% производительности
Оптимизация memmove Функция memmove, входящая в стандартную библиотеку языка Си, выгодно отличается от своей ближайшей родственницы memcpy тем, что умеет копировать перекрывающиеся блоки памяти. За счет чего это достигается? Если адрес приемника расположен "левее" источника (т.е. лежит в младших адресах) алгоритм копирования реализуется аналогично memcpy, поскольку ячейки памяти переносятся "назад", – в свободную неинициализированную область (см. рис 37 сверху). Единственное условие – количество ячеек памяти, переносимых за одну итерацию, не должно превышать разницу адресов приемника и источника. То есть, если приемник расположен всего в двух байтах от источника, переносить памяти двойными словами уже не получится!

Рисунок 44 0х37 Копирование перекрывающихся блоков памяти. Если источник расположен правее приемника (верхний рисунок), то перенос ячеек памяти происходит без каких-либо проблем. Напротив, если источник расположен левее приемника (нижний рисунок), то перенос ячеек "вперед" приведет к затиранию источника
Гораздо сложнее справится с ситуацией, когда приемник расположен правее источника (т.е. лежит в старших адресах). Попытка скопировать память слева направо приведет к краху, т.к. перенос ячеек будет осуществляться в уже "занятые" адреса с неизбежным затиранием их содержимого. Попросту говоря, memcpy в этом случае будет работать как memset (см. рис. 37 снизу), что явно не входит в наши планы. Как же выйти из этой ситуации? Обратившись к исходным текстам функции memmove (в частности у Microsoft Visual C++ они расположены в каталоге \Microsoft Visual Studio\VC98\CRT\SRC\memmove.c), мы обнаружим следующий подход:
/*
* Overlapping Buffers
* copy from higher addresses to lower addresses
*/
dst = (char *)dst + count - 1;
src = (char *)src + count - 1;
while (count--) {
*(char *)dst = *(char *)src;
dst = (char *)dst - 1;
src = (char *)src - 1;
}
Листинг 30 Пример реализации функции memmove в компиляторе Microsoft Visual C++
Ага, память копируется с зада наперед, т.е. справа налево. В таком случае затирания ячеек гарантированно не происходит, но… за это приходится платить. Ведь ## как мы уже знаем, подсистема памяти оптимизирована именно под прямое чтение, и попытка погладить ее "против шерсти" ничего хорошего в плане быстродействия не несет. Насколько сильно это снижает производительность? На этот вопрос нет универсального ответа. В зависимости от особенностей архитектуры используемого аппаратного обеспечения эта величина может колебаться в несколько раз.
В частности, на Intel P-III/Intel 815EP обратное копирование памяти уступает прямому приблизительно в полтора раза. А вот на AMD Athlon/VIA KT133 разница в скорости между прямым и обратным копированием составляет всего ~2%, чем со спокойной совестью можно пренебречь. Тем не менее, компьютеры на основе Athlon/KT133 занимают значительно меньшую долю рынка, нежели системы на базе Intel Pentium, поэтому не стоит закладываться на такую конфигурацию.
При интенсивном использовании memmove общее снижение производительности может оказаться весьма значительным и неудивительно, если у разработчика возникнет жгучее желание ну хоть немного его поднять. Это действительно возможно сделать, достаточно лишь копировать память не байтами и даже не двойными словами, а… блоками с размером равным разнице адресов приемника и источника. Если размер блока составит хотя бы пару килобайт, память будет копироваться в прямом направлении, хотя и задом наперед. Как это можно реализовать на практике? Рассмотрим следующий пример, написанный на чистом Си без применения ассемблера. Для повышения наглядности отсюда исключен вспомогательный код, обеспечивающий, в частности, обработку ошибок и выравнивание стартовых адресов с последующим переносом "хвоста" (см. "Выравнивание данных").
int __MyMemMoveX(char *dst, char *src, int size)
{
char *p1,*p2;
int a,x=1;
int delta;
delta=dst-src;
if ((delta<1)) return -1;
for(a=size;a>delta;a-=delta)
memcpy(dst+a-delta,src+a-delta,delta);
return 0;
}
Листинг 31 Оптимизированный вариант реализации memmove, копирующий память блоками в прямом направлении, хотя и задом на перед
В сравнении со штатной memmove данная функция работает на 20% быстрее (если разница адресов источника и приемника не превышает размер кэш-памяти первого уровня) и на 30% при перемещении блоков памяти на большое расстояние (см. graph 15). Причем, это еще не предел, – переписав функцию MyMemMoveX на ассемблер, мы получим еще больший прирост производительности (см. так же "Параллельная обработка данных", "Кэш. Предвыборка").
Разумеется, если разница адресов источника и приемника невелика (менее килобайта), то "обмануть" систему не получится, и память будет копироваться скорее в обратном направлении, чем в прямом. При этом, накладные расходы на организацию цикла поблочного копирования вызовут значительное снижение производительности, проигрывая штатной memmove в два и более раз. Поэтому, на область применения MyMemMoveX наложены определенные ограничения.
Хорошо, но ведь бывают же такие случаи, когда перекрывающиеся области необходимо копировать именно "вперед"? Допустим, у нас имеется поток данных, не поддерживающий позиционирование указателя или, скажем, весь блок памяти на момент начала копирования целиком еще недоступен. Вот вполне жизненный пример (из практики автора) – при перемещении фрагментов изображения в графическом редакторе техническое задание требовало выполнять обновление изображения от начала блока к концу. (Дабы пользователь мог приступать к работе с изображением, не дожидаясь, пока блок будет перемещен целиком). Причем, использовать ссылочную организацию данных запрещалось. (Кто сказал, что это глупость? нет, это не глупость, это ? техническое задание).
Тупик? Вовсе нет! Напротив, – возможность поразмять мозги ( ну не все же время рисовать интерфейсы в Visual Studio).
Первое, что приходит на ум, – перенос памяти через промежуточный буфер. Все идея реализуется тривиальным кодом вида:
mymovemem(char *dst, char *src, int size)
{
char *tmp;
tmp=malloc(BLOCK_SIZE);
memcpy(tmp, src, BLOCK_SIZE);
memcpy(dst, tmp, BLOCK_SIZE);
}
Листинг 32 Вариант реализации memmove с переносом памяти через промежуточный буфер
Гуд? Да какой там гуд!!! Во-первых, предложенный алгоритм удваивает
количество потребляемой памяти, что в ряде случаев просто неприемлемо; во-вторых, он в два раза увеличивает время копирования и, наконец, в-третьих, он не решает задачи, поставленной в ТЗ. Ведь обновление начала изображения начнется не сразу, а через довольно продолжительное время, в течение которого будет заполняться временный буфер. Так что не стоит этот алгоритм и обсуждать!
Но, постойте, зачем нам копировать весь перемещаемый блок в промежуточный буфер целиком? Достаточно сохранить лишь ту его часть, которая затирается выступающим влево "хвостом". Т.е. максимально разумный размер буфера равен dst – src. Рассмотрим упрощенный вариант алгоритма прямого перемещения памяти, использующий два таких буфера. Назовем его четырехтактным потоковым алгоритмом копирования памяти. Почему "четырехтактным" станет ясно ниже.
Итак, такт первый. memcpy(BUF_1, dst, dst - src)
– мы сохраняем память приемника, поскольку, этот фрагмент будет затерт в следующем такте (см. рис. 36).
Такт второй: memcpy(dst, src, dst - src)
– мы копируем (dst – src) байт из источника в приемник, не беспокоясь о затираемой памяти, т.к. она уже сохранена в буфере.
Такт третий: memcpy(BUF_2, dst + (dst – src), dst – src) – сохраняем следующую порцию данных приемника во втором промежуточном буфере.
Такт четвертый: memcpy(dst+ (dst – src), BUF_1, dst – src) – "выливаем" содержимое буфера BUF_1 на положенное место (оно только что было сохранено в BUF_2).
Все! Первый буфер освобождается и можно смело переходить к такту I, – "рабочий цикл" нашего "движка" завершен.
Как нетрудно убедиться, копирование происходит только в прямом направлении, причем память приемника обновляется от начала к концу маленькими порциями по (dst – src) байт. (При "мышином" перетаскивании областей изображений в графическом редакторе они, действительно, недалеко уползают за один шаг; кстати, Microsoft Paint (графический редактор из штатной поставки Windows) при перетаскивании изображений перемещает память именно memmove, поэтому жутко тормозит даже на P-III).
Причем, если разница адресов источника и приемника составляет порядка 4-8 Кб, то, несмотря на двойной перегон памяти к буферам и обратно, предложенный алгоритм даже обгоняет
memmove на 10%, – во всяком случае на P-III (см. рис. graph 18). А на AMD Athlon/VIA KT133 разница достигает аж 1,7 крат (в пользу нашего алгоритма, естественно), впрочем, это отнюдь не показатель крутости алгоритма, – просто VIA KT133 так уж устроен.
А теперь давайте подумаем: можно ли уменьшить количество буферов с двух до одного? Разумеется, да, – ведь на момент завершения второго такта, регион [src[0]…src[dst-src]] (на рис. 36 он закрашен красным цветом) уже свободен и может использоваться для временного хранения данных. Однако тут есть один подводный камень, – если адреса "своих" временных буферов мы можем выбирать самостоятельно с учетом архитектуры и организации DRAM, то адрес источника нам дается "извне" со всеми отсюда вытекающими… Разумеется, ничего невозможно нет и при желании обойтись всего одним буфером при не сильно худшей эффективности – вполне возможно, но это значительно усложнит алгоритм и снизит его наглядность. А алгоритм, надобно сказать, и без того не слишком прозрачен (см. листинг…MyMemMove)

Рисунок 45 0x036 "Четырехтактный" алгоритм прямого переноса памяти с использованием двух промежуточных буферов
#define BUF_SIZE 256*K
int __MyMemMove(char *dst, char *src, int size)
{
char BUF_1[BUF_SIZE];
char BUF_2[BUF_SIZE];
char *p1, *p2;
int a,x = 1;
int delta;
delta=dst-src;
if ((delta>BUF_SIZE) || (delta<1)) return -1;
p1 = BUF_1;
p2 = src;
for(a = 0; a
{
memcpy(p1,dst,delta);
memcpy(dst,p2,delta);
if (x)
{
p1 = BUF_2; p2 = BUF_1;
x =
0;
}
else
{
p1 = BUF_1; p2 = BUF_1;
x =
1;
}
dst +=
delta;
}
return 0;
}
Листинг 33 Вариант реализации четырехтактного алгоритма переноса памяти только в прямом направлении с использованием двух промежуточных буферов

Рисунок 46 graph 15 Демонстрация эффективности различных алгоритмов переноса памяти

Рисунок 47 graph 18 Демонстрация эффективности различных алгоритмов переноса памяти (увеличено)

Рисунок
48 graph 16 Сравнение функций memmove и MyMemMove на системе AMD Athlon 1050/100/100/VIA KT133
Оптимизация функции memcmp
Несмотря на то, что функция memcmp не относится к числу самых популярных (так, в MSDN memcpy упоминается 500 раз, а memcmp и memmove – всего 150 и 50 раз соответственно) это еще не дает оснований пренебрегать качеством ее реализации. Начнем с анализа штатных библиотек вашего компилятора. В большинстве случаев сравнение блоков памяти осуществляется приблизительно так:
void * __cdecl _memccpy (void * dest, const void * src, int c, unsigned count)
{
while ( count && (*((char *)(dest = (char *)dest + 1) - 1) =
*((char *)(src = (char *)src + 1) - 1)) != (char)c ) count--;
return(count ? dest : NULL);
}
Листинг 34 Реализация memcmp в компиляторе Microsoft Visual C++ 6.0
Фи! Тормозное побайтное сравнение безо всяких попыток оптимизации! Правда в комплект поставки Visual C++ входит и ассемблерная реализация той же самой функции (ищите ее в каталоге \SRC\Intel). Ну-ка, посмотрим, что там (по соображениям экономии места исходный текст не приводится): ага, если оба указателя кратны четырем, сравнение ведется двойными словами (что намного быстрее) и лишь в противном случае – по байтам. Гуд? А вот и не гуд! Кратность начальных адресов – условие вовсе необязательное для 32-разрядного сравнения (строго говоря, процессоры серии 80x86 вообще не требуют осуществлять выравнивания, просто небрежное отношение с не выровненными адресами может несколько снизить быстродействие – подробнее см. "Выравнивание адресов"). Если три младших бита обоих указателей равны, функция может выровнять их и самостоятельно, просто сместившись на один, два или три байта "вперед".
Впрочем, эти рассуждения все равно беспредметны, поскольку, в режиме оптимизации по скорости (ключ "/O2") Microsoft Visual C++ отказывается от использования ряда библиотечных функций и заменяет их intrinsic-ами (см. "pragma intrinsic" в документации по компилятору). Забавно, но разработчики компилятора, по-видимому, сочли, что выполнять множество проверок и "тянуть" за собой несколько вариантов реализации функции сравнения будет нерационально (?) и потому они ограничились одним универсальным решением – тривиальным побайтовым сравнением. Неудивительно, что после такой "оптимизации" быстродействие memcmp значительно ухудшилось.
Чтобы запретить компилятору самовольничать, – используйте прагму "function" с указанием имени функции, например, так: "#pragma function(memcmp)". В частности, на P-III это ускорит выполнение функции приблизительно на 36%! Правда, на Athlon разница в производительности будет существо меньше – порядка 10%. Кстати, в защиту Microsoft можно сказать, что ее реализация memcmp на 20%-30% быстрее, чем у Borland C++ 5.5.
Но и это еще не предел!
Для memcmp (как и для большинства остальных функций, работающих с памятью) актуальна проблема оптимального чередования DRAM-банков (см. "Стратегия распределения данных по DRAM-банкам"). Если оба сравниваемых блока начинаются с различных страниц одного и того же банка, время доступа к памяти существенно замедляется. Поэтому, мы должны уметь отслеживать такую ситуацию, при необходимости увеличивая один из указателей на длину DRAM-страницы. Это повысит скорость выполнения функции приблизительно на 40% на P-III и на целых 60%-70% на AMD Athlon (да-да, практически в три раза!). Правда, тут есть одно "но". Память должна обрабатываться не байтами, а двойными словами, в противном случае прирост производительности составит всего лишь 5% для P-III и немногим менее 30% для AMD Athlon.
Хорошо, а если адреса сравниваемых блоков к нам поступают "извне" и скорректировать их невозможно? Существует два пути: смириться с низкой производительностью или… сравнивать не сами блоки памяти, а их контрольную сумму. Конечно, теоретически не исключено, что контрольные суммы различных блоков памяти "волшебным" образом совпадут, но в подавляющем большинстве случаев эта вероятность настолько мала, что ей вполне можно пренебречь. К тому же, считать контрольную сумму всего блока абсолютно необязательно – достаточно ограничиться одной DRAM-страницей (можно в принципе и меньшей величиной, главное, чтобы переключения между страницами одного банка происходили не слишком часто). За счет сокращения количества параллельно обрабатываемых потоков данных с двух до одного, хеш-алгоритм работает намного быстрее штатной функции сравнения памяти, обгоняя ее на ~35% и ~55% на P-III и AMD Athlon соответственно. Правда, при оптимальном чередовании банков памяти, хеш-алгоритм все же проигрывает функции, сравнивающей память двойными словами. Причем, если на P-III хеш-алгоритм отстает от нее всего на 1%, то на AMD Athlon разрыв в производительности достигает целых 10%!
Таким образом, хеш-алгоритм целесообразно использовать только
при неоптимальном чередовании DRAM-банков. Впрочем, категоричность этого утверждения смягчает одна оговорка. Если мы сократим длину хешируемого блока до величины пакетного цикла обмена, на P-III мы получим практически 60% выигрыш в производительности, обогнав самый быстрый алгоритм двойных слов более чем на 20%! Ценой же за это станет постоянное переключение DRAM-страниц и, как следствие, потеря возможности противостоять неблагоприятному чередованию банков памяти. Однако такой значительный прирост скорости стоит того! Увы, этот эффект имеет место лишь на Intel и не переносим на AMD/VIA. С другой стороны, Pentium?ам принадлежит более половины компьютерного рынка и оснований для отказа от предложенного трюка, в общем-то, нет. Тем более что даже на AMD Athlon он (хеш-алгоритм) работает значительно быстрее штатной функции сравнения памяти. Один из возможных вариантов его реализации будет выглядеть так:
for(a=0;a
{
crc_1=0; crc_2=0;
for(b = 0; b < DRAM_PG_SIZE; b += sizeof(int))
// Внимание! Это очень слабый алгоритм подсчета CRC
// и его можно использовать _только_ для демонстрации
crc_1 += *(int*)((int)p1+a+b);
for(b = 0; b < DRAM_PG_SIZE; b += sizeof(int))
crc_2 += *(int*)((int)p2+a+b);
if (crc_1 != crc_2)
break; // Если CRC не совпадают, следовательно,
// блоки памяти различны.
// При необходимости можно вызвать
// memcmp(p1+a, p2+a, BLOCK_SIZE-a)
// для уточнения результата
}
Листинг 35 Оптимизированный вариант реализации memcmp с использованием хеш-алгоритма

Рисунок 49 graph 19 Демонстрация эффективности различных алгоритмов сравнения блоков памяти.
Оптимизация memset. Нет никаких идей по поводу оптимизации данной функции.
Особое замечание по функциями Win32 API В win32 API входит множество функций для работы с блоками памяти, среди которых присутствуют и прямые эквиваленты штатных функций языка Си: CopyMemory (эквивалент memcpy), MoveMemory (эквивалент memmove) и FillMemory
(эквивалент memset).
Возникает вопрос: чем лучше пользоваться – функциями операционной системы или функциями самого языка? Ответ: компания Microsoft намеренно заблокировала возможность использования функций ядра операционной системы, включив в заголовочные файлы WINBASE.H и WINNT.H следующий код:
#define MoveMemory RtlMoveMemory
#define CopyMemory RtlCopyMemory
#define FillMemory RtlFillMemory
#define ZeroMemory RtlZeroMemory
Листинг 36 Фрагмент WINBASE.H
#define RtlEqualMemory(Destination,Source,Length)
(!memcmp((Destination),(Source),(Length)))
#define RtlMoveMemory(Destination,Source,Length)
memmove((Destination),(Source),(Length))
#define RtlCopyMemory(Destination,Source,Length)
memcpy((Destination),(Source),(Length))
#define RtlFillMemory(Destination,Length,Fill)
memset((Destination),(Fill),(Length))
#define RtlZeroMemory(Destination,Length)
memset((Destination),0,(Length))
Листинг 37 Фрагмент WINNT.H
Вот это номер! Оказывается, функции семейства xxxMemory представляют собой макро-переходники к штатным функциям языка! Причем, это отнюдь не корпоративная тайна, а вполне документированная особенность, косвенно подтверждаемая Platform SDK. При внимательном изучении описания функции MoveMemory мы обнаружим следующее:
Quick Info
Windows NT: Requires version 3.1 or later.
Windows: Requires Windows 95 or later.
Windows CE: Unsupported.
Header: Declared in winbase.h.
Ну и что здесь особенного? А вот что: строка "Import Library" отсутствует! Следовательно, функция MoveMemory целиком реализована во включаемом файле WINBASE.H, о чем Microsoft нас и предупреждает.
Но это еще не конец истории. Скорее, только ее начало.
Давайте, воспользовавшись утилитой DUMBDIN, посмотрим на список функций, экспортируемых "ядреной" библиотекой операционной системы – файлом KERNEL32.DLL. Вопреки логике и здравому смыслу мы обнаружим следующее:
598 255 RtlFillMemory (forwarded to NTDLL.RtlFillMemory)
599 256 RtlMoveMemory (forwarded to NTDLL.RtlMoveMemory)
600 257 RtlUnwind (forwarded to NTDLL.RtlUnwind)
601 258 RtlZeroMemory (forwarded to NTDLL.RtlZeroMemory)
Выходит, что функции RtlMoveMemory, RtlFillMemory и RtlZeroMemory в ядре системы все-таки есть! Причем, это не просто "заглушки", все тело которых состоит из одного оператора return, а вполне работоспособные функции. Чтобы убедиться в этом, достаточно вызвать любую из функций напрямую в обход SDK. Одина из возможных реализаций приведена ниже (обработка ошибок по соображениям наглядности не приведена):
HINSTANCE h;
#undef RtlMoveMemory
void (__stdcall *RtlMoveMemory)(void *dst, void* src, int count);
h=LoadLibrary("KERNEL32.DLL");
RtlMoveMemory = (void (__stdcall *)(void *dst, void* src, int count))
GetProcAddress(h, "RtlMoveMemory");
Листинг 38 Пример вызова RtlMoveMemory явной компоновкой
Впрочем, использовать RtlMoveMemory вместо memmove, – не очень хорошая идея и Microsoft не зря заблокировала ее вызов. Функция RtlMoveMemory совершенно отвратительно оптимизирована. Во-первых, она не выравнивает адреса перемещаемых блоков памяти, а, во?вторых, перекрывающиеся блоки памяти в случае src < dst копирует по байтам, что нельзя признать оптимальным.
На платформе P-III/733/133/100/I815EP функция RtlMoveMemory проигрывает штатной функции memmove компилятора Microsoft Visual C++ 6.0 чуть ли не в полтора раза! Правда, на AMD Athlon 1050/100/100/VIA KT133 ситуация диаметрально противоположная, – здесь функция memmove отстает от своей конкурентки, причем весьма значительно, – на целых ~30%!
С функцией FillMemory ситуация более постоянна. На всех системах она показывает ничуть не худший результат, чем штатная функция языка memset и потому совершенно все равно какую из них использовать. Аналогичная картина наблюдается и с функцией ZeroMemory, являющиеся прямой родственной FillMemory, но заполняющий блок памяти нулями, а не произвольным значением. С другой стороны, с практической точки зрения "FillMemory" на целых три символа длиннее, чем "memset" и потому использование последней все же предпочтительнее. Впрочем, эта оценка достаточна субъективна. Встречаются эстеты, которые находят, что "FillMemory" выглядит красивее, чем "memset" и к тому же намного легче читается. Что ж, выбирайте то, что вам больше по душе!
Может показаться, что при инициализации множества крошечных блоков памяти использование FillMemory повлечет за собой значительные накладные расходы на многократный вызов функции. (memset в отличие от нее может быть непосредственно вживлена в исполняемый код как inline, – обычно она и вживляется). На самом же деле, современные процессоры так быстры, что временем вызова функции можно полностью пренебречь. Разница в производительности memset и FillMemory едва ли превысит несколько процентов, что практически не скажется на общем быстродействии программы.
Вы, наверное уже обратите внимание, что в списке win32 API функций отсутствует какой бы то ни было аналог memcmp. Это действительно странно, поскольку в файле WINNT.H такая функция все-таки есть:
#define RtlEqualMemory(Destination,Source,Length)
(!memcmp((Destination),(Source),(Length)))
А в среди функций, экспортируемых NTDLL.DLL есть RtlCompareMemory, которая, как нетрудно догадаться из нее названия, именно та, которая нам и нужна! Причем, в отличие от функции RtlMoveMemory, функция сравнения памяти достаточно прилично оптимизирована и даже обгоняет штатную функцию memcmp компилятора Microsoft Visual C++ 6.0 (см.
рис. graph 23). На P-III/733/133/I815EP разрыв в производительности составляет ~40%, а на AMD Athlon 1050/100/100/VIA KT133 – ~15%.
К сожалению, функция RtlCompareMemory не реализована на Windows 9x и программа, ее использующая, будет работать только под NT/W2K. Конечно, можно распространять свой продукт вместе с библиотекой NTDLL.DLL, позаимствованной, из каталога WINNT\SYSTEM (только переименуйте ее во что ни будь другое, т.к. в Win9x уже есть "своя" NTDLL.DLL), но не проще ли самостоятельно реализовать memcmp, тем более, что в этом нет ничего сложного? "Изюминка" функции RtlCompareMemory заключается в том, что в отличие от memcmp она сравнивает память не байтами, а двойными словами. Вот и весь секрет ее производительности!
Заключительный вердикт: при разработке критичных к быстродействию приложений лучше всего использовать собственные реализации функций, работающих с памятью, оптимизированных с учетом рекомендаций, приводимых в данной главе. И штатные функции языка, и функции операционной системы в той или иной степени не оптимальны.

Рисунок 50 graph 23 Сравнительная характеристика шатанных функций компилятора Microsoft Visual C++ и эквивалентных им функций операционной системы. Кстати, все они в той или иной степени не оптимальны
Оптимизация сортировки больших массивов данных
"По оценкам производителей компьютеров в 60-х годах в среднем более четверти машинного времени тратилось на сортировку. Во многих вычислительных системах на нее уходит больше половины машинного времени…" Дональд Э. Кнут "Искусство программирования. Том 3. Сортировка и поиск".…прошло полвека. Процессорные мощности за это время необычайно возросли, но ситуация с сортировкой навряд ли значительно улучшилось. Так, на AMD Athlon 1050 MHz упорядочивание миллиона чисел одним из лучших алгоритмов сортировки – quick sort – занимает 3,6 сек., а десяти миллионов – уже свыше пяти минут (см. рис. graph 33). Сортировка сотен миллионов чисел вообще требует астрономических количеств времени. И это при том, что сортировка – она из наиболее распространенных операций, встречающееся буквально повсюду. Конечно, потребность в сортировке сотен миллионов чисел есть разве что у ученых, моделирующих движения звезд в галактиках или расшифровывающих геном, но ведь и в бизнес приложениях таблицы данных с сотнями тысяч записей – не редкость! Причем, к производительности интерактивных приложений предъявляются весьма жесткие требования, – крайне желательно, чтобы обновление ячеек таблицы происходило параллельно с работой пользователя, т.е. осуществлялась налету.
Алгоритму быстрой сортировки требуется O(n lg n) операций в среднем и O(n2) в худшем случае. Это действительно очень быстрый алгоритм, который навряд ли можно значительно улучшить. Действительно, нельзя. Но надо! Вспомните Понедельник Стругакцих: "Мы сами знаем, что она [задача] не имеет решения, - сказал Хунта, немедленно ощетинившись. – Мы хотим знать, как ее решать"

Рисунок 55 graph 0x33 Время сортировки различного количества чисел алгоритмами quick sort и linear sort
Ведь существует же весьма простой и эффективный алгоритм сортировки, требующий в худшем случае порядка O(n) операций. Нет, это не шутка и не первоапрельский розыгрыш! Такой алгоритм действительно есть.
Так, на компьютере AMD Athlon 1050 он упорядочивает десять миллионов чисел всего 0,3 сек, что в тысячу раз быстрее quick sort!
Впервые с этим алгоритмом мне пришлось столкнуться на олимпиаде по информатике, предлагающей в одной из задач отсортировать семь чисел, используя не более трех сравнений. Решив, что по такому поводу не грех малость повыпендриваться, я быстро написал программку, которая выполняла сортировку, не используя вообще ни одного сравнения. К сожалению, по непонятным для меня причинам, решение зачтено не было, и только спустя пару лет, изучив существующие алгоритмы сортировки, я смог оценить не тривиальность полученного результата.
Собственно, вся идея заключалась в том, что раз неравенство k + 1 > k > k – 1 справедливо для любых k, то можно сопоставить каждому числу kx соответствующую ему точку координатной прямой и в итоге мы получим… "естественным образом" отсортированную последовательность точек. Непонятно? Давайте разберем это на конкретном примере. Пусть у нас имеются числа 9, 6, 3 и 7. Берем первое из них – 9 – отступаем вправо на девять условных единиц от начала координатной прямой и делам в этом месте зарубку. Затем берем следующее число – 6– и повторяем с ним ту же самую операцию… В конечном счете у нас должно получится приблизительно следующее (см. рис. 44).

Рисунок 56 0х44 Сортировка методом отображения
А теперь давайте, двигаясь по координатной прямой слева направо просто выкинем все неотмеченные точки (или, иначе говоря, выделим отмеченные). У нас получится… получиться последовательность чисел, упорядоченная по возрастанию! Соответственно, если двигаться по прямой справа налево, мы упорядочим числа по убыванию.
И вот тут мы подходим к самому интересному! Независимо от расположения сортируемых чисел, количество операций, необходимых для их упорядочивания, всегда равно: N+VAL_N, где N – количество сортируемых чисел, а VAL_N – наибольшее количество значений, которые могут принимать эти числа.
Поскольку, VAL_N константа, из формулы оценки сложности алгоритма ее можно исключить и тогда она (формула сложности) будет выглядеть так: O(N). Wow! У вам уже чешутся руки создать свою первую реализую? Что ж, это нетрудно. Заменим числовую ось одномерным массивом и вперед:
#define DOT 1
#define NODOT 0
int a;
int src[N];
int coordinate_line[VAL_N];
memset(coordinate_line, NODOT, VAL_N*sizeof(int));
// ставим на прямой зарубки в нужных местах
for (a = 0; a < N; a++)
coordinate_line[src[a]]=DOT;
// просматриваем прямую справа налево в поисках зарубок
// все "зарубленные" точки копируем в исходный массив
for(a = 0; a < N_VAL; a++)
if (coordinate_line[a]) { *src = a; src++;}
Листинг 44 Простейший вариант реализации алгоритма линейной сортировки
Ага! Вы уже заметили один недостаток этой реализации алгоритма? Действительно, побочным эффектом такой сортировки становится отсечение всех "дублей", т.е. совпадающих чисел. Возьмем, например, такую последовательность: 3, 9, 6, 6, 3, 2, 9. После сортировки мы получим: 2, 3, 6, 9. Знаете, а с одной стороны это очень даже хорошо! Ведь зачастую "дубли" совершенно не нужны и только снижают своим хламом производительность.
Хорошо, а как быть если уничтожение дублей в таком-то конкретном случае окажется неприемлемо? Нет ничего проще, – достаточно лишь слегка модифицировать наш алгоритм, не просто ставя зарубку на координатной прямой, но еще и подсчитывая их количество в соответствующей ячейке массива. Усовершенствованный вариант реализации может выглядеть, например, так:
int* linear_sort(int *p, int n)
{
int N;
int a, b;
int count = 0;
int *coordinate_line; // массив для сортировки
// выделяем память
coordinate_line = malloc(VAL_MAX*sizeof(int));
if (!coordinate_line) /* недостаточно памяти */
return 0;
// init
memset(coordinate_line, 0, VAL_MAX*sizeof(int));
// сортировка
for(a = 0; a < n; a++)
coordinate_line[p[a]]++;
// формирование ответа
for(a = 0; a < VAL_MAX; a++)
for(b = 0; b < coordinate_line[a]; b++)
p[count++]=a;
// освобрждаем память
free(coordinate_line);
return p;
}
Листинг 45 [Memory/sort.linear.c] Пример улучшенной реализации алгоритма линейной сортировки
Давайте сравним его с quick sort при различных значениях N и посмотрим насколько он окажется эффективен. Эксперименты, проведенные автором, показали, что даже такая примитивная реализация линейной сортировки намного превосходит quick sort и при малом, и при большом количестве сортируемых значений (см. рис. graph 32). Причем, этот результат можно существенно улучшить если прибегнуть к услугам разряженных массивов, а не тупо сканировать virtual_array целиком!

Рисунок 57 graph 32 Превосходство линейной сортировки над qsort. Смотрите, линейная сортировка двух миллионов чисел (вполне реальное количество, правда) выполняется в двести пятьдесят раз быстрее!
Но не все же время говорить о хорошем! Давайте поговорим и о печальном. Увы, за быстродействие в данном случае приходится платить оперативной памятью. Алгоритм линейной сортировки пожирает ее прямо-таки в чудовищных количествах. Вот приблизительные оценки.
Очевидно, количество ячеек массива coordinate_line
равно количеству значений, которые могут принимать сортируемые данные. Для восьми битных типов char это составляет 28=256 ячеек, для шестнадцати и тридцати двух битных int – 216= 65.536 и 232= 4.294.967.296 соответственно. С другой стороны, каждая ячейка массива coordinate_line
должна вмещать в себя максимально возможное количество дублей, что в худшем случае составляет N. Т.е. в большинстве ситуаций под нее следует отводить не менее 16, а лучше все 32 бита. Учитывая это, составляем следующую нехитрую табличку.
|
тип данных |
кол-во требуемой памяти при сохранении дублей |
кол-во требуемой памяти без сохранения дублей |
|
char |
1 Кб |
32 байта |
|
char (без учета знака) |
512 байт |
16 байт |
|
_int16 |
256 Кб |
8 Кб |
|
_int16 (без учета знака) |
128 Кб |
4 Кб |
|
_int32 |
16 Гб |
1 Гб |
|
_int32 (без учета знака) |
8 Гб |
256 Кб |
Таблица 5 Количество памяти, потребляемой алгоритмом линейной сортировки при упорядочивании данных различного типа
Ничегошеньки себе потребности! Для сортировки 32-разядных элементов с сохранением "дублей" потребуется восемь гигабайт оперативной памяти! Конечно, 99.999% ячеек памяти будут пустовать и потому подкачка страниц с диска не сильно ухудшит производительность, но… Вся проблема как раз и заключается в том, что нам просто не дадут этих восьми гигабайт. Операционные системы Windows 9x/NT ограничивают адресное пространство процессора всего четырьмя гигабайтами, причем больше двух из них расходуется на "служебные нужны" и максимально доступный объем кучи составляет гигабайт - полтора.
Правда, можно поровну распределить массив coordinate_line
между восемью процессами (ведь возможность читать и писать в "чужое" адресное пространство у нас есть – см. описания функций ReadProcessMemory и WriteProcessMemory в Platform SDK). Конечно, это очень кривое и уродливое решение, но зато крайне производительное. Ну пусть за счет накладных расходов на вызов API-функций алгоритм линейной сортировки превзойдет quick sort не в тысячу, а в шестьсот–девятьсот раз. Все равно он будет обрабатывать данные на несколько порядков быстрее.
Впрочем, ведь далеко не всегда сортируемые данные используют весь диапазон значений _int32: от –2,147,483,648 до 2,147,483,647. А раз так, – потребности в памяти можно существенно сократить! Действительно, количество требуемой памяти составляет: Cmem = N_VAL*sizeof(cell), где N_VAL – кол-во допустимых значений, а sizeof(cell) – размер ячеек, хранящих "зарубки" (они же – дубли). В частности, для сортировки данных диапазона [0; 1.000.000] потребуется не более 4 мегабайт памяти. Это весьма незначительная величина!
Сортировка вещественных типов данных.
До сих пор мы говорили лишь о сортировке целочисленных типов данных, между тем программистам приходится обрабатывать и строки, и числа с плавающей запятой, и...
Первоначально предложенный мной алгоритм действительно поддерживал работу лишь с целыми числами, но после публикации статьи "Немного о линейной сортировке" (ее и сейчас можно найти в сети…) им заинтересовались остальные программисты, среди которых были и те, кто приспособил линейную сортировку под свои нужды.
В частности, линейную сортировку вещественных чисел первым (насколько мне известно) реализовал Дмитрий Коробицын, письмо которого приводится ниже:
----- Original Message -----
From: "Дмитрий
Коробицын"
To: "Крис Касперски"
Sent: Friday, June 22, 2001 2:27 AM
Subject: Re: О линейной сортировке
ДК> Сегодня хочу написать про сортировку чисел Float.
ДК> Вы пишете:
КК>> Один только ньюанс - как отсортировать числа по возрастанию? Формат
КК>> float и dooble не сложен, но попробуй-ка вывести все числа в порядке
КК>> возрастания!
Да, действительно для сортировки чисел Float придется построить функцию long int f(float x), такую, чтобы для любых x и y, если x
Доказательство: Float устроен следующим образом: 32 бита в памяти, самый старший бит это знак, тоже и у четырехбайтового целого числа. После бита знака следующие 8 битов "смещенный порядок" - показатель степени двойки.
Для положительных чисел float, чем больше порядок, тем больше число, но, с другой стороны, для положительного длинного целого чем большее число записано в старших битах, тем число больше. Оставшиеся 23 разряда – это значащая часть числа, мантисса. Значение положительного числа float получается следующим образом:
Значение = (1+мантисса*2^(-23)) * 2 ^ (смещенный порядок -127) Здесь следует пояснить, что если мантисса равна нулю, то значение числа float совпадает со степенью двойки. Если все биты мантиссы установлены в 1 (самая большая мантисса = 0x7FFFFF = 2^23 - 1), то получаем 1 + 0x7FFFFF*2^(-23) = 1.99999988079071044921875
Получаем, что для положительных float при одинаковом значении "смещенного порядка" чем больше мантисса, тем больше число, но для длинного целого та же ситуация, при одинаковых старших битах чем большее значение записано в младших битах тем больше число. Рассмотрим еще ситуацию, когда при увеличении числа меняется "смещенный порядок".
Возьмем для примера числа 1.99999988079071044921875 и 2.0 оба этих числа положительные, значит старший бит равен нулю. У первого показатель степени двойки равен 0 значит "смещенный порядок" = 127 = 0111 1111. Мантисса состоит вся из 1.
Следовательно в памяти это будет выглядеть так - старшие два бита равны 0, остальные - 1. У второго числа показатель степени двойки равен 1, а значение мантиссы равно 0. Смещенный порядок = 128 = 1000 0000. В памяти - все биты кроме второго равны 0.
Шестнадцат. Целое число Float
0x3F FF FF FF 1073741823 1.99999988079071044921875
0x40 00 00 00 1073741824 2.0
Еще несколько чисел:
Шестнадцат. Целое число Float
0x3D CC CC CD 1036831949 0.100000001490116119
0x3E 4C CC CD 1045220557 0.200000002980232239
0x3E 99 99 9A 1050253722 0.300000011920928955
0x3E CC CC CD 1053609165 0.400000005960464478
0x3F 00 00 00 1056964608 0.5
0x3F 19 99 9A 1058642330 0.60000002384185791
0x3F 33 33 33 1060320051 0.699999988079071045
0x3F 4C CC CD 1061997773 0.800000011920928955
0x3F 66 66 66 1063675494 0.89999997615814209
0x3F 80 00 00 1065353216 1.0
0x40 00 00 00 1073741824 2.0
0x40 40 00 00 1077936128 3.0
0x40 80 00 00 1082130432 4.0
0x40 A0 00 00 1084227584 5.0
0x40 C0 00 00 1086324736 6.0
0x40 E0 00 00 1088421888 7.0
0x41 00 00 00 1090519040 8.0
0x41 10 00 00 1091567616 9.0
0x41 20 00 00 1092616192 10.0
Из приведенной таблицы видно, что при возрастании числа float возрастает и соответствующее ему целое число. Заметим, что целое число ноль соответствует нулю с плавающей точкой.
Таким образом, для неотрицательных чисел никакого преобразования не требуется. Как же быть с отрицательными числами?
Отрицательные целые числа хранятся в дополнительном формате,то есть целому числу минус один соответствует 0xFF FF FF FF, а это самое большое по модулю отрицательное число (в действительности компьютер считает, что это "нечисло"). Числу минус десять миллионов соответствует float примерно равный минус три на десять в 38 степени.
Шестнадцат. Целое число Float
0xFF 67 69 80 -10000000 -3.07599454344890991e38
0xFA 0A 1F 00 -100000000 -1.79291430293879854e35
0xC4 65 36 00 -1000000000 -916.84375
0xC0 00 00 00 -1073741824 -2.0
0xBF 80 00 00 -1082130432 -1.0
0xBF 00 00 00 -1090519040 -0.5
0xA6 97 D1 00 -1500000000 -1.05343793584122825e-15
0x88 CA 6C 00 -2000000000 -1.21828234519221877e-33
Из приведенной таблицы видно, что чем больше по модулю отрицательное целое число, тем меньше по модулю число float. Неужели придется проверять знак у числа, и если оно отрицательное, то делать преобразование? На самом деле нехитрым программистским приемом избавляемся от всяких преобразований. Сначала прейдем от целых чисел со знаком к целым числам без знака. При этом преобразования не потребуются, просто область памяти вместо long int надо определить как unsigned long. (Заметим, что на сортировку положительных чисел это никак не повлияет.) Далее заметим, что самому большому по модулю отрицательному числу float вместо минус единицы теперь будет соответствовать 0xFF FF FF FF – это (два в степени 32) - 1.
Таким образом, самое большое целое число без знака будет соответствовать самому большому по модулю отрицательному числу. Но если теперь у нас все целые числа не имеют знака, то как же отделить положительные float от отрицательных? Потребуются преобразования? Нет!
Пример программы:
Программу напишем в виде функции, которой на вход передается не отсортированный массив float и его размер - n. После работы функции массив должен быть отсортирован по возрастанию.
void Sort(float *u, unsigned long N)
{
unsigned long *a,*int_u, c, n, k;
PrepareMem(&a);
// преобразуем указатель. Предполагая, что
sizeof(float) = sizeof(long)
int_u=(unsigned long *)u;
// сортировка
for (c=0; c < N; c++) a[int_u[c]]++;
// формируем отсортированный массив
k=0;
// сначала отрицательные числа, начиная с самых больших по модулю
for(c=0xFFFFFFFF; c > 0x7FFFFFFF; c--)
for(n=0; n < a[c]; n++)int_u[k++]=с;
// теперь положительные числа, начиная с (float) нуля.
for(c=0; c < 0x80000000; c++)
for(n=0; n < a[c]; n++)int_u[k++]=с;
}
Заметим, что инициализация памяти выделена в отдельную функцию. Во-первых, не плохо было бы проверить, чем закончилось выделение 16 гигабайт памяти. Во-вторых, на забивание массива нулями тратится почти половина времени работы алгоритма. Я предполагаю, что используя прямой доступ к памяти (DMA) это время можно сократить.
---- конец письма ---
Оптимизация ссылочных структур данных
Итак, если мы не хотим, чтобы наша программа ползала со скоростью черепахи в летний полдень и на полную использовала преимущества высокопроизводительной DDR- и DRRAM памяти, – следует обязательно устранить зависимость по данным. Как это сделать?Вот скажем, графический файл в формате BMP, действительно, можно обрабатывать и параллельно, поскольку он представляет собой однородный массив данных фиксированного размера. Совсем иная ситуация складывается с двоичными деревьями, списками и прочими ссылочными структурами, хранящими разнородные данные.
Расщепление списков (деревьев). Рассмотрим список, "связывающий" пару десятков мегабайт текстовых строк переменной длины. Как оптимизировать прохождение по списку, если адрес следующего элемента заранее неизвестен, а список к тому же сильно фрагментирован? Первое, что приходит на ум: разбить один список на несколько независимых списков, обработка которых осуществляется параллельно. Остается выяснить: какая именно стратегия разбиения наиболее оптимальна. В этом нам поможет следующая тестовая программа, последовательно прогоняющая списки с различной степенью дробления (1:1, 1:2, 1:4, 1:6 и 1:8). Ниже, по соображениям экономии бумажного пространства, приведен лишь фрагмент, реализующий комбинации 1:1 и 1:2. Остальные же степени дробления реализуются полностью аналогично.
#define BLOCK_SIZE (12*M) // размер обрабатываемого блока
struct MYLIST{ // элемент списка
struct MYLIST *next;
int val;
};
#define N_ELEM (BLOCK_SIZE/sizeof(struct MYLIST))
/* -----------------------------------------------------------------------
*
* обработка одного списка
*
----------------------------------------------------------------------- */
// инициализация
for (a = 0; a < N_ELEM; a++)
{
one_list[a].next = one_list + a + 1;
one_list[a].val = a;
} one_list[N_ELEM-1].next = 0;
// трассировка
p = one_list;
while(p = p[0].next);
/* -----------------------------------------------------------------------
*
* обработка двух расщепленных списков
*
----------------------------------------------------------------------- */
// инициализация
for (a = 0; a < N_ELEM/2; a++)
{
spl_list_1[a].next = spl_list_1 + a + 1;
spl_list_1[a].val = a;
spl_list_2[a].next = spl_list_2 + a + 1;
spl_list_2[a].val = a;
} spl_list_1[N_ELEM/2-1].next = 0;
spl_list_2[N_ELEM/2-1].next = 0;
// трассировка
p1 = spl_list_1; p2 = spl_list_2;
while((p1 = p1[0].next) && (p2 = p2[0].next));
// внимание! Данный способ трассировки предполагает, что оба списки
// равны по количеству элементов, в противном случае потребуется
// слегка доработать код, например, так:
// while(p1 || p2)
// {
// if (p1) p1 = p1[0].next;
// if (p2) p2 = p2[0].next;
// }
// однако это сделает его менее наглядным, поэтому в книге приводится
// первый вариант
Листинг 11 [Memory/list.splint.c] Фрагмент программы, определяющий оптимальную стратегию расщепления списков
На P-III 733/133/100/I815EP заметна ярко выраженная тенденция уменьшения времени прохождения списков, по мере приближения степени дробления к четырем. При этом быстродействие программы возрастает более чем в полтора раза (точнее – в 1,6 раз)! Дальнейшее увеличение степени дробления лишь ухудшает результат (правда незначительно). Причина в том, что при параллельной обработке более чем четырех потоков данных происходят постоянные открытия/закрытия DRAM-страниц, "съедающие" тем самым весь выигрыш от параллелизма (подробнее см. "Планирование потоков данных").
На AMD Athlon 1050/100/100/VIA KT133 ситуация совсем иная. Поскольку, и сам процессор Athlon, и чипсет VIA KT133 в первую очередь оптимизирован для работы с одним потоком данных, параллельная обработка расщепленных списков ощутимо снижает производительность.
Впрочем, расщепление одного списка на два все-таки дает незначительный выигрыш в производительности. Однако, наиболее оптимальна стратегия расщепления отнюдь не на два, и даже не на четыре, а… на шесть списков. Именно шесть списков обеспечивают наилучший компромисс при оптимизации программы сразу под несколько процессоров.
Разумеется, описанный прием не ограничивается одними списками. Ничуть не менее эффективно расщепление двоичных деревьев и других структур данных, в том числе и не ссылочных.

Листинг 12 graph 20 Зависимость времени обработки данных от степени расщепления списков. Как видно, наилучшая стратегия заключается в шестикратном расщеплении списков. Это обеспечивает наилучший компромисс для обоих процессоров
Быстрое добавление элементов. Чтобы при добавлении нового элемента в конец списка не трассировать весь список целиком, сохраняйте в специальном после ссылку на последний элемент списка. Это многократно увеличит производительность программы, под час больше чем на один порядок (а то и на два-три).
Подробнее об этом см. "Оптимизация строковых штатных Си-функций". Какое отношение имеют строки к спискам? Да самое непосредственное! Ведь строка это одна из разновидностей вырожденного списка, не сохраняющего ссылку на следующий элемент, а принудительно располагающая их в памяти так, чтобы они строго следовали один за другим.
Оптимизация строковых штатных Си-функций
С разительным отличием скорости обработки двойных слов и байтов мы уже столкнулись (см. "Обработка памяти байтами, двойными и четвертными словами"). Теперь самое время применить наши знания для оптимизации строковых функций.Типичная Си-строка (см. рис. 41) представляет собой цепочку однобайтовых символов, завершаемую специальным символом конца строки – нулевым байтом (не путать с символом "0"!), поэтому Си-строки так же называют ASCIIZ-стоками ('Z' – сокращение от "Zero", – нуль на конце). Это крайне неоптимальная структура данных, особенно для современных 32?разрядных процессоров!
Основной недостаток Си-строк заключается в невозможности быстро определить их длину, – для этого нам приходится сканировать всю строку целиком в поисках завершающего ее нуля. Причем, поиск завершающего символа должен осуществляется побайтовым сравнением каждого символа строки с нулем. А ведь производительность побайтового чтения памяти крайне низка! Конечно, можно попробовать сделать "ход котом": загружать ячейки памяти двойными словами, а затем "просвечивать" их сквозь четыре битовых маски, только вряд ли это добавит производительности, – скорее лишь сильнее ухудшит ее.
По тем же самым причинам невозможно реализовать эффективное копирование и объединение Си-строк. Действительно, как прикажете копировать строку не зная какой она длины?
В довершении ко всему, Си-строки не могут содержать символа нуля (т.к. он будет ошибочно воспринят как завершитель строки) и потому плохо подходят для обработки двоичных данных.
Всех этих недостатков лишены Pascal-строки, явно хранящие свою длину в специальном поле, расположенном в самом начале строки. Для вычисления длины Pascal-строки достаточно всего одного обращения к памяти (грубо говоря, длина Pascal-строк может быть вычислена мгновенно). /* Кстати, при работе с любыми структурами данных, в частности, со списками, настоятельно рекомендуется сохранять в специальном после ссылку на последний элемент, чтобы при добавлении новых элементов в конец списка не приходилось каждый раз трассировать его целиком */
Как это обстоятельство может быть использовано для оптимизации копирования и объединения Pascal-строк? А вот смотрите:
char *с_strcpy(char *dst, char *src) char *pascal_strcpy(char *dst, char *src)
{ {
char * cp = dst; int a;
while( *cp++ = *src++ ); for(a=0; a < ((*src+1) & ~3); a += 4)
// копируем строку по байтам *(int *)(dst+a)=*(int *)(src+a);
// одновременно с этим проверяя // копируем строку двойными словами
// каждый символ на равенство нулю // проверять каждый символ на равенство
// нулю в данном случае нет необходимости
// т.к. длина строки наперед известна
for(a=((*src+1) & ~3); a<(*src+1); a ++)
*(char *)(dst+a)=*(char *)(src+a);
// копируем остаток хвоста строки
// (если он есть) по байтам.
// это не снижает производительности,
// т.к. максимально возможная длина
// хвоста составляет всего три байта
return( dst ); return( dst );
} }
Листинг 39 Пример реализации функций копирования Си (слева) и Pascal строк (справа)
char *с_strcat (char *dst, char *src) char *pascal_strcat (char *dst, char *src)
{ {
char *cp = dst; int len;
while( *cp ) ++cp; len=*dst;
// читаем всю строку-источник // за одно обращение к памяти
// байт за байтом в поисках // определяем длину строки-приемника
// ее конца
*dst+=*src;
// корректируем длину строки-приемника
while( *cp++ = *src++ ); pascal_strcpy(dst+len,src);
// байт за байтом дописываем // копируем строку двойными словами
// источник к концу приемника,
// до тех пор пока не встретим нуль
return( dst ); return( dst );
} }
Листинг 40 Пример реализации функций объединения Си (слева) и Pascal строк (справа)
Итак, в отличие от Си-строк, Pascal-строки допускают эффективную блочную обработку и при желании могут копироваться хоть восьмерными словами. Другое немаловажное обстоятельство: при объединении Pascal-строк нам незачем просматривать всю строку-приемник целиком в поисках ее конца, поскольку конец строки определяется алгебраическим сложением указателя на начала строки с первым байтом строки, содержащим ее длину.
Интенсивная работа с Си-строками способна серьезно подорвать производительность программы и потому лучше совсем отказаться от их использования. Проблема в том, что мы не можем "самовольно" перейти на Pascal-строки, не изменив все сопутствующие им библиотеки языка Си и API-функций операционной системы. Ведь функции наподобие fopen или LoadLibrary рассчитаны исключительно на ASCIIZ-строки и попытка "скормить" им Pascal?строку ни к чему хорошему не приведет, – функция, не обнаружив в положенном месте символа-завершителя строки, залезет совершенно в постороннею память!
Выход состоит в создании "гибридных" Pascal + ASCIIZ-строк, явно хранящих длину строки в специально на то отведенном поле, но вместе с тем, имеющих завершающий ноль на конце строки. Именно так и поступили разработчики класса CString библиотеки MFC, распространяемой вместе с компилятором Microsoft Visual C++.

Рисунок 51 0х41 Устройство Си, Pascal, Delphi и MFC-строк.
Си- строки могут иметь неограниченную длину, но не могут содержать в себе символа нуля, т.к. он трактуется как завершитель строки. Pascal-строки хранят длину строки в специальном однобайтовом поле, что значительно увеличивает эффективность строковых функций, позволяет хранить в строках любые символы, но ограничивает их размер 256 байтами. Delphi-строки представляют собой разновидность Pascal-строк и отличаются от них лишь увеличенной разрядностью поля длины, теперь строки могут достигать 64Кб длины. MFC-строки – это гибрид Си и Pascal строк с 32-битным полем длины, благодаря чему макс. длина строки теперь равна 4Гб.
Несложный тест (см. [Memory/MFC.c]) поможет нам сравнить эффективность обработки Си- и MFC-строк на операциях сравнения, копирования и вычисления длины. На последней операции, собственно, и наблюдается наибольших разрыв в производительности, достигающих в зависимости от длины "подопытной" строки от одного до нескольких порядков.
Объединение двух MFC-строк (при условии, что обе они одинаковой длины) осуществляется практически вдвое быстрее, чем аналогичных им Си-строк, что совсем неудивительно, т.к. в первом случае мы обращаемся к вдвое меньшему количеству ячеек памяти. Разумеется, если к концу очень длиной строки дописывается всего несколько символов, то выигрыш от использования MFC-строк окажется много большим и приблизительно составит: крат.
А вот сравнение Си- и MFC- строк происходит одинаково эффективно, точнее одинаково неэффективно, поскольку разработчики библиотеки MFC- предпочли побайтовое сравнение сравнению двойными словами, что не самым лучшим образом сказалось на производительности. Забавно, но штатная функция strcmp из комплекта поставки Microsoft Visual C++ (не intrinsic!), – похоже единственная функция сравнения строк, обрабатывающая их не байтами, а двойными словами, что в среднем происходит вдвое быстрее. В общем, наиболее предпочтительное сравнение MFC-строк выглядит так:
#include
#pragma function(strcmp) // вырубаем intrinsic'ы
if (strcmp(s0.GetBuffer(0),s1.GetBuffer(0)))
// строки не равны
else
// строки равны
Листинг 41 Пример эффективного сравнения MFC-строк

Рисунок 52 graph 24 Сравнение эффективности MFC и Си функций, работающий со строками. Как видно, MFC строки более производительны
Оптимизация структур данных под аппаратную предвыборку
Грамотное использование программной предвыборки позволят полностью забыть о существовании аппаратной и не брать особенностей последней в расчет. Это тем более предпочтительно, что механизмом аппаратной предвыборки на момент написания этой книги оснащен один лишь P-4 (прим. сейчас аппаратная предвыборка появилась и в старших моделях процессора AMDAthlon), да и перспектива его развития в последующих моделях весьма туманна. Однако, как уже было показано выше, в ряде случаев достижение эффективной работы программной предвыборки без индивидуальной "заточки" критического кода под конкретный процессор просто невозможно! Фактически это обозначает, что один и тот же фрагмент программы приходится реализовывать в нескольких ипостасях – отдельно под K6 (VIA C3), Athlon, P-II, P-III и P-4. Если все равно приходится оптимизировать программу под каждый процессор по отдельности, то почему бы задействовать возможности P-4 на всю мощь?Поскольку, накладные расходы на программную предвыборку не равны нулю, следует отказаться от нее везде, где и аппаратная предвыборка справляется хорошо. В первую очередь это – многократно повторяемые циклы, обрабатывающие данные по регулярным шаблонам. Причем на страницу должно приходиться не более одного потока, а общее количество потоков – не превышать восьми. Например, так:
int x[BIGNUM];
for(a=0;a
Листинг 30 Пример кода, эффективно оптимизируемый аппаратной предвыборкой – один регулярный шаблон на страницу
А вот незначительная модификация предыдущего примера – теперь в цикле суммируется не один массив, а сразу два:
int x[256];
int y[256];
for(a=0;a<256, a++)
{
sum1+= x[a];
sum2+= y[a];
}
Листинг 31 Пример кода, "ослепляющий" аппаратную предвыборку – два шаблона на страницу
Поскольку, оба массива расположены в пределах одной страницы, механизм аппаратной предвыборки "слепнет" и упреждающая загрузка данных не осуществляется. Повысить эффективность выполнения кода можно либо разбив один цикл на два, каждый из которых будет обрабатывать "свой" массив, либо разнести массивы x
и y
так, чтобы их разделяло более четырех килобайт (внимание: этого нельзя достичь, просто поместив между ними еще один массив, т.к. порядок размещения массивов в памяти целиком лежит на "совести" компилятора и не всегда совпадает с порядком их объявления в программе), либо… преобразовать два массива в массив элементов одной структуры:
struct ZZZ{int x; int x;} zzz[1024];
for(a=0;a<1024, a++)
{
sum1+= zzz.x[a];
sum2+= zzz.y[a];
}
Листинг 32 Исправленный пример листинга ???12 – один регулярный шаблон на страницу
На первый взгляд непонятно, что дает такое преобразование – ведь по-прежнему, на одну страницу приходится два регулярных шаблона. Да, это так – но в последнем случае оба шаблона сливаются в один общий шаблон. Если до этого происходило обращение к N, N+1024, N+4, N+1028, N+8, N+1032 ячейками памяти, то теперь: N, N+4, N+8, N+12… вот и весь фокус!
Кстати, всегда следует помнить, что шаблон определяется не адресами ячеек, к которым происходит обращение, а адресами ячеек, которые вызывают кэш-промах. Совсем не одно и то же! Благодаря этому обстоятельству в пределах всякого 128-байтового блока памяти, уже находящегося в L2-кэше, можно обращаться и по нерегулярному шаблону – лишь бы сами 128-байтовые блоки запрашивались регулярно.
Но вернемся к нашим баранам. Как вы думаете, сможет ли эффективно выполняться на P-4 следующий пример?
struct ZZZ{int x; int x; int sum; } zzz[BIGNUM];
for(a=0;a
{
zzz.sum[a]=zzz.x[a]+zzz.y[a];
}
Листинг 33 Пример кода, "ослепляющего" аппаратную предвыборку – и чтение, и запись в пределах одной страницы
Конечно же, он будет исполняться неэффективно! Поскольку в пределах одной страницы осуществляется и чтение, и запись, аппаратная предвыборка не осуществляется. Как быть? Если массив zzz содержит не менее 1024 элементов, разбив структуру ZZZ на три независимых массива, мы добьемся того, что чтение и запись будут происходить в различные страницы:
int x[BIGNUM]; int x[BIGNUM]; int sum[BIGNUM];
for(a=0;a
{
sum[a]=x[a]+y[a];
}
Листинг 34 Исправленный вариант листинга ??? 14 – чтение и запись происходят в различные страницы
Кстати, будет не лишним отметить, что такой прием существенно замедляет эффективность выполнения кода на всех остальных процессорах. Почему? Вспомним, что размещение данных в пределах одной DRAM-страницы значительно уменьшает ее латентность, т.к. для доступа к ячейке достаточно передать лишь номер ее столбца, а номер строки будет тот же самый, что и в прошлый раз.
Поочередное обращение к данным, расположенным в различных DRAM-страницах, напротив, требует передачи полного адреса ячейки, а это как минимум 2-3 такта системной шины. Но, если на P-4 латентность компенсируется аппаратной предвыборкой данных, на других процессорах ее скомпенсировать нечем! Вот еще одно подтверждение того, что код, оптимальный для P-4 не всегда оптимален для остальных процессоров, и, соответственно, наоборот…
_загрузка кода,
Оптимизация заполнения (инициализации) памяти
Техника оптимизации копирования памяти в целом применима и к ее инициализации – заполнению блока памяти некоторым значением (чаще всего нулями). Эта операция обычно осуществляется либо стандартной функцией языка Си memset, либо win32 функцией FillMemory. (Впрочем, на самом деле это одна и та же функция – в заголовочном файле WINNT.h макрос FillMemory определен как RtlFillMemory, а RtlFillMemory на x86 платформе определен как memset).Подавляющее большинство реализаций функции memset использует инструкцию циклической записи в память REP STOSD, инициализирующей одно двойное слово за одну итерацию. Но, в отличие от REP MOVSD, она требует совсем другого выравнивания. Причем, об этом обстоятельстве не упоминает ни Intel, ни AMD, ни сторонние руководства по оптимизации!
Достаточно неожиданным эффектом инициализации ячеек памяти уже находящихся в кэше первого (а на P-III+ и второго) уровня является существенное увеличение производительности при выравнивании начального адреса по границе 8 байт. На P-II и P-III в этом случае за 32 такта выполняется 42 итерации записи двойных слов. Рекомендуемое же документацией выравнивание по границе 4 байт дает гораздо худший результат – за 32 такта выполняется всего лишь 12 итераций, т.е. в три с половиной раза меньше!
Это объясняется тем, что в первом случае не тратится время на выравнивание внутренних буферов и строк кэша – сброс данных происходит по мере заполнения буфера и не интерферирует с операциями выравнивания. Поскольку разрядность шины (и буфера) - 64 бита (8 байт), выбор начального адреса, не кратного 8, приводит к образованию "дыры" в 4 байта и, прежде чем свалить данные, потребуется выровнять буфер к кэшу и "закрасить" недостающие 4 байта. А это – время.
Сказанное справедливо не только для инструкции REP STOS, но и вообще для любой циклической записи – не важно слов, двойных слов или даже байт. Поэтому, многократно инициализируемые структуры данных, на момент инициализации уже находящиеся в кэше, целесообразно выравнивать по адресам, кратным восьми.
Циклическая запись в область памяти, отсутствующую в кэше – совсем другое дело. Выравнивание начального адреса по границе восьми байт, ничем не предпочтительнее четырех. Причем, на P-III начальный адрес можно вообще не выравнивать, т.к. выигрыш измеряется долями процента. Правда, на P-II цикл записи, начинающийся с адреса не кратного четырем, замедляется более чем в два раза. Такой существенный проигрыш никак нельзя не брать в расчет, даже в свете того, что парк P-II с каждым годом будет все сильнее и сильнее истощаться.
Сказанное наглядно иллюстрируют графики, приведенные на рис. 0x27 и рис. 0x028, изображающие зависимость скорости инициализации блоков памяти различного размера от кратности начального адреса на процессорах Pentium-II и Pentium-III. (см. программу memstore_align).

Рисунок 52 graph 0x027 График зависимости времени инициализации блоков памяти различного размера от кратности начального адреса. [Pentium-III 733/133/100]

Рисунок 53 graph 0x028 График зависимости времени инициализации блоков памяти различного размера от кратности начального адреса. [CELERON-300A/66/66]
Скорость записи ячеек памяти, отсутствующих в кэше крайне непостоянна и зависит в первую очередь от состояния внутренних буферов процессора. Время инициализации небольших, порядка 4-8 килобайт блоков, может отличаться в два и более раз, особенно если операции записи следуют друг за другом всплошную – без пауз на сброс буферов. Отсутствие пауз при инициализации большого количества блоков памяти приводит к образованию "затора" – переполнению кэша второго уровня и, как следствие, значительным тормозам. И хотя средне взятый разброс скорости записи при этом существенно уменьшается (составляя порядка 5%), на графике появляются высокие пики и глубокие провалы, причем пики традиционно предшествуют провалам. Их происхождение связано с переключением задач многозадачной операционной системой, - если остальные задачи не слишком плотно налегают на шину (что чаще всего и случается) буфера (или хотя бы часть из них) успевают выгрузиться и подготовить себя к эффективному приему следующей порции записываемых данных (см.
рис.???7).
Непостоянство скорости записи создает проблемы профилирования приложений – разные участки программы поставлены в разные условия. Тормоза одного участка вполне могут объясняться тем, что предшествующий ему код до отказа заполнил все буфера и теперь инициализация происходит крайне неэффективно – т.е. выражаясь словами одного сказочного героя "когда болит горло – лечи хвост".
Серьезные проблемы наблюдаются и при оптимизации функции инициализации памяти – большой разброс замеров скорости выполнения затрудняет оценку эффективности оптимизации. Приходится делать множество прогонов для вычисления "средневзвешенного" времени выполнения.

Рисунок 54 graph 0x023 График иллюстрирует непостоянность скорости записи ячеек памяти, отсутствующих в кэше (Бардовая линия). В данном примере последовательно обрабатывается 512 четырех килобайтных блока памяти. Для сравнения приведен график скорости копирования блоков памяти такого же точно размера (Синяя линия). Видно, что разброс скорости записи уменьшается при заполнении кэша второго уровня, в то время как разброс скорости копирования памяти остается постоянным. [Premium-III 733/133/100] (см. mem_mistake)
В отличие от копирования, инициализировать память всегда лучше в прямом направлении, независимо от того, как обрабатывается проинициализированный блок – с начала или с конца. Объясняется это тем, что запись ячейки, отсутствующей в кэше, не приводит к загрузке этой ячейки в кэш первого уровня – данные попадают в буфера, откуда выгружаются в кэш второго уровня. Поэтому, на блоках, не превышающих размера кэша второго уровня, никакого выигрыша заведомо не получится. Блоки, в несколько раз превосходящие L2-кэш, действительно, быстрее обрабатываются будучи проинициализированными с заду наперед, но выигрыш этот столь несущественен, что о нем не стоит и говорить. Обычно он составляет 5%-10% и "тонет" на фоне непостоянства скорости инициализации. (см. рис.???8)

Рисунок 55 graph 0x024 Диаграмма иллюстрирует относительное время инициализации блоков памяти различного размера с последующей обработкой.
За 100% взято время инициализации штатной функции memset (синие столбики). и прямая инициализация небольшими блоками, обрабатываемыми от конца к началу (желтые столбики). [Pentium-III 733/133/100] (см. memstore_direct)
Оптимизация инициализации памяти в старших моделях процессоров Pentium. Инструкция некэшируемое записи восьмерных слов movntps, уже рассмотренная ранее, практически втрое ускоряет инициализацию памяти, при этом не "загаживая" кэш второго уровня. Это идеально подходит для инициализации больших массивов данных, которые все равно не помещаются в кэше, а вот инициализация компактных структур данных с их последующей обработкой – дело другое. На компактных блоках movntps заметно отстает от штатной функции memset, проигрывай ей в полтора-два раза, а на блоках умеренного размера, movntps хотя и лидирует, но обгоняет memset всего на 25%-30%, что ставит под сомнение целесообразность ее применения (ведь на P-II и более ранних процессорах ее нет!). (см. рис.???9)

Рисунок 56 graph 0x025 Диаграмма иллюстрирует относительное время инициализации блоков памяти различного размера. За 100% взято время инициализации штатной функции memset (синие столбики). С нею состязаются инструкция копирования четверных слов movq (красные столбики) и инструкция не кэширующей записи восьмерных слов movntps (желтые и голубые столбики). Голубые столбики выражают скорость инициализации с последующей обработкой инициализированного блока.

Рисунок 57 graph 0x026 Использование movq на AMD Athlon
адресация кэша - полинейная, адресация байтов в линии при помощи битов-атрибутов. любая райт-бэк операция эффективно транслируется в рид-модифай-райт.
Организация кэша
Для упрощения взаимодействия с оперативной памятью (и еще по ряду других причин), кэш-контроллер оперирует не байтам, а блоками данных, соответствующих размеру пакетного цикла чтения/записи (см. "ЧастьI. Оперативная память. Устройство и принципы функционирования оперативной памяти. Взаимодействие памяти и процессора."). Программно, кэш-память представляет собой совокупность блоков данных фиксированного размера, называемых кэш-линейками (cache-line) или кэш-строками.Каждая кэш-строка полностью заполняется (выгружается) за один пакетный цикл чтения и всегда заполняется (выгружается) целиком. Даже если процессор обращается к одному байту памяти, кэш-контроллер инициирует полный цикл обращения к основной памяти и запрашивает весь блок целиком. Причем, адрес первого байта кэш-линейки всегда кратен размеру пакетного цикла обмена. Другими словам: начало кэш-линейки всегда совпадает с началом пакетного цикла.
Поскольку, объем кэша много меньше объема основной оперативной памяти, каждой кэш-линейке соответствует множество ячеек кэшируемой памяти, а отсюда с неизбежностью следует, что приходится сохранять не только содержимое кэшируемой ячейки, но и ее адрес. Для этой цели каждая кэш-линейка имеет специальное поле, называемое тегом. В теге хранится линейный и/или физический адрес первого байта кэш-линейки. Т.е. кэш-память фактически является ассоциативной памятью (associative memory) по своей природе.
В некоторых процессорах (например, в младших моделях процессоров Pentium) используется только один набор тегов, хранящих физические адреса. Это удешевляет процессор, но для преобразования физического адреса в линейный требуется по меньшей мере один дополнительный такт, что снижает производительность.
Другие же процессоры (например, AMD K5) имеют два набора тегов, для хранения физических и линейных адресов соответственно. К физическим тегам процессор обращается лишь в двух ситуациях: при возникновении кэш-промахов (в силу используемой в x86 процессорах схемы адресации одна и та же ячейка может иметь множество линейных адресов и потому несовпадение линейных адресов еще не свидетельство промаха) и при поступлении запроса от внешних устройств (в т.ч.
и других процессоров в многопроцессорных системах): имеется ли такая ячейка в кэш памяти или нет (см. "Протокол MESI"). Во всех остальных случаях используются исключительно линейные теги, что предотвращает необходимость постоянного преобразования адресов.
Доступ к ассоциативной памяти, в отличии от привычной нам адресной памяти, осуществляется не по номеру ячейки, а по ее содержанию, поэтому такой тип памяти еще называют content addressed memory. Кэш-строки, в отличии от ячеек оперативной памяти, не имеют адресов и могут нумероваться в произвольном порядке, да и то чисто условно. Выражение "кэш-контроллер обратился к кэш-линейке №69" лишено смысла и правильнее сказать: "кэш-контроллер, обратился к кэш-линейке 999", где 999 – содержимое связанного с ней тега.
Таким образом, полезная емкость кэш-памяти всегда меньше ее полной (физической) емкости, т.к. некоторая часть ячеек расходуется на создание тегов, совокупность которых так и называется "память тегов" (остальные ячейки образуют "память кэш-строк"). Следует заметить, что производители всегда указывают именно полезную, а не полную емкость кэш-памяти, поэтому, за память, "отъедаемую" тегами, можно не волноваться.
__Разрядность тегов определяется объемом кэшируемой памяти, вернее наоборот, максимально достижимый объем кэшируемой памяти ограничивается разрядностью тегов.
__Естественно, как только будет прочитан запрошенный процессором байт, кэш-контроллер тут же передаст его процессору.
Особенности кэш-подсистемы процессора AMD Athlon
Процессор AMD Athlon в целом ведет себя подобно своему предшественнику AMD K6, тем не менее, внутренняя архитектура его кэш-подсистемы претерпела значительные изменения. В частности, впервые за всю историю x86 процессоров в нем реализован эксклюзивный кэш второго уровня, эффективная емкость которого по заверениям разработчиков равна сумме размеров кэш-памяти обеих уровней, т.е. в данном случае: 64 + 256 = 320 Кб. Доверие – это прекрасно, но все-таки давайте попробуем прокатиться на этой машинке сами!…ну и где же обещанные нам 320 Кб? Хорошо, пусть "ступенька", расположенная у отметки в ~257 Кб, вызвана конфликтом стека и обрабатываемых данных, но ведь тотальное падение производительности начинается уже с ~273 Кб, что много меньше ожидаемого (читай: заявленного разработчиками) значения!
достигая максимума насыщения на отметке в ~385 Кб.
Тем не менее, на участке от 273 Кб до 320 Кб время доступа растет не линейно, а подчиняется формуле 1/x, т.е. эксклюзивная архитектура все-таки смягчает падение производительности при выходе за границы кэша второго уровня. Во всяком случае, эффективный объем кэша второго уровня оказался не меньше, а даже чуть-чуть больше его физической емкости, в то время как аналогичный по размеру кэш процессора P-III начал "валится" уже на 194 Кб (см. рис. graph 3).

Рисунок 20 graph 2 Зависимость скорости обработки от размера блока на AMD Athlon
Особенности кэш-подсистемы процессоров P-II и P-III
Несмотря на свою в общем-то далеко не передовую inclusive–архитектуру, кэш-подсистема процессора P-II, а уж тем более его преемника P-III, всухую уделывает Athlon, значительно обходя его в производительности.Благодаря своей 256-битной шине, процессор P-III может загружать 32-байтовый пакет данных из кэша второго уровня всего за один такт, против девяти тактов, которые тратит на это Athlon (т.к. при ширине шины в 64бита протяженности кэш-линеек составляет 64 байта, а кэш второго уровня работает по формуле 2-1-1-1-1-1-1-1, в сумме мы и получаем 9 тактов). Взгляните на рис. 3, – видите, кривая чтения (она выделена синим цветом) на всем протяжении остается идеально горизонтальной и "ступеньки" между кэшем первого и второго уровней на ней нет! Эффективный размер кэша перового уровня процессора P-III равен размеру кэша второго уровня, что составляет 256 Кб! Terrific!!! Правда, с записью памяти дела обстоят не так благоприятно и некоторое падение производительности при выходе за границы кэша первого уровня все же наблюдается, но оно так мало, что им можно безболезненно пренебречь.
Интересно другое. Характер изменения кривой записи памяти при выходе за пределы кэша второго уровня на P-II и P-III носит ярко выраженный нелинейный
характер. Вместо стремительного падения производительности, имеющей место на K6 и Athlon, здесь она убывает крайне медленно, как бы нехотя, достигая насыщения лишь при достижении 1 Мб отметки, что вчетверо превышает размеры кэша второго уровня. Не правда ли, очень здорово!
Здорово оно, может быть и здорово, но вот за счет чего этот выигрыш достигается? Официальная документация, увы, не дает прямой ответа на этот вопрос, а сторонние руководства позорно уходят в кусы, объясняя происходящее "особенностями буферизации". Что же это за особенности такие – пускай разбираются сами читатели! И разберемся!
Секрет (впрочем, какой это теперь секрет) фирмы Intel состоит в том, что при записи ячеек памяти, соответствующие им линейки загружаются в кэш второго уровня как эксклюзивные.
Остальные же процессоры ( в частности, уже упомянутые Athlon и K6), напротив, помечают эти линейки как модифицируемые. Не знаю, раздерет ли меня Intel на клочки, но я все-таки рискну не только пустить пыль в глаза, но и подробно разъясню все вышесказанное.
Итак, мысленно представим, как происходит процесс записи ячейки, отсутствующей в кэш-памяти первого уровня. Если ни одного свободного буфера записи нет (а при интенсивной записи памяти их и не будет), процессор вынужден загружать модифицируемую ячейку в кэш первого уровня. Он посылает сигнал кэшу второго уровня, который считывает 32 байтный блок памяти в одну из своих строк, присваивает ей атрибут "эксклюзивная" и передает ее копию кэшу первого уровня. Так продолжается до тех пор, пока обрабатываемый блок не превысит размеров кэша первого уровня и тогда процессор будет вынужден избавиться от наименее нужной строки, чтобы освободить место для новой. Поскольку все строки модифицированы, выбирается наиболее старя из них и отправляется в кэш второго уровня. Поскольку, в кэше второго уровня уже есть ее копия, он просто обновляет содержимое соответствующей ей линейки и изменяет атрибут "эксклюзивный" на "модифицируемый".
А теперь мы дождемся момента, когда кэш второго уровня будет полностью заполнен, но процессор предпримет попытку записи еще одной ячейки. Что происходит? Кэш первого уровня отправляет кэшу второго уровня сразу два запроса: запрос на загрузку новой порции данных и запрос на обновление вытесняемой кэш-линейки, чем серьезно его озадачивает, – ведь свободное место уже исчерпано. Ага, – говорит кэш второго уровня, – сейчас мы выкинем самое ненужное. А что у нас ненужное? Правильно, – эксклюзивные строки. Их удаление не требует предварительной выгрузки в основную память, а потому и обходится дешевле. Тем временем, пока кэш второго уровня загружает новую порцию данных из оперативной памяти, строка, вытесненная из кэша первого уровня содержится в специальном буфере и в дальнейшем записывается в основную память минуя кэш второго уровня.
Ключевой момент истории состоит в том, что вновь загруженная порция данных получает атрибут эксклюзивной, что делает ее кандидатом номер один на вытеснение. Постойте! Но ведь это означает, что при выходе за пределы кэша второго уровня, записываемые данные будут замещать одну и ту же кэш-строку, сохраняя ранее записанные строки в неприкосновенности! Это выгодно отличает P-II, P-III от процессоров K6 и Athlon, в обработка блока, не умещающегося в кэше второго уровня, приводит к последовательному замещению всех его строк.
Допустим, размер записываемых данных вдвое превышает емкость кэш-памяти второго уровня. Тогда в K6 и Athlon кэш будет крутиться полностью вхолостую, а на P-II и P-III только половина обращений вызовет промахи, а остальная благополучно сохранится в кэше. Впрочем, если говорить объективно, это не совсем так. Вследствие ограниченной ассоциативности кэша постоянным перезагрузкам подвернется не одна-единственная строка, а целый банк.
Теперь, в свете вновь открывшихся обстоятельств, становится ясен характер кривой, сопровождающий выход обрабатываемого блока за границы кэша второго уровня. Действительно, сохранение части кэшируемой памяти как бы оттягивает момент насыщения, скругляя острые углы графика записи. Более того полное насыщение вообще не наступает, т.к. при любом конечном размере обрабатываемого блока, сколь бы большим он ни был, сохраненные строки в той или иной мере повышают производительность. Другой вопрос, что с ростом соотношения эффективность такой стратегии стремится к нулю и если размер обрабатываемого блока превосходит емкость кэша в четыре или более раз, ей можно пренебречь.

Рисунок 21 graph 3 Зависимость скорости обработки от размера блока на P-III
Чтение перед записью и запись перед чтением.
Операция записи ячейки с последующим ее чтением выполняется так же быстро, как и одиночная запись. Это не покажется удивительным, если вспомнить, что при промахе кэша первого уровня записываются данные временно сохраняются в буфере откуда они могут быть прочитаны в течение такта записи.
Другое дело – чтение с последующей записью. Тут, крути не крути, а при каждом промахе придется дожидаться пока данные не будут прочитаны из кэша второго уровня, в результате каждая операция требует по меньшей мере четырех дополнительных тактов. Измерения показывают, что так оно и есть.
На графике ??? изображены четыре кривые: ###синяя соответствует чтению тестируемого блока, фиолетовая – записи, желтая – чтению с последующей записью, а голубая – записью с последующим чтением.
Но вот ступенька пройдена и размер обрабатываемого блока становится таким большим, что не умещается в кэш-памяти второго уровня и мало-помалу начинает с нее "свешивается". График скоростного показателя чтения поднимается вверх и продолжает расти до тех пор, пока размер обрабатываемого блока не превысит емкости кэш-памяти первого уровня в 1.28 раза. Эта цифра хорошо согласуется с теоретическим значением – 1.25 (ассоциативность L2-кэша равна четырем). А вот три других графика ведут себя совсем не так, демонстрируя просто чудовищное падение производительности. Впрочем, этого и следовало ожидать – ведь промахи записи кэша второго уровня обходятся очень "дорого".
Особенности обработки двумерных массивов
Техника параллельной загрузки данных (подробно рассмотренная в первой части настоящей книги) весьма эффективный способ… отправить свою программу на кладбище. Подсистема памяти не так проста как кажется, и один неосторожный шаг способен разнести всю оптимизацию к черту. Но довольно лирики! Переходим к делу.Допустим, у нас имеется здоровенный двухмерный массив, сумму ячеек которого и требуется подсчитать. Для определенности возьмем матрицу 512х512, состоящую из переменных типа int. Двухмерность массива порождает буриданову проблему: как вести подсчет– по строкам или по столбцам?
Подсчет по строкам фактически сводится к последовательному чтению памяти, а это (как мы помним) далеко не лучший способ обработки данных. Куда заманчивее читать массив по столбцам. Если ширина матрицы превышает длину пакетного цикла обмена, запросы к памяти генерируются при каждом
кэш-промахе (см. "Часть I.Оптимизация работы с памятью. Параллельная обработка данных"). Единственное условие – произведение столбцов на размер кэш-линии не должно превышать емкости кэш-памяти первого уровня.
Соблюдается ли это условие в данном случае? На первый взгляд как будто бы соблюдается. Смотрите: на P-III размер кэш-линеек составляет 32 байта, а размер кэш-памяти первого уровня – 16 Кб. В то же время: 512 x 32 = 16.384. Совпадают ли цифры? Совпадают! Хорошо, возьмем, например, AMD Athlon, имеющий 64 Кб кэш при длине линеек в 64 байт. Умножаем 512 на 64 – получаем 32 Кб, что с лихвой должно вместится в кэш. Но вместится ли? Сейчас проверим! Запустим на выполнение следующий пример:
#define N_ROW (512)
#define N_COL (512) // неоптимальное колю строк матр.
// поскольку оно кратно размеру
// кэш-банка и кэш используется
// _не_ полностью
/*----------------------------------------------------------------------------
*
* ПОСЛЕДОВАТЕЛЬНАЯ ОБРАБОТКА МАССИВА ПО СТОЛБЦАМ
*
----------------------------------------------------------------------------*/
int FOR_COL(int (*foo)[N_COL])
{
int x, y;
int z = 0;
for (x = 0;x < N_ROW; x++)
{
for (y = 0; y < N_COL; y++)
z += foo[x][y];
}
return z;
}
/*----------------------------------------------------------------------------
*
* ПОСЛЕДОВАТЕЛЬНАЯ ОБРАБОТКА МАССИВА ПО СТРОКАМ
*
----------------------------------------------------------------------------*/
int FOR_ROW(int (*foo)[N_COL])
{
int x, y;
int z = 0;
for (x = 0; x < N_COL; x++)
{
// внимание: если высота матрицы кратна размеру кэш-банка, то
// вследствии ограниченной ассоацитивности кэша его эффективная
// емкость значительно снизится и кэш-памяти может по просту
// не хватить, что приведет к постоянным промахам!
for (y = 0; y < N_ROW; y++)
z += foo[y][x];
}
return z;
}
Листинг 12 [Cache/column.big.c] Пример, демонстрирующий особенности обработки больших двухмерных массивов
Да как бы ни так! Мы совсем забыли про ассоциативность – поскольку, адреса читаемых ячеек кратны 4096, всего лишь четыре ячейки могут одновременно находится в кэше, но никак не 1024! Даже емкости кэша второго уровня для этих целей окажется недостаточно! Допустим, мы имеем, четырех ассоциативный 128 Кб L2-кэш. Каждый его банк способен хранить CACHE.SIZE/WAY/STEP.SIZE = 131.072/4/4096 == 32 таких ячеек. Следовательно, четыре банка разместят 32*4 = 128 ячеек. А у нас их аж 1024… Облом-с!
Сверхоперативная память будет крутиться полностью вхолостую, а величина кэш-промахов достигнет даже не 100%, а… 800% на P-II/P-III и 1.600% на AMD Athlon! Действительно, ведь в силу пакетного обмена, кэш-строки заполняются целиком, но только малая их часть оказывается реально востребована! В результате, данный код будет исполняется очень медленно.

Рисунок 31 graph 0x011 Зависимость времени обработки двухмерных массивов в зависимости от шага чтения.
Поскольку, обмен с кэшем и основной оперативной памятью осуществляется не отдельными ячейками, а пакетами достигающими в длину от 32 до 128 байт, при последовательной обработке ячеек время доступа к ним оказывается крайне неоднородным. Задержки возникают лишь при чтении первой ячейки пакета, а остальные ячейки пакета обрабатываются практически мгновенно.
Из этого в частности следует, что большие многомерные массивы (т.е. не умещающиеся в кэш-памяти первого, а тем более второго уровней) выгоднее обрабатывать не по столбцам, а по строкам. Массивы, целиком влезающие в кэш, можно обрабатывать как угодно – от этого хуже не будет.
На первый взгляд, программа column.big.c переставляет собой пример чрезвычайно высоко оптимизированного кода. Благодаря величие своего шага, первый проход цикла for y инициирует параллельную загрузку ячеек из основной оперативной памяти (или кэша второго уровня). Поскольку, размер обрабатываемых данных составляет всего лишь 1024*CACHE_LINE_SIZE == 32 Кб (что целиком умещается в кэш первого или на худой конец второго уровня), – может показаться, что последующие итерации цикла будут обрабатываться практически мгновенно, – ведь данные уже в кэше, а не в памяти!
Чтобы решить проблему, программу следует реорганизовать так:
int x,y,z;
int foo[1024][1024];
for (x=0;x<1024;x++)
{
for (y=0; y<1024;y++)
{
z=foo[x][y];
}
}
Листинг 13
Обрабатывая цикл не по столбцам, а по строкам, мы избавляемся от кэш-конфликтов и снижаем процент кэш-промахов до разумного минимума. Если хотите – мы даже можем рассчитать его поточнее. Несмотря на то, что массив foo, занимая 1024x1024x4 = 4 Мб памяти, намного превосходит и кэш первого, и кэш второго уровня, мы имеем всего лишь 12.5% промахов на P-II/P-III, а AMD Athlon и вовсе обходимся пактически без промахов– 6.25%! Согласитесь, что 6.25% это совсем не тоже самое, что и 1.600%!
Вот такая она разница между строками и столбцами!
В кэше второго уровня ячейка foo[1][0] так же имеет немного шансов сохранится. Пускай, емкости L2-кэша хватает с лихвой, но в многозадачной системе этот кэш приходится делить между множеством приложений – поскольку, первый проход цикла растянется на десятки тысяч тактов процессора, в течение этого времени не раз и не два произойдет переключение контекста и управление будет передано другим задачам. Если хотя бы одна из них интенсивно работает с памятью она может затереть таким трудом загруженные в L2-кэш строки массива foo, и – весь труд насмарку!
Поскольку, величина шага цикла for y превышает размер пакетного цикла обмена с памятью, в осуществляется параллельная загрузка ячеек
Смотрите, как все происходит: при попытке обращения к ячейке foo[0][0] кэш-контроллер, выяснив, что в кэше первого уровня она отсутствует, обращается к кэшу второго уровня. Там этой ячейки, скорее всего, тоже нет и приходится загружать полную 32-байтную строку из оперативной памяти, на что расходуется десятки тактов процессора.
Следующая ячейка – foo[1][0] – расположенная в соседней строке массива, отстоит от только считанной ячейки на 1024x4 байт, что много больше длины 32-байтной кэш-линии, поэтому она вновь загружается из оперативной памяти…
Наконец, первая колонка полностью обработана и наступает черед второй. Ячейки foo[1][0] уже не содержится в кэше. Почему? Ведь мы прочли всего четыре килобайта (1.024х4 = 4.096), что много меньше емкости L1-кэша? Если же реорганизовать цикл, читая его вот так: z=foo[x][y] скорость обработки многократно возрастет! Действительно, задержка при обращении к ячейке foo[0][0] компенсируется тем, что последующие восемь ячеек будут прочитаны практически мгновенно! Аналогично – foo[0][4] вызывает задержку на время подрузки данных из оперативной памяти, но каждая из последующих ячеек foo[0][5] foo[0][6] … foo[0][12] читается за один такт! В результате мы получаем практически семикратное ускорение – неплохо, правда?
Особое замечание о создании защитного кода на ассемблере
Защитные механизмы, бесспорно, предпочтительнее всего реализовывать на голом ассемблере, используя максимум трюков и извращений. Эффективность ассемблера здесь вторична, главное – максимально запутать взломщика. Компиляторы же генерируют достаточно предсказуемый код и почерк каждого из них профессиональным хакерам хорошо известен. Достоинства ассемблера в том, что он практически не ограничивает полет фантазии и позволяет воплощать в жизнь практически любые идеи. Полиморфный, шифрованный, самомодифицирующийся код, антиотладочные и антидизасемблерные приемы… этом список можно продолжать бесконечно. Целесообразность использования тех или иных защитных механизмов – тема другого разговора, здесь же мы будем обсуждать лишь пути их реализации.Ассемблерные трюки – вообще больная тема, однако, следует различать трюк как таковой (оригинальная идея и/или нетрадиционный примем программирования) и недокументированные возможности
процессора и операционной системы. Трюки, при грамотном подходе к ним, вполне безобидны и никаких проблем не создают. Классический пример трюка – расшифровка программы одноразовым блокнотом, возвращаемым функцией rand. Поскольку, функция rand всегда возвращает одну и ту же последовательность, она идеально подходит для динамической шифровки/расшифровки программы. Если дизассемблер не сумеет распознать rand в откомпилированной программе, – хакер ногу сломит, пока не догадается: как устроена и работает такая защита. Какие проблемы может создать этот трюк? Совершенно верно, – никаких.
А вот пример "грязного хака", основанного на недокументированных возможностях: в Windows95 регион адресного пространства от 0xC0000000 до 0xF0000000, хранящий низкоуровневые компоненты системы, свободно доступен прикладным приложениям, что очень облегчает борьбу с отладчиками и всякими мониторами. Правда, под Window NT первая же попытка обращения к этой области приводит к генерации исключения с последующим закрытием приложения–нарушителя. В результате, конечный пользователь теряет возможность запускать защищенную программу под Windows NT.
Вот за это многие и не любят ассемблер. Но, позвольте, разве ж ассемблер виноват? Не используйте недокументированных особенностей (а если уж совсем невтерпеж, то используйте их с умом) – и проблем ни у кого не будет!
В последнее время, кстати, наметилась устойчивая тенденция к отказу от ассемблера даже в защитных механизмах. Действительно, многие трюки замечательно реализуются и на языках высокого уровня. В частности, динамическую расшифровку кода (равно как и исполнение кода в стеке) можно реализовать и на чистом Си/Си++, достаточно лишь получить указатель на функцию (Си это позволяет), после чего с ее содержимым можно делать все, что угодно. И вовсе не обязательно для этого спускаться на уровень голого ассемблера. Так же, язык высокого уровня облегчает написание полиморфных генераторов и виртуальных машин (машин Тьюринга, сетей Петри, стрелок Пирса и т.д.). Единственное, что нельзя на нем реализовать – так это самомодифицирующийся код. Вернее, можно, но с жесткой привязкой к конкретному компилятору (ибо необходимо знать: как и во что транслируется каждая строка), а подобная практика – дурной тон. Привязываться ни к чему и ни когда не стоит, к тому же трудозатраты при создании самомодифицирующегося кода на языке высокого уровня намного выше, чем на ассемблере.
Тем не менее, возможность создания защит на чистых Си/Си++ многих хакеров старого поколения просто корежит, – они и слышать об этом не хотят (автор, кстати, сам такой). Что поделаешь! Традиции и привычки – штучки упрямые. Ну, красиво программирование на голом ассемблере, понимаете? А создание защит непосредственно в машинных кодах вызывает ничем не передаваемое удовлетворение по своему эмоциональному накалу сравнимое разве что с оргазмом.
Это – программирование ради программирования, нацеленное не на конечный результат, а на сам процесс его достижения. Не могу удержать, чтобы не процитировать: "Для некоторых людей программирование является такой же внутренней потребностью, подобно тому, как коровы дают молоко, или писатели стремятся писать" Николай Безруков
Отказ от индикатора завершения
По возможности не используйте какой бы то ни было индикатор завершения для распознания конца данных (например, символ нуля для задания конца строки). Во-первых, это приводит к неопределенности в длине самих данных и количества памяти, необходимой для их размещения, в результате чего возникают ошибки типа "buff = malloc(strlen(Str))", которые с первого взгляда не всегда удается обнаружить. (Пояснение для начинающих разработчиков: правильный код должен выглядеть так: "buff = malloc(strlen(Str)+1)", поскольку, в длину строки, возвращаемой функцией srtlen, не входит завершающий ее ноль).Во-вторых, если по каким-то причинам индикатор конца будет уничтожен, функция, работающая с этими данными, "забредет" совсем не в свой "лес".
В-третьих, такой подход приводит к крайне неэффективному подсчету объема памяти, занимаемого данным, - приходится их последовательно перебирать один за другим до тех пор пока не встретится символ конца, а, поскольку, по соображениям безопасности, при каждой операции контекции и присвоения необходимо проверять достаточно ли свободного пространства для ее завершения, очень важно оптимизировать этот процесс.
Значительно лучше явным образом указывать размер данных в отдельном поле (так, например, задается длина строк в компиляторах Turbo-Pascal и Delphi). Однако, такое решение не устраняет несоответствия размера данных и количества занимаемой ими памяти, поэтому, надежнее вообще отказаться от какого бы то ни было задания длины данных и всегда помещать их в буфер строго соответствующего размера.
Избавится от накладных расходов, связанных с необходимостью частных вызовов достаточно медленной функции realloc можно введением специального ключевого значения, обозначающего отсутствие данных. В частности, для строк сгодится тот же символ нуля, однако, теперь он будет иметь совсем другое значение – обозначать не конец строки, а отсутствие символа в данной позиции. Конец же строки определяется размером выделенного под нее буфера данных. Выделив буфер "под запас" и забив его "хвост" нулями, можно значительно сократить количество вызовов realloc.
Отображение физических DRAM-адресов на логические
С точки зрения процессора оперативная память представляется однородным массивом данных, доступ к ячейками которого осуществляется посредством 32-разрядных указателей. В тоже время адресное пространство физической оперативной памяти крайне неоднородно и делится на банки, адреса страниц и номера столбцов (а так же номера модулей памяти, если их установлено более одного). Согласованием интерфейсов оперативной памяти и процессора занимается чипсет, а сам процесс такого согласования называется трансляцией(отображением) физических DRAM-адресов на логические процессорные адреса.
Конкретная схема трансляции зависит от и типа установленной памяти, и от конструктивных особенностей чипсета. Программист полностью абстрагирован от деталей технической реализации всей этой кухни и лишен возможности "потрогать" физическую оперативную память руками. А, собственно, зачем это? Какая разница в какой строке и в каком столбце находится ячейка, расположенная по такому-то процессорному адресу? Достаточно лишь знать, что эта ячейка существует, – вот и все. Что ж, абстрагирование от аппаратуры, – действительно великая вещь и отличный способ заставить программу работать на любом оборудовании, но… насколько эффективно она будет работать?
В главе "Оптимизация работы с памятью" будет показано, что обеспечить эффективную обработку больших массивов данных без учета архитектурных особенностей DRAM – невозможно. Как минимум мы должны иметь представление по какому именно физическому адресу происходит чтение/запись ячеек памяти.
К счастью, схема трансляции адресов в подавляющем большинстве случаев практически идентична (см. рис. 42). Младшие биты логического адреса представляют собой смещение ячейки относительно начала пакетного цикла обмена и никогда не передаются на шину. В зависимости от модели процессора длина пакетного цикла обмена колеблется от 32 байт (K6, P?II, P-III) до 64 байт (AMD Athlon) и даже до 128 байт (P-4). Соответственно, количество битов, отводимых под смещение внутри пакета различно и составляет на 4-, 5- и 6 битов на K6/P?II/P?III, Athlon и P-4 соответственно.
Следующая порция битов указывает на смещение ячейки внутри DRAM-страницы (или, другими словами говоря, представляет собой номер столбца). В зависимости от конструктивных особенностей микросхемы памяти длина DRAM-страниц может составлять 1-, 2,-, или 4 Кб, поэтому, количество бит, необходимых для ее адресации, различно. Но ведь разработчики чипсетов тоже люди и реализовывать несколько систем трансляции адресов им не в кайф! Большинство существующих чипсетов поддерживают модули памяти только с 2 Кб DRAM?страницами, что соответствует 7 битам, отводимых для их адресации. Более продвинутые чипсеты (в частности Intel 815) умеют обрабатывать страницы и большего размера, отображая старшие биты номера столбца в самый "конец" процессорного адреса. Таким образом, программная длина DRAM-страниц практически во всех системах равна 2 Кб, – и это обстоятельство еще не раз пригодится нам в будящем.
Следующие один или два бита отвечают за выбор банков памяти. Все модули памяти, емкость которых превышает 64 Мб имеют четыре DRAM-банка и потому отображают на логическое адресное пространство два бита (22=4).
Оставшиеся биты представляют собой номер DRAM-страницы и их количество напрямую зависит от емкости модуля памяти.

Рисунок 15 0х42 Типовая схема трансляция процессорных адресов в физические адреса DRAM-памяти
Пара слов в заключении
Многие считают использование самомодифицирующегося кода "дурным" примером программирования, обвиняя его в отсутствии переносимости, плохой совместимости с различными операционными системами, необходимости обязательных обращений к ассемблеру и т.д. С появлением Windows 95/Windows NT этот список пополнился еще одним умозаключением, дескать "самомодифицирующийся код – только для MS-DOS, в нормальных же операционных системах он невозможен (и поделом!)".Как показывает настоящая статья, все эти притязания, мягко выражаясь, неверны. Другой вопрос – так ли необходим самомодифицирующийся код, и можно ли без него обойтись? Низкая эффективность существующих защит (обычно программы ломаются быстрее, чем успевают дойти до легального потребителя) и огромное количество программистов, стремящихся "топтанием клавиш" заработать себе на хлеб, свидетельствует в пользу необходимости усиления защитных механизмов любыми доступными средствами, в то числе и рассмотренным выше самомодифицирующимся кодом.
Параллельная обработка данных
Итак, обработка независимых данных выполняется намного быстрее, но насколько быстро она выполняется? Увы, если от относительных величин перейти к абсолютным цифрам,– весь восторг мгновенно улетучится и наступит глубокое уныние.Наивысшая пропускная способность, достигаемая при линейном чтении независимых данных, составляет не более чем 40%-50% от завяленной пропускной способности данного типа памяти. И это притом, что подсистема памяти для линейного доступа как раз и оптимизирована, и хаотичное чтение ячеек происходит, по меньшей мере, на порядок медленнее. А что может быть быстрее линейного доступа? (Аналогичный вопрос: что может быть короче, чем путь по прямой). Вот с поиска ответов на такие вопросы и начинается проникновение в истинную сущность предмета обсуждения.
В обработке независимых данных есть одна тонкость. Попытка одновременного чтения двух смежных ячеек в подавляющем большинстве случаев инициирует один, а не два запроса к подсистеме памяти. Это не покажется удивительным, если вспомнить, что минимальной порцией обмена с памятью является не отнюдь не байт, а целый пакет, длина которого в зависимости от типа процессора варьируется от 32- до 128 байт.
Таким образом, линейное чтение независимых данных еще не обеспечивает их параллельной обработки (обстоятельство, о котором популярные руководства по оптимизации склонны умалчивать). Вернемся к нашей программе (см. листинг [Memory/dependence.c]). Вот процессору потребовалось узнать содержимое ячейки *(int *) ((int) p1 + a). Он формирует запрос и направляет его чипсету, а сам тем временем приступает к обработке следующей команды – x += *(int *)((int)p1 + a + 4). "Ага", – думает процессор, – зависимости по данным нет и это хорошо! Но, с другой стороны… эта ячейка и без того возвратится с предыдущим запрошенным блоком, и посылать еще один запрос нет необходимости (чипсет, сколько его ни подгоняй, он быстрее работать не будет). Что ж, придется отложить выполнение данной команды до лучших времен.
Так, что там у нас дальше? Следующая команда – x += *(int *)((int)p1 + a + 8)
тоже отправляется на "консервацию", поскольку пытается прочесть ячейку из уже запрошенного блока. В общем, до тех пор, пока чипсет не обработает вверенный ему запрос, процессор (при линейном чтении данных!) ничего не делает, а только "складирует". Затем, по мере готовности операндов, команды "размораживаются" и процессор завершает их выполнение.
…наконец процессору встречается команда, обращающаяся к ячейке, следующей за концом последнего запрошенного блока. "Эх", – вздыхает процессор, – "если бы она мне встретилась раньше, – я бы смог отправить чипсету сразу два запроса!".
Более эффективный алгоритм обработки данных выглядит так: в первом проходе цикла память читается с шагом 32 байта (или 64/128 байт, если программа оптимизируется исключительно под Athlon/P-4), что заставляет процессор генерировать запросы чипсету при каждом обращении к памяти. В результате, на шине постоянно присутствуют несколько перекрывающихся запросов/ответов, обрабатывающихся параллельно (ну, почти параллельно). Во втором проходе цикла считываются все остальные ячейки, адреса которых не кратны 32 байтам. Поскольку, на момент завершения первого прохода они уже находятся в кэше, обращение к ним не вызовет больших задержек (см. рис 39).

Рисунок 19 39. По возможности избегайте линейного чтения ячеек памяти. Лучше в первом проходе цикла читать ячейки с шагом, кратным размеру пакетного цикла обмена, а оставшиеся ячейки обрабатывать как обычно
Рассмотрим усовершенствованный вариант программы параллельного чтения независимых данных:
/* -----------------------------------------------------------------------
*
* измерение пропускной способности при параллельном чтении данных
*
----------------------------------------------------------------------- */
#define
BLOCK_SIZE (32*M) // размер обрабатываемого блока
#define
STEP_SIZE L1_CACHE_SIZE // размер обрабатываемого подблока
for (b=0; b < BLOCK_SIZE; b += STEP_SIZE)
{
// первый проход цикла, в котором осуществляется
// параллельная загрузка данных
for (a = b; a < (b + STEP_SIZE); a += 128)
{
// загружаем первую ячейку;
// поскольку ее пока нет в кэше,
// процессор отправляет чипсету
// запрос на ее чтение
x += *(int *)((int)p + a + 0);
// загружаем следующую ячейку
// поскольку зависимости по данным нет,
// процессор может выполнять эту команду,
// не дожидаясь результатов предыдущей
// но, поскольку процессор видит, что
// данная ячейка не возвратиться с
// только что запрошенным блоком,
// он направляет еще чипсету еще один запрос
// не дожидаясь завершения предыдущего
x += *(int *)((int)p + a + 32);
// аналогично, - теперь на шине уже три запроса!
x += *(int *)((int)p + a + 64);
// на шину отправляется четвертый запрос,
// причем, первый запрос возможно еще и
// не завершен
x += *(int *)((int)p + a + 96);
}
for (a = b; a < (b + STEP_SIZE); a += 32)
{
// следующую ячейку читать не надо
// т.к. она уже прочитана в первом цикле
// x += *(int *)((int)p + a + 0);
// а эти ячейки уже будут в кэше!
// и они смогут загрузиться быстро-быстро!
x += *(int *)((int)p + a + 4);
x += *(int *)((int)p + a + 8);
x += *(int *)((int)p + a + 12);
x += *(int *)((int)p + a + 16);
x += *(int *)((int)p + a + 20);
x += *(int *)((int)p + a + 24);
x += *(int *)((int)p + a + 28);
}
}
Листинг 10 [Memory/parallel.test.c] Фрагмент программы, реализующий алгоритм параллельного чтения памяти, позволяющий "разогнать" ее на максимальную пропускную способность
На P-III 733/133/100 такой трюк практически в полтора раза обгоняет алгоритм линейного чтения, достигая пропускной способности порядка 600 Mb/s, что лишь на 25% меньше теоретической пропускной способности (см. рис. graph 02). Еще лучший результат наблюдается на Athlon'е, всего на 20% не дотянувшим до идеала. Смотрите, латентность его неповоротливого чипсета практически полностью компенсирована, а сама система прямо-таки дышит мощью и летает, будто ей в вентилятор залетел шмель! И это притом, что сама тестовая программа написана на чистом Си без каких либо "хаков" и ассемблерных вставок! (То есть резерв для увеличения производительности еще есть!)

Рисунок 20 graph 0x002 Демонстрация эффективности параллельного чтения. На AMD Athlon 1050/100/100/VIA KT133 этот простой и элегантный трюк обеспечивает более чем двукратный прирост производительности. На P-III 733/133/100/I815EP выигрыш, правда, гораздо меньше – 20% – но все равно более чем ощутим
Переход на другой язык
В идеале, контроль за ошибками переполнения следовало бы поручить языку, сняв это бремя с плеч программиста. Достаточно запретить непосредственное обращение к массиву, заставив вместо этого пользоваться встроенными операторами языка, которые бы постоянно следили: происходит ли выход за установленные границы и если да, либо возвращали ошибку, либо динамически увеличивали размер массива.Именно такой подход и был использован в Ада, Perl, Java и некоторых других языках. Но сферу его применения ограничивает производительность – постоянные проверки требуют значительных накладных расходов, в то время как отказ от них позволяет транслировать даже серию операций обращения к массиву в одну инструкцию процессора! Тем более, такие проверки налагают жесткие ограничения на математические операции с указателями (в общем случае требуют запретить их), а это в свою очередь не позволяет реализовывать многие эффективные алгоритмы.
Если в критических структурах (атомной энергетике, космонавтике) выбор между производительностью и защищенностью автоматически делается в пользу последней, в корпоративных, офисных и уж тем более бытовых приложениях наблюдается обратная ситуация. В лучшем случае речь может идти только о разумном компромиссе, но не более того! Покупать дополнительные мегабайты и мегагерцы ради одного лишь достижения надлежащего уровня безопасности и без всяких гарантий на отсутствие ошибок других типов, рядовой клиент ни сейчас, ни в отдаленном будущем не будет, как бы фирмы-производители его ни убеждали.
Тем более, что ни Ада, ни Perl, ни Java (т.е. языки, не отягощенные проблемами переполнения) принципиально не способны заменить Си\C++, не говоря уже об ассемблере! Разработчики оказываются зажатыми несовершенством используемого ими языка программирования с одной стороны, и невозможностью перехода на другой язык, с другой.
Даже если бы и появился язык, удовлетворяющий всем мыслимым требованиям, совокупная стоимость его изучения и переноса (переписывания с нуля) созданного программного обеспечения многократно бы превысила убытки от отсутствия в старом языке продвинутых средств контроля за ошибками.
Фирмы-производители вообще несут очень мало издержек за "ляпы" в своих продуктах и не особо одержимы идей их тотального устранения. В то же время, они заинтересованы без особых издержек свести их количество к минимуму, т.к. это улучшает покупаемость продукта и дает преимущество перед конкурентами.
Перемещение по тексту
Команда "End(Brief)" циклически перемещается к концу текущей линии, нижней строке в окне и, наконец, последней строке текста. Возможность быстрого перемещения к нижней строке окна, действительно, очень удобна, поэтому, имеет смысл назначить этой команде свою "горячую" клавишу. ("Tools à Customize à Keyboard à Category Edit àEnd(Brief)").
Команда "Home(Brief)" очень похожа на предыдущую за тем исключением, что циклически перемещается не вниз, а вверх. По умолчанию ей так же не соответствует никакая "горячая" клавиша.
Комбинации <Ctrl-Стрелка вверх> и <Ctrl-Стрелка вниз> перемещают текст в окне соответственно вверх и вниз, сохраняя положение курсора, по крайней мере, до тех пор, пока он не достигнет последней строки окна. Это удобно при просмотре текста программы – чтобы увидеть следующую строку вам не обязательно через все окно гнать курсор вниз (вверх), как в обычном редакторе.
"Горячая" клавиша <F4> перемещает курсор к следующей строке, содержащий ошибку и отмечает ее черной стрелкой. Соответственно, <Shift-F4> перемещает курсор к предыдущей "ошибочной" строке.

Рисунок 4 0x04
Пишем собственный профилировщик
Какой смыл разрабатывать свой собственный профилировщик, если практически с каждым компилятором уже поставляется готовый? А если возможностей штатного профилировщика окажется недостаточно, то – пожалуйста – к вашим услугам AMD Code Analyst и Intel VTune.К сожалению, штатный профилировщик Microsoft Visual Studio (как и многие другие подобные ему профилировщики) использует для измерений времени системный таймер, "чувствительности" которого явно не хватает для большинства наших целей. Профилировщик Intel VTune слишком "тяжел" и кроме того не бесплатен, а AMD Code Analyst не слишком удобен в работе и нет ни каких гарантий, что завтра за него не начнут просить деньги. Все это препятствует использованию перечисленных выше профилировщиков в качестве основных инструментов данной книги.
Предлагаемый автором DoCPU Clock, собственно, и профилировщиком язык назвать не поворачивается. Он не ищет горячие точки, не подсчитывает количество вызовов, более того, вообще не умеет работать с исполняемыми файлами. DoCPU Clock представляет собой более чем скромный набор макросов, предназначенных для непосредственно включения в исходный текст программы, и определяющих время выполнения профилируемых фрагментов. В рамках данной книги этих ограниченных возможностей оказывается вполне достаточно. Ведь все, что нам будет надо, – оценить влияние тех или иных оптимизирующих алгоритмов на производительность.
Планирование дистанции предвыборки
Поскольку, оперативная память – устройство медленное, загрузка кэш-линеек – дело долгое. Соответственно, предвыборку необходимо выполнять заблаговременно, с таким расчетом, чтобы, когда до обрабатываемых данных дойдет очередь, они уже находились в кэше.При линейной обработке данных добиться этого очень просто, вот циклы – дело другое. Как быть, если время загрузки данных превышает продолжительность одной итерации? В примерах???2,4, приведенных выше, проблема решалась предвыборкой данных, обрабатываемых в следующей итерации цикла. Обратите внимание – именно следующей
итерации, а не через одну итерацию. Поэтому, лишь в первом проходе цикл исполняется неэффективно, а все последующие идут "на ура".
Как же это получается?! Весь если предвыборка успевает загружать данные за время выполнения предыдущей итерации, продолжительность загрузки не превышает продолжительности одной итерации, а раз так – зачем вообще потребовался этот сдвиг, ведь по идее данные должны успевать загружаться в течение текущего прохода цикла?
Ответ на вопрос кроется в механизме взаимодействия ядра процессора с подсистемой памяти. ### Подробно он рассматривался в первой части настоящей книги (см. "Устройство и принципы функционирования оперативной памяти. Взаимодействие памяти и процессора"), здесь же напомним читателю лишь основные моменты. ### Ввиду ограниченности объема журнальной статьи не будем останавливаться на этом подробно, а рассмотрим лишь основные моменты. В первую очередь нас будет интересовать поведение процессора при чтении ячеек памяти, отсутствующих в кэшах обоих уровней. Убедившись, что ни L1, ни в L2 кэше требуемой ячейки нет (и израсходовав на это один такт), процессор принимает решение получить недостающие данные из оперативной памяти. "Выплюнув" в адресную шину адрес ячейки, процессор, ценой еще нескольких тактов, передает его контроллеру памяти. Затем чипсет вычисляет номер столбца и номер строки ячейки, и смотрит: открыта ли соответствующая страница памяти или нет? Если страница действительно открыта, чипсет выставляет сигнал CAS и спустя 2-3 такта (в зависимости от величины задержки CAS, обусловленной качеством микросхемы памяти) на шине появляются долгожданные данные. (Если же требуемая строка закрыта, но максимально допустимое количество одновременно открытых строк еще не достигнуто, чипсет посылает микросхеме памяти сигнал RAS вместе с адресом строки и дает ей 2-3 такта на его "переваривание", затем посылается CAS и все происходит по сценарию описанному выше, в противном случае приходится терять еще такт на закрытие одной из страниц).
Контроллер памяти "проглатывает" первую порцию данных за один такт и с началом следующего такта передает ее заждавшемуся процессору, параллельно с этим "выдаивая" из микросхемы памяти вторую. Количество порций данных, загружаемых за один шинный цикл обращения к памяти, на разных процессорах различно и определяется размером линеек кэша второго уровня. В частности, P-II и K6, с 32-байтными кэш-линейками заполняют их четырьмя 64-битных порциями данных. Легко подсчитать, что полное время доступа к памяти требует от 10 до 12 тактов системной шины, но только 4 из них уходят на непосредственную передачу данных, а все остальное время шина свободна.
Однако если адрес следующей обрабатываемой ячейки известен заранее, процессор может отправить контроллеру очередной запрос, не дожидаясь завершения предыдущего. Конвейеризация позволяет скрыть латентность (начальную задержку) памяти, сократив время доступа к памяти с 10(12) тактов до 4 (см. рис 0х27). Правда, чтение первой ячейки будет по-прежнему требовать 10-12 тактов, но при циклической обработке данных этой задержкой можно пренебречь. Вот и ответ на вопрос – почему для эффективной предвыборки данных потребовался сдвиг на одну итерацию. Это необходимо для компенсации времени латентности (Tl), которая в данном случае существенно превосходит полезное время передачи данных (Tb).

Рисунок 42 0х27 Демонстрация конвейеризации обмена с памятью.
Время выполнения цикла без использования предвыборки. В отсутствии предвыборки время выполнения цикла определяется суммой времени выполнения вычислительных инструкций цикла (Tc), времени латентности (Tl) и времени загрузки данных (Tb). Причем, во время выполнения вычислений системная шина простаивает, а во время загрузки данных, наоборот, вычислительный конвейер простаивает, а шина – работает. Причем, время доступа к памяти определяется не пропускной способностью шины, а латентностью подсистемы памяти. Шина же в лучшем случае нагружена на 15%-20% (см. рис. 0х028)

Рисунок 43 0х28 Исполнение цикла без использования предвыборки.
Время выполнения цикла в случае Tc>=Tl+Tb. Если время выполнения вычислительных инструкций цикла равно или превышает сумму времен латентности памяти и загрузки данных, упреждающая предвыборка эффективно распараллеливает работу системной шины с работой вычислительного конвейера (см. рис. 0х029).
Задержки доступа к памяти полностью маскируются, и время выполнения цикла определяется исключительно объемом вычислений, при этом производительность увеличивается в раз, т.е. в лучшем случае (при Tc=Tl+Tc) время выполнения цикла сокращает вдвое.
Минимальная дистанция предвыборки равна одной итерации, однако, если программа попадет на быстрый компьютер с медленной памятью (что типично для офисных и домашних компьютеров), запрошенные ячейки не успеют загрузиться за время выполнения предыдущей итераций. Это приведет к образованию "затора" на шине, вынужденному простою процессора и, как следствие, - тормозам.
Наилучший выход из ситуации – увеличить дистанцию предвыборки до двух-трех итераций. Да, мы теряем несколько первых проходов цикла, но при большом числе итераций, два-три прохода – это "капля в море"!

Рисунок 44 0х29 Исполнение цикла Tc>=Tl+Tb с дистанцией предвыборки в одну итерацию. Стрелкой с символом df показана зависимость по данным.
Время выполнения цикла в случае Tl+Tb > Tc> Tb. Если полное время доступа к памяти (т.е. сумма времени латентности и времени загрузки данных) превышает время выполнения вычислительных инструкций (Tc), но время выполнения вычислений в свою очередь превышает время загрузки данных, эффективное распараллеливание по-прежнему возможно! Необходимо лишь конвейеризовать запросы на загрузку данных, запрашивая очередную порцию данных до получения предыдущей. Это достигается увеличением дистанции предвыборки на несколько итераций, минимальное количество которых определяется по формуле:
(1),
где psd - Prefetch Scheduling Distance, – планируемая дистанция предвыборки, измеряемая в количестве итераций. Точно так, как и в предыдущем примере, в данном случае дистанцию предвыборки лучше взять с запасом, чем недобрать.
В худшем случае производительность увеличивается в два раза, а в среднем – в 4-5 раз (поскольку, типичная латентность памяти порядка 10 тактов, а время загрузки данных – 4 такта, то при Tc = Tb
получаем: ), причем загрузка системной шины достигает 80%-90% (в идеале – 100%). Великолепный результат, не так ли?

Рисунок 45 0х30 Исполнение цикла Tl+Tb > Tc> Tb. с дистанцией предвыборки в две итерации. Стрелкой с символом df показана зависимость по данным.
Время выполнения цикла в случае Tb > Tc. Наконец, если время загрузки данных (Tb) превышает время выполнения вычислительных инструкций цикла, полный параллелизм становится невозможен в принципе, – раз загрузка превышает продолжительность одной итерации, простой вычислительного конвейера неизбежен и никакая предвыборка тут не поможет. Тем не менее, предвыборка все же будет полезной, поскольку позволяет маскировать латентность памяти, что дает двух-трех кратный прирост производительности. Согласитесь – величина не малая. Оптимальная дистанция предвыборки определяется по формуле: (2).
Поскольку время выполнения цикла определяется исключительно скоростью загрузки данных (т.е. фактически частотой системной шины, загрузка которой в этом случае достигает 100%), излишне усердствовать над оптимизацией кода нет никакого смысла. Такая ситуация имеет место в частности, при копировании или сравнении блоков памяти. (Хотя, о оптимизация копирования – разговор особый. см. "Секреты копирования памяти или практическое применение новых команд процессоров Pentium-III и Pentium-4").

Рисунок 46 0х31 Исполнение цикла Tb > Tc. с дистанцией предвыборки в три итерации. Стрелкой с символом df показана зависимость по данным.
Практическое планирование предвыборки. Для вычисления оптимальной дистанции предвыборки необходимо знать: величину латентности памяти (Tl), время загрузки данных (Tb) и продолжительность выполнения одной итерации цикла (Tc). Поскольку, все три аргумента аппаратно–зависимы, приходится либо динамически
определять их значения в ходе выполнения программы, либо статически вычислять нижний предел дистанции предвыборки. Рассмотрим оба алгоритма подробнее.
Динамическое определение дает наивысший прирост производительности и достаточно просто реализуется. Один из возможных алгоритмов выглядит так: замеряв время выполнения первой итерации цикла (это можно сделать, например, посредством инструкции RDTSC), мы получаем сумму Tc+Tl+Tb. Затем, повторным выполнением этой же итерации, мы находим величину Tc (т.к. данные уже находятся в кэше время доступа к ним пренебрежительно мало). Разность этих двух величин даст сумму Tl+Tb, которой, вкупе с "чистой" Tc, вполне достаточно для вычисления формулы (???1).
Правда, определить "чистое" время Tb, необходимое для формулы (???2), таким способом довольно затруднительно, и лучше прибегнуть к алгоритму "вилки", суть которого заключается в следующем. Перебирая различные дистанции предвыборки и сравнения продолжительность соответствующих им итераций цикла, мы всего за несколько проходов найдем наиболее оптимальное значение, да не просто оптимальное, а самое-самое оптимальное, т.к. наилучшая дистанция предвыборки зачастую изменяется в процессе выполнения цикла. Особенно это характерно для разветвленных циклов, обрабатывающих неоднородные данные. К тому же динамический алгоритм определения дистанции предвыборки автоматически адоптируется под новые, еще не знакомые ему модели процессоров, в то время как статический анализ бессилен предвидеть их характеристики (Никто случайно не знает вычислительную скорость P-7?)
Статическое определение. В программах, рассчитанных на долговременное использование, статическое определение дистанции предвыборки нецелесообразно.
Никто не знает: какими будут процессоры через несколько лет, да и в настоящее время их характеристики повержены значительному разбросу. Если у CELERON-800 отношение частоты системной шины к частоте ядра равно 1:8, то у Pentium-4 1.300 оно лишь чуть-чуть не дотягивает 1:3. Вследствие этого соотношение у них будет сильно не одинаковым, и дистанция предвыборки, оптимальная для CELERON'а, окажется слишком малой для P-4, которому придется проставить, томительно ожидания загрузки очередной порции данных, в результате чего переход с CELETON на P-4 практически не увеличит производительности такой программы.
Поэтому, дистанцию предвыборки всегда планируйте с приличным запасом, по крайней мере, на 3-4 итерации больше необходимого минимума. Минимально необходимая дистанция предвыборки упрощенно вычисляется так:
(4)
где:
psd - Дистанция предвыборки (итераций цикла)
- Латентность памяти (тактов)
- Время загрузки кэш-строки (тактов)
- Количество предвыбираемых и сбрасываемых кэш-линий (штук)
CPI - Время выполнения одной инструкции (такты)
- Количество инструкций в одной итерации цикла (штуки)
В этой формуле почти все члены – неизвестные. Латентность памяти варьируется в очень широких пределах, поскольку определяется и типом самой памяти (SDRAM, DDR SDRAM, Rambus), и качеством чипов памяти (т.е. латентностью RAS и CAS), и "продвинутостью" чипсета, и, наконец, отношением частоты системной шины к частоте ядра процессора. Время загрузки кэш-строк пропорционально длине этих самых строк, которая составляет 32, 64 или 128 байт в зависимости от модели процессора (причем имеется ярко выраженная тенденция увеличения длины кэш-строк производителями).
Среднее время выполнения одной инструкции – вообще абстрактная величина, вроде среднего дохода граждан. Наряду с командами по трое сходящими с конвейера за один такт, некоторые (между прочим, достаточно многие) инструкции требуют десятков, а то и сотен тактов для своего выполнения! (В частности, целочисленное деление – не самая редкая операция, правда? – занимает от 50 до 70 тактов).
Таким образом, статическое планирование предвыборки в чем-то сродни гаданию на кофейной гуще. Но почему бы действительно не погадать? Intel приводит следующую эвристическую формулу, явно оговаривая ее ограниченность:
(5)
Подсчет количества инструкций в цикле () – очень интересный момент. Даже реализуя критические циклы на ассемблере (что для сегодняшнего дня вообще-то редкость), программист не может быть абсолютно уверен, что транслятор не впихнул туда чего-нибудь по собственной инициативе (например, NOP'ов для выравнивания). Что же тогда говорить о языках высокого уровня! Количество инструкций достоверно определяется лишь ручным их подсчетом в ассемблерном листинге (большинство компиляторов такие листинги генерировать умеют), однако, во-первых, это утомительное занятие придется проводить при всяком изменении текста программы. Во-вторых, если в цикле вызываются внешние функции (например, API-функции операционной системы) потребуется либо раздобыть исходные тексты ОС, либо дизассемблировать соответствующую функцию (но исходные тексты в большинстве случаев недоступны, а дизассемблер далеко не каждый умеет держать в руках). Наконец, в-третьих, полученный таким трудом результат все равно окажется неверным и ничуть не уступающим в точности киданию кости.
К счастью, интервал оптимальной дистанции предвыборки очень широк – даже увеличение минимального значения на порядок (!) в большинстве случаев снижает производительность не более чем на 10%-15% (а на многократно выполняющихся циклах – и того меньше). Поэтому, если скорость выполнения кода некритична, – динамическое определение дистанции предвыборки допустимо заменить статическим планированием, увеличив предвычисленный результат в несколько раз.
Хорошая идея – позволить пользователю задавать дистанцию предвыборки опционально. Чтобы не загромождать интерфейс и не смущать неопытных пользователей эти настойки можно запихнуть в реестр.
Планирование потоков данных
С проблемами, сопутствующими параллельной обработке нескольких блоков памяти (далее по тексту – потоков данных), мы уже столкнулись в предыдущем пункте. Здесь же этот вопрос будет рассмотрен более подробно. Итак, первое (и главное) правило – добиться, что бы потоки начинались с различных DRAM-банков (за TLB можно не беспокоиться, т.к. при параллельной обработке необходимые страницы уже будут там). Поскольку, большинство современных модулей памяти имеет четырех банковую организацию, очевидно, что работа с пятью и более потоками данных – нецелесообразна.Однако это лишь вершина айсберга. Здесь, в зарослях тростника, притаилось немало тонкостей. Возьмем, например, руководство по чипсету VIAKT133. В нем черным по белому написано: "Supports maximum 16-bank interleave (i.e., 16 pages open simultaneously)… " – "Поддерживается чередование максимум 16-банков (т.е. 16 страниц могут быть открытыми одновременно)..." Означает ли это, что на чипсете VIA KT133 (и других, подобных ему, чипсетах) мы можем обрабатывать до 16 потоков данных? И да, и нет, причем скорее нет, чем да. Ключевое слово "максимум". Если воткнуть в системную плату всего один DIMM с четырех банковой организацией, то никакие конструкторские ухищрения не позволят чипсету удержать открытыми пять и более страниц DRAM-памяти. Поскольку микросхема памяти имеет всего лишь один сигнальный вывод RAS, то для открытия еще одной страницы в том же самом банке, этот сигнал придется дезактивировать, т.е. закрыть активную страницу.
Таким образом, крайне нежелательно обрабатывать более четырех потоков данных параллельно, в противном случае вы столкнетесь с проблемами производительности. Да, да, – хмыкнет читатель, – советовать что-либо не делать – проще всего. Гораздо сложнее найти решение как именно это делать! Положим, нам необходимо
обрабатывать более четырех потоков данных одновременно, причем, расплачиваться производительностью за постоянные открытия/закрытия DRAM-страниц мы категорически не хотим.
Тупик? Вылезаем, мы приехали? Вовсе нет!
Первое, что приходит на ум, – перейти на оперативную память типа RDRAM. В сочетании с чипсетом Intel 850 это обеспечит восемь реально открытых страниц, а это – восемь потоков данных! Удовлетворяет вас такое решение? В общем-то, да, но далеко не во всех случаях. RDRAM на сегодняшний день (точнее, момент написания этих строк – июнь 2002 года для справки) – не самый дешевый и распространенный тип памяти.
На самом деле, даже на обычной SDRAM памяти можно обрабатывать практически неограниченное
количество потоков данных, ничем не расплачиваясь взамен. Ведь никто не требует обрабатываемые именно физические
потоки. Вот и давайте, создав один физический поток, разобьем его на несколько логических (виртуальных) потоков или, другими словами говоря, используем interleave-трансляцию адресов. Тогда между адресами логических и физического потока будет установлено следующее соответствие:
p[N][a] == a*MAX_N + N /* 1 */
P[a] == a mod MAX_N /* 2 */
где:
p – массив указателей на адреса начала логических потоков,
P – указатель на адрес начала физического потока,
N – индекс логического потока,
а – индекс элемента логического потока N,
MAX_N – количество логических потоков,
"Живой" пример interleave–трансляции изображен на рис. 35. Смотрите, до оптимизации у нас было два обособленных блока памяти a) и b), каждый из которых хранил восемь ячеек памяти, обозначенных a0, a1…a7 и b0, b1…b7 соответственно. В оптимизированном варианте программы эти два блока объедены в один непрерывный блок, составленный из шестнадцати чередующихся ячеек блоков а и b, – a0, b0, a1, b1….a7, b7. Теперь, при параллельной обработке логических потоков a и b запрашиваемые данные сливаются в один физический поток, что: во-первых, позволяет избежать постоянного открытия/закрытия DRAM-страниц; во-вторых, гарантирует, что смежные ячейки потоков а и b не попадут на различные страницы одного и того же DRAM-банка, находящегося в момент обращения на регенерации.
И, в-третьих, облегчает работу системе аппаратной предвыборке (если таковая имеется), поскольку большинство таких систем оптимизированы всего под один поток (это легче реализовать, да и увеличение пакетного цикла обмена с памятью эффективно лишь при последовательном обращении к ней).
Причем, заметьте, все эти блага достаются практически даром и не слишком "утяжеляют" алгоритм. Правда, тут есть одна тонкость. Поскольку, переход от физического адреса потока к логическому, неизбежен без взятия остатка, то следует подумать: как избавиться от машинной команды DIV, выполняющей целочисленное деление. Дело в том, что деление – очень медленная операция, по времени приблизительно сопоставимая с закрытием одной DRAM-страницы. Если количество потоков соответствует степени двойки, то взятие остатка можно осуществить и быстрыми битовыми операциями. Другой путь – заменить взятие остатка умножением (см. ….).

Рисунок 30 0x35. Виртуализация потоков данных. Несколько исходных потоков (слева) сливаются в один физический
поток, сконструированный по принципу чередования адресов, что фактически равносильно его расщеплению на два логических
потока
Теперь давайте воочию убедимся насколько эффективной может быть оптимизация потоков при большом их числе. Для этого напишем следующую несложную программу и посмотрим на ее результат.
#define BLOCK_SIZE (2*M) // макс. объем виртуального потока
#define MAX_N_DST 16 // макс. кол-во виртуальных потоков данных
#define MAIL_ROOL(a) for(a = 2; a <= MAX_N_DST; a++)
/* ^^^^^^^ начинаем с двух виртуальных потоков */
int a, b, r, x=0;
int *p, *px[MAX_N_DST];
// шапка
printf("N DATA STREAM"); MAIL_ROOL(a) printf("\t%d",a);printf("\n");
/* -----------------------------------------------------------------------
*
* обработка потоков классическим (не оптимизированным) способом
*
------------------------------------------------------------------------ */
// выделяем память всем потокам
for (a = 0; a < MAX_N_DST; a++) px[a] = (int *) _malloc32(BLOCK_SIZE);
printf("CLASSIC");
MAIL_ROOL(r)
{
A_BEGIN(0) /* начало замера времени выполнения */
for(a = 0; a < BLOCK_SIZE; a += sizeof(int))
for(b = 0; b < r; b++)
x += *(int *)((int)px[b] + a );
// перебор всех потоков один за другим
// причем, как легко убедиться, ячейки
// всех потоков находятся в различных
// страницах DRAM, поэтому при обработке
// более четырех потоков, DRAM страницы
// будут постоянно закрываться/открываться
// снижая тем самым производительность
// ВНИМАНИЕ! в данном случае циклы a и b
// в принципе, возможно обменять местами,
// увеличив тем самым производительность,
// но мы же договорились обрабатывать
// потоки _параллельно_
A_END(0) /* конец замера времени выполнения */
printf("\t%d",Ax_GET(0)); // вывод времени обработки потока
} printf("\n"); /* end for */
/* -----------------------------------------------------------------------
*
* оптимизированная обработка виртуальных потоков
*
------------------------------------------------------------------------ */
// выделяем память физическому потоку
p = (int*) _malloc32(BLOCK_SIZE*MAX_N_DST);
printf("OPTIMIZED");
MAIL_ROOL(r)
{
A_BEGIN(1) /* начало замера времени выполнения */
for(a = 0; a < BLOCK_SIZE * r; a += (sizeof(int)*r))
// что изменилось? Смотрите, ^^^ - шаг приращения ^^^
// теперь равен кол-ву виртуальных потоков
for(b = 0; b < r; b++)
x += *(int *)((int)p + a + b*sizeof(int));
// теперь ячейки всех потоков расположены _рядом_
// поэтому, время их обработки - минимально
A_END(1) /* конец замера времени выполнения */
printf("\t%d",Ax_GET(1)); // вывод времени обработки потока
} printf("\n"); /* end for */
Листинг 20 [Memory/stream.virtual.c] Фрагмент программы, демонстрирующий эффективность виртуализации потоков в зависимости от их числа
Ого! Нет, конечно, мы догадывались, что оптимизирующий вариант обгонит классический, но кто же мог представить: насколько
он его обгонит! (см. рис. 13, 14). Вообще-то, на P-III 733/133/100/I815EP/2x4 вплоть до четырех потоков (максимально возможного количества одновременно открытых DRAM-страниц), оптимизированный вариант заметно отставал от не оптимизированного. Но уже на пяти потоках оба варианта сравнялись в скорости, а дальше… дальше с добавлением каждого нового потока время работы не оптимизированного варианта стало стремительно взлетать вверх. А у оптимизированного, напротив, – росло практически линейно (небольшие осцилляции объясняются особенностями кэш-подсистемы, о которых мы поговорим чуть позже см. "Кэш"). Так, уже на шестнадцати потоках (вполне реальная величина для типичных вычислительных задач), оптимизация дала более чем трехкратный выигрыш в скорости! И все это – повторяюсь, – без значительных изменений базового алгоритма. Оптимизацию потоков необязательно закладывать на этапе проектирования программы, – это можно сделать в любое удобное для разработчика время. К тому же, это далеко не предел производительности! Быстродействие программы можно значительно увеличить, если использовать параллельную обработку данных (см. "Параллельная обработка данных").

Рисунок 31 graph 13 Демонстрация эффективности виртуализации потоков данных на системе P-III 733/133/100/I815EP/2x4. Уже на 16 потоках оптимизация дает более чем трехкратный выигрыш
А вот и тесты системы AMD Athlon 1050/100/100/VIA KT133/4x4 (см. рис. graph 14). Забавно, но в данном случае оптимизированный вариант значительно обогнал не оптимизированный во всех случаях, даже при обработке всего двух потоков. Как же такое могло произойти? Помниться, документация обещала аж 16 одновременно открытых страниц, а на практике "сваливалась" всего лишь на двух. Верно, было нам такое обещано, но ведь в то же самое время утверждалось, что: "Four cache lines (32 quad words) of CPU to DRAM read prefetch buffers" – "Буфер предварительной выборки из DRAM, размером в четыре кэш-линии (32 четверых слова) центрального процессора [256 байт – КК]". Для уменьшения латентности инженеры из VIA решились на весьма ответственный шаг – осуществление упреждающего чтения из оперативной памяти. Алгоритм предвыборки должен уметь распознавать регулярные шаблоны обращения к данным и на их основе с высокой вероятностью предсказывать, к каким именно ячейкам произойдет следующее обращение. В противном случае от предвыборки будут одни убытки, – ведь в момент чтения оперативная память недоступна и вместо уменьшения латентности мы многократно увеличим ее!
К сожалению, документация вообще ничего не говорит о сценарии предвыборки, но ведь никто не запрещает нам догадываться, правда? Судя по всему, алгоритм упреждающего чтения в KT133 даже и не пытается распознать стратегию обращения к памяти, а просто загружает последующие 32 четверных слова при обращении ко всякой ячейке. Как следствие, – при работе с несколькими потоками данных содержимое буфера предвыборки будет вытесняться прежде, чем к нему произойдет реальное обращение и… "упрежденные" данные окажутся "упреждены" вхолостую. Отсюда и снижение производительности.
Поэтому, на чипсете VIA KT133 ( и подобных ему) крайне не рекомендуется работать более чем с одним физическом потоком данных. Причем, выигрыш в оптимизации даже превосходит систему на базе P-III/I815, – уже при 10 потоках наблюдается более чем пятикратный выигрыш! Не правда ли, VIA KT133 – хороший чипсет?

Рисунок 32 graph 14 Демонстрация эффективности виртуализации потоков данных на системе P-III/I815EP/2x4 AMD Athlon/VIA KT133/4x4
Особые случаи виртуализации потоков
Однако на этом сюрпризы не заканчиваются. Все что мы видим – верхушка айсберга. А если копнуть в глубь? Вот, например, как вы думаете: на каком минимальном расстоянии потоки данных могут располагаться друг от друга? Здравый смысл подсказывает: чем ближе, – тем лучше. А вот как бы не так! Особенности буферизации некоторых чипсетов (попросту говоря: криво реализованный механизм буферизации и/или неинтеллектуальной предвыборки) способен вызывать значительное снижение производительности, если происходит попеременное обращение к "близким" (с точки зрения чипсета) ячейкам памяти. Рассмотрим следующий пример:
for(a=0; a
{
x += *(int *)((int)p + a );
y += *(int *)((int)p + a + DELTA_SIZE + STEP_FACTOR/2 );
}
Листинг 21 Пример неэффективного кода, не учитывающего особенности буферизации некоторых чипсетов
На системе Intel P-III 733/133/100/I815EP/2*4 время обработки блока практически не зависит от величины расстояния между потоками (DELTA_SIZE), естественно, если при этом не происходит постоянного попадания в один и тот же банк, но эту проблему мы уже обсудили (см. "Стратегия распределения данных по DRAM-банкам"). Казалось бы, какие тут могут быть сюрпризы? А вот взгляните на верхнюю кривую графика graph 12, иллюстрирующую поведение системы AMD Athlon 1050/100/100/VIA KT133/4 х 4. Оказывается, параллельная обработка данных, расположенных на расстоянии менее 512 байт друг от друга крайне невыгодна и несет чуть ли не двукратные издержки быстродействия.
Ох, уж эта система предвыборки в чипсете KT133! Конечно, можно просто заявить: "Используйте Intel (Intel – это рулез) и у вас не будет никаких проблем", но разработчик, заботящийся о своих клиентах, не должен ограничивать свободу выбора поставщика. Нравится кому-то VIA? – Да ради Бога! – Мы просто сместим начало каждого логического потока на 512 байт относительно предыдущего! Если количество требуемых потоков невелико, то с потерей нескольких килобайт можно и смириться, в противном случае возникнут попадания в регенерируемые банки и, – как следствие, – упадает производительность. Есть ли выход? Увы, общих решений нет… Правда, можно усложнить механизм трансляции адресов, расположив потоки приблизительно следующим образом: p0, 512 + p1, p2, 512 + p3…. тогда, при условии что потоки обрабатываются в строгой очередности, каждое обращение будет осуществляться без накладных расходов. Но что произойдет, если придется попеременно обращаться к данным потоков p1 и p3? Правильно – тормоза. Чтобы их избежать вам придется "заточить" механизм трансляции адресов потоков под алгоритм их обработки или… или вообще отказаться от оптимизации под VIA.

Рисунок 33 graph 12 Демонстрация особенностей механизма буферизации на чипсете VIA KT133. Смотрите, при узком "зазоре" между виртуальными потоками время их обработке чудовищно возрастает
Хорошо, с минимальным расстоянием мы разобрались. А существует ли максимально
разумное расстояние между потоками? Да, существует. И равно оно, как не трудно догадаться, – (N?1)*c /* на самом деле – даже (N?2)*c при хаотичной обработке блоков, но грамотным планированием потоков этой проблемы легко избежать */ Отсюда следует, что в один физический поток можно вместить не более чем: (N?1)*c/sizeof(element) логических потоков, где sizeof(element) – размер элементов потока. Так, для массивов, состоящих из элементов типа _int32, максимально разумное количество логических потоков равно: (4?1)*2048/4 == 1.536.
Не правда ли, это число более чем достаточно для всех – мыслимых и не мыслимых – задач?
Однако при этом максимально разумное количество физических потоков равно… одному. Ведь все DRAM-банки уже задействованы и при обращении ко второму физическому потоку никто не гарантирует, что мы не попадаем в регенерирующийся банк. Впрочем, тут все зависит от алгоритма работы с потоками, – вполне возможно добиться согласованной работы и четырех физических потоков, каждый из которых содержит по полторы тысячи логических. Но, постойте, сколько же это всего потоков получается? Шесть тысяч сто сорок четыре?! Трудно представить себе задачу, реально нуждающуюся в таком количестве потоков. Даже если это вычислительный кластер какой… Хотя, автору доводилось сталкиваться и с большим количеством параллельно обрабатываемых блоков данных – в системе, моделирующей движения звезд в галактиках, и пытающейся тем самым поближе подобраться к загадочной "темной материи", но это уже тема другого разговора… На персональных компьютерах пока подобные процессы не моделируют.
"Подводные камни" перемещаемого кода
При разработке кода, выполняющегося в стеке, следует учитывать, что в операционных системах Windows 9x, Windows NT и Windows 2000 местоположение стека различно, и, чтобы сохранить работоспособность при переходе от одной системы к другой, код должен быть безразличен к адресу, по которому он будет загружен. Такой код называют перемещаемым,и в его создании нет ничего сложного, достаточно следовать нескольким простым соглашениям – вот и все.
Замечательно, что у микропроцессоров серии Intel 80x86 все короткие переходы (short jump) и близкие вызовы (near call) относительны, т.е. содержат не линейный целевой адрес, а разницу целевого адреса и адреса следующей выполняемой инструкции. Это значительно упрощает создание перемещаемого кода, но вместе с этим накладывает на него некоторые ограничения.
Что произойдет, если следующую функцию "void Demo() { printf("Demo\n");}" скопировать в стек и передать ей управление? Поскольку, инструкция call, вызывающая функцию pritnf, "переехала" на новое место, разница адресов вызываемой функции и следующей за call
инструкции станет совсем иной, и управление получит отнюдь не printf, а не имеющий к ней никакого отношения код! Вероятнее всего им окажется "мусор", порождающий исключение с последующим аварийным закрытием приложения.
Программируя на ассемблере, такое ограничение можно легко обойти, используя регистровую адресацию. Перемещаемый вызов функции printf упрощенно может выглядеть, например, так:"lea eax, printf\ncall eax." В регистр eax (или любой другой регистр общего назначения) заносится абсолютный линейный, а не относительный адрес и, независимо от положения инструкции call, управление будет передано функции printf, а не чему-то еще.
Однако такой подход требует значения ассемблера, поддержки компилятором ассемблерных вставок, и не очень-то нравится прикладным программистам, не интересующихся командами и устройством микропроцессора.
Для решения данной задачи исключительно средствами языка высокого уровня, - необходимо передать стековой функции указатели на вызываемые ее функции как аргументы.
Это несколько неудобно, но более короткого пути, по-видимому, не существует. Простейшая программа, иллюстрирующая копирование и выполнение функций в стеке, приведена в листинге 2.
void Demo(int (*_printf) (const char *,...) )
{
_printf("Hello, Word!\n");
return;
}
int main(int argc, char* argv[])
{
char buff[1000];
int (*_printf) (const char *,...);
int (*_main) (int, char **);
void (*_Demo) (int (*) (const char *,...));
_printf=printf;
int func_len = (unsigned int) _main - (unsigned int) _Demo;
for (int a=0;a
buff[a]= ((char *) _Demo)[a];
_Demo = (void (*) (int (*) (const char *,...))) &buff[0];
_Demo(_printf);
return
0;
}
Листинг 3 Программа, иллюстрирующая копирование и выполнение функции в стеке
Поиск уязвимых программ
Приемы, предложенные в разделе "Предотвращение ошибок переполнения", хорошо использовать при создании новых программ, а внедрять их в уже существующие и более или менее устойчиво работающие продукты – бессмысленно. Но ведь даже отлаженное и проверенное временем приложение не застраховано от наличия ошибок переполнения, которые годами могут спать, пока не будут кем-то обнаружены.Самый простой и наиболее распространенный метод поиска уязвимостей заключается в методичном переборе всех возможных длин входных данных. Как правило, такая операция осуществляется не в ручную, а специальными автоматизированными средствами. Но таким способом обнаруживаются далеко не все ошибки переполнения! Наглядной демонстрацией этого утверждения служит следующая программа:
int file(char *buff)
{
char *p;
int a=0;
char proto[10];
p=strchr(&buff[0],':');
if (p)
{
for (;a!=(p-&buff[0]);a++) proto[a]=buff[a];
proto[a]=0;
if (strcmp(&proto[0],"file")) return 0;
else
WinExec(p+3,SW_SHOW);
}
else WinExec(&buff[0],SW_SHOW);
return 1;
}
main(int argc,char **argv)
{
if (argc>1) file(&argv[1][0]);
}
Листинг 1 Пример, демонстрирующий ошибку переполнения буферов
Она запускает файл, имя которого указано в командной строке. Попытка вызвать переполнение вводом строк различной длины, скорее всего, ни к чему не приведет. Но даже беглый анализ исходного кода позволит обнаружить ошибку, допущенную разработчиком.
Если в имени файла присутствует символ “:”, программа полагает, что имя записано в формате “протокол://путь к файлу/имя файла”, и пытается выяснить какой именно протокол был указан. При этом она копирует название протокола в буфер фиксированного размера, полагая, что при нормальном ходе вещей его хватит для вмещения имени любого протокола. Но если ввести строку наподобие “ZZZZZZZZZZZZZZZZZZZZZZ:”, произойдет переполнение буфера со всеми вытекающими отсюда последствиями.
Приведенный пример относится к одним из самых простых. На практике нередко встречаются и более коварные ошибки, проявляющиеся лишь при стечении множества маловероятных самих по себе обстоятельств. Обнаружить подобные уязвимости одним лишь перебором входных данных невозможно (тем не менее, даже такой поиск позволяет выявить огромное число ошибок в существующих приложениях).
Значительно лучший результат дает анализ исходных текстов программы. Чаще всего ошибки переполнения возникают вследствие путаницы между длинами и индексами массивов, выполнения операций сравнения до модификации переменной, небрежного обращения с условиями выхода из цикла, злоупотребления операторами "++" и "—", молчаливого ожидания символа завершения и т.д.
Например, конструкция “buff[strlen(str)-1]=0”, удаляющая символ возврата каретки, стоящий в конце строки, "спотыкаться" на строках нулевой длины, затирая при этом байт, предшествующий началу буфера.
Не менее опасна ошибка, допущенная в следующем фрагменте:
// …
fgets(&buff[0], MAX_STR_SIZE, stdin);
while(buff[p]!='\n') p++;
buff[p]=0;
// …
На первый взгляд все работает нормально, но если пользователь введет строку равную или превышающую MAX_STR_SIZE, функция fgets
автоматически отбросит ее хвост, вместе с символом возврата каретки. В результате этого цикл while выйдет за пределы сканируемого буфера и залезет в совсем не принадлежащую ему область памяти!
Так же часты ошибки, возникающие при преобразовании знаковых типов переменных в беззнаковые и наоборот. Классический пример такой ошибки – атака teardrop, возникающая при сборке TCP пакетов, один из которых находится целиков внутри другого. Отрицательное смещение конца второго пакета относительно конца первого, будучи преобразованным в беззнаковый тип, становится очень большим числом и выскакивает далеко за пределы отведенного ему буфера. Огромное число операционных систем, подверженных атаке teardrop наглядно демонстрирует каким осторожным следует быть при преобразовании типов переменных, и без особой необходимости такие преобразования и вовсе не следует проводить!
Вообще же, поиск ошибок – дело неблагодарное и чрезвычайно осложненное психологической инерцией мышления – программист подсознательно исключает из проверки те значения, которые противоречат "логике" и "здравому смыслу", но тем не менее могут встречаться на практике. Поэтому, легче решать эту задачу с обратного конца: сначала определить какие значения каждой переменной приводят к ненормальной работе кода (т.е. как бы смотреть на программу глазами взломщика), а уж потом выяснить выполняется ли проверка на такие значения или нет.
Особняком стоят проблемы многопоточных приложений и ошибки их синхронизации. Однопоточное приложение выгодно отличается воспроизводимостью аварийных ситуаций, - установив последователь операций, приводящих к проявлению ошибки, их можно повторить в любое время требуемое количество раз. Это значительно упрощает поиск и устранение источника их возникновения.
Напротив, неправильная синхронизация потоков (как и полное ее отсутствие), порождает трудноуловимые "плавающие" ошибки, проявляющиеся время от времени с некоторой (возможно пренебрежительно малой) вероятностью.
Рассмотрим простейший пример: пусть один поток модифицирует строку, и в тот момент, когда на место завершающего ее нуля помещен новый символ, а завершающий строку ноль еще не добавлен, второй поток пытается скопировать эту строку в свой буфер. Поскольку, завершающего нуля нет, происходит выход за границы массива со всеми вытекающими отсюда последствиями.
Поскольку, потоки в действительности выполняются не одновременно, а вызываются поочередно, получая в своей распоряжение некоторое (как правило, очень большое) количество "тиков" процессора, то вероятность прерывания потока в данном конкретном месте может быть очень мала и даже самое тщательное и широкомасштабное тестирование не всегда способно выловить такие ошибки.
Причем, вследствие трудностей воспроизведения аварийной ситуации, разработчики в подавляющем большинстве случаев не смогут быстро обнаружить и устранить допущенную ошибку, поэтому, пользователям придется довольно длительное время работать с уязвимым приложением, ничем не защищенным от атак злоумышленников.
Печально, что получив в свое распоряжение возможность делить процессы на потоки, многие программисты через чур злоупотребляют этим, применяя потоки даже там, где легко было бы обойтись и без них. Приняв во внимание сложность тестирования многопоточных приложений, стоит ли удивляется крайней нестабильности многих распространенных продуктов?
Не призывая разработчиков отказываться от потоков совсем, автор этой статьи хотел бы заметить, что гораздо лучше распараллеливать решение задач на уровне процессов. Достоинства такого подхода следующие: а) каждый процесс исполняется в собственном адресном пространстве и полностью изолирован от всех остальных; б) межпроцессорный обмен может быть построен по схеме, гарантирующей синхронность и когерентность данных; с) каждый процесс можно отлаживать независимо от остальных, рассматривая его как однопоточное приложения.
К сожалению, заменить потоки уже существующего приложения на процессы достаточно сложно и трудоемко. Но порой это все же гораздо проще, чем искать источник ошибок многопоточного приложения.
Поиск
Команда "FindBackwardDlg" открывает диалоговое окно "Find", автоматически устанавливая обратное направление поиска строки. По умолчанию она не связана ни с какой "горячей" клавишей и назначить ее вы должны самостоятельно ("Tools à Customize à Keyboard à Category Edit à FindBackwardDlg").Соответственно, команда "FindForwardDlg" открывает окно "Find", автоматически устанавливая прямое направление поиска. Штатный вызов диалога "Find" комбинацией
Команды "FindRegExpr" и "FindRegExprPrev" открывают диалоговое окно "Find" автоматически устанавливая галочку "Поиск регулярных выражений", причем, первая из них задает прямое, а вторая – обратное направление поиска.
"Горячая" клавиша <F3> повторяет поиск предыдущей подстроки не вызывая диалог "Find", что намного быстрее.
Еще удобнее комбинация <Ctrl-F3>, которая ищет следующее вхождение выделенного текста. Т.е. вместо того, чтобы вводить искомую подстроку в диалог "Find" достаточно выделить ее и нажать
Пара "горячих" клавиш <Ctrl-]>
и <Ctrl-Shift-]> перемещают курсор к следующей или предыдущей парной скобке соответственно. Это чрезвычайно полезно при разборе "монтроузных" выражений. Допустим, у нас имеется выражение "(((A) + (B)) + (C))" и необходимо найти пару второй слева скобке. Подводим к ней курсор, нажимаем
Команды "LevelUp" и "LevelDown" очень похожи на предыдущие, но, во-первых, не требуют, чтобы курсор находился на скобке, а, во-вторых, не имеют собственных горячих клавиш.
На мой взгляд, это несправедливо и нелогично, т.к. они намного удобнее в работе!
Команды "LevelCutToEnd" и "LevelCutToStart" вырезают в буфер обмена тело выражения до следующей или предыдущей парной скобки соответственно. Если же вам надо не вырезать, а копировать, то можно прибегнуть к небольшой хитрости – вырезать текст и тут же выполнить откат (Undo). Фокус в том, что откат не затрагивает буфер обмена, но восстанавливает удаленный текст. Как нетрудно догадаться, обе команды "горячими" клавишами не обременены, и назначать их придется самостоятельно.

Рисунок 2 0х03 Поиск парных скобок
"Горячая" клавиша <Ctrl-D> перемещает курсор в "Find Tools" – ниспадающий бокс, расположенный на панели инструментов и сохраняющий несколько последних шаблонов поиска (см. рис. 3). Конечно, по нему можно кликнуть и мышкой, но клавиатура позволит сделать это быстрее, без отрыва рук от производства!

Рисунок 3 0х02 Переход в окно "Find Tools"
Полезные макросы
Вместе с Visual Studio поставляется несколько образцов макросов, которые могут быть использованы не только для изучения Visual Basic'а, но и как самостоятельные утилиты. Нажмите <Shift-Alt-M>, затем в ниспадающем боксе "Macro File" выберите "SAMPLE" и в списке "Macro Name" появится список доступных макросов.В первую очередь хотелось обратить внимание на макрос "OneTimeInclude", одним мановением руки добавляющий в заголовочный файл программы код, предотвращающий его повторное включение, что, согласитесь, очень удобно:
#ifndef __IDD_xxx_
#define __IDD_xxx_
// Текст программы
#endif //__IDD _xxx_
Весьма полезна и пара макросов "ifdefOut" и "ifndefOut", ограждающих выделенный текст директивами условной компиляции "#ifdef" и "#idndef" соответственно. Условие компиляции запрашивается автоматически в диалоговом окне. Вроде бы мелочь, а как экономит время!
Макрос "ToggleCommentStyle" меняет в выделенном блоке стиль комментариев с '//' на "/* … */" и обратно, что чрезвычайно облегчает приведение всех листингов к единому стилю (особенно это полезно при работе в больших программистских коллективах – свой листинг вы оформляете так, как вам заблагорассудится, а потом просто переформатируете его – и все).
Макрос "PrintAllOpenDocument", как и следует из его названия, просто выводит все открытые активные документы на печать – при работе с большим количеством листингов эта возможность очень удобна.
Макрос "CloseExceptActive" закрывает все активные окна, за исключением текущего, что очень удобно для очистки Студии от "мертвых душ", открытых, но не используемых документов.
Политики записи и продержка когерентности
Если бы ячейки памяти были доступны только на чтение, то их скэшированная копия всегда совпадала бы с оригиналам. Возможность записи (ну какая же программа обходится без операций записи?) рождает следующие проблемы: во-первых, кэш-контроллер должен отслеживать модификацию ячеек кэш-памяти, выгружая в основную память модифицированные ячейки при их замещении, а, во-вторых, необходимо как-то отслеживать обращения всех периферийных устройств (включая остальные микропроцессоры в многопроцессорных системах) к основной памяти. В противном случае, мы рискуем считать совсем не то, что записывали!Кэш-контроллер обязан обеспечивать когерентность (coherency) – согласованность кэш-памяти с основной памятью. Допустим, к некоторой ячейке памяти, уже модифицированной в кэше, но еще не выгруженной в основную память, обращается периферийное устройство (или другой процессор) – кэш-контроллер должен немедленно обновить основную память, иначе оттуда почитаются "старые" данные. Аналогично, если периферийное устройство (другой процессор) модифицирует основную память, например посредством DMA, кэш-контроллер должен выяснить – загружены ли в модифицированные ячейки в его кэш-память, и если да – обновить их.
Поддержка когерентности – задача серьезная. Самое простое (но не самое лучшее) решение, мгновенно приходящее на ум, – кэшировать ячейки основной памяти только для чтения, а запись осуществлять напрямую, минуя кэш, сразу в основную память. Это, так называемая, сквозная (Write True write policy) политика. Сквозная политика легка в аппаратной реализации, но крайне неэффективна.
Частично компенсировать задержки обращения к памяти помогает буферизация. Записываемые данные на первом этапе попадают не в основную память, а в специальный буфер записи (store/write buffer), размером порядка 32-байт. Там они накапливаются до тех пор, пока буфер целиком не заполниться или не освободиться шина, а затем все содержимое буфера записывается в память "одним скопом". Такой режим сквозной записи с буферизацией (Write Combining write policy) значительно увеличивает производительность системы, но решает далеко не все проблемы.
В частности, значительная часть процессорного времени по-прежнему расходуется именно на выгрузку буфера в основную память. Тем более обидно, что в подавляющем большинстве компьютеров установлен всего один процессор и именно он, а не периферия, интенсивнее всех работает с памятью – не слишком ли дорого обходится поддержка когерентности?
Более сложный (но и совершенный!) алгоритм реализует обратная политика записи (Write Back write policy), до минимума сокращающая количество обращений к памяти. Для отслеживания операций модификации с каждой ячейкой кэш-памяти связывается специальный флаг, называемый флагом состояния. Если кэшируемая ячейка была модифицирована, то кэш-контроллер устанавливает соответствующий ей флаг в грязное (dirty) состояние. Когда периферийное устройство обращается к памяти, кэш-контроллер проверяет – находится ли соответствующий адрес в кэш-памяти и если да, тогда он, глядя на флаг, определяет: грязная она или нет? Грязные ячейки выгружаются в основную память, а их флаг устанавливается в состояние "чисто" (clear). Аналогично – при замещении старых кэш-строк новыми, кэш-контроллер в первую очередь стремится избавиться от чистых кэш-строк, т.к. они могут быть мгновенно удалены из кэша без записи в основную память. И только если все строки грязные – выбирается одна, наименее ценная (с точки зрения политики замещения данных) и "сбрасывается" в основную память, освобождая место для новой, "чистой" строки.
Таким образом, операция записи ячейки занимает от 1 такта процессора до 14-16 тактов системной шины. Такой разнобой объясняется очень просто – если кэш-контроллер поддерживает обратную политику записи, а к записываемой ячейке долгое время никто не обращается и не вытесняет ее из кэша, - кэш-контроллер "сбросит" ее в основную память во время простоя шины, нисколько не отнимая процессорного времени. И всего-то потребуется один такт на запись ячейки в кэш-память. В противном случае (если одно из вышеперечисленных условий не выполняется) расходуется 4-1-1-1 (или 5-1-1-1) тактов системной шины на запись ячейки в основную память и потом еще столько же на загрузку ее в кэш.
Понятие ассоциативности кэша
Проследим по шагам как работает кэш. Вот процессор обращается к ячейке памяти с адресом xyz. Кэш-контроллер, перехватив это обращение, первым делом пытается выяснить: присутствует ли запрошенные данные в кэш-памяти или нет? Вот тут-то и начинается самое интересное! Легко показать, что проверка наличия ячейки в кэш-памяти фактически сводится к поискусоответствующего диапазона адресов в памяти тегов.
В зависимости от архитектуры кэш-контроллера просмотр всех тегов осуществляется либо параллельно, либо они последовательно перебираются один за другим. Параллельный поиск, конечно, чрезвычайно быстр, но и сложен в реализации (а потому – дорог). Последовательный же просмотр при большом количестве тегов крайне непроизводителен. Кстати, а сколько у нас тегов? Правильно – ровно столько, сколько и кэш-строк. Так, в частности, в 32килобайтном кэше насчитывается немногим более тысячи тегов.
Стоп! Сколько времени потребует просмотр тысячи тегов?! Даже если несколько тегов будут просматриваться за один такт, поиск нужной нам линейки растянется на сотни тактов, что "съест" весь выигрыш в производительности. Нет уж, какая динамическая память ни тормозная, а к ней обратится побыстрее будет, чем сканировать кэш…
Но ведь кэш все таки работает! Спрашивается: как? Оказывается (и это следовало ожидать), что последовательный поиск – не самый продвинутый алгоритм поиска. Существуют и более элегантные решения. Рассмотрим два наиболее популярные из них.
В кэше прямого отображения проблема поиска решается так: пусть каждая ячейка памяти соответствует не любой, а одной строго определенной строке кэша. В свою очередь, каждой строке кэша будет соответствовать не одна, а множество ячеек кэшируемой памяти, но опять-таки, не любых, а строго определенных.
Пусть наш кэш состоит из четырех строк, тогда (см. рис 0х10) первый пакет кэшируемой памяти связан с первой строкой кэша, второй – со второй, третий – с третьей, четвертый – с четвертой, а пятый – вновь с первой! Достаточно очевидно, что адреса ячеек кэшируемой памяти связаны с номерами кэш-строк следующим отношением: , где N – условный номер кэш-линейки, ADDR – адрес ячейки кэшируемой памяти; CACHE.LINE.SIZE – длина кэш-линейки в байтах; CACHE.SIZE – размер кэш-памяти в байтах; "—" – операция целочисленного деления.
Таким образом, чтобы узнать: присутствует ли искомая ячейка в кэш-памяти или нет, достаточно просмотреть всего один-единственный тег соответствующей кэш-линейки, для вычисления номера которого требуется совершить всего три арифметические операции (поскольку, длина кэш-линеек и размер кэш памяти всегда представляют собой степень двойки, операции деления и взятия остатка допускают эффективную и простую аппаратную реализацию). Необходимость просматривать все теги в этой схеме естественным образом отпадает.
Да, отпадает, но возникает другая проблема. Задумайтесь, что произойдет, если процессор попытается последовательно обратиться ко второй, шестой и десятой ячейкам кэшируемой памяти? Правильно – несмотря на то, что в кэше будет полно свободных строк, каждая очередная ячейка будет вытеснять предыдущую, т.к. все они жестко закреплены именно за второй строкой кэша. В результате кэш будет работать максимально неэффективно, полностью вхолостую (trashing).
Программист, заботящийся об оптимизации своих программ, должен организовать структуры данных так, чтобы исключить частое чтение ячеек памяти с адресами, кратными размеру кэша. Понятное дело – такое требование не всегда выполнимо и кэш прямого отображения обеспечивает далеко не лучшую производительность. Поэтому, в настоящее время он практически нигде не встречаются и полностью вытеснен наборно-ассоциативной сверхоперативной памятью.

Рисунок 10 0x010 Устройство кэша прямого отображения
Наборно-ассоциативным кэш состоит из нескольких независимых банков,
каждый из которых представляет собой самостоятельный кэш прямого отображения. Взгляните на рисунок 0х012. Видите, каждая ячейка кэшируемой памяти может быть сохранена в любой из двух строк кэш-памяти. Допустим, процессор читает шестую и десятую ячейку кэшируемой памяти. Шестая ячейка идет во вторую строку первого банка, в десятая – во вторую строку следующего банка, т.к. первый уже занят.
Количество банков кэша и называют его ассоциативностью (way).Легко видеть, что с увеличением степени ассоциативности, эффективность кэша существенно возрастает (редкие исключения из этого правила мы рассмотрим позднее в главе "Оптимизация обращения к памяти и кэшу. Влияние размера обрабатываемых данных на производительность").
В идеале, при наивысшей степени дробления, в каждом банке будет только одна линейка, и тогда любая ячейка кэшируемой памяти сможет быть сохранена в любой строке кэша. Такой кэш называют полностью ассоциативным кэшем или просто ассоциативным кэшем.
Ассоциативность кэш-памяти, используемой в современных персональных компьютеров колеблется от двух (2-way cache) до восьми (8-way cache), а чаще всего равна четырем (4-way cache).

Рисунок 11 0х012 Устройство наборно-ассоциативного кэша
Повтор действий
Для многократного выполнения некоторой операции, скажем, вставки тысячи символов "звездочки", абсолютно незачем кидать кирпичи на клавишу "*" – достаточно воспользоваться командой "SetRepeatCount", устанавливающей количество повторов следующей выполняемой операции. Количество повторов задается либо с цифровой клавиатуры, либо повторными вызовами "SetRepeatCount", при этом количество повторов будет каждый раз умножаться на четыре. Не правда ли здорово?!Команды "SetRepeatCount0" .. "SetRepeatCount9" изменяют значение счетчика повторов от нуля от девяти соответственно.
Практический сеанс профилировки с VTune в десяти шагах
Любой, даже самый совершенный, инструмент бесполезен, если мастер не умеет держать его в руках. Профилировщик VTune не относится к категории интуитивно-понятных программных продуктов, которые легко осваиваются методом "тыка". VTune – это профессиональный инструмент, и грамотная работа с ним немыслима без специального обучения. В противном случае, большой пласт его функциональных возможностей так и останется незамеченным, заставляя разработчика удивленно пожимать плечами "и что только в этом VTune остальные нашли?".Настоящая глава ### статья, на учебник не претендует, но автор все же надеется, что она поможет сделать вам в освоении VTune первые шаги и познакомится с его основными функциональными возможностями и к тому же поможет вам решать: стоит ли использовать VTune или же лучше остановить свой выбор на более простом и легком в освоении профилировщике.
В качестве "подопытного кролика" для наших экспериментов с профилировкой и оптимизацией мы используем простой переборщик паролей. Во?первых, это наглядный и вполне реалистичный пример, а, во-вторых, в программах подобного рода требование к производительности превыше всего. Предвидя возможное негодование некоторых читателей, сразу же замечу, что ни о каком взломе настоящих шифров и паролей здесь речь не идет! Реализованный в программе криптоалгоритм не только нигде не используется в реальной жизни, но к тому же допускает эффективную атаку, раскалывающую зашифрованный текст практически мгновенно.
В первую нас очередь будет интересовать не сам взлом, а техника поиска горячих точек и возможные способы их ликвидации. Словом, ничуть не боясь оскорбить свою пуританскую мораль, набейте в редакторе исходный тест следующего содержания:
//----------------------------------------------------------------------------
// Это пример того, как не нужно писать программы! Здесь допущено множество
// ошибок, снижающих производительность. Профилировка позволяет найти их все
// --------------------------------------------------------------------------
// КОНФИГУРАЦИЯ
#define ITER 100000 // макс. итераций
#define MAX_CRYPT_LEN 200 // макс. длина шифротекста
// процедура расшифровки шифротекста найденным паролем
DeCrypt(char *pswd, char *crypteddata)
{
int a;
int p = 0; // указатель текущей позиции расшифровываемых данных
// * * * ОСНОВНОЙ ЦИКЛ РАСШИФРОВКИ * * *
do {
// расшифровываем текущий символ
crypteddata[p] ^= pswd[p % strlen(pswd)];
// переходим к расшифровке следующего символа
} while(++p < strlen(crypteddata));
}
// процедура вычисления контрольной суммы пароля
int CalculateCRC(char *pswd)
{
int a;
int x = -1; // ошибка вычисления CRC
// алгоритм вычисления CRC, конечно, кривой как бумеранг, но ногами чур
// не пинать, - это делалось исключительно для того, чтобы
// подемонстрировать missaling
for (a = 0; a <= strlen(pswd); a++) x += *(int *)((int)pswd + a);
return x;
}
// процедура проверки контрольной суммы пароля
int CheckCRC(char *pswd, int validCRC)
{
if (CalculateCRC(pswd) == validCRC)
return validCRC;
// else
return 0;
}
// процедура обработки текущего пароля
do_pswd(char *crypteddata, char *pswd, int validCRC, int progress)
{
char *buff;
// вывод текущего состояния на терминал
printf("Current pswd : %10s [%d%%]\r",&pswd[0],progress);
// проверка CRC пароля
if (CheckCRC(pswd, validCRC))
{ // <- CRC совпало
// копируем шифроданные во временный буфер
buff = (char *) malloc(strlen(crypteddata));
strcpy(buff, crypteddata);
// расшифровываем
DeCrypt(pswd, buff);
// выводим результат расшифровки на экран
printf("CRC %8X: try to decrypt: \"%s\"\n",
CheckCRC(pswd, validCRC),buff);
}
}
// процедура
перебора паролей
int gen_pswd(char *crypteddata, char *pswd, int max_iter, int validCRC)
{
int a;
int p = 0;
// генерировать
пароли
for(a = 0; a < max_iter; a++)
{
// обработать
текущий пароль
do_pswd(crypteddata, pswd, validCRC, 100*a/max_iter);
// * основной цикл генерации паролей *
// по алгоритму "защелка" или "счетчик"
while((++pswd[p])>'z')
{
pswd[p] = '!';
p++; if (!pswd[p])
{
pswd[p]=' ';
pswd[p+1]=0;
}
} // end while(pswd)
// возвращаем указатель на место
p = 0;
} // end for(a)
return 0;
}
// Функция выводит число, разделяя разряды точками
print_dot(float per)
{
// * * * КОНФИГУРАЦИЯ * * *
#define N 3 // отделять по три разряда
#define DOT_SIZE 1 // размер точки-разделителя
#define DOT "." // разделитель
int a;
char buff[666];
sprintf(buff,"%0.0f", per);
for(a = strlen(buff) - N; a > 0; a -= N)
{
memmove(buff + a + DOT_SIZE, buff + a, 66);
if(buff[a]==' ') break;
else
memcpy(buff + a, DOT, DOT_SIZE);
}
// выводиим на экран
printf("%s\n",buff);
}
main(int argc, char **argv)
{
// переменные
FILE *f; // для чтения исходного файла (если есть)
char *buff; // для чтения данных исходного файла
char *pswd; // текущий тестируемый
пароль (need by gen_pswd)
int validCRC; // для хранения оригинального CRC пароля
unsigned int t; // для замера времени исполнения перебора
int iter = ITER; // макс. кол-во перебираемых паролей
char *crypteddata; // для хранения шифрованных
// build-in default crypt
// кто прочтет, что здесь зашифровано, тот постигнет Великую Тайну
// Крис Касперски ;)
char _DATA_[] = "\x4B\x72\x69\x73\x20\x4B\x61\x73\x70\x65\x72\x73\x6B"\
"\x79\x20\x44\x65\x6D\x6F\x20\x43\x72\x79\x70\x74\x3A"\
"\xB9\x50\xE7\x73\x20\x39\x3D\x30\x4B\x42\x53\x3E\x22"\
"\x27\x32\x53\x56\x49\x3F\x3C\x3D\x2C\x73\x73\x0D\x0A";
// TITLE
printf( "= = = VTune profiling demo = = =\n"\
"==================================\n");
// HELP
if (argc==2)
{
printf("USAGE:\n\tpswd.exe [StartPassword MAX_ITER]\n");
return 0;
}
// выделение
памяти
printf("memory malloc\t\t");
buff = (char *) malloc(MAX_CRYPT_LEN);
if (buff) printf("+OK\n"); else {printf("-ERR\n"); return -1;}
// получение шифротекста для расшифровки
printf("get source from\t\t");
if (f=fopen("crypted.dat","r"))
{
printf("crypted.dat\n");
fgets(buff,MAX_CRYPT_LEN, f);
}
else
{
printf("build-in data\n");
buff=_DATA_;
}
// выделение CRC
validCRC=*(int *)((int) strstr(buff,":")+1);
printf("calculate CRC\t\t%X\n",validCRC);
if (!validCRC)
{
printf("-ERR: CRC is invalid\n");
return -1;
}
// выделение шифрованных данных
crypteddata=strstr(buff,":") + 5;
//printf("cryptodata\t\t%s\n",crypteddata);
// выделение памяти для парольного буфера
printf("memory malloc\t\t");
pswd = (char *) malloc(512*1024); pswd+=62;
/* демонстрация последствий ^^^^^^^^^^^ не выровненных данных */
/* размер блока объясняется тем, что при запросе таких блоков */
/* malloc всегда выравнивает адрес на 64 Кб, что нам и надо */
memset(pswd,0,666); // <-- инициализация
if (pswd) printf("+OK\n"); else {printf("-ERR\n"); return -1;}
// разбор аргументов командной строки
// получение стартового пароля и макс. кол-ва итераций
printf("get arg from\t\t");
if (argc>2)
{
printf("command line\n");
if(atol(argv[2])>0) iter=atol(argv[2]);
strcpy(pswd,argv[1]);
}
else
{
printf("build-in default\n");
strcpy(pswd,"!");
}
printf("start password\t\t%s\nmax iter\t\t%d\n",pswd,iter);
// начало
перебора паролей
printf("==================================\ntry search... wait!\n");
t=clock();
gen_pswd(crypteddata,pswd,iter,validCRC);
t=clock()-t;
// вывод кол-ва перебираемых паролей за сек
printf(" \rPassword per sec:\t");
print_dot(iter/(float)t*CLOCKS_PER_SEC);
return 0;
}
Листинг 11 [Profile/pdsw.c] Не оптимизированный вариант парольного переборщика
Откомпилировав этот пример с максимальной оптимизацией, запустим его на выполнение, чтобы убедиться насколько хорошо справился со своей работой машинный оптимизатор.
Прогон программы на P-III 733 даст скорость перебора… всего лишь порядка 30 тысяч паролей в секунду! Да это меньше, чем совсем ничего и такими темпами зашифрованный текст будет ломаться ну очень долго!!! Куда же уходят такты процессора?
Для поиска узких мест программы мы воспользуемся профилировщиком Intel VTune. Запустим его (не забывая, что под w2k/NT от требует для своей работы привилегий администратора) и, тем временем пока компьютер деловито шуршит винчестером, создадим таблицу символов (не путать с отладочной информацией!), без которой профилировщик ни за что не сможет определить какая часть исполняемого кода к какой функции относится. Для создания таблицы символов в командой строке компоновщика (линкера) достаточно указать ключ "/profile". Например, это может выглядеть так: "link /profile pswd.obj". Если все сделано правильно, образуется файл pswd.map приблизительно следующего содержания:
0001:00000000 _DeCrypt 00401000 f pswd.obj
0001:00000050 _CalculateCRC 00401050 f pswd.obj
0001:00000080 _CheckCRC 00401080 f pswd.obj
Ага, VTune уже готов к работе и терпеливо ждет наших дальнейших указаний, предлагая либо открыть существующий проект – "Open Existing Project" (но у нас нечего пока открывать), либо вызывать Мастера
для создания нового проекта – "New Project Wizard" (вот это, в принципе, нам подходит, но сумеем ли мы разобраться в настойках Мастера?), либо же выполнить быстрый анализ производительности приложения – "Quick Performance Analyses", – выбираем его! В появившемся диалогом окне указываем путь к файлу "pswd.exe" и нажимаем кнопочку "GO" (то есть "Иди").
VTune автоматически запускает профилируемое приложение и начинает собирать информацию о времени его выполнения в каждой точке программы, сопровождая этот процесс симпатичной змейкой - индикатором.
Если нам повезет и мы не зависнем, то через секунду-другую VTune распахнет себя на весь экран и вывалит множество окон с полезной и не очень информацией. Рассмотрим их поближе (см. рис. 0x001). В левой части экрана находится Навигатор Проекта, позволяющий быстро перемещаться между различными его части. Нам он пока не нужен и потому сосредоточим все свое внимание в центр экрана, где расположены окна диаграмм.
Верхнее окно показывает сколько времени выполнялась каждая точка кода, позволяя тем самым обнаружить "горячие" точки (Hot Spots), – т.е. те участки программы, на выполнение которых уходит наибольшее количество времени. В данном случае профилировщик обнаружил 187 горячих точке, о чем и уведомил нас в правой части окна. Обратите внимание на два пика, расположение чуть левее середины центра экрана. Это не просто горячие, а прямо-таки адски раскаленные точечки, съедающие львиную долю быстродействия программы, и именно с их оптимизации и надо начинать!
Подведем курсор к самому высокому пику – VTune тут же сообщит, что оно принадлежит функции out. Постой! Какой out?! Мы ничего такого не вызывали!! Кто же вызвал эту нехорошую функцию? (Несомненно, вы уже догадались, что это сделала функция printf, но давайте притворимся будто бы мы ничего не знаем, ведь в других случаях найти виновника не так просто).

Рисунок 6 0х001 Содержимое окон VTune сразу же после анализа приложения. В первую очередь нас интересует верхнее окно, "картографирующее" горячие точки, расположенные согласно их адресам. Нижнее окно содержит информацию о относительном времени выполнении всех модулей системы. Обратите внимание, модуль pswd.exe (на диаграмме он отмечен стрелкой) занял далеко не первое место и основную долю производительности "съел" кто-то другой. Создается обманчивое впечатление, что оптимизировать модуль pswd.exe бессмысленно, но это не так…
Чтобы не рыскать бес толку по всему коду, воспользуется другим инструментом профилировщика – "Call Graph", позволяющим в удобной для человека форме отобразить на экране иерархическую взаимосвязь различных функций (или классов – если вы пишите на Си ++).
В меню "Run" выбираем пункт "Win32* Call Graph Profiling Session" и вновь идем перекурить, пока VTune профилирует приложение. По завершению профилировки на экране появится еще два окна. Верхнее, содержащее электронную таблицу, мы рассматривать не будем (оно понятно и без слов), а вот к нижнему присмотримся повнимательнее. Пастельно-желтый фон украшают всего два ядовито-красных прямоугольника с надписями "Thread 400" и "mainCRTStartup". Щелкнем по последнему из них два раза, – VTune тут же выбросит целый веер дочерних функций, вызываемых стартовым кодом приложения. Находим среди них main (что будет очень просто, т.к. только main выделен красным цветом) и щелкаем по нему еще раз…. и будем действовать так до тех пор, пока не раскроем все дочерние функции процедуры main.
В результате выяснится, что функцию out действительно вызывает функция printf, а саму printf вызывает… do_pswd. Ну, да! Теперь мы "вспомнили", что использовали ее для вывода текущего тестируемого пароля на экран! Какая глупая идея! Вот оказывается куда ушла вся производительность!

Рисунок 7 0х002 Иерархия "горячих" функций, построенная Мастером Call Graph. Цвет символизирует "температуру" функции, а стоящее возле нее число сообщает сколько именно она вызвалась раз.
Практическое использование предвыборки
Если вычислительный алгоритм позволяет с той или иной вероятностью предсказать адрес следующей обрабатываемой ячейки – это хороший кандидат на оптимизацию, причем выигрыш от использования предвыборки будет тем значительнее, чем точнее определяется адрес следующей обрабатываемой ячейки. В первую очередь это относится к циклам с постоянным шагом, геометрическим преобразованиям в 2D/3D графике, операциям сортировки, копирования и инициализация памяти, строковым операциям и т.д. В меньшей степени поддается оптимизации обработка списков и двоичных деревьев. Поскольку, порядок размещения их элементов заранее не известен и определяется исключительно в процессе прохода по списку (дереву), гарантированно определить адрес следующего обрабатываемого элемента в общем случае невозможно. Однако достаточно часто его удается угадать. Например, можно предположить, что начало очередного элемента находится непосредственно за концом текущего. Если список (двоичное дерево) не очень сильно фрагментирован, процент попаданий значительно превосходит количество промахов и предвыборка дает положительный эффект.Рассмотрим следующий пример:
#define STEP_SIZE L1_CACHE_LINE_SIZE
for(a=0;a
// Делаем некоторые вычисления (какие - не важно)
_jn(c, b);
// Считываем очередную ячейку
b+=p[c];
}
Листинг 20 Кандидат на оптимизацию с использованием предвыборки
Если обрабатываемый блок отсутствует в кэше первого и второго уровней, а шаг цикла равен или превышает размер кэш-линейки, то каждое обращение к памяти будет вызывать значительную задержку – порядка 10-12 тактов системной шины, требующихся на передачу запрашиваемых ячеек из медленной оперативной памяти в быстрый кэш. На P-III 733 это составит более полусотни тактов процессора! В результате – время выполнения данного примера в большей степени зависит от быстродействия подсистемы памяти, и в меньшей – от тактовой частоты процессора.
Однако поскольку адрес очередной обрабатываемой ячейки известен заранее, данные можно загружать в кэш параллельно с выполнением вычислений.
Пример, оптимизированный под P-III, в первом приближении будет выглядеть приблизительно так:
#define STEP_SIZE L1_CACHE_LINE_SIZE
for(a=0;a
{
// Даем команду на загрузку следующей 32-байтной строки
// в L1-кэш. Загрузка будет осуществляться параллельно
// с выполнением функции _jn.
// Когда же соответствующая ячейка будет затребована,
// она уже окажется в L1-кэше, откуда процессор сможет
// извлечь ее безо всяких задержек.
_prefetchnta(p+c+STEP_SIZE);
// ^^^^^^^^ обратите внимание: в кэш
// загружается ячейка, обрабатываемая не в текущей, а
// следующей итерации цикла. Дело в том, что за время
// выполнения функций _jn запрашиваемая кэш-линейка
// просто не успевает загрузится!
// (подробнее см. "Планирование дистанции предвыборки")
// Выполняем некоторые вычисления
_jn(c, b);
// Считываем очередную ячейку
// Во всех, кроме первой, итерациях цикла ячейка будет
// гарантированно находиться в кэше первого уровня,
// в результате время ее чтения сократится до 1 такта CPU
b+=p[c];
}
Листинг 21 Оптимизированный вариант с использованием предвыборки [P-III]
На P-III 733/133/100 оптимизированный вариант выполняется быстрее на целых 64%, а на AMD Athlon 1050/100/100 – на ~60%, т.е. предвыборка увеличивает производительность более чем в два раза! (см. рис. 0х014) И это притом, что в цикле выполняется лишь одно обращение к памяти за каждую итерацию. А чем больше происходит обращений к памяти – тем больший выигрыш дает оптимизация!
Максимальный прирост производительности достигается в тех случаях когда: а) предвыборка данных осуществляется в кэш иерархию, соответствующую их назначению; б) запрашиваемые данные загружаются аккурат к моменту обращения; в) осуществляется предвыборка только тех данных, которым она действительно требуется (хотя prefetchx
– не блокируемая инструкция, и достаточно интеллектуальная для того, чтобы не загружать данные, уже находящиеся в кэше, ее обработка достается "не бесплатно" и лишние вызовы снижают производительность).
На P-4 данный пример в оптимизации вообще не нуждается, – процессор и сам, определив последовательность обращений к данным, осуществит их упреждающую загрузку самостоятельно. Инструкция программной предвыборки будет лишним балластом, лишь снижающим производительность системы (впрочем, не намного).
Для переноса примера 21 на K6 (VIA C3) достаточно лишь заменить инструкцию prefetchnta
на ее ближайший аналог – prefetch. А вот с переносом на Athlon дела обстоят намного сложнее. Попав на него, приведенный выше пример оптимизации покажет далеко не лучший результат: во-первых, предвыборка данных просто не успеет осуществиться за время выполнения функции _jn (ведь Athlon намного быстрее K6!) и процессор будет вынужден какое-то время простаивать, ожидая заполнения 64-байтной линейки кэша второго уровня. Во-вторых, вследствие того, что кэш-линейки на Athlon вдвое длиннее, чем на K6 (VIA C3), каждый второй запрос на предвыборку становятся бесполезным балластом, впустую отъедающим процессорное время. Проблема, однако!
Но программисты – на то они и программисты, чтобы не искать легких путей. К тому же, все проблемы решаемы…

Рисунок 39 graph 0x014 Эффективность программной предвыборки в оптимизации примера 21
Правило I
Прежде, чем оптимизировать код, обязательно следует иметь надежно работающий не оптимизированный вариант или "...put all your eggs in one basket, after making sure that you've built a really *good* basket" ("…прежде, чем класть все яйца [не обязательно именно свои яйца – прим. КК] в одну корзину – убедись, что ты построил действительно хорошую корзину). Т.е. прежде, чем приступать к оптимизации программы, убедись, что программа вообще-то работает.Создание оптимизированного кода "на ходу", по мере написания программы, невозможно! Такова уж специфика планирования команд – внесение даже малейших изменений в алгоритм практически всегда оборачивается кардинальными переделками кода. Потому, приступайте к оптимизации только после тренировки на "кошках", – языке высокого уровня. Это поможет пояснить все неясности и темные места алгоритма. К тому же, при появлении ошибок в программе подозрение всегда падает именно на оптимизированные участки кода (оптимизированный код за редкими исключениями крайне ненагляден и чрезвычайно трудно читаем, потому его отладка – дело непростое), – вот тут-то и спасает "отлаженная кошка". Если после замены оптимизированного кода на не оптимизированный ошибки исчезнут, значит, и в самом деле виноват оптимизированный код. Ну, а нет, – ищите их где-нибудь в другом месте.
Правило II
Помните, что основой прирост оптимизации дает не учет особенностей системы, а алгоритмическая оптимизация.Никакая, даже самая "ручная" оптимизация не позволит существенно увеличить эффективность пузырьковой сортировки или процедуры линейного поиска. Правильное планирование команд и прочите программистские трюки ускорят программу в лучшем случае в несколько раз. Переход к быстрой сортировке (quick sort) и двоичному поиску сократят время обработки данных как минимум на порядок, – как бы криво ни был написан программный код. Поэтому, если ваша программа выполняется слишком медленно, лучше поищите более эффективные математические алгоритмы, а не выжимайте из изначально плохого алгоритма скорость по капле.
Правило III
Не путайте оптимизацию кода и ассемблерную реализацию.Обнаружив профилировщиком узкие места в программе, не торопитесь переписывать их на ассемблер. Сначала убедитесь, что все возможное для увеличения быстродействия кода в рамках языка высокого уровня уже сделано. В частности, следует избавиться от прожорливых арифметических операций (особенно обращая внимание на целочисленное деление и взятие остатка), свести к минимуму ветвления, развернуть циклы с малым количеством итераций… в крайнем случае, попробуйте сменить компилятор (как было показано выше – качество компиляторов очень разниться друг к другу). Если же все равно останетесь недовольны результатом тогда…
Правило IV
Прежде, чем порываться переписывать программу на ассемблер, изучите ассемблерный листинг компилятора на предмет оценки его совершенства.Возможно, в неудовлетворительной производительности кода виноват не компилятор, а непосредственно сам процессор или подсистема памяти, например. Особенно это касается наукоемких приложений, жадных до математических расчетов и графических пакетов, нуждающихся в больших объемах памяти. Наивно думать, что перенос программы на ассемблер увеличит пропускную способность памяти или, скажем, заставит процессор вычислять синус угла быстрее. Получив ассемблерный листинг откомпилированной программы (для Microsoft Visual C++, например, это осуществляется ключом "/FA"), бегло просмотрите его глазами на предмет поиска явных ляпов и откровенно глупых конструкций наподобие: "MOV EAX,[EBX]\MOV [EBX],EAX". Обычно гораздо проще не писать ассемблерную реализацию с чистого листа, а вычищать уже сгенерированный компилятором код. Это требует гораздо меньше времени, а результат дает ничуть не худший.
Правило V
Если ассемблерный листинг, выданный компилятором, идеален, но программа без видимых причин все равно исполняется медленно, не отчаивайтесь, а загрузите ее в дизассемблер. Как уже отмечалось выше, оптимизаторы крайне неаккуратно подходят к выравниванию переходов и кладут их куда глюк на душу положит. Наибольшая производительность достигается при выравнивании переходов по адресам, кратным шестнадцати, и будет уж совсем хорошо, если все тело цикла целиком поместиться в одну кэш-линейку (т.е. 32 байта). Впрочем, мы отвлеклись. Техника оптимизации машинного кода – тема совершенно другого разговора. Обратитесь к документации, распространяемой производителями процессоров – Intel и AMD.Правило VI
Если существующие команды процессора позволяют реализовать ваш алгоритм проще и эффективнее, – вот тогда действительно, тяпнув для храбрости пивка, забросьте компилятор на полку и приступайте к ассемблерной реализации с чистого листа. Однако с такой ситуацией приходится встречаться крайне редко, и к тому же не стоит забывать, что вы – не на одиноком острове. Вокруг вас – огромное количество высокопроизводительных, тщательно отлаженных и великолепно оптимизированных библиотек. Так зачем же изобретать велосипед, если можно купить готовый?Правило VII
Если уж взялись писать на ассемблере, пишите максимально "красиво" и без излишнего трюкачества. Да, недокументированные возможности, нетрадиционные стили программирования, "черная магия", – все это безумно интересно и увлекательно, но… плохо переносимо, непонятно окружающим (в том числе и себе самому после возращения к исходнику десятилетней давности) и вообще несет в себе массу проблем. Автор этих строк неоднократно обжигался на своих же собственных трюках, причем самое обидное, что трюки эти были вызваны отнюдь не "производственной необходимостью", а… ну, скажем так, "любовью к искусству". За любовь же, как известно, всегда приходится платить. Не повторяете чужих ошибок! Не брезгуйте комментариями и непременно помещайте все ассемблерные функции в отдельный модуль. Никаких ассемблерных вставок – они практически непереносимы и создают очень много проблем при портировании приложений на другие платформы или даже при переходе на другой компилятор.Единственная предметная область, не только оправдывающая, но, прямо скажем, провоцирующая ассемблерные извращения, это – защита программ, но это уже тема совсем другого разговора…
и VIA C3 программная предвыборка
В K6\Athlon и VIA C3 программная предвыборка осуществляется одной из двух инструкций prefetchили prefetchw. Суффикс на конце последней сообщает процессору, что загружаемые данные планируется модифицировать. Это отнюдь не означает, что данные, загружаемые, посредством prefetch, модифицировать нельзя. Модифицировать их можно, но не желательно, т.к. в этом случае процессор вынужден совершать дополнительный цикл, изменяя атрибуты соответствующей кэш-линейки с эксклюзивной
на модифицируемую.
Эксклюзивные, т.е. неизменяемые кэш-линейки, при их вытеснении их кэша просто выбрасываются в битовую корзину, иначе называемую устройством /dev/null или "черной дырой".
Модифицируемые же кэш-строки независимо от того, были ли они реально модифицированы или нет, всегда вытесняются в оперативную память (кэш вышестоящего уровня), что требует определенного количества тактов процессора (подробнее см. "Кэш –Принципы функционирования. Организация кэша. Протокол MESI").
Инструкция prefetch
просто инициирует запрос ячейки памяти, точно так, как это делает любая команда, обращающаяся к памяти, но, в отличие от последней, prefetch
не помещает загружаемые данные ни в какой регистр, более того, она вообще не дожидается окончания загрузки этих данных, тут же возвращая управление. Преждевременное завершение инициатора запроса еще не освобождает кэш-контроллер от обязанности выполнения этого запроса, но, если запрошенная ячейка уже находится в кэше первого уровня, – ничего не происходит и инструкция prefetch
ведет себя аналогично команде NOP (нет операции). В противном случае кэш-контроллер обращается к кэшу второго уровня, а если искомой ячейки не оказывается и там – к оперативной памяти (кэшу третьего уровня), целиком заполняя соответствующие кэш-строки кэшей всех нижестоящих уровней. (Длина кэш-строк составляет 32 байта на AMD K6 (VIA C3) и 64 байта на Athlon\Duron). Поскольку кэш-контроллер работает независимо от вычислительного конвейера процессора, предвыборка позволяет загружать очередную порцию данных параллельно с обработкой предыдущей.
Если время загрузки данных не превышает времени их обработки, то простоя процессора вообще не происходит – вычислительный конвейер вращается безостановочно, а время доступа к памяти полностью маскируется.
Инструкция prefetchw
работает аналогично prefetch, но автоматически присваивает загружаемой строке статус модифицируемой. Если строку действительно планируется модифицировать, это сэкономит от 15 до 25 тактов процессорного времени. Обратно, если вы неуверенны – будет ли реально модифицировать строка или нет, лучше загрузите ее как исключительную, т.к. выгрузка модифицируемой, но реально не модифицированной строки в оперативную память обойдется намного дороже.
Несмотря на то, что AMD позиционирует команды предвыборки как аппаратно-независимые, они таковыми не являются, поскольку, количество байт, загружаемых инструкциями prefetch
и prefetchw,
определяется размером кэш-линий процессора, а их длина различна: 32 байта для K-6 (VIA C3) и 64 байта для Athlon\Duron. Соответственно, различны оптимальный шаг и минимальная дистанция предвыборки (подробнее см. "Практическое использование предвыборки/Планирование дистанции предвыборки").
В этом свете становится очень интересным следующее высказывание AMD, почерпнутое из руководства по оптимизации под Athlon: "The PREFETCHNTA/T0/T1/T2 instructions in the MMX extensions are processor implementation dependent. If the developer needs to maintain compatibility with the 25 million AMD-K6 ® - 2 and AMD-K6-III processors already sold, use the 3DNow! PREFETCH/W instructions instead of the various prefetch instructions that are new MMX extensions", что в переводе на русский звучит приблизительно так: "Инструкции PREFETCHNTA/T0/T1/T2 из MMX-расширения аппаратно зависимы. Если вы, господин разработчик, нуждаетесь в совместимости с 25 миллионами уже проданных процессоров AMD-K6®-2 и AMD-K6-III, вместо инструкций предвыборки нового расширения MMX, пользуйтесь командами PREFETCH/W из расширения 3DNow!"
Вот вам хорошая демонстрация искусства умолчания! Если уж бросать камень в огород Intel, то нелишне бы отметить, что, во-первых, и собственные инструкции предвыборки аппаратно зависимы, а, во-вторых, процессорами Pentium они не поддерживается. Так что никаких преимуществ у AMD'ушной предвыборки перед Intel нет и использовать ее не рекомендуется.
программная предвыборка осуществляется следующими
В процессорах P-III и P- 4 программная предвыборка осуществляется следующими инструкциями: prefetchnta, prefetcht0, prefetcht1, prefetcht2. Суффикс указывает на тип загружаемых данных, что определяет уровень кэш-иерархии, в которую эти данные будут загружены. Так, "NTA" расшифровывается как "Non-TemporAl [Data]" – не временные данные, т.е. данные, многократное использование которых не планируется. Соответственно "T0", "T1" и "T2" обозначает временные данные, использовать которые планируется неоднократно.Какой бы командной предвыборка ни осуществлялась, кэш - линейкам, загружаемым из основной памяти, всегда присваивается эксклюзивный
статус. При предвыборке линеек из кэша второго уровня, их прежний статус сохраняется. Возможность загрузки кэш-линейки с автоматической установкой статуса модифицируемой в процессорах Pentium не реализована. Однако ввиду многоступенчатой схемы буферизации записи, изменение атрибутов кэш-линеек происходит в основном, а не дополнительном, как в K6\Athlon, цикле обмена, т.е. без ущерба для производительности.
Причем, в отличие от prefetch/w, инструкции prefetchnta/t0/t1/t2 не приказывают, а рекомендуют (или, если так угодно, – просят) осуществить предвыборку. Процессор отклоняет просьбу и не осуществляет предвыборку, если:
· запрошенные данные уже содержится в кэше соответствующей или ближайшей к процессору иерархии;
· сведения о станице, к которой принадлежат загружаемые данные, отсутствуют в DTLB (Data Translation Look aside Buffer – Буфере Ассоциативной Трансляции);
· подсистема памяти процессора занята перемещением данных между L1- и L2-кэшем;
· запрошенные данные принадлежат региону некэшируемой памяти (странице с атрибутами UC или USWC);
· данные не могут быть загружены из-за ошибки доступа (при этом исключение не вырабатывается);
· инструкция предвыборки предваряется префиксом LOCK (в этом случае генерируется исключение "неверный опкод").
Во всех остальных случаях предвыборка выполняется. Алгоритм ее выполнения аппаратно - зависим и сильно варьируется от одной модели процессора к другой, поэтому, поведение "предвыборных" команд на P-III и P-4 ниже рассматривается по отдельности.
Pentium-III:
Инструкция prefetchnta
загружает данные в кэш первого уровня, минуя кэш-второго. Действительно, данные, повторное обращение к которым не планируется, целесообразно помещать в кэш самой ближайший к процессору иерархии, не затирая содержимое остальных, т.к. оно может еще пригодиться, а вот однократно используемые данные после их вытеснения из L1-кэша, из L2-кэша затребованы уж точно не будут.
Инструкция prefetcht0
загружает данные в кэш иерархии обоих уровней. Данные, обращение к которым происходит многократно, будучи загруженными в L2-кэш, окажутся как нельзя кстати, когда будут вытеснены из L1-кэша.
Инструкции prefetcht1 и prefetcht2
загружают данные в один лишь кэш второго уровня, не помещая их в кэш первого. Поскольку, выгрузка буферов записи происходит в кэш второго уровня, минуя первый, то предвыборку соответствующих линеек в L1-кэш осуществлять нецелесообразно. Вот тут-то и пригодится prefetcht1/t2!
Размер загружаемых данных равен длине кэш-линеек соответствующей иерархии и составляет 32 байта.
Pentium-4:
Ни одна из команд предвыборки P-4 не позволяет загружать данные в кэш первого уровня. Все – и временные, и не временные данные помещаются лишь в кэш второго уровня, поскольку… поскольку, создатели процессора захотели поступить именно так. Эффективность такой стратегии дискуссионна, но, как бы там ни было, время доступа к кэшу второго уровня, намного меньше времени доступа к оперативной памяти, поэтому, даже такая предвыборка все же значительно лучше, чем ничего.
Возникает вопрос – если все команды предвыборки помещают загружаемые данные в кэш второго уровня, то какая между ними разница? Между командами prefetcht0, prefetcht1 и prefetcht2 – действительно, никакой.
А вот команда prefetchnta
отличается тем, что помещает загружаемые данные не в любой, а исключительно в первый банк кэша второго уровня (восьми ассоциативный L2-кэш P-4 содержит восемь таких банков), благодаря чему prefetchnta
никогда не вытесняет более 1/8 объема кэша второго уровня. Однократно используемые данные, как уже говорилось выше, действительно, не должны вытеснять многократно используемые данные из верхних кэш-иерархий, но в P-4 такое вытеснение все же происходит, и предотвратить его, увы, нельзя. Причем, вытесняются отнюдь не те ячейки, к которым дольше всего не было обращений, а линейки фиксированного банка, возможно интенсивно используемые обрабатываемым их приложением! Словом, в P-4 программная предвыборка реализована далеко не лучшим образом – непродуманно, что называется "спустя рукава". (Не иначе как дикая конкурентная спешка дает о себе знать).
Размер загружаемых данных равен длине линеек кэша второго уровня, что составляет 128 байт.
Различия в реализации предвыборки на P-III и P-4 существенно затрудняют оптимизацию приложений, поскольку каждый процессор требует к себе особого, индивидуального подхода и одновременно угодить всем им невозможно. Для достижения максимальной эффективности все критические процедуры рекомендуется реализовывать как минимум в двух вариантах – отдельно для P-III и отдельно для P-4. В противном случае, либо P?III будет зверски тормозить, либо P-4 не раскроет подлинного потенциала своей производительности. Учитывая существование K6\Athlon процессоров, вариантов реализации набирается уже четыре. Не слишком ли много головной боли для программистов? Нет, это вовсе не призыв к отказу от предвыборки, – в ряде случаев такой отказ просто невозможен. Это всего лишь незлобное ворчание замученного программиста… (А программисты, как и комсомольцы, легкими путями не избалованы).
Препроцессор
Каждый, кто работал с листингами (особенно чужими), знает: какую сумятицу вносят директивы условной компиляции. Допустим, встречается в тексте директива "#ifdef _A_". Как определить – какие строки программы относятся к ее телу, а какие нет? Теоретически в этом нет ничего сложного – достаточно найти директиву "#else" или "#endif", но как ее найти? Контекстный поиск здесь непригоден. Поскольку директивы условной компиляции могут быть вложенными, нет никаких гарантий, что ближайший найденный "#endif" или "#else" относится к той же самой директиве "#ifdef". Приходится пролистывать программу вручную, мысленно отслеживая все вложения и переходы. Однако это, во-первых, очень медленно, а во-вторых, так легко "прозевать" несколько директив, особенно если они разделены большим количеством строк.К счастью, Visual Studio умеет трассировать директивы условной компиляции в обоих направлениях. Правда, по совершенно непонятым мотивам эта возможность не афишируется и вообще скрыта от посторонних глаз.
Если курсор находится в теле одной из ветвей директивы условной компиляции (т.е. либо ветви "#if … #else", либо "#else … #endif", либо "#if … #endif"), то нажатием <Ctrl-K> мы переместимся в ее конец! Повторное нажатие приведет к переходу либо на следующую ветвь, либо (если текущая ветвь исчерпана) – на следующую вышележащую директиву. Во избежание путаницы обращайте внимание на строку статуса: переход с ветви на ветвь сопровождается сообщением: "Matching #ifdef..#endif found", а переход к вложенной директиве – "Enclosing #ifdef..#endif found". Соответственно, сообщение "No Enclosing #ifdef..#endif found" говорит о том, что ничего найти не удалось.
Для обратной трассировки (т.е. прохождению цепочки директив снизу вверх) нажмите <Ctrl-J>.
Комбинации <Shift-Ctrl-K> и <Shift-Ctrl-J> автоматически выделяют тело трассируемых директив, – это очень полезно, если его планируется копировать в буфер или вообще вырезать из программы.
Трассировка условных директив – это просто сказка, в которую беззаветно влюбляешься с первых же минут знакомства! Изучение SDK'шных файлов, таких, например, как WINNT.H без нее просто немыслимо!
#ifdef
_A_ // ß
если здесь нажать Ctrl-K, мы переместимся в L1
// много строк текста
#ifdef
_B_
// много строк текста
#else // ß
если здесь нажать Ctrl-K, мы переместимcя в S1
// много строк текста
#endif // à
S1
#else // à
L1
// много строк текста
#endif
Причины и последствия ошибок переполнения
В большинстве языков программирования, в том числе и в Cи/Cи++, массив одновременно является и совокупностью определенного количества данных некоторого типа, и безразмернымрегионом памяти. Программист может получить указатель на начало массива, но не имеет возможности непосредственно определить его длину. Си/Cи ++ не делает особых различный между указателями на массив и указателями на ячейку памяти, и позволяет выполнять с указателями различные математические операции.
Мало того, что контроль выхода указателя за границы массива всецело лежит на плечах разработчика, строго говоря, этот контроль вообще невозможен в принципе! Получив указатель на буфер, функция не может самостоятельно вычислить его размер и вынуждена либо полгать, что вызывающий код выделил буфер заведомо достаточно размера, либо требовать явного указания длины буфера в дополнительном аргументе (в частности, по первому сценарию работает gets, а по второму – fgets).
Ни то, ни другое не может считаться достаточно надежным, - знать наперед сколько памяти потребуется вызывающей функции (за редкими исключениями) невозможно, а постоянная "ручная" передача длины массива не только утомительна и непрактична, но и ничем не застрахована от ошибок (можно передать не тот размер или размер не того массива).
Другая частая причина возникновения ошибок переполнения буфера: слишком вольное обращение с указателями. Например, для перекодировки текста может использоваться такой алгоритм: код преобразуемого символа складывается с указателем на начало таблицы перекодировки и из полученной ячейки извлекается искомый результат. Несмотря на изящество этого (и подобных ему алгоритмов) он требует тщательного контроля исходных данных – передаваемый функции аргумент должен быть неотрицательным числом не превышающим последний индекс таблицы перекодировки. В противном случае произойдет доступ совсем к другим данным. Но о подобных проверках программисты нередко забывают или реализуют их неправильно.
Можно выделить два типа ошибок переполнения: одни приводят к чтению не принадлежащих к массиву ячеек памяти, другие – к их модификации.
В зависимости от расположения буфера за его концом могут находится: а) другие переменные и буфера; б) служебные данные (например, сохраненные значения регистров и адрес возврата из функции); с) исполняемый код; д) никем не занятая или несуществующая область памяти.
Несанкционированное чтение не принадлежащих к массиву данных может привести к утере конфиденциальности, а их модификация в лучшем случае заканчивается некорректной работой приложения (чаще всего "зависанием"), а худшем – выполнением действий, никак не предусмотренных разработчиком (например, отключением защиты).
Еще опаснее, если непосредственно за концом массива следуют адрес возврата из функции – в этом случае уязвимое приложение потенциально способно выполнить от своего имени любой код, переданный ему злоумышленником! И, если это приложение исполняется с наивысшими привилегиями (что типично для сетевых служб), взломщик сможет как угодно манипулировать системой, вплоть до ее полного уничтожения!
Сказанное справедливо и для случая, когда вслед за буфером, подверженном переполнению, расположен исполняемый код. Однако, в современных операционных системах такая ситуация практически не встречается, поскольку они довольно далеко разносят код, данные и стек друг от друга.
А вот наличие несуществующей станицы памяти за концом переполняющегося буфера – не редкость. При обращении к ней процессор генерирует исключение в большинстве случаев приводящее к аварийному завершению приложения, что может служить эффективной атакой "отказа в обслуживании".
Таким образом, независимо от того где располагается переполняющийся буфер – в стеке, сегменте данных или в области динамической памяти (куче), он делает работу приложения небезопасной.
Поэтому, представляет интерес поговорить о том, можно ли предотвратить такую угрозу и если да, то как.
Придя в этот мир - оглянись!
- Если ты убедишь себя, искренно сможешь нести любую галиматью (что за очаровательное старинное слово; проверь, что оно значит), совершеннейший бред в каждом слове и тебе поверят. Но только не один из наших ясновидцев.Френк Херберт "Дом Глав Дюны"
Все – и старый подсвечник, и таинственный заговор полумрака в колеблющемся пламени свечи, казалось нереальным в тусклом свете нагроможденных мониторов и повсюду змеящихся проводов. Но у Криса были свои странности. Огонь нес в себе какую-то долю мистицизма, ушедшего в песок истории. Цивилизация достигла своих высот здесь, в конце 20 столетия. И пик взлета грозил обернуться распадом и деградацией. Устойчивость зиждилась лишь на тонком волоске безразличия. Все были слишком заняты, что бы остановиться и обернуться вокруг. Посмотреть на небо, на капельки росы, стекающие с листка, вздохнуть полной грудью, и удобно усевшись в кресле потянуться за кремнием, что бы зажечь свечу.
Электрический свет сделал все таким простым, и очевидным, что не осталось вопросов, которые было бы можно задать. Но как неузнаваемо может измениться та же обстановка, если зажечь Свечу. Таинственный полумрак – это Пустыня истории. Ветер Пустыни колеблет Свечу, и обдает Путника обжигающем дыханием пустыни. Ноги вязнут в песке, каждый шаг – это преодоление собственной беспомощности перед природой. Еще одна сложность, но идти надо. Движение – это жизнь. В этой стихии песка и ветра ты один на тысячи миль вокруг. Никто не сможет помочь в борьбе с природой. Если ты проиграешь, она поглотит тебя, даже не заметив этого. Если ты выживешь, она все равно этого не заметит. Кто же ты, Путник - мыслящий человек или песчинка в Пустыне?
Человек победил Пустыню, проложив железные дороги, построив самолеты, но не смог в этом убедить себя. Можно скрыться в шумном и многолюдном Городе, куда не достает ветер и не проникает песок. Но в этом нагромождении пластика и бетона становишься песчинкой социума. Город тебя подминает, но никто не замечает этого. Бежать уже некуда.
Открыт лишь один путь – назад в Пустыню, где ты будешь один на один в борьбе за существование.
Как и у всего на свете, у одиночества есть свои плюсы и минусы. Ты работаешь один, без подстраховки, и если оступишься – смерть. Но никто не ограничивает в выборе путей достижения цели. Ты ОДИН. Ты верховный судья, Бог и Дьявол в одном теле. Границ нет, все пространство вокруг открыто. Иди куда хочешь. Иди, но если ошибешься в направлении – уйдешь в пески Пустыни без воды и еды. Ветер заметет твои следы и никто не найдет где кончился твой путь...
И сколько бы не светило Солнце, а люди всегда шли. Кто-то бежал, подальше от Города, преследуемый законом, кто-то возненавидел ближних своих, ну а кто-то уходил в Пустыню от одиночества. Там оно не было таким гнетущим, как вокруг не замечающих тебя людей.
В Пустыне все они равны... изгнанники Города, не важно, по какой причине. Все сталкиваются с одними и теми же проблемами солнца, еды и воды. Неверно думать, что ни того, ни другого нет в раскаленном песке, обжигающем ноги. Даже в Центре, где воздух сухой и колючий, все же можно найти немного влаги. Ночью, когда воздух достаточно охладиться, она конденсируется в капельки росы – стоит только положить на песок холодный предмет. Правда, к утру влага испаряется, так что надо спешить.
Ночи в Пустыне – вообще удивительное явление. Небо темнеет быстро и, словно остывая вместе с Пустыней, плавно меняет цвет, еще долго оставаясь раскаленным оранжевым маревом у западной кромки горизонта. Изнуряющая жара внезапно сменяется бьющей ознобом прохладой. В Пустыне нет дров, чтобы развести костер, а сухие колючки сгорают быстро и совсем не дают углей. Однако огонь может привлечь внимание мелких ящерок и других пресмыкающихся, мясо придает вам сил.
В этой борьбе грань между человеком и животным очень тонка. Природа берет свое, и первобытные инстинкты начинают доминировать над разумом. Это очень скользкая игра в прятки с самим собой. Прошлые сознания всплывает из памяти и подминает тебя настоящего...
Крис размышлял: кем он был в настоящий момент. Мимолетное изменение восприятия не сулило ничего хорошего. Нервы были и без того слишком напряжены. Еще один подарок судьбы – это уж слишком… Слишком многое произошло за последний месяц… Его стихия сократилась до предела, сжалась в маленькую скорлупу и все существо звало убежать, спрятаться, затаиться и переждать нашедшие на Город свинцовые тучи.
Крис еще не знал, что в этой борьбе у него окажется союзник. Привыкший к одиночеству, он всегда полагался лишь на самого себя, на собственные силы, навыки и умения, даже когда их явно не хватало. До этого ему элементарно везло. Стоило лишь сделать шаг назад, как он оказывался далеко впереди. Необъяснимо с точки зрения здравого смысла, но факт. А может быть, так лишь казалось. В Пустыне нет ориентиров. "взад" и "вперед" – слишком субъективные ощущения, чтобы быть до конца в них уверенным.
Но чем больше везет, тем больше боишься проиграть. Неуверенность в себе притупляет интуицию и начинаешь действовать нервно, хаотично, наугад, только усугубляя положение.
Чтобы ничего не бояться, приходилось ничего не иметь. Не привязываться к вещам, не обрастать мебелью, не привязываться к людям. Жить кочевой жизнью, переезжая из квартиры в квартиру, где только голые стены, диван да компьютер.
В какой-то степени это даже нравилось. Просто было интересно, бродить по новым местам, осваивать новые территории... находить себе новые сложности, с удовлетворением отмечая, что каждый раз их решать все легче и легче, а вот находить становилось труднее...
* * *
Пустыня не делит путников на женщин и мужчин, но так уж получилось, что мужчин было всегда больше. Женщины существа на удивление домашние, привязанные к семейному очагу. Может быть поэтому, Крис никогда не задумывался, что он может Там ее встретить. С чем можно было говорить с девушкой из Города, если она не видела ни неба, ни звезд, ни этих бескрайних песчаных просторов, ни стояла на гребнях дюн, рискуя сорваться, оказавшись погребенной под тоннами песка.
В Городе нет пространств, и нет горизонта. В этой тесноте редко кто ухитряются видеть дальше собственного носа или чужого затылка. Пустыня меняет человека. Наполовину это даже не человек, а животное, или если так угодно – робот, или все тот же человек с обостренным восприятием мира и расстроенной психикой? Союз нормальной во всех отношениях городской женщины и полудикого степного кочевника... будет очень коротким. Если вообще будет.
Принципы функционирования SRAM
Автор долго колебался – включать эту главу в книгу или нет. С одной стороны, для достижения грамотной работы с кэшем вдаваться в технические подробности устройства статической памяти совершенно необязательно, поскольку статическая память абсолютно прозрачна для программиста и конструктивные особенности ее реализации полностью маскируются кэш-контроллером. Но, в то же время, работать с "железкой", не имея никаких представлений о том, что находится у нее внутри – по меньшей мере невежественно, если не сказать "непрофессионально".В конечном счете, небольшой ликбез никогда не помешает. Эта, весьма скромная по объему, глава ### статья, конечно же, не раскроет всех секретов статической памяти, но, по крайней мере, объяснит, что это такое и почему оно работает именно так, а не иначе.
Pro et contra целесообразности оптимизации
Это в наше-то время говорить об оптимизации программ? Бросьте! Не лучше ли сосредоточиться на изучении классов MFC или технологии .NET? Современные компьютеры так мощны, что даже Windows XP оказывается бессильна затормозить их!Нынешние программисты к оптимизации относятся более чем скептически. Позволю себе привести несколько типичных высказываний:
"…я применяю относительно медленный и жадный до памяти язык, Perl, поскольку на нем я фантастически продуктивен. В наше время быстрых процессоров и огромной памяти эффективность – другой зверь. Большую часть времени я ограничен вводом/выводом и не могу читать данные с диска или из сети так быстро, чтобы нагрузить процессор. Раньше, когда контекст был другим, я писал очень быстрые и маленькие программы на C. Это было важно. Теперь же важнее быстро писать, поскольку оптимизация может привести к столь малому росту быстродействия, что он просто не заметен" говорит Robert White;
"…а стоит ли тратить усилия на оптимизацию и чего этим можно достичь? Дело в том, что чем сильнее вы будете адаптировать вашу программу к заданной архитектуре, тем, с одной стороны, вы достигнете лучших результатов, а, с другой стороны, ваша программа не будет хорошо работать на других платформах. Более того, "глубокая" оптимизация может потребовать значительных усилий. Все это требует от пользователя точного понимания чего он хочет добиться и какой ценой" пишет в своей книге "Оптимизация программ под архитектуру CONVEX C" М. П. Крутиков;
"Честно говоря, я сам большой любитель "вылизывания" кода с целью минимизации используемой памяти и повышения быстродействия программ. Наверное, это рудименты времен работы на ЭВМ с оперативной памятью в 32 Кбайт. С тем большей уверенностью я отношу "эффективность" лишь на четвертое место в критериях качества программ" признается Алексей Малинин – автор цикла статей по программированию на Visual Basic в журнале "Компьютер Пресс".
С приведенными выше тезисами, действительно, невозможно не согласиться. Тем не менее, не стоит бросаться и в другую крайность. Начертавший на своем знамени лозунг "на эффективность – плевать" добьется только того, что плевать (причем дружно) станут не в эффективность, а в него самого. Не стоит переоценивать аппаратные мощности! И сегодня существуют задачи, которым не хватает производительности даже самых современных процессоров. Взять хотя бы моделирование различных физических процессов реального мира, обработку видео-, аудио- и графических изображений, распознавание текста… Да что угодно, вплоть до элементарного сжатия данных архиватором a la Super Win Zip!
Да, мощности процессоров растут, но ведь параллельно с этим растут и требования к ним. Если раньше считалось нормальным, поставив программу на выполнение, уйти пить пиво, то сегодняшний пользователь хочет, чтобы все операции выполнялись мгновенно, ну если не мгновенно, то с задержкой не превышающей нескольких минут. Не стоят на месте и объемы обрабатываемых данных. Признайтесь, доводилось ли вам находить на своем диске файл размером в сотню-другую мегабайт? А ведь буквально вчера емкость целого жесткого диска была на порядок меньше!!!
Цель – определяет средства. Вот из этого, на протяжении всей книги, мы и будем исходить. Ко всем оптимизирующим алгоритмам будут предъявляется следующие жесткие требования:
а) оптимизация должна быть максимально машинно-независимой и переносимой на другие платформы (операционные системы) без дополнительных затрат и существенных потерь эффективности.
То есть никаких ассемблерных вставок! Мы должны оставаться исключительно в рамках целевого языка, причем, желательно использовать только стандартные средства, и любой ценой избегать специфичных расширений, имеющихся только в одной конкретной версии компилятора;
б) оптимизация не должна увеличивать трудоемкость разработки (в т.ч. и тестирования) приложения более чем на 10%-15%, а в идеале, все критические алгоритмы желательно реализовать в виде отдельной библиотеки, использование которой не увеличивает трудоемкости разработки вообще;
с) оптимизирующий алгоритм должен давать выигрыш не менее чем на 20%-25% в скорости выполнения.
Приемы оптимизации, дающие выигрыш менее 20% в настоящей книге не рассматриваются вообще, т.к. в данном случае "овчинка выделки не стоит". Напротив, основной интерес представляют алгоритмы, увеличивающие производительность от двух до десяти (а то и более!) раз и при этом не требующие от программиста сколь ни будь значительных усилий. И такие алгоритмы, пускай это покажется удивительным, в природе все-таки есть!
d) оптимизация должна допускать безболезненное внесение изменений. Достаточно многие техники оптимизации "умерщвляют" программу, поскольку даже незначительная модификация оптимизированного кода срубает всю оптимизацию на корню. И пускай все переменные аккуратно распределены по регистрам, пускай тщательно распараллелен микрокод и задействованы все функциональные устройства процессора, пускай скорость работы программы не увеличить и на такт, а ее размер не сократить и на байт! Все это не в силах компенсировать утрату гибкости и жизнеспособности программы. Поэтому, мы будем говорить о тех, и только тех приемах оптимизации, которые безболезненно переносят даже кардинальную перестройку структуры программы. Во всяком случае, грамотную перестройку. (Понятное дело, что кривые руки угробят что угодно – против лома нет приема).
Согласитесь, что такая постановка вопроса очень многое меняет. Теперь никто не сможет заявить, что, дескать, лучше прикупить более мощный процессор, чем тратить усилия на оптимизацию. Ключевой момент предлагаемой концепции состоит в том, что никаких усилий на оптимизацию тратить как раз не надо. Ну… почти не надо, – как минимум вам придется прочесть эту книжку, а это какие – ни какие, а все-таки усилия. Другой вопрос, что данная книга предлагает более или менее универсальные и вполне законченные решения, не требующие индивидуальной подгонки под каждую решаемую задачу.
Это одна из тех редких книг, если вообще не уникальная книга, которая описывает переносимую оптимизацию на системном уровне и при этом ухитряется не прибегать к ассемблеру.
Все остальные книги подобного рода, требуют свободного владения ассемблером от читателя. Впрочем, совсем уж без ассемблера обойтись не удалось, особенно в частях, посвященных технике профилировки и алгоритмам машинной оптимизации. Тем не менее, весь код подробно комментирован и его без труда поймет даже прикладной программист, доселе даже не державший отладчика в руках. Ассемблер, кстати, – это довольно простая штука, но его легче показать, чем описать.
И в заключении позвольте привести еще одну цитату:
"Я программирую, чтобы решать проблемы, и обнаружил, что определенные мысли блокируют все остальные мысли и творческие цели, которые у меня есть.
Это – мысли об эффективности в то время, когда я пытаюсь решить проблему. Мне кажется, что гораздо логичнее концентрироваться полностью на проблеме, решить ее, а затем творчески запрограммировать, затем, если решение медленное (что затрудняет работу с ним), то..."
Gary Mason.
Проблема наведенные эффектов
Исправляя одни ошибки, мы всегда потенциально вносим другие и потому после любых, даже совсем незначительных модификаций кода программы, цикл профилировки следует повторять целиком.Вот простой и очень типичный пример. Пусть в оптимизированной программе встретилась функция следующего вида:
ugly_func(int foo)
{
int a;
…
…
…
if (foo<1) return ERR_FOO_MUST_BE_POSITIVELY;
for(a=1; a <= foo; a++)
{
…
…
…
}
}
Листинг 10 Фрагмент кода, демонстрирующий ситуацию, в которой удаление лишнего кода может обернуться существенным и труднообъясним падением производительности
Очевидно, если попытаться передать функции ноль или отрицательное число, то цикл for_a не выполнится ни разу, а потому принудительная проверка значения аргумента (в тексте она выделена жирным шрифтом) бессмысленна! Конечно, при больших значениях foo накладные расходы на такую проверку относительно невелики, но в праве ли мы надеяться, что удаление этой строки по крайней мере не снизит скорость выполнения функции?
Постойте, это отнюдь не бредовый вопрос, относящийся к области теоретической абстракции! Очень может статься так, что удаление выделенной строки будет носить эффект прямо противоположный ожидаемому, и вместо того чтобы оптимизировать функцию, мы даже снизим скорость ее выполнения, причем, весьма значительно!
Как же такое может быть? Да очень просто! Если компилятор не выравнивает циклы в памяти (как например, MS VC), то с довольно высокой степенью вероятности мы рискуем нарваться на кэш-конфликт (см. "Часть II. Кэш"), облагаемый штрафным пенальти. А можем и не нарваться! Это уж как фишка ляжет. Быть может, эта абсолютно бессмысленная (и, заметьте, однократно
выполняемая) проверка аргументов как раз и спасала цикл от штрафных задержек, возникающих в каждой итерации.
Сказанное относится не только к удалению, но и вообще любой модификации кода, влекущий изменение его размеров.
В ходе оптимизации производительность программы может меняться самым причудливым образом, то повышаясь, то понижаясь без всяких видимых на то причин. Самое неприятное, что подавляющее большинство компиляторов не позволяют управлять выравниванием кода и, если цикл лег по неудачным с точки зрения процессора адресам, все, что нам остается – добавить в программу еще один кусок кода с таким расчетом, чтобы он вывел цикл из неблагоприятного состояния, либо же просто "перетасовать" код программы, подобрав самую удачную комбинацию.
"Идиотизм какой-то", – скажите вы и будете абсолютно правы. К счастью, тот же MS VC выравнивает адреса функций по адресам, кратным 0x20 (что соответствует размеру одной кэш-линейки на процессорах P6 и K6). Это исключает взаимное влияние функций друг на друга и ограничивает область тасования команд рамками всего "лишь" одной функции.
Тоже самое относится и к размеру обрабатываемых блоков данных, числу и типу переменных и т.д. Часто бывает так, что уменьшение количества потребляемой программой памяти приводит к конфликтам того или иного рода, в результате чего производительность естественно падает. Причем, при работе с глобальными и/или динамическими переменными мы уже не ограничивается рамками одной отдельно взятой функции, а косвенно воздействуем на всю программу целиком! (см. "Часть I. Конфликт DRAM банков").
Сформулируем три правила, которыми всегда следует руководствоваться при профилировке больших программ, особенно тех, что разрабатываются несколькими независимыми людьми. Представляете – в один "прекрасный" день вы обнаруживаете, что после последних внесенных вами "усовершенствований" производительность вверенного вам фрагмента неожиданно падает… Но, чтобы вы не делали, пусть даже выполнили "откат" к прежней версии, вернуть производительность на место вам никак не удавалось. А на завтра она вдруг – без всяких видимых причин! – восстанавливалась до прежнего уровня сама.Да, правильно, причина в том, что ваш коллега чуть-чуть изменил свой модуль, а это "рикошетом" ударило по вам!
Итак, обещанные правила:
Первое: никогда – никогда не оптимизируйте программу "вслепую", полагаясь на "здравый смысл" и интуицию;
Второе: каждое внесенное изменение проверяйте на "вшивость" профилировщиком и, если производительность неожиданного упадает, вместо того чтобы увеличиться, незамедлительно устаивайте серьезные разборки: "кто виноват" и "чья тут собака порылась", анализируя весь, а не только свой собственный код;
Третье: после завершения оптимизации локального фрагмента программы, выполните контрольную профилировку всей программы целиком на предмет обнаружения новых "горячих" точек, появившихся в самых неожиданных местах.
Проблема второго прохода
Для достижения приемлемой точности измерений профилируемое приложение следует прогнать по крайней мере 9– 12 раз (см. "Непостоянства времени выполнения. Обработка результатов измерений"), причем каждый прогон должен осуществляться в идентичных условиях окружения. Увы! Без написания полноценного эмулятора всей системы это требование практически невыполнимо. Дисковый кэш, сверхоперативная память обоих уровней, буфера физических страниц и история переходов чрезвычайно затрудняют профилировку программ, поскольку при повторных прогонах время ее выполнения значительно сокращается.Если мы профилируем многократно выполняемый цикл, то этим обстоятельством можно и пренебречь, поскольку время загрузка данных и/или кода в кэш практически не сказывается на общем времени выполнения цикла. К сожалению, так бывает далеко не всегда (такой случай как раз и был разобран в главе "Цели и задачи профилировки. Информация о пенальти").
Да и можем же мы наконец захотеть оптимизировать именно инициализацию приложения?! Пускай, она выполняется всего лишь один раз за сеанс, но какому пользователю приятно, если запуск вашей программы растягивается на минуты и то и десятки минут? Конечно, можно просто перезагрузить систему, но… сколько же тогда профилировка займет времени!
Хорошо. Очистить кэш данных – это вообще раз плюнуть. Достаточно считать очень большой блок памяти, намного превышающий его (кэша) емкость. Не лишнем будет и записать большой блок для выгрузки всех буферов записи (см. "Часть II. Кэш"). Это же, кстати, очистит и TLB (Translate Look aside Buffer) – буфер, хранящий атрибуты страниц памяти для быстрого обращения к ним (см. "предвыборка?"). Аналогичным образом очищается и кэш/TLB кода. Достаточно сгенерировать очень большую функцию, имеющую размер порядка 1 – 4 Мб, и при этом ничего не делающую (для определенности забьем ее NOP'ами – машинными командами "нет операции"). Всем этим мы уменьшим пагубное влияние всех, перечисленных выше эффектов, хотя и не устраним его полностью.
Увы! В этом мире есть вещи, не подвластные ни прямому, ни косвенному контролю (во всяком случае на прикладном уровне).
С другой стороны, если мы оптимизируем одну, отдельно взятую функцию, (для определенности остановимся на функции реверса строк), то как раз таки ее первый прогон нам ничего не даст, поскольку в данном случае основным фактором, определяющим производительность, окажется не эффективность кода/алгоритма самой функции, а накладные расходы на загрузку машинных инструкций в кэш, выделение и проецирование страниц операционной системой, загрузку обрабатываемых функцией данных в сверхоперативную память… В реальной же программе эти накладные расходы как правило уже устранены (даже если эта функция вызывается однократно).
Давайте проведем следующий эксперимент. Возьмем следующую функцию и десять раз подряд запустим ее на выполнение, замеряя время каждого прогона.
#define a (int *)((int)p + x)
A_BEGIN(0)
#define b (int *)((int)p + BLOCK_SIZE - x - sizeof(int))
for (x = 0; x < BLOCK_SIZE/2; x+=sizeof(int))
{
#ifdef __OVER_BRANCH__
if (x & 1)
#endif
*a = *a^*b; *b = *b^*a; *a = *a^*b;
}
A_END(0)
Листинг 9 Пример функции, однократно обращающийся к каждой загруженной в кэш ячейке
Для блоков памяти, полностью умещающихся в кэш-памяти первого уровня, на P-III 733/133/100/I815EP мы получим следующий ряд замеров:
__OVER_BRANCH__ not define __OVER_BRANCH__ is define
68586 63788
17629 18507
17573 18488
17573 18488
17573 18488
17573 18488
17573 18488
17573 18488
Обратите внимание: время выполнения первого прогона функции (не путать с первой итерации цикла!) практически вчетверо превосходит все последующие! Причем, результаты замеров непредсказуемым образом колеблются от 62.190 до 91.873 тактов, что соответствует погрешности ~50%.
Означает ли это, что если данный цикл в реальной программе исполняется всего один раз, то оптимизировать его бессмысленно? Конечно же нет! Давайте, для примера избавимся от этого чудачества с XOR и как нормальные люди обменяем два элемента массива через временную переменную. Оказывается, это сократит время первого прогона до 47.603 – 65.577 тактов, т.е. увеличит эффективность его выполнения на 20% – 40%!
Тем не менее, устойчивая повторяемость результатов начинается лишь с третьего прогона! Почему так медленно выполняется первый прогон – это понятно (загрузка данных в кэш и все такое), но вот что не дает второму показать себя во всю мощь? Оказывается – ветвления. За первый прогон алгоритм динамического предсказания ветвлений еще не накопил достаточное количество информации и потому во втором прогоне еще давал ошибки, но начиная с третьего наконец-то "въехал" в ситуацию и понял, что от него ходят.
Убедиться, что виноваты именно ветвления, а ни кто ни будь другой, позволяет следующий эксперимент: давайте определим __OVER_BRANCH__ и посмотрим как это скажется на результат. Ага! Разница между вторым и третьим проходом сократилась с 0,3% до 0,1%. Естественно, будь наш алгоритм малость поразлапистее (в смысле – "содержи побольше ветвлений"), и всех трех прогонов могло бы не хватить для накопления надежного статистического результата. С другой стороны, погрешность, вносимая переходами, крайне невелика и потому ей можно пренебречь. Кстати, обратите внимание, что постоянство времени выполнения функции на всех последних проходах соблюдается с точностью до одного такта!
Таким образом, при профилировании многократно выполняющихся функций, результаты первых двух – трех прогонов стоит вообще откинуть, и категорически не следует их арифметически усреднять.
Напротив, при профилировании функций, исполняющихся в реальной программе всего один раз, следует обращать внимание лишь на время первого прогона и отбрасывать все остальные, причем, при последующих запусках программы необходимо каким либо образом очистить все кэши и все буфера, искажающие реальную производительность.
Проблемы оптимизации программ на отдельно взятой машине
Большинство программистов, особенно их тех, что пасутся на вольных хлебах, имеют в своем распоряжении одну, ну максимум две машины, на которой и осуществляются все стадии создания программы: от проектирования до отладки и оптимизации. Между тем, как уже успел убедиться читатель "что русскому хорошо, то немцу – смерть". Код, оптимальный для одной платформы, может оказаться совсем неоптимальным для другой. Планирование потоков данных (см. одноименную главу) – яркое тому подтверждение. Ну вспомните: особенности реализации предвыборки данных в чипсете VIA KT133 приводят к резкому падению производительности при параллельной обработке нескольких близко расположенных потоков. Об этом малоприятном факте умалчивает документация, он не может быть предвычислен логически, – обнаружить его можно лишь экспериментально.Совершенно недопустимо профилировать программу на одной-единственной машине, – это не позволит выявить все "узкие" места алгоритма. Следует, как минимум, охватить три-четыре типовые конфигурации, обращая внимания не только на модели процессоров, но и чипсетов. Этим вы более или менее застрахуете себя от "сюрпризов", подобных уже описанным странностям чипсета VIA KT 133.
Сложнее найти компромисс, наилучшим образом "вписывающийся" во все платформы.
Проблемы тестирования оперативной памяти
Разгон памяти – весьма радикальное средство увеличения производительности, но и чрезвычайно требовательное к качеству модулей памяти. Впрочем, некачественные модули могут сбоить даже в штатном режиме безо всякого разгона. Последствия таких ошибок весьма разнообразны: от аварийного завершения приложения до потери и/или искажения обрабатываемых данных. Судя по всему, приобретение "битой" памяти – отнюдь не редкость и со сбоями памяти народ сталкивается достаточно регулярно. Забавно, но подавляющее большинство разработчиков программного обеспечения начисто игнорируют эту проблему, заявляя, что всякое приложение вправе требовать для своей работы исправного "железа". Теоретически оно, может быть и так, но на практике факт исправности железа предстоит еще подтвердить. Причем, популярные диагностические утилиты (такие, например, как Check It) для этой цели абсолютно не пригодны. За исключением совсем уж клинических случаев, тест проходит без малейших помарок, но стоит запустить тот же Word или Quake, как система мгновенно виснет. Меняем модуль память – все работает на ура. Выходит, виновата все же память? Тогда почему это не обнаружил CheckIt?!Причина в том, что далеко не всякая неисправность чипа памяти приводит к его немедленному отказу. Чаще всего дефект проявляется лишь при определенном стечении ряда обстоятельств. Тяжеловесное приложение, гоняющее память "и в хвост, и в гриву", имеет все шансы за короткое время "подобрать" нужную комбинацию, провоцирующую сбой. Популярные диагностические программы, напротив, тестируют весьма ограниченный спектр режимов в весьма щадящих условиях. Соответственно, и вероятность обнаружить ошибку в последнем случае значительно ниже.
Выход? – Разрабатывать собственную тестирующую программу. Во-первых, необходимо учитывать, что вероятность сбоя тесно связана с температурой кристалла. Чем выше температура – тем вероятнее сбой. А температура в свою очередь зависит от интенсивности работы памяти. При линейном чтении ячеек, микросхема памяти за счет пакетного режима успевает несколько приостыть, поддерживая внутри себя умеренную температуру.
Действительно, при запросе одной ячейки, вся DRAM-страница читается целиком, сохраняясь во внутренних буферах и до тех пор, пока не будет запрошена следующая страница этого же банка, никаких обращений к ядру памяти не происходит!
Поэтому, прежде чем приступать к реальному тестированию, память необходимо как следует прогреть, читая ее с шагом, равным длине DRAM-банка. Это заставит ядро данного банка работать максимально интенсивно, на каждом шаге выполняя процедуру чтения и восстанавливающей записи данных. Не стоит тестировать несколько банков одновременно. Во-первых, это несколько снизит температуру "накала" каждого из них, а, во-вторых, перепад температур внутри кристалла увеличивает вероятность обнаружения сбоя. (Вообще-то, микросхеме при этом приходится по-настоящему туго, но она обязана выдержать такой режим работы, в противном случае, ее место – на свалке).
Ага, модуль памяти нагрелся так, что не удержишься рукой. Самое время приступать к настоящим тестам. Заполняем DRAM-страницу контрольной последовательностью чисел (далее по тексту – шаблоном), переключаем страницу, чтобы гарантированно обновить ячейки памяти (в противном случае микросхема может возвратить содержимое своих буферов, не обращаясь к матрице памяти). Вновь переключаем страницу назад и проверяем, что мы записали. Это может выглядеть приблизительно так:
for (a=0; a < DRAM_BANK_SIZE; a += DRAM_PAGE_SIZE)
{
WriteTemplate(a); // записываем шаблон
x = (DRAM_BANK_SIZE-a); // переключаем DRAM-страницу
CompareTemplate(a); // проверяем, что мы записали
}
Листинг 46 Упрощенный пример реализации функции тестирования памяти
Причем, к шаблону предъявляются весьма жесткие требования. Во-первых, он должен тестировать каждый бит ячейки, причем на оба значения – единицу и нуль, поскольку, "битые" ячейки матрицы могут давать либо "всегда ноль", либо "всегда единица". Во-вторых, крайне желаться, чтобы во всем восьмерном слове соседние биты имели противоположные значения.
Такая комбинация создает наибольший уровень помех и тем самым провоцирует систему на ошибку. Третье, шаблон должен обеспечивать выявление ошибок адресации, – т.е. микросхема возвратила содержание ячейки не той строки и/или столбца.
Поскольку, все эти требования взаимоисключающие, для тестирования потребуется несколько шаблонов. Только не забывайте время от времени выполнять "холостое" чтение для поддержания температуры микросхемы на максимально достижимом уровне, иначе эффективность теста начнет падать.
Остается обсудить лишь последовательность перебора станиц. Первое, что приходит на ум, тривиальный последовательный перебор, затем – хаотичное обращения к страницам по случайному шаблону. Достаточно ли этого для выявления всех типов ошибок? К сожалению, нет. Многие (если не все) современные контроллеры памяти самостоятельно определяют предпочтительный порядок обработки запросов. Возьмем, например, листинг рассмотренный выше. Контролер, проанализировав очередь запросов, видит, что двойного переключения страниц можно избежать, если… не выполнять повторное чтение из матрицы памяти, а возвратить процессору содержимое буфера! Похоже, есть только один путь обхитрить контроллер, – проверять ячейки не сразу после записи, а спустя некоторое время, когда внутренние буфера контроллера будут гарантированно перекрыты последующими запросами. Это же, кстати, позволяет выявить ошибки регенерации, – когда из-за каких-то дефектов заряд с ячейки матрицы стекает раньше, чем ее успевают регенерировать.
И в заключении – о ECC (Error Checking and Correction – Выявление и Исправление Ошибок). Теоретически система с поддержкой ECC (а таких на сегодняшний день большинство) должна уметь распознавать одиночные сбои памяти и, если не исправлять, то, по крайней мере, останавливать систему в случае обнаружения двойного сбоя. Ну, насчет испр
Рекомендуемые ссылки
http://www.gvu.gatech.edu/gvu/people/randy.carpenter/folklore/
Программирование на ассемблере как особый род творчества
Компьютер уже давно перестал быть машиной для небольшой горстки Избранных и с каждым днем он все стремительнее и стремительнее превращается в… пылесос. Ну, или что-то очень на него похожее. Современные программисты, абстрагировавшись от "железа" и даже от самих вычислительных алгоритмов, видят перед собой лишь мышь да визуальную панель с компонентами. Написать программу стало так же легко, как сварить пакетный суп. Конечно, свои положительные моменты в этом есть, но… существует определенная категория людей, для которых жизнь – ни на секунду не прекращающийся поиск и преодоление сложностей (ни слова по гамак и ласты!). Если задуматься: какую практическую ценность несет в себе, ну скажем, покорение горных вершин? Ведь гораздо комфортнее и куда с меньшим риском к ним можно добраться и на вертолете…Увы! Чем легче достается, – тем меньше удовлетворения оно приносит. Визуальное программирование слишком просто, чтобы быть по настоящему интересным. С другой стороны, чем выше уровень языка, тем больше приходится соблюдать предписаний, и тем меньше остается возможности для самовыражения. А Художники как раз и отличаются от окружающих тем, что в каждой работе передают свое видение мира, частицу своего "Я".
Интерес к ассемблеру, часто доходящий до фанатизма, как раз и объясняется тем, что ассемблер – лучшее средство "пощупать" железо компьютера; это превосходная арена для интеллектуальной борьбы, и, наконец, – великолепный способ с пользой и интересом скоротать свободное от работы время.
Существует огромное множество ассемблерных головоломок – от "написать программу на байт короче, чем у соседа", до "создать самообучающуюся шахматную игру, занимающую не более двух килобайт". На ассемблере пишутся многие "демки", на нем же создаются "крякмисы" (в дословном переводе "взломай меня")… Никто не спорит, что все, перечисленное выше, можно реализовать и на языках высокого уровня, причем за несравнимо более короткое время при не сильно худшей эффективности. Да! Можно! Но… неинтересно. Мы, комсомольцы, видите ли, без ласт и гамака любить не можем…
Поэтому (и это очень важно!), если вы встретите человека, беззаветно преданного ассемблеру и презирающего языки высокого уровня, не спешите ломать пальцы о клавиатуру, переубеждая его в обратном. Девять из десяти – ассемблер любит он не за достоинства, а, напротив, за отсутствие таковых (если понимать под "достоинствами" удобства цивилизации). Один из десяти – он просто выпендривается и программирует на ассемблере, чтобы продемонстрировать окружающим свою крутость. Тогда тем более не стоит его переубеждать – с возрастом само рассосется.
Программная предвыборка в процессорах K6+ и P-III+
Поддержка программной предвыборки имеется как в K6 (и совместимом с ним микропроцессоре VIAC3), так и в P-III\P-4, однако, их реализации различны и к тому же несовместимы друг с другом. Это печальное обстоятельство существенно снижает популярность предвыборки, поскольку программистам приходится либо реализовывать использующие ее функции в двух вариантах один для Intel, другой – для AMD (VIA), либо ограничивать аудиторию пользователей каким-то одним процессором. И то, и другое влечет за собой большие издержки, зачастую не компенсируемые увеличением производительности приложения.Появление процессора AMD Athlon, поддерживающего "дуальный" набор команд предвыборки, обещает исправить сложившуюся ситуацию, хотя на этом пути еще много нерешенных проблем и программному управлению кэшированием ох как не просто отвоевать свой кусок места под солнцем.
Ввиду прекращения производства K6 и его неизбежного вытеснения с рынка, команды предвыборки из набора 3D Now! в настоящей главе рассматриваются лишь кратко, а основное внимание уделяется командам предвыборки, входящим в состав набора MMX-команд, который поддерживается практически всеми современными процессорами.
Поэтому, к предвыборке целесообразно прибегать лишь в действительно крайних случаях, когда никакими другими путями обеспечить требуемое быстродействие уже не удается.
Программное непостоянство
В многозадачной среде, коей и является популярнейшая на сегодняшний день операционная система Windows, никакая программа не владеет всеми ресурсами системы единолично и вынуждена делить их с остальными задачами. А это значит, что скорость выполнения профилируемой программы не постоянна и находится в тесной зависимости от "окружающей среды". На практике разброс результатов измерений может достигать 10% –15%, а то и больше, особенно если параллельно с профилировкой исполняются интенсивно нагружающие систему задачи.Тем не менее, особой проблемы в этом нет, – достаточно лишь в каждом сеансе профилировки делать несколько контрольных прогонов и затем… нет, не усреднять, а выбирать замер с наименьшим временем выполнения. Мотив такой: измерения производительности – это не совсем обычные инструментальные измерения и типовые правила метрологии здесь неуместны. Процессор никогда не ошибается и каждый полученный результат точен. Другое дело, что он в той или иной степени искажен побочными эффектами, но! никакие побочные эффекты никогда не приводят к тому, чтобы программа начинает исполняться быстрее, нежели она исполняется в действительности, а потому, прогон с минимальным временем исполнения и представляет собой измерение в минимальной степени испорченное побочными эффектами.
Кстати, во многих руководствах утверждается, что перед профилировкой целесообразно выходить из сети ("что бы машина не принимала пакеты"), завершать все-все приложения, кроме самого профилировщика и вообще лучше даже "на всякий случай" перегрузиться. Все это чистейшей воды гон! Автор частенько отлаживал программы параллельно с работой в Word'e, приемом корреспонденции, скачкой нескольких файлов из Интернет и – точностью профилировки всегда оставалась удовлетворительной! Конечно, без особой нужды не стоит так рисковать, и параллельно работающие приложения перед началом профилировки, действительно, лучше завершить, но не следует доводить ситуацию до абсурда, и пытаться обеспечить полную "стерильность" своей машине.
Протокол MESI
Под загадочной аббревиатурой MESI, частенько встречающийся в отечественной и зарубежной литературе, скрывается ни что иное, как первые буквы четырех статусов кэш-линейки Modified Exclusive Shared Invalid (Модифицированная Девственная Скоммунизденая Инвалидная).Но что каждый из этих статусов обозначает? Вот это мы сейчас и рассмотрим! Итак…
Статус "Modified" автоматически присваивается кэш-строкам при их модификации. Строка с таким атрибутом не может быть просто выброшена из кэша и при ее вытеснении обязательно должна выгружаться в кэш память более высокой иерархии или же основную память;
Статус "Exclusive" автоматически присваивается кэш-строкам при их загрузке из кэша более высокой иерархии или основной оперативной памяти. Модификация строки с атрибутом Exclusive влечет его автоматическую смену на атрибут Modified.
Строка с атрибутом Exclusive при ее вытеснении из кэша в зависимости от архитектурных особенностей системы либо просто уничтожается (inclusive - кэш), либо обменивается своим содержимым с одной из строк кэш-памяти более высокой иерархии (exclusive – кэш). см. так же. "Двухуровневая организация кэша"
Статус "Shared" присваивается кэш-строкам, потенциально присутствующим в кэш-памяти других процессоров (если это многопроцессорная система). Помимо этого, атрибут Shared указывает еще и на то, что строка когерентна содержимому соответствующих ей ячеек основной памяти. Поскольку, многопроцессорные системы далеко выходят за рамки нашего разговора, отложим этот вопрос до специального тома книги.
(Примечание: в AMD Athlon добавился новый статус "Owner" – "Владелец", и сам протокол стал записываться так: MOESI. Подробнее об этом будет так же рассказано в томе, посвященном многопроцессорным архитектурным).
Статус "Invalid" строка отсутствует в кэше и должна быть загружена из кэш памяти более высокой иерархии или же основной памяти.
Кэш данных первого уровня и кэш второго уровня Pentium- и AMD K6\Athlon процессоров поддерживает все четыре статуса, а кэш кода – только два из них Shared и Invalid. Остальные не поддерживаются по той простой причине, что кодовый кэш не допускает модификации своих линеек. А как же в этом случае работает самомодифицирующий код? – удивится иной читатель. А кто вам сказал, что он вообще работает? – возражу я. Независимо от того, присутствует ли модифицируемая ячейка в кодовом кэше или нет, инструкция записи не может непосредственно изменить ее содержимое, и она помещается в кэш первого (второго) уровня или основную оперативную память. Несмотря на то, что процессор все-таки отслеживает эти ситуации и обновляет соответствующие строки кодового кэша, самомодифицирующегося кода по возможности следует избегать, поскольку: во-первых, при обновлении строк гибнет вся преддекодированная информация, а, во-вторых, процессору приходится очищать конвейер и вновь начинать его заполнять сначала.
Причем, под самомодифицирующимся кодом в современных системах понимается не только истинно самомодифицирующийся код в его каноническом понимании, но и вообще всякая запись в область памяти, находящуюся в кодовом кэше. То есть, смесь кода и данных, которая так часто встречается в "ручных" ассемблерных программах, будет исполняться не скорее асфальтового катка, хотя формально она и не изменяет машинный код (но процессор-то об этом не знает!).
|
Статус |
Modified |
Exclusive |
Shared |
Invalid |
|
эта кэш линия действительна? |
да |
да |
да |
нет |
|
копия в памяти действительна? |
устарела |
действительна |
действительна |
этой строке вообще не соответствует никакая память |
|
содержится ли копия этой строки в других процессорах? |
нет |
нет |
может быть |
может быть |
|
запись в эту линию осуществляется… |
только в эту строку, без обращения к шине |
только в эту строку, без обращения к шине |
сквозной записью в память с аннулированием строки в кэшах остальных процессоров |
непосредственно через шину |
Распространенные заблуждения
Оптимизация овеяна многочисленными заблуждениями, которые вызывают снисходительную улыбку у профессионалов, но зачастую необратимо уродуют психику и калечат мировоззрение новичков. Я думаю, профессионалы не обидятся на меня за то, что я потратил несколько страниц книги, чтобы их развеять (естественно, заблуждения, а не самих профессионалов):Раздельное хранение кода и данных
Поскольку кодовый кэш тесно связан с декодером, в настоящей книге он не рассматривается, т.к. декодер и все, что с ним связано, – тема второго тома (см. "Введение в книгу. О серии книг "Оптимизация"), подробно рассказывающим об устройстве процессора и методиках планирования эффективного потока команд.Разгон… Sound Blaster'а
Сказали бы мне некоторое время назад, что обычный Sound Blaster может наглым образом тормозить всю систему, я бы просто не поверил. Но… именно такой экземпляр Blaster'а мне попал недавно в руки. Причем, как показало небольшое расследование, это был дефект не конкретного экземпляра, а непосредственно самой модели, причем не одной, а сразу нескольких!Изменением всего одного байта драйвера звуковой карты мне удалось значительно увеличить производительность системы, причем безо всякого ущерба для функциональности! О том, как был найден этот зловредный байт, и рассказывает настоящая статья.
…все началось с того, что я, заинтересовавшись новыми мультимедийными командами, решился-таки приобрести Pentium-III, поскольку мой старичок CELERON-300A их не поддерживал. Понятное дело, вместе с процессором потребовалось приобретать и новую материнскую плату, а, поскольку на современных матерях ISA-слоты отсутствуют, пришлось менять звуковую карту Gold Edison на что-нибудь поновее. Выбор пал на Sound Blaster-128 PCI
Ничуть не стремясь к чудесам производительности (и на прежнем процессоре все буквально "летало"), я все же ожидал, что Pentium-III 733 будет, по крайней мере, не медленнее. А вот как бы не так! Производительность системы после апгрейда существенно упала и работа с ней превратилась в сплошное мучение. Постоянно используемые мною приложения Adobe Acrobat Reader и компьютерный словарь Lingvo выдерживали пяти-семи секундную паузу при загрузке (тогда как прежде они запускались практически мгновенно), такая же пауза наблюдалась у Microsoft Word и Microsoft Visual Studio при первом обращении к панелям инструментов и меню. Особенно замедлилась скорость открывания файлов в "Универсальном проигрывателе" – прежде выполняясь практически мгновенно, теперь она отнимала несколько секунд.
Перерыв все настойки BIOS, и даже переустановив операционную систему (Windows 2000 для справки), я не добился ровным счетом ничего, правда выяснил, что на Windows 98 все работает отлично – ни задержек, ни тормозов там нет, и все приложения грузятся как из пушки, значительно быстрее, чем раньше.
В конце туннеля замаячил свет: раз под Windows 98 система работает, значит, это не аппаратный, а программный глюк Windows 2000. Что ж, будем лечить!
Первым делом требовалось установить – кто же вызывает задержку. Традиционно такие задачи решаются с помощью профилировщика, но как назло ни одного из них не оказалось под рукой, а бежать покупать диск среди глубокой ночи как-то не хотелось. На выручку пришел отладчик, интегрированный в Visual Studio (впрочем, подошел бы и любой другой, например, Soft-Ice или Windeb).
Рассуждал я следующим образом: "раз и Word, и Visual Studio, и многие другие приложения при первом обращении к меню вызывают задержку – очевидно, виновники не они, а какой-то системный компонент, загружаемый ими. Если этот зловредный компонент автоматически загружается вместе с самим приложением, то пауза возникает при его запуске (как, например, в Adobe Acrobat Reader). А если же он загружается динамически при возникновении в нем необходимости – задержка появляется при обращении к меню".
Загрузив Word в отладчик и щелкнув мышкой по меню, я с удовлетворением отметил, что в окне "DEBUG" появились следующие строки:
Loaded 'C:\WINNT\system32\winmm.dll', no matching symbolic information found.
Loaded 'C:\WINNT\system32\wdmaud.drv', no matching symbolic information found.
Loaded 'C:\WINNT\system32\wdmaud.drv', no matching symbolic information found.
Loaded 'C:\WINNT\system32\wdmaud.drv', no matching symbolic information found.
Ага! Все-таки что-то грузится! Остается только разобраться что… Заглянув в Platform SDK я выяснил, что динамическая библиотека "winmm.dll" обеспечивает работу со звуком и мультимедиа, а, загрузив ее в HEX-редактор, быстро нашел текстовую строку "wdmaud.drv", красноречиво свидетельствующую о том, что драйвер "wdmaud.drv" грузит никто иной, как "winmm.dll".
Логично, что "wdmaud.drv" предназначается для вывода звука, и это действительно так, поскольку его имя обнаружилось во вкладке "Драйвера" Sound Blaster'a в "Устройствах системы".
Может ли Blaster или его драйвер вызывать тормоза? А почему бы и нет! Чтобы проверить это предположение я решился временно удалить "winmm.dll" из папки SYSTEM32. Но под Windows 2000 это сделать не так-то просто! Во-первых, winmm.dll уже используется системой и, стало быть, доступ к ней заблокирован даже для администраторов, а, во-вторых, даже если ее и удалить, она все равно будет автоматически восстановлена операционной системой из резервной копии!
Чтобы воспрепятствовать этому, необходимо заблаговременно удалить winmm.dll из папки WINNT\system32\dllcache. А затем – переименовать "winmm.dll" во что-нибудь другое, скажем "winmm.dl_". Весь фокус в том, что переименования используемых динамических библиотек система не запрещает, автоматически корректируя все активные ссылки, но после перезагрузки приложения будут по-прежнему пытаться загрузить именно "winmm.dll" (откуда же им знать о переименовании?!) и… не найдут ее! (Что, мне собственно, и нужно).
После удаления "winmm.dll" все тормоза мигом исчезли, но… вместе с ними исчез и звук, а, согласитесь, что за компьютер без звука! С другой стороны, приложениям наподобие Word, Visual Studio, Adobe Acrobat Reader звук совсем ни к чему (во всяком случае у меня отключена озвучка пунктов меню и закрытия/открытия окон). Так почему бы не подсунуть им липовую "пустышку": динамическую библиотеку, имеющую то же самое имя, но не выполняющую никаких действий – просто возвращающую управление при вызове, а всем остальным приложениям (действительно нуждающимся в звуке) позволить обращаться к настоящей "winmm.dll"?
Сказано – сделано! Наскоро склепав dll'ку, не экспортирующую ни одной функции, я раскидал ее по всем каталогам, в которых находились исполняемые файлы Word, Visual Studio и других приложений. Дело в том, что при загрузке динамических библиотек система сначала ищет их в каталоге приложения, и только если ее там нет переходит к системному каталогу Windows.
C Word и Visual Studio этот фокус удался, а вот с Acrobat'ом не прошел – не понравилось ему, что в "пустышке" отсутствует функция "timeGetTime". Но трудно, что ль ее создать? (Тем более что ее прототип присутствует в SDK).
Наконец-то, с новым компьютером стало можно работать, а не мучаться как раньше! Разве не прелесть – ни тормозов, ни задержек! Все буквально летает – не успеваешь щелкнуть по иконке, как приложение уже появляется на экране! Но подобный "грязный хак" не мог вызвать у меня должного удовлетворения. Ведь я не нашел причину глюка, а всего лишь загнал его поглубже внутрь.
И вот на Новый год, когда чудом выдалось несколько часов свободного времени, я решил взять врага если не штурмом, то осадой. Пошагово трассируя стартовую процедуру этой злощастной DLL, я искал функцию, вызывающую задержку, а, находя такую, – перебирал все вложенные в нее функции одну за одной, а затем вложенные во вложенные... (В том, что тормоза вызвала именно стартовая процедура, у меня не было никаких сомнений, ибо задержка возникала именно при инициализации).
Через десяток минут я был вознагражден! Следы вели к функции OpenDriver, что выглядело логично, ибо наличие бага в штатной библиотеке "winmm.dll" производства Microsoft Corporation мне казалось сомнительным – она ведь одина на все карты, да и с прежним Blaster'ом работала без нареканий. Вот драйвер нового Blaster'а – дело другое! Нет ничего невероятного в том, что его инициализация (то бишь "открытие") происходит с задержкой. Круг "подозреваемых" сузился, но все же не было ясно кто истинный виновник – непосредственно железяка или ее родной драйвер.
Пройдясь несколько раз дизассемблером по драйверу Blaster'a я не нашел ничего, способного вызывать задержку, – напротив, очень аккуратный и профессионально составленный код. Выходит, все же железка?! Эх, если бы не Новый год было бы можно вернуть ее продавцу с претензией на обмен… А вдруг не железка? Может мать? Теоретически мог иметь место конфликт с контроллером PCI-шины или просто кривой контроллер…
Продолжая копаться в недрах драйвера, я неожиданно заметил, что OpenDriver вызывается четырежды
с последовательной инициализацией портов "wave", "midi", "mixer" и "aux", причем первые три инициализации пролетали на ура, а последняя-то и вызывала задержку.
Вот оно что! Виновата все же железка! AUX-порт на Blaster'е действительно был (хотя мною никак не использовался) и даже успешно функционировал, хотя до жути медленно инициализировался.
А зачем мне AUX-порт? Мне и обычных портов с лихвой хватает! Стоит ли ради него терпеть задержки?! В общем, я решил запретить драйверу инициализацию AUX'а (действительно, глупо инициализировать то, с чем все равно не работаешь). Конечно, правильнее всего было бы переписать код "winmm.dll", но… вносить исправления в двоичный файл чрезвычайно тяжело и весьма небезопасно. Поэтому я ограничился тем, что забил строку "AUX" нулями (в моей версии файла она расположена по адресу ".7752BB18"). Функция OpenDriver, получив на вход пустую строку, ничего не инициируя просто возвращает управление.
Итак, что же конкретно должны сделать читатели, желающие устранить этот дефект у себя? (Если, конечно, у них он есть). Распишу все действия по шагам:
Шаг первый: зайдя в систему под именем (или с правами) администратора, переместите "winmm.dll" из каталога WINNT\system32\dllcache
в какой-нибудь другой каталог, где вы храните резервные файлы. (Это на тот случай, если вы передумаете и захотите вернуть все на место)
Шаг второй: скопируйте WINNT\system32\winmm.dll
в winmm.dl1 и откройте его в любом HEX-редакторе (например, Hiew'e, Qview'e или на худой конец в том, что встроен в популярную файловую оболочку DOS Navigator)
Шаг третий: в winmm.dll найдите строку "AUX", завершаемую одним или несколькими нулями. Если эта та строка, которая вам действительно нужна, поблизости должны быть строки "Wave" и "Mixer" или "Midi" (в winmm.dll может быть несколько строк aux, использующихся различными ветвями программы)
Шаг четвертый: забейте строку "AUX" нулями (т.е. символами с кодом \x00, а не символами "0" с кодом \x30). Хотя, на самом деле, нулем достаточно затереть первую букву "A" – но это будет не так аккуратно.
Шаг пятый: создайте командный файл следующего содержания: "ren WINNT\system32\winmm.dll WINNT\system32\winmm.dl_ & ren WINNT\system32\winmm.dl1 WINNT\system32\winmm.dll" и запустите его на выполнение.
Шаг шестой: перезагрузитесь
Шаг седьмой: удалите WINNT\system32\winmm.dl_ и созданный вами командный файл.
Шаг восьмой: скопируйте измененную WINNT\system32\winmm.dll в папку WINNT\system32\dllcache
Вот и все! Счастливой работы! Если же у вас не все будет гладко получаться, то обращайтесь к автору этой статьи.
Разворачивание циклов
Разворот циклов, – простой и весьма эффективный способ оптимизации. Конвейерные микропроцессоры крайне болезненно реагируют на ветвления, значительно уменьшая скорость выполнения программ (а цикл как раз и представляет собой одну из разновидностей ветвления). Образно говоря, процессор – это гонщик, мчащийся по трассе (программному коду) и сбрасывающий газ на каждом повороте (ветвлении). Чем меньше поворотов содержит трасса (и чем протяженнее участки беспрепятственной прямой),– тем меньше времени требуется на ее прохождение.Техника разворачивания циклов в общем случае сводится к уменьшению количества итераций за счет дублирования тела цикла соответствующее число раз. Рассмотрим следующий цикл:
for(a = 0; a < 666; a++)
x+=p[a];
Листинг 2 Не оптимизированный исходный цикл
С точки зрения процессора этот цикл представляет собой сплошной ухаб, не содержащий ни одного мало-мальски протяженного прямого участка. Разворот цикла позволяет частично смягчить ситуацию. Чтобы уменьшить количество поворотов вдвое следует реорганизовать цикл так:
for(a = 0; a < 666; a+=2)
{// обратите внимание ^^^ с разверткой цикла
// мы соответственно увеличиваем и шаг
x+=p[a];
x+=p[a + 1]; // продублированное тело цикла
/* ^^^ корректируем значение счетчика цикла */
}
Листинг 3 Пример реализации двукратного разворота цикла
Разворот цикла в четыре раза будет еще эффективнее, но непосредственно этого не сделать, ведь количество итераций цикла не кратно четырем: 666 на 4 нацело не делится! Один из возможных путей решения: округлить количество итераций до величины кратной четырем (или, в более общем случае, – кратности разворота цикла), а оставшиеся итерации поместить за концом цикла.
Оптимизированный код может выглядеть, например, так:
for(a = 0; a < 664; a+=4)
{ // округляем ^^^ количество итераций до величины
// кратной четырем
x+=p[a]; // четырежды
x+=p[a + 1]; // дублируем
x+=p[a + 2]; // тело
x+=p[a + 3]; // цикла
}
x+=p[a]; // оставшиеся две итерации добавляем в конец
x+=p[a + 1]; // цикла
Листинг 4 Пример реализации четырехкратного разворота цикла в случае, когда количество итераций цикла не кратно четырем
Хорошо, а как быть, если количество итераций на стадии компиляции еще неизвестно? (То есть, количество итераций – переменная, а не константа). В этой ситуации разумнее всего прибегнуть к битовым операциям:
for(a = 0; a < (N & ~3); a += k)
{ // округляем ^^^ количество итераций до величины
// кратной степени разворота
x+=p[a];
x+=p[a + 1];
x+=p[a + 2];
x+=p[a + 3];
}
// оставшиеся итерации добавляем в конец цикла
for(a = (N & ~3)); a < N; a++)
x+=p[a];
Листинг 5 Пример реализации четырехкратного разворота цикла в случае заранее неизвестного количества итераций
Как нетрудно догадаться, выражение (N & ~3)) и осуществляет округление количества итераций до величины кратной четырем. А почему, собственно, четырем? Как вообще зависит скорость выполнения цикла от глубины его развертки? Что ж, давайте поставим эксперимент! Несколько забегая вперед отметим, что эффективность оптимизации зависит не только от глубины развертки цикла, но и рода обработки данных. Поэтому, циклы, читающие память, и циклы, записывающие память, должны тестироваться отдельно. Вот с чтения памяти мы, пожалуй, и начнем…
/* -----------------------------------------------------------------------
не оптимизированный вариант
(чтение)
----------------------------------------------------------------------- */
for (a = 0; a < BLOCK_SIZE; a += sizeof(int))
x += *(int *)((int)p + a);
/* -----------------------------------------------------------------------
разворот на четыре итерации
(чтение)
----------------------------------------------------------------------- */
for (a = 0; a < BLOCK_SIZE; a += 4*sizeof(int))
{
x += *(int *)((int)p + a );
x += *(int *)((int)p + a + 1*sizeof(int));
x += *(int *)((int)p + a + 2*sizeof(int));
x += *(int *)((int)p + a + 3*sizeof(int));
}
Листинг 6 [Memory/unroll.read.c] Фрагмент программы, исследующий влияние глубины развертки цикла, читающего память, на время его выполнения
Что ж, результаты тестирования нас не разочаровали! Оказывается, глубокая развертка цикла сокращает время его выполнения более чем в два раза. Впрочем, здесь главное – не переборщить! (Скупой, как хорошо известно, платит дважды). Чрезмерная глубина развертки ведет к катастрофическому увеличению размеров цикла и совершенно не оправдывает привносимый ей выигрыш. Шестидесяти четырех кратное дублирование тела цикла смотрится довольно таки жутковато. Хуже того, – такой монстр может просто не влезть в кэш, что вызовет просто обвальное падение производительности!
Целесообразнее всего, как следует из диаграммы graph 28, разворачивать цикл в восемь или шестнадцать раз. Дальнейшее увеличение степени развертки практически не добавляет производительности.

Рисунок 16 graph 28 Эффективность разворачивания циклов, читающих память. Видно, что время выполнения цикла резко уменьшается с его глубиной
С записью же картина совсем другая (см. рис. graph 29). /* Поскольку, тестовая программа мало чем отличается от предыдущей ради экономии места она опускается. */ На P?III/I815EP время выполнения цикла, записывающего память, вообще не зависит от глубины развертки. Ну, практически не зависит. Развернутый цикл выполняется на ~2% медленнее за счет потери компактности кода. Впрочем, этой величиной можно и пренебречь. Для увеличения эффективности выполнения программы под процессором Athlon следует развернуть записывающий цикл в шестнадцать раз. Это практически на четверть повысит его быстродействие!

Рисунок 17 graph 29 Эффективность разворачивания циклов, записывающих память. Время выполнения цикла практически не зависит от глубины развертки и лишь на AMD Athlon шестнадцати кратная развертка несколько увеличивает его производительность
Что же касается смешанных циклов, обращающихся к памяти и на запись, и на чтение одновременно, – их так же рекомендуется разворачивать в восемь–шестнадцать раз. (см. так же "Группировка операций чтения с операциями записи").
Разворот циклов средствами макроассемблера. В заключении отметим одно печальное обстоятельство. Препроцессор языка Си не поддерживает циклических макросов и потому не позволяет реализовывать эффективную развертку циклов. Программист вынужден самостоятельно проделывать утомительную и чреватую ошибками работу по многократному дублированию тела цикла, не забывая к тому же о постоянной коррекции счетчика.
"Астматики" находятся гораздо в лучшем положении. Развитые макросредства ассемблеров MASM и TASM позволяют переложить всю рутинную работу на компилятор, и программисту ничего не стоит написать макрос разворачивающий цикл какое угодно количество раз. Это необычайно облегчает отладку и оптимизацию программы!
Одна из таких программ и приведена ниже в качестве наглядной иллюстрации. Согласитесь, элегантно решить эту (отнюдь не надуманную!) задачу средствами ANSI Cи/Си++ физически невозможно!
; /* -------------------------------------
; *
; * макрос, дублирующий свое тело N раз
; *
;
-------------------------------------- */
READ_BUFF MACRO N
MYN = N
MYA = 0
; // цикл дублирования своего тела
WHILE MYA NE MYN
; // тело цикла
; // макропроцессор продублирует его заданное число раз
MOV EDX, [EBX + 32 * MYA]
MYA = MYA + 1
ENDM
ENDM
UNROLL_POWER EQU 8 ; // глубина разворота цикла
Loop:
READ_BUFF UNROLL_POWER
; // ^^^^^^^^^^^^ обратите внимание, глубина разворота
; // задается препроцессорной константой!
; // никакой ручной работы!
ADD EAX, EDX
ADD EBX, 4 * UNROLL_POWER
; // коррекция ^^^^^^^^^^^^^^ количества итераций
DEC ECX
JNZ Loop
Листинг 7 Пример разворачивания цикла средствами макроассемблера. Макрос READ_BUFF позволяет разворачивать цикл произвольное количество раз
Разворот цикла средствами препроцессора Си. Хотя автоматически развернуть цикл директивами препроцессора ANSI Cи/Си++ невозможно, пути изгнания рутины из жизни программиста все-таки существуют! Стоит только подумать…
Одна из идей состоит в отказе от модификации счетчика цикла внутри заголовка цикла и выполнении этой операции внутри макроса "продразверстки". В результате, необходимость ручной коррекции счетчика отпадает и все копии тела цикла становятся идентичны друг другу. А раз так, – почему бы их не засунуть в препроцессорный макрос?
Это можно сделать, например, так:
#define BODY x+=p[a++]; // тело цикла
for(a=0; a < BLOCK_SIZE;)
{
// разворот цикла в 8 раз
BODY; BODY; BODY; BODY;
BODY; BODY; BODY; BODY;
}
Листинг 8 Пример разворачивания цикла средствами препроцессора языка Си. Это лучшее, что язык Си в состоянии нам предложить
RDRAM (Rambus DRAM) - Rambus-память
С DDR-SDRAM жесточайшим образом конкурирует Direct RDRAM, разработанная компанией Rambus. Вопреки распространенному мнению, ее архитектура довольно прозаична и не блещет какой бы то ни было революционной новизной. Основных отличий от памяти предыдущих поколений всего три:а) увеличение тактовой частоты за счет сокращения разрядности шины,
б) одновременная передача номеров строки и столба ячейки,
в) увеличение количества банков для усиления параллелизма.
А теперь обо всем этом подробнее. Повышение тактовой частоты вызывает резкое усиление всевозможных помех и в первую очередь электромагнитной интерференции, интенсивность которой в общем случае пропорциональна квадрату частоты, а на частотах свыше 350 мегагерц вообще приближается к кубической. Это обстоятельство налагает чрезвычайно жесткие ограничения на топологию и качество изготовления печатных плат модулей микросхемы, что значительно усложняет технологию производства и себестоимость памяти. С другой стороны, уровень помех можно значительно понизить, если сократить количество проводников, т.е. уменьшить разрядность микросхемы. Именно по такому пути компания Rambus и пошла, компенсировав увеличение частоты до 400 MHz (с учетом технологии DDR эффективная частота составляет 800 MHz) уменьшением разрядности шины данных до 16 бит (плюс два бита на ECC). Таким образом, Direct RDRAM в четыре раза обгоняет DDR?1600 по частоте, но во столько же раз отстает от нее в разрядности! А от DDR?2100, Direct RDRAM отстает конкретно, и это при том, что себестоимость DDR заметно дешевле!
Второе (по списку) преимущество RDRAM – одновременная передача номеров строки и столбца ячейки – при ближайшем рассмотрении оказывается вовсе не преимуществом, а фичей – конструктивной особенностью. Это не уменьшает латентности чтения произвольной ячейки (т.е. интервалом времени между подачей адреса и получения данных), т.к. она в большей степени определяется скоростью ярда, а RDRAM функционирует на старом ядре.
Из спецификации RDRAM следует, что время доступа составляет 38,75 нс. (для сравнения время доступа 100 MHz SDRAM составляет 40 нс.). Ну, и стоило бы огород городить?
Стоило! Большое количество банков позволяет (теоретически) достичь идеальной конвейеризации запросов к памяти, – несмотря на то, что данные поступают на шину лишь спустя 40 нс. после запроса (что соответствует 320 тактам в 800 MHz системе), сам поток данных непрерывен.
Стоило?! Для потоковых алгоритмов последовательной обработки памяти это, допустим, хорошо, но во всех остальных случаях RDRAM не покажет никаких преимуществ перед DDR-SDRAM, а то и обычной SDRAM, работающей на скромной частоте в ~100 MHz. К тому же (как будет показано ниже, – см. "Кэш"), солидный объем кэш-памяти современных процессоров позволяет обрабатывать подавляющее большинство запросов локально, вообще не обращаясь к основной памяти или на худой конец, отложить это обращение до "лучших времен". Производительность RDRAM памяти реально ощущается лишь при обработке гигантских объемов данных, например, при редактировании изображений полиграфического качества в PhotoShop.
Таким образом, использование RDRAM в домашних и офисных компьютеров, ничем, кроме желания показать свою "крутость", не оправдано. Для высокопроизводительных рабочих станций лучший выбор – DDR-SDRAM, не уступающая RDRAM в производительности, но значительно выигрывающая у нее в себестоимости.
В этом свете не очень понятно стремление компании Intel к продвижению Rambus'а на рынке. Еще раз обращу внимание читателя: ничего революционного Rambus в себе не несет. Чрезвычайно сложная и требовательна к качеству производства интерфейсная обвязка обеспечивает высокую тактовую частоту, но не производительность! Соотношение 400x2 MHz на 16 бит оптимальным соотношением категорически не является уже хотя бы потому, что DDR?SDRAM без особых ухищрений тянет 133x2 MHz на 64 бит. Причем производители DDR?SDRAM в ближайшем будущем планируют взять барьер в 200x4 MHz на 128 бит, что увеличит пропускную способность до 12,8 Гбайт/с., что в восемь раз превосходит пропускную способность Direct RDRAM при меньшей себестоимости и аппаратной сложности!
Не стоит, однако, бросаться и в другую крайность, считая Rambus "кривой" и "идиотской" памятью. Отнюдь! Инженерный опыт, приобретенный в процессе создания этой, не побоюсь сказать, чрезвычайно высокотехнологичной памяти, несомненно, найдет себе применение в дальнейших разработках. Взять хотя бы машину Бэббиджа. Согласитесь, несмотря на передовые идеи, ее реальное воплощение проигрывало по всем позициям даже конторским счетам. Аналогично и с Direct RDRAM. Достичь пропускной способности в 1,6 Гбайт/с. можно и более простыми путями…

Рисунок 9 ramnus Внешний вид модулей Rambus памяти

Рисунок 10 1733rambus Модули Rambus-памяти установленные на материнской плате. Обратите внимание на близость контроллера памяти (большая микросхема слева, снабженная радиатором) к модулям памяти.
Самомодифицирующийся код как средство защиты приложений
И вот после стольких мытарств и ухищрений злополучный пример запущен и победно выводит на экран "Hello, World!". Резонный вопрос – а зачем, собственно, все это нужно? Какая выгода оттого, что функция будет исполнена в стеке? Ответ:– код функции, исполняющееся в стеке, можно прямо "на лету" изменять, например, расшифровывать ее.Шифрованный код чрезвычайно затрудняет дизассемблирование и усиливает стойкость защиты, а какой разработчик не хочет уберечь свою программу от хакеров? Разумеется, одна лишь шифровка кода – не очень-то серьезное препятствие для взломщика, снабженного отладчиком или продвинутым дизассемблером, наподобие IDA Pro, но антиотладочные приемы (а они существуют и притом в изобилии) – тема отдельного разговора, выходящего за рамки настоящей статьи.
Простейший алгоритм шифрования заключается в последовательной обработке каждого элемента исходного текста операцией "ИЛИ-исключающее-И" (XOR). Повторное применение XOR к шифротексту позволяет вновь получить исходный текст.
Следующий пример (см. листинг 3) читает содержимое функции Demo, зашифровывает его и записывает полученный результат в файл.
void _bild()
{
FILE *f;
char buff[1000];
void (*_Demo) (int (*) (const char *,...));
void (*_Bild) ();
_Demo=Demo;
_Bild=_bild;
int func_len = (unsigned int) _Bild - (unsigned int) _Demo;
f=fopen("Demo32.bin","wb");
for (int a=0;a
fclose(f);
}
Листинг 5 Шифрование функции Demo
Теперь из исходного текста программы функцию Demo
можно удалить, взамен этого, разместив ее зашифрованное содержимое в строковой переменной (впрочем, не обязательно именно строковой). В нужный момент оно может быть расшифровано, скопировано в локальный буфер и вызвано для выполнения. Один из вариантов реализации приведен в листинге 4.
Обратите внимание, как функция printf
в листинге 2 выводит приветствие на экран. На первый взгляд ничего необычного, но, задумайтесь, где
размещена строка "Hello, World!". Разумеется, не в сегменте кода – там ей не место ( хотя некоторые компиляторы фирмы Borland помещают ее именно туда). Выходит, в сегменте данных, там, где ей и положено быть? Но если так, то одного лишь копирования тела функции окажется явно недостаточно – придется скопировать и саму строковую константу. А это – утомительно. Но существует и другой способ – создать локальный буфер и инициализировать его по ходу выполнения программы, например, так: …buf[666]; buff[0]='H'; buff[1]='e'; buff[2]='l'; buff[3]='l';buff[4]='o',… - не самый короткий, но, ввиду своей простоты, широко распространенный путь.
int main(int argc, char* argv[])
{
char buff[1000];
int (*_printf) (const char *,...);
void (*_Demo) (int (*) (const char *,...));
char code[]="\x22\xFC\x9B\xF4\x9B\x67\xB1\x32\x87\
\x3F\xB1\x32\x86\x12\xB1\x32\x85\x1B\xB1\
\x32\x84\x1B\xB1\x32\x83\x18\xB1\x32\x82\
\x5B\xB1\x32\x81\x57\xB1\x32\x80\x20\xB1\
\x32\x8F\x18\xB1\x32\x8E\x05\xB1\x32\x8D\
\x1B\xB1\x32\x8C\x13\xB1\x32\x8B\x56\xB1\
\x32\x8A\x7D\xB1\x32\x89\x77\xFA\x32\x87\
\x27\x88\x22\x7F\xF4\xB3\x73\xFC\x92\x2A\
\xB4";
_printf=printf;
int code_size=strlen(&code[0]);
strcpy(&buff[0],&code[0]);
for (int a=0;a
buff[a] = buff[a] ^ 0x77;
_Demo = (void (*) (int (*) (const char *,...))) &buff[0];
_Demo(_printf);
return 0;
}
Листинг 6 Зашифрованная программа
Теперь (см. листинг 4) даже при наличии исходных текстов алгоритм работы функции Demo будет представлять загадку! Этим обстоятельством можно воспользоваться для сокрытия некоторой критической информации, например, процедуры генерации ключа или проверки серийного номера.
Проверку серийного номера желательно организовать так, чтобы даже после расшифровки кода, ее алгоритм представлял бы головоломку для хакера. Один из примеров такого алгоритма предложен ниже.
Суть его заключается в том, что инструкция, отвечающая за преобразование бит, динамически изменяется в ходе выполнения программы, а вместе с нею, соответственно, изменяется и сам результат вычислений.
Поскольку при создании самомодифицирующегося кода требуется точно знать в какой ячейке памяти какой байт расположен, приходится отказываться от языков высокого уровня и прибегать к ассемблеру.
С этим связана одна проблема – чтобы модифицировать такой-то байт, инструкции mov требуется передать его абсолютный линейный адрес, а он, как было показано выше, заранее неизвестен. Однако его можно узнать непосредственно в ходе выполнения программы. Наибольшую популярность получила конструкция "CALL $+5\POP reg\mov [reg+relative_addres], xx" – т.е. вызова следующей инструкцией call
команды и извлечению из стека адреса возврата – абсолютного адреса этой команды, который в дальнейшем используется в качестве базы для адресации кода стековой функции. Вот, пожалуй, и все премудрости.
MyFunc:
push esi ; сохранение регистра esi
в стеке
mov esi, [esp+8] ; ESI = &username[0]
push ebx ; сохранение прочих регистров в стеке
push ecx
push edx
xor eax, eax ; обнуление рабочих регистров
xor edx, edx
RepeatString: ; цикл обработки строки байт-за-байтом
lodsb ; читаем очередной байт в AL
test al, al ; ?достигнут конец строки
jz short Exit
; Значение счетчика для обработки одного байта строки.
; Значение счетчика следует выбирать так, чтобы с одной стороны все биты
; полностью перемешались, а с другой - была обеспечена четность (нечтность)
; преобразований операции xor
mov ecx, 21h
RepeatChar:
xor edx, eax
; циклически меняется с xor
на adc
ror eax, 3
rol edx, 5
call $+5 ; ebx = eip
pop ebx ; /
xor byte ptr [ebx-0Dh], 26h; Эта команда обеспечивает цикл.
; изменение инструкции xor
на adc
loop RepeatChar
jmp short RepeatString
Exit:
xchg eax, edx ; результат
работы (ser.num) в eax
pop edx ; восстановление регистров
pop ecx
pop ebx
pop esi
retn ; возврат из функции
Листинг 7 Процедура генерации серийного номера, предназначенная для выполнения в стеке
Приведенный алгоритм интересен тем, что повторный вызов функции с передачей тех же самых аргументов может возвращать либо той же самый, либо совершенно другой результат – если длина имени пользователя нечетна, то при выходе из функции XOR меняется на ADC с очевидными последствиями. Если же длина имени четна – ничего подобного не происходит.
Разумеется, стойкость предложенной защиты относительно невелика. Однако она может быть значительно усилена. На то существует масса хитрых приемов программирования – динамическая асинхронная расшифровка, подстановка результатов сравнения вместо коэффициентов в различных вычислениях, помещение критической части кода непосредственно в ключ и т.д.
Но назначение статьи состоит не в том, чтобы предложить готовую к употреблению защиту (да и, зачем? чтобы хакерам ее было бы легче изучать?), а доказать (и показать!) принципиальную возможность создания самомодифицирующегося кода под управлением Windows 95/Windows NT/Windows 2000. Как именно предоставленной возможностью можно воспользоваться – надлежит решать читателю.
Самомодифицирующийся код в современных операционных системах
Лет десять-двадцать тому назад, в эпоху рассвета MS-DOS, программистами широко использовался самомодифицирующийся код, без которого не обходилась практически ни одна мало-мальски серьезная защита. Да и не только защита, - он встречался в компиляторах, компилирующих код в память, распаковщиках исполняемых файлов, полиморфных генераторах и т.д. и т.п.В середине девяностых началась массовая миграция пользователей с MS-DOS на Windows 95\Windows NT, и разработчиком пришлось задуматься о переносе накопленного опыта и приемов программирования на новую платформу – от бесконтрольного доступа к "железу", памяти, компонентам операционной системы и связанным с ними хитроумными трюками программирования пришлось отвыкать. В частности стала невозможна непосредственная модификация исполняемого кода приложений, поскольку Windows защищает его от непреднамеренных изменений. Это привело к рождению нелепого убеждения, дескать, под Windows создание самомодифицирующегося кода вообще невозможно, по крайней мере, без использования VxD и недокументированных возможностей операционной системы.
На самом деле существует по крайней мере два документированных способа изменения кода приложений, одинаково хорошо работающих как под управлением Windows 95\Windows 98\Windows Me, так и под Windows NT\Windows 2000, и вполне удовлетворяющихся привилегиями гостевого пользователя.
Во-первых, kernel32.dll экспортирует функцию WriteProcessMemory, предназначенную, как и следует из ее названия, для модификации памяти процесса. Во-вторых, практически все операционные системы, включая Windows и LINUX, разрешают выполнение и модификацию кода, размещенного в стеке.
В принципе, задача создания самомодифицирующегося кода может быть решена исключительно средствами языков высокого уровня, таких, например, как Си, Си++, Паскаль без применения ассемблера.
Материал, изложенный в настоящей главе, большей частью ориентирован на компилятор Microsoft Visual C++ и 32-разрядный исполняемый код. Под Windows 3.x приведенные примеры работать не будут. Но это вряд ли представляет существенную проблему - доля машин с Windows 3.x на рынке очень невелика, поэтому, ими можно полностью пренебречь.
SDRAM (Synchronous DRAM) – синхронная DRAM
Появление микропроцессоров с шинами на 100 MHz привело к радикальному пересмотру механизма управления памятью, что привело к созданию синхронной динамической памяти – SDRAM (Synchronous-DRAM синхронная DRAM). Как и следует из ее названия, микросхемы SDRAM-памяти работают синхронно с контроллером, что гарантирует завершение цикла обмена в строго заданный срок. (Помните, "как хочешь, крутись, теща, но что бы к трем часам как штык была готова"). Кроме того, номера строк и столбцов подаются с таким расчетом, чтобы к приходу следующего тактового импульса сигналы уже успели стабилизироваться и были готовы к считыванию.Также, в SDRAM реализован усовершенствованный пакетный режим обмена. Контроллер может запросить как одну, так и несколько последовательных ячеек памяти, а при желании – даже всю строку целиком! Это стало возможным благодаря использованию полноразрядного адресного счетчика уже не ограниченного, как в BEDO, двумя битами.
Другое усовершенствование. Количество матриц (банков) памяти в SDRAM было увеличено сначала с одного до двух, а затем и до четырех. Это позволило обращаться к ячейкам одного банка параллельно с перезарядкой внутренних цепей другого, что (при условии правильного планирования потоков данных см. "Оптимизация работы с памятью: стратегия распределения данных по DRAM банкам") приблизительно на ~30% увеличивает производительность.
Помимо этого появилась возможность одновременного открытия двух (четырех) страниц памяти, причем открытие одной страницы (т.е. передача номера строки) может происходить во время считывания информации с другой, что позволяет обращаться к новому столбцу ячейки памяти на каждом тактовом цикле.
В отличие от FPM-DRAM\EDO-DRAM\BEDO, выполняющих перезарядку внутренних цепей при закрытии страницы (т.е. при дезактивации сигнала RAS), синхронная память проделывает эту операцию автоматически, позволяя держать страницы открытыми столь долго, сколько это угодно.
Наконец, разрядность линий данных увеличилась с 32- до 64 бит, что еще вдвое увеличило ее производительность!
Формула чтения произвольной ячейки из закрытой строки для SDRAM обычно выглядит так: 4?1?x?x, а открытой так: 2?1?х?х.
В настоящее время (2002 год) подавляющее большинство персональных компьютеров оснащаются именно SDRAM-памятью, которая прочно удерживает свои позиции, несмотря на активный натиск современных разработок.

Рисунок 8 0x24 Временная диаграмма, иллюстрирующая работу некоторых типов памяти (окончание)
Секреты копирования памяти или
Обработка строк, структур, массивов, объектов, передача аргументов функциям, проигрывание звука или вывод изображения на экран – вот далеко не полный перечень областей применения функции копирования памяти. Разработчики компиляторов прилагают значительные усилия, чтобы штатная функция копирования памяти (например, в языке Си она называется memcpy) "летала" настолько быстро, насколько это вообще возможно. Но задачи оптимизации не имеют решений общего вида – алгоритм, оптимальный для одной ситуации, зачастую оказывается чрезвычайно неоптимальным в другой.Копирование памяти – отнюдь не такая тривиальная операция, какой кажется с первого взгляда. Здесь есть свои тонкости и секреты… Им-то и посвящена настоящая глава. (см. так же. "Часть I Оптимизация работы с памятью. Оптимизация штатных Си-функций для работы с памятью").
Секреты Visual Studio
Современные программные интерфейсы заметно потеснили, если не сказать вытеснили, документацию, которая, по мнению большинства пользователей, никому кроме "ламеров" уже не нужна. Назначение большинства пунктов меню интуитивно понятно и так, а, если даже и непонятно, его нетрудно выяснить экспериментально.На самом же деле, меню – лишь верхушка айсберга, а большая часть функциональных возможностей многих приложений скрыта под водой. Продвинутые программные пакеты, такие как, например, MicrosoftVisual Studio содержат тысячи команд, и, если бы все они были "втиснуты" в меню, его бы размеры выросли по меньшей мере до луны.… Вот и приходится помещать в меню лишь наиболее важные (с точки зрения разработчиков) команды, скрывая остальные от глаз пользователя.
Не стоит думать, что все скрытые команды – никому не нужный балласт. Среди них притаилось немало подлинных драгоценностей, значительно облегчающих жизнь программисту.
Данная глава посвящена малоизвестным, но чрезвычайно полезным секретам среды разработки Visual Studio 6.0. Поскольку, эта среда так же необъятна, как и мир, рассказать обо всех возможностях в рамках одной главы просто невозможно. Поэтому, ограничимся лишь встроенным редактором текстов – важнейшим компонентом любой среды разработки.
Маленькое замечание мимоходом: если вы пришли на Windows из UNIX и вам ужасно недостает возможностей тех сред разработки – эта статья для вас! Все, что было в UNIX, есть и в Windows, только скрытно от посторонних глаз. Но мы-то с вами не посторонние, правда? :-)
Семь китов оптимизации или Жизненный цикл оптимизации
Часто программист (даже высококвалифицированный!) обнаружив профилировщиком "узкие" места в программе, автоматическипринимает решение о переносе соответствующих функций на ассемблер. А напрасно! Как мы еще убедимся (см. "Часть III. Ассемблер vs компилятор"), разница в производительности между ручной и машинной оптимизацией в подавляющем большинстве случаев очень невелика. Очень может статься так, что улучшать уже нечего, – за исключением мелких, "косметических" огрехов, результат работы компилятора идеален и никакие старания не увеличат производительность, более чем на 3%–5%. Печально, если это обстоятельство выясняется лишь после переноса одной или нескольких таких функций на ассемблер. Потрачено время, затрачены силы… и все это впустую. Обидно, да?
Прежде, чем приступать к ручной оптимизации не мешало бы выяснить: насколько не оптимален код, сгенерированный компилятором, и оценить имеющийся резерв производительности. Но не стоит бросаться в другую крайность и полагать, что компилятор всегда генерирует оптимальный или близкий к тому код. Отнюдь! Все зависит от того, насколько хорошо вычислительный алгоритм ложиться в контекст языка высокого уровня. Некоторые задачи решаются одной машинной инструкцией, но целой группой команд на языках Си и Паскаль. Наивно надеяться, что компилятор поймет физический смысл компилируемой программы и догадается заменить эту группу инструкций одной машинной командой. Нет! Он будет тупо транслировать каждую инструкцию в одну или (чаще всего) несколько машинных команд, со всеми вытекающими отсюда последствиями…
четвертый. Избавление от strlen
Возвращаясь к рис. 0x004 отметим, что обращение к не выровненным данным – не единственная горячая точка функции Calculate CRC. С небольшим отрывом за ней следует инструкция PUSH, временно сохраняющая регистры в стеке и… опять та противная strlenс которой мы уже столкнулись.
Действительно, вычисление длины пароля вполне сопоставимо по времени с подсчетом его контрольной суммы. Вот если бы этого удалось избежать… А для собственно, вообще вычислять длину каждого пароля? Ведь пароли перебираются не хаотично, а генерируется по вполне упорядоченной схеме и приращение длины пароля на единицу происходит не так уж и часто. Так, может быть, лучше возложить эту задачу на функцию gen_pswd? Пусть при первом вызове она определяет длину начального пароля, а затем при "растяжке" строки увеличивает глобальную переменную length на единицу. Сказано – сделано.
Теперь код gen_pswd выглядит так:
int a;
int p = 0;
length = strlen(pswd); // определение длины начального пароля
…
if (!pswd[p])
{
pswd[p]=' ';
pswd[p+1]=0;
length++; // "ручное" увеличение длины пароля
}
…
Листинг 16 Удаление функции strlen и "ручное" приращение длины пароля при его удлинении на один символ
А код Calculate CRC так:
for (a = 0; a <= length; a++)
Листинг 17 Использование глобальной переменной для определения длины пароля
В результате этих нехитрых преобразований мы получаем скорость в… восемь миллионов паролей в секунду. Много? Подождите! Самое интересное еще только начинается…
десятый. Заключительный
Все оставшиеся 17 горячих точек представляют собой издержки обращения к кэш-памяти и… штрафные такты ожидания за неудачную с точки зрения процессора группировку команд. Ладно, оставим обращения к памяти в стороне, вернее отдадим эту задачу на откуп неутомимым читателям (задумайтесь: зачем вообще теперь генерировать пароли, если их контрольная сумма считается без обращения к ним?) и займемся оптимальным планированием потока команд.Обратимся к другому мощному средству профилировщика VTune – автоматическому оптимизатору, по праву носящему гордое имя "Assembly Coach" (Ассемблерный Тренер, – не путайте его с Инструктором!). Выделим, удерживая левую клавишу мыши, все тело функции gen_pswd
и найдем на панели инструментов кнопку с "учителем" (почему-то ярко-красного цвета), держащим указку на перевес. Нажмем ее.
На выбор нам предоставляется три варианта оптимизации, выбираемые в ниспадающем боксе "Mode of Operation" – Автоматическая Оптимизация (Automatic Optimization), Пошаговая Оптимизация (Single Step Optimization) и Интерактивная Оптимизация (Interactive Optimization). Первые два режима представляют собой сплошное барахло, не представляющего особого интереса, а вот качество Интерактивной Оптимизации – выше всяких похвал. Итак, выбираем интерактивную оптимизацию и нажимаем кнопку "Next" расположенную чуть правее ниспадающего бокса.
Содержимое экрана тут же преобразится (см. рис 0х005): в левой панели показан исходный ассемблерный код, в правой – оптимизируемый код. В нижнем углу экрана по ходу оптимизации будут отображаться так называемые "assumption" (буквально – допущения), за разрешением которых оптимизатор будет обращаться к программисту. Сейчас в этом окне горит следующее допущение: "Offset: 0x55 & 0x72: Instructions Reference to Same Memory" (Инструкции со смещениями 0x55 и 0x72 обращаются к одной и той же области памяти). Смотрим: что за инструкции расположены по таким смещениям. Ага:
1:55 mov ebp, DWORD PTR [esp+018h]
1:72 mov DWORD PTR [esp+010h], ecx
Несмотря на кажущееся различие в операндах, на самом деле они адресуют одну и ту же переменную, т.к. между ними расположены две машинные команды PUSH, уменьшающие значение регистра ESP на 8. Таким образом, это предположение верно и мы подтверждаем его нажатием "Apply".
Теперь обратим внимание на инструкции, залитые красным цветом и отмеченные красным огоньком светофора слева. Это отвратительно спланированные инструкции, обложенные штрафными тактами процессора.

Рисунок 10 0x005 Использование Ассемблерного Тренера для оптимизации планирования машинных команд
Давайте щелкнем по самому нижнему "светофору" и посмотрим, как VTune перегруппирует наши команды… Ну вот, совсем другое дело! Теперь все инструкции залиты пастельным желтым цветом, что означает: никаких конфликтов и штрафных тактов – нет. Что в оптимизированном коде изменилось? Ну, во-первых, теперь команды PUSH (заталкивающие регистры в стек) отделены от команды, модифицирующей регистр указатель вершины стека, что уничтожает паразитную зависимость по данным (действительно, нельзя заталкивать свежие данные в стек пока не известно положение его вершины).
Во-вторых, арифметические команды теперь равномерно перемешаны с командами записи/чтения регистров, – поскольку вычислительное устройство (АЛУ – арифметически логическое устройство) у процессоров Pentium всего одно, то эта мера практически удваивает производительность.
В-третьих, процессоры Pentium содержат только один полноценный x86 декодер и потому заявленная скорость декодирования три инструкции за такт достигается только при строго определенном следовании инструкцией. Инструкции, декодируемые только полноценным x86-декодером, следует размещать в начале каждого триплета, заполняя "хвост" триплета командами, которые по зубам остальным двум декодерам. Как легко убедиться, компилятор MS VC генерирует весьма неоптимальный с точки зрения Pentium-процессора код и VTune перетасовывает его команды по своему.
sub esp, 08h sub esp, 08h
push ebx or ecx, -1
push ebp push ebx
mov ebp, DWORD PTR [esp+018h] push ebp
push esi mov ebp, DWORD PTR [esp+018h]
push edi xor eax, eax
mov edi, ebp push esi
or ecx, -1 push edi
xor eax, eax xor ebx, ebx
xor ebx, ebx mov edx, -1
mov edx, -1 mov edi, ebp
repne scasb repne scasb
not ecx mov DWORD PTR [esp+020h], edx
dec ecx not ecx
mov DWORD PTR [esp+020h], edx dec ecx
mov DWORD PTR [esp+010h], ecx mov DWORD PTR [esp+010h], ecx
Листинг 20 Ассемблерный код, оптимизированный компилятором Microsoft Visual C++ 6.0 в режиме максимальной оптимизации (слева) и его усовершенствованный вариант, переработанный VTune (справа).
Нажимаем еще раз "Next" и переходим к анализу следующего блока инструкций. Теперь VTune устраняет зависимость по данным, разделяя команды чтения и сложения регистра ESI командой увеличение регистра EAX
mov esi, DWORD PTR [eax+ebp] mov esi, DWORD PTR [eax+ebp]
add edx, esi inc eax
inc eax add edx, esi
cmp eax, ecx cmp eax, ecx
…и таким Макаром мы продолжаем до тех пор, пока весь код целиком не будет оптимизирован.
И тут возникает новая проблема. Как это ни прискорбно, но VTune не позволяет поместить оптимизированный код в исполняемый файл, видимо, полагая, что программист–ассемблерщик без труда перебьет его с клавиатуры и вручную. Но мы то с вами не ассемблерщики! (В смысле: среди нас с вами есть и не ассемблерщики).
И потом – куда прикажите перебивать код? Не резать же двоичный файл "в живую"? Конечно нет! Давайте поступим так (не самый лучший вариант, конечно, но ничего более умного мне в голову пока не пришло). Переместив курсор в панель оптимизированного кода в меню файл выберем пункт "печать". В окне "Field Selection" (выбор полей) снимем галочки со всего, кроме "Labels" (метки) и "Instructions" (инструкции) и зададим печать в файл или буфер обмена.
Тем временем, подготовим ассемблерный листинг нашей программы, задав в командной строке компилятора ключ "/FA" (в других компиляторах этот ключ, разумеется, может быть и иным). В результате мы станем обладателями файла pswd.asm, который даже можно откомпилировать ("ml /c /coff pswd.asm"), слинковать ("link /SUBSYSTEM:CONSLE pswd.obj LIBC.LIB") и запустить. Но что за черт! Мы получаем скорость всего ~65 миллионов паролей в секунду против 83 миллионов, которые получаются обычным путем. Оказывается, коварный MS VC просто не вставляет директивы выравнивания в ассемблерный текст! Это затрудняет оценку производительности качества оптимизации кода профилировщиков VTune. Ну да ладно, возьмем за основу данные 65 миллионов и посмотрим насколько VTune сможет улучшить этот результат.
Открываем файл, созданный профилировщиком и… еще одна проблема! Его синтаксис совершенно не совместим с синтаксисом популярных трансляторов ассемблера!
Label Instructions
gen_pswd sub esp, 08h
js gen_pswd+36 (1:86)
gen_pswd+28 mov esi, DWORD PTR [eax+ebp]
Листинг 21 Фрагмент ассемблерного файла, сгенерированного VTune
Во-первых, после меток не стоит знак двоеточия, во-вторых, в метках встречается запрещенный знак "плюс", в третьих, условные переходы содержат лишний адрес, заключенный в скобки на конце.
Словом нам предстоит много ручной работы, после которой "вычищенный" фрагмент программы будет выглядеть так:
Label Instructions
gen_pswd: sub esp, 08h
js gen_pswd+_36 (1:86)
gen_pswd+_28 mov esi, DWORD PTR [eax+ebp]
Листинг 22 Исправленный фрагмент сгенерированного VTune файла стал пригоден к трансляции ассемблером TASM или MASM
Остается заключить его в следующую "обвязку" и оттранслировать ассемблером TASM или MASM – это уже как вам по вкусу:
.386
.model FLAT
PUBLIC _gen_pswd
EXTERN _DeCrypt:PROC
EXTRN _printf:NEAR
EXTRN _malloc:NEAR
_DATA SEGMENT
my_string DB 'CRC %8X: try to decrypt: "%s"', 0aH, 00H
_DATA ENDS
_TEXT SEGMENT
_gen_pswd PROC NEAS
// код функции gen_pswd
_gen_pswd ENDP
_TEXT ENDS
END
Листинг 23 "Обвязка" ассемблерного файла в которую необходимо поместить оптимизированный код функции _gen_pswd для его последующей трансляции
А в самой программе pswd.c функцию gen_pswd объявить как внешнюю. Это можно сделать например так:
extern int _gen_pswd(char *crypteddata,
char *pswd, int max_iter, int validCRC);
Листинг 24 Объявление внешней функции gen_pswd в Си-программе
Теперь можно собирать наш проект воедино:
ml /c /coff gen_pswd.asm
cl /Ox pswd.c /link gen_pswd.obj
Листинг 25 Финальная сборка проекта pswd
Прогон оптимизированной программы показывает, что она выдает ~78 миллионов паролей в секунду, что на ~20% чем было до оптимизации. Что ж! Профилировщик VTune весьма не хило оптимизирует код! Тем не менее, полученный результат все же не дотягивает до скорости, достигнутой на предыдущем шаге.Конечно, камень преткновения не в профилировщике, а в компиляторе, но разве от этого нам легче?
Впрочем, на оптимизацию собственных ассемблерных программ эта проблема никак не отражается.
девятый. VTune – ваш персональный тренер
А теперь мы обратимся к наименее известному средству профилировщика VTune – Инструктору (в оригинале Coach так же переводимое как "тренер" или "учитель").Фактически инструктор – это ни что иное как высококлассный интерактивный оптимизатор, поддерживающий целый ряд языков: C, C++, FORTRAN и Java. Он анализирует исходный текст программы на предмет поиска "слабых" мест, а обнаружив такие – дает подробные рекомендации по их устранению.
Разумеется, интеллектуальность Инструктора не идет ни в какое сравнение с сообразительностью живого программиста и вообще, как мы увидим в дальнейшем, Инструктор скорее туп, чем умен… все-таки рассмотреть его поближе будет небесполезно.
Несмотря на то, что Инструктор в первую очередь ориентирована на программистов-новичков (на что указывает полностью "разжеванный" стиль подсказок), и для профессионалов он под час оказывается не лишним, особенно когда приходится оптимизировать чужой код, в котором лень досконально разбираться.
Плохая новость (впрочем, ее и следовало ожидать)– при отсутствии отладочной информации в профилируемой программе инструктор не может работать с исходным текстом и опускается на уровень чистого ассемблера (см. "Шаг десятый"). Тем не менее, это обстоятельство не доставляет непреодолимых неудобств, т.к. текст программы именно анализируется, но не профилируется. Поэтому, пусть вас не смущает, что включение в исполняемый файл отладочной информации приводит к автоматическому "вырубанию" всех оптимизирующих опций компилятора. Инструктор, работая с исходным текстом программы, вообще не будет касаться скомпилированного машинного кода!
Итак, перекомпилируем нашу демонстрационный пример, добавив ключ "/Zi" в командную строку компилятора и ключ "/DEBIG" – в командную строку линкера. Загрузим полученный файл в VTune и, дождавшись появления диаграммы "Hot Spots", дважды щелкнем мышкой по самому высокому прямоугольнику, соответствующему, как мы уже знаем, функции gen_pswd, в которой программа проводит большую часть своего времени.
Loop unrolling
Examples: C, Fortran, Java*
The loop contains instructions that do not allow efficient instruction scheduling and pairing. The instructions are few or have dependencies that provide little scope for the compiler to schedule them in such a manner as to make optimal use of the processor's multiple pipelines. As a result, extra clock cycles are needed to execute these instructions.
Advice
Unroll the loop as suggested by the coach. Create a loop that contains more instructions, but is executed fewer times. If the unrolling factor suggested by the coach is not appropriate, use an unrolling factor that is more appropriate.
To unroll the loop, do the following:
– Replicate the body of the loop the recommended number of times.
– Adjust the index expressions to reference successive array elements.
–Adjust the loop control statements.
Result:
– Increases the number of machine instructions generated inside the loop.
– Provides more scope for the compiler to reorder and schedule instructions so that they pair and execute simultaneously in the processor's pipelines.
– Executes the loop fewer times.
Caution:
Be aware that increasing the number of instructions within the loop also increases the register pressure.
В переводе на русский язык все вышесказанное будет звучать приблизительно таким образом:
Разворачивание цикла:
Данный цикл содержит инструкции, которые не могут быть эффективно спланированы и распараллелены процессором, поскольку они малочисленны [то бишь кворума мы здесь не наберем – КК] или содержат зависимости, что сужает возможности компилятора в их группировке для достижения наиболее оптимального использования конвейеров процессора. В результате, на выполнение этих инструкций расходуется значительно большее количество циклов.
Совет:
Развертите цикл, согласно советам "Учителя". Создайте цикл, что содержит больше инструкций, но исполняется меньшее количество раз.
Если степень развертки, рекомендуемая "Учителем", кажется вам неподходящей, используйте более подходящую степень.
Для развертки цикла сделайте следующее:
– Продублируйте тело цикла соответствующее количество раз;
– Скорректируйте ссылки на продублированные элементы массива;
– Скорректируйте условие цикла.
Результат:
– Увеличивается количество машинных инструкций внутри цикла;
– Появляется место, где "развернуться" компилятору для переупорядочивания и планирования потока инструкций так, чтобы они спаривались и выполнялись параллельно в конвейерах процессора.
– Выполнение цикла занимает меньшее время.
Предостережение:
Знайте, что увеличение количества инструкций в теле цикла влечет за собой увеличение "регистрового давления".
Согласитесь, весьма исчерпывающее руководство по развертке циклов! Причем, если вам все равно не понятно как именно разворачиваются циклы, можно кликнуть по ссылке "Examples" (примеры) и увидеть конкретный пример "продразверстки" на Си, Java или Fortran. Давайте выберем "Си" и посмотрим, что нам еще посоветует VTune:
Original Code Optimized Code
for(i=0; i
a[i] = c[i] ;
a[i+1] = c[i+1];
a[i+2] = c[i+2];
}
for(i;i < n; i++)
a[i] = c[i];
Тем не менее, мы этот цикл разворачивать не будем и пойдем дальше. Совет номер два вновь рекомендует развернуть тот же самый цикл, но уже находящийся внутри цикла while. Поскольку, этот цикл получает управление лишь при удлинении перебираемого пароля на один символ (что происходит прямо-таки скажем не часто) он, как и предыдущий, не оказывает практически никакого влияния на производительность, а потому рекомендацию по его развороту мы отправим в /dev/null.
Совет номер три придирается к с виду безобидной конструкции p++, увеличивающий переменную p на единицу:
114 p++;
115 if (!pswd[p])
116 {
117 pswd[p]='!';
118 pswd[p+1]=0;
119 length++;
120 x = -1;
121 for (b = 0; b <= length; b++)
122 x += *(int *)((int)pswd + b);
The loop whose index is incremented at line 114 should be interchanged with the loop whose index is incremented at line 121, for more efficient memory access
"Для достижения более эффективного доступа [к памяти] цикл, чей индекс увеличивается в строке 114, должен быть заменен циклом, чей индекс увеличивается в строке 121". ?! Инструктор судя по всему или пьян или от перегрева процессора спятил. Это вообще разные циклы. И индексы у них разные. И вообще они не имеют к друг другу никакого отношения, причем цикл, расположенный в строке 121, исполняется редко, так что совсем не понятно, что это VTune к нему так пристал?!
Может быть, дополнительная информация от Инструктора все разъяснит? Дважды щелкаем по строке 114 и читаем:
Loop interchange:
Loops with index variables referencing a multi-dimensional array are nested. The order in which the index variables are incremented causes out-of-sequence array referencing, resulting in many data cache misses. This increases the loop execution time.
Advice:
Do the following:
– Change the sequence of the array dimensions in the array declaration.
– Interchange the loop control statements.
Result:
The order in which the array elements are referenced is more sequential. Fewer data cache misses occur, significantly reducing the loop execution time.
Перестановка циклов:
Здесь наблюдается вложенные циклы с индексными переменными, обращающимися к многомерным массивам, Порядок, в котором увеличиваются индексные переменные, приводит к несвоевременному обращению к массивам, и как следствие этого – множественным кэш-промахам.
В результате увеличивается время выполнения цикла.
Совет:
Сделайте следующее:
– Измените последовательность измерений массивов в их объявлении;
– Поменяйте местами "измерения" управление цикла
[подразумевается: сделайте либо то, либо это, но ни в коем случае ни то и другое вместе – иначе "минус на минус даст плюс" и вы получите тот же самый результат – КК]
Результат:
Порядок, в котором обрабатываются элементы массива станет более последовательным. Меньше кэш-промахов будет происходить, от чего время выполнения цикла значительно сократиться.
Какие многомерные массивы? Какие кэш-промахи? Здесь у нас и близко нет ни того, ни другого! Судя по всему мы столкнулись с грубой ошибкой Инструктора (шаблонный поиск дает о себе знать!) но все же не поленимся, а заглянем в предлагаемый Инструктором пример, памятуя о том, что всегда в первую очередь следует искать ошибку у себя, а не у окружающих. Быть может, это мы чего-то недопонимаем…
|
Original Code |
Optimized Code |
|
|
int b[200][120]; void xmpl17(int *a) { int i, j; for (i = 0; i < 120; i++) for (j = 0; j < 200; j++) b[j][i]=b[j][i]+a[2*j]; } |
int b[200][120]; void ympl17(int *a) { int i, j; int atemp; for (j = 0; j < 200; j++) for (i = 0; i < 120;i++) b[j][i]=b[j][i]+a[2*j]; } |
Ну вот, все правильно. Приводимый VTune фрагмент кода наглядно демонстрирует, что к двухмерные массивы лучше обрабатывать по строкам, а не столбцам (см. "Часть II. Кэш"). Но ведь у нас нет двухмерных массивов, а – стало быть – и слушаться Инструктора в данном случае не надо.
Совет номер четыре и слова этот несчастный цикл подсчета контрольной суммы. Ну понравился от Инструктору – что поделаешь! Что же ему не понравилось на этот раз? Читаем…
121 for (b = 0; b <= length; b++)
122 x += *(int *)((int)pswd + b);
123 pswd[p]=' ';
124 y = 0;
125 }
126 } // end while(pswd)
Use the Intel C/C++ Compiler vectorizer to automatically generate highly optimized SIMD code. The statement on line 122 and others like it will be vectorized if the following program changes are made (double-click on any line for more information):
==> Simplify the pointer expression to indicate contiguous array accesses.
==> Restructure the loop to isolate the statement or construct that interferes with vectorization.
==> Try loop interchanging to obtain vector code in the innermost loop.
==> Simplify the pointer expression to indicate contiguous array accesses.
Используйте векторизатор компилятора Intel С/C++ для автоматической генерации высоко оптимизированного SIMD-кода. Оператор, находящийся в линии 122 и остальные подобные ему операторы, будут векторизованы при условии следующих изменений программы:
==> Упростите выражение указателя для индикации смежных доступов к массиву;
==> Реструктурируйте цикл для отделения выражения или логической конструкции, препятствующей векторизации;
==> Попытайтесь перестроить цикл для получения векторного кода во вложенном цикле;
==> Упростите выражение указателя для индикации смежных доступов к массиву;
Хорошие, однако, советы! А рекомендация упростить и без того примитивную форму адресации повторяется аж два раза! И это при том, что векторизовать данный цикл все равно не получится даже на Intel C/C++, а уж про все остальные компиляторы я и вовсе промолчу.
Тем не менее, все-таки заглянем в помощь – может быть, что-нибудь интересное скажут!
Intel C++ Compiler Vectorizer
The coach has identified an assignment or expression that is a candidate for SIMD technology code generation using Intel C++ Compiler vectorizer.
Advice
Use the Intel C++ Compiler vectorizer to automatically generate highly optimized SIMD code wherever appropriate in your application.
Use the following syntax to invoke the vectorizer from the command line: prompt> icl -O2 -QxW myprog.cpp.
The -QxW command enables vectorization of source code and provides access to other vectorization-related options.
Result
The Intel C++ Compiler vectorizer optimizes your application by processing data in parallel, using the Streaming SIMD Extensions of the Intel processors. Since the Streaming SIMD Extensions that the class library implements access and operate on 2, 4, 8, or 16 array elements at one time, the program executes much faster.
Векторизатор компилятора Intel C++
Инструктор идентифицирован присвоение или выражение, являющееся кандидатом для генерации кода по SIMD-технологии, используемой векторизатором компилятора Intel C++.
Совет:
Используйте векторизатор компилятора Intel C++ для автоматической генерации высоко оптимизированного SIMD-кода, подходящего к вашему приложению. Используйте следующий синтаксис для вызова векторизатора из командной строки: icl –O2 QxW myprog.cpp.
Ключ "-QxW" разрешает векторизацию исходного кода и предоставляет доступ к остальным векторным опциям.
Результат:
Векторизатор компилятора Intel C++ оптимизирует ваше приложение путем парализации обработки данных, с использованием поточного SIMD-расширения команд процессоров Intel. С тех пор как потоковые SIMD расширения библиотеки классов осуществляют доступ и обработку 2, 4, 8 или 16 элементов массива за один раз, скорость выполнения программы весьма значительно возрастает.
Бесспорно, векторизация – полезная штука, действительно позволяющая многократно увеличить скорость работы программы, но ее широкому внедрению в массы препятствует по меньшей мере два минуса: во-первых, подавляющее большинство x86-компилятор не умеют векторизовать код, а переход на компилятор Intel не всегда приемлем. Во-вторых, векторизация будет по настоящему эффективна лишь в том случае, если программа изначально заточена под эту технологию. И хотя в мире "больших" машин векторизация кода известна уже давно, для x86-программистов это еще тот конек!
Совет номер пять или еще один просчет Инструктора. Так, посмотрим, что за перл выдал Инструктор на этот раз.
91 if (x==validCRC)
92 {
93 // копируем шифроданные во временный буфер
94 buff = (char *) malloc(strlen(crypteddata));
95 strcpy(buff, crypteddata);
96
97 // расшифровываем
98 DeCrypt(pswd, buff);
99
The argument list for the function call to _malloc on line 94 appears to be loop-invariant. If there are no conflicts with other variables in the loop, and if the function has no side effects and no external dependencies, move the call out of the loop.
(Список аргументов функции malloc, находящейся в строке 94, вероятно, инвариантен относительно цикла. Если это не вызовет конфликта с остальными переменными цикла, и если не имеет посторонних эффектов и внешних зависимостей, вынесите ее за пределы цикла).
Вообще-то, формально Инструктор прав. Вынос инвариантных функций из тела цикла – хороший тон программирования, поскольку, находясь в теле цикла, функция вызывается множество раз, но, в силу своей независимости от параметров цикла, при каждом вызове дает один и тот же результат. Действительно, не проще ли единожды выделив память при входе в функцию, просто сохранить возращенный malloc указатель в специальной переменной, а затем использовать его по мере необходимости?
Возражения: ну и что мы в результате этого получим? Данная ветка вызывается лишь при совпадении контрольной суммы текущего перебираемого пароля с эталонной контрольной суммой, что происходит крайне редко – в лучшем случае несколько раз за все время выполнения программы.
Возражение номер два: перефразируя известный анекдот "я девушку кормил-поил, я ее и танцевать буду" можно сказать "та ветвь программы, которая выделила блок памяти, сама же его и освобождает, конечно, если это не приводит к неоправданному снижению производительности".
Таким образом, ничего за пределы цикла мы выносить не будем, что бы там нам не советовал Инструктор.
Совет номер шесть. Данный совет практически полностью повторяет предыдущий, однако, на этот раз, Инструктор посоветовал вынести за пределы цикла функцию De Crypt. Да, да! Счел ее инвариантом и посоветовал вынести куда подальше и это не смотря на то, что: а) код самой функции в принципе был в его распоряжении ("в принципе" потому, что мы приказали Инструктору анализировать только gen_pswd). б) функции De Crypt
передается указатель pswd, который явным образом изменяется в цикле! А раз так, то инвариантом De Crypt
быть ну никак не может! И как только Инструктору не стыдно давать такие советы? Или все-таки стыдно – а вы думали почему он красный такой?
Совет номер семь. Сейчас Инструктор обращает наше внимание, на то, что: "The value returned by De Crypt() on line 98 is not used…" ("Значение, возвращаемое функцией De Crypt, расположенной в строке 98, не используется..") и дает следующий совет "If the return value is being ignored, write an alternate version of the function which returns void" ("Если возвращенное значение игнорируется, создайте альтернативную версию данной функции, возвращающей значение void").
В основе данного совета лежит допущение Инспектора, что функция, не возвращающая никакого значения, будет работать быстрее функции такое значение возвращающей. На самом деле это более, чем спорно. Во-первых, возврат значения занимает не так уж много времени, во-вторых, большинство компиляторов при выходе из void
функций все равно возвращают "ноль", а вовсе ни "ничто". В-третьих, создание двух экземпляров одной функции обойдется много дороже, чем накладные расходы на возврат никому не нужного значения.
Так что игнорируем этот совет и идем дальше.
Совет номер восемь. Теперь Инспектор принял за инвариант функцию printf, распечатывающую содержимое буфера buff, только что возвращенного функцией De Crypt.
Мм… не ужели разработчикам VTune было трудно заложить в башку Инспектора смысловое значение хотя бы основных библиотечных функций? Функция printf
не зависимо от того является ли она инвариантом или нет, никогда не может быть вынесена за пределы цикла! И вряд ли стоит объяснять почему.
Совет номер девять. …Значение, возвращаемое функций printf не используется, поэтому…
Что ж! Результатами такого инструктажа трудно остаться удовлетворенным. Из девяти советов мы не воспользовались ни одним, поскольку это все равно бы не увеличило скорость выполнения программы. Тем не менее, Инструктора не стоит считать совсем уж никчемным чукчей. Во всяком случае он рассказывает о действительно интересных и эффективных приемах оптимизации, не все из которых известны новичкам.
Возможно мне возразят, что такая непроходимая тупость Инструктора объясняется тем, что мы подсунули ему уже до предела оптимизированную программу и ему ничего не осталось, как придираться к второстепенным мелочам. Хорошо, давайте напустим Инструктора на самый первый вариант программы, заставив его проанализировать весь код целиком. Он сделает нам 33 замечания, из которых полезных по прежнему не окажется ни одного!
первый. Удаление printf
Конечно, полностью отказываться от вывода текущего состояния программы – глупо (пользователю ведь интересно знать сколько паролей уже перебрано, и потом надо ведь как-то контролировать машину – не зависла ли?), но можно ведь отображать не каждый перебираемый пароль, а скажем, каждый шестисотый, а еще лучше – каждый шеститысячный. При этом, накладные расходы на вызов printf значительно упадут, а то и вовсе приблизятся к нулю.Давайте перепишем фрагмент, ответственный за вывод текущего состояния следующим образом:
static int x=0;
// вывод текущего состояния на терминал
if (++x>6666)
{
x = 0;
printf("Current pswd : %10s [%d%%]\r",&pswd[0],progress);
}
Листинг 12 Сокращение количества вызова функции printf
Батюшки мои! После перекомпиляции мы получаем скорость перебора свыше полутора миллионов паролей в секунду! То есть скорость программы возросла более, чем в пять раз! Программа выполняется так быстро, что "чувствительности" функции clock уже оказывается недостаточно для измерений и количество итераций приходится увеличивать раз в сто! И это, как мы убедимся в самом непосредственном будущем, еще отнюдь не предел быстродействия!
пятый. Удаление операции деления
Теперь на первое место вырывается функция gen_pswd, в которой процессор проводит более половины всего времени исполнения программы.За gen_pswd
с большим отрывом следует CalculateCRC – ~21% и Check CRC
– ~15%. Причем, ~40% от общего времени исполнения функции gen_pswd
сосредоточено в одной - единственной горячей точке. Непорядок! Надо оптимизировать!
Двойной щелчок по высоченному прямоугольнику приводит нас к… инструкции IDIV, выполняющий целочисленное деление. Постой, постой, а где у нас в gen_pswd
целочисленное деление? А вот, уже нашли!
do_pswd(crypteddata, pswd, validCRC, 100*a/max_iter);
Здесь мы вычисляем процент проделанной работы. Забавно, но на это уходит приблизительно столько же времени, сколько и на саму работу! А раз так – ну все эти "градусники" к черту! Удаляем команду деления, подставляя вместо значения прогресса "0" или любое другое понравившееся вам число.
Перекомпилируем, и…. четырнадцать с половиной миллионов паролей в секунду!
седьмой. Объединение функций
На этот раз самой горячей точкой становится сохранение регистра ESI где-то глубоко внутри функции CalculateCRC. Это компилятор "заботливо" бережет его содержимое от посторонних модификаций. Несмотря на то, что количество используемых переменных в программе довольно невелико и в принципе обращений к памяти можно было бы и избежать, разместив все переменные в регистра, компилятор не в состоянии этого сделать, т.к. оптимизирует каждую функцию по отдельности.Так давайте же, плюнув на структурность, объединим все наиболее интенсивно используемые функции (gen_pswd, do_paswd, Check CRC и Calculate CRC) в одну "супер-функцию".
Ее реализация может выглядеть например так:
int gen_pswd(char *crypteddata, char *pswd, int max_iter, int validCRC)
{
int a, b, x;
int p = 0;
char *buff;
int length = strlen(pswd);
for(a = 0; a < max_iter; a++)
{
x = -1; for (b = 0; b <= length; b++) x += *(int *)((int)pswd + b);
if (x==validCRC)
{
buff = (char *) malloc(strlen(crypteddata));
strcpy(buff, crypteddata); DeCrypt(pswd, buff);
printf("CRC %8X: try to decrypt: \"%s\"\n", validCRC,buff);
}
while((++pswd[p])>'z')
{
pswd[p] = '!'; p++; if (!pswd[p])
{
pswd[p]=' '; pswd[p+1]=0;length++;
}
}; p = 0;
}
return 0;
}
Листинг 18 Объединение функций gen_pswd, do_paswd, Check CRC и Calculate CRC
в одну супер функцию
Компилируем, запускаем… ой! прямо не верим своим глазам – тридцать пять миллионов паролей в секунду! А ведь казалось, что резерв быстродействия уже исчерпан. Ну и кто теперь скажет, что Pentium – медленный процессор? Генерация очередного пароля, вычисление и проверка его контрольной суммы укладывается в каких-то двадцать тактов…
Двадцать тактов?! Хм! Тут еще есть над чем поработать!
шестой. Удаление мониторинга производительности
Несмотря на прямо-таки гигантскую скорость перебора, функция gen_pswd все еще оттягивает на себя ~22% времени исполнения программы, что не есть хорошо.Двойной щелчок по ней показывает, что среди более или менее ровного ряда практически одинаковых по высоте диаграмм, возвышается всего лишь один красный прямоугольник. А ну-ка посмотрим что там!
Дизассемблирование позволяет установить, что за этой горячей точкой скрывается уже знакомая нам конструкция:
if (++x>66666)
{
x = 0;
printf("Current pswd : %10s [%d%%]\r",&pswd[0],progress);
}
Что ж! Во имя Ее Величества Производительности, мы решаемся полностью отказаться от мониторинга текущего состояния и выбрасываем эти шесть строк напрочь.
В результате скорость перебора повышается еще на пять миллионов паролей в секунду. Хм, вообще-то не такая уж и большая прибавка, так что не факт, что такую оптимизацию следовало выполнять…
третий. Выравнивание данных
Тем не менее профилировка показывает, что количество горячих точек не только сократилось, но даже возросло на одну! Почему? Так дело в том, что алгоритм подсчета горячих точек учитывает не абсолютное, а относительное быстродействие различных частей программы по отношению друг к другу. И по мере удаления самых больших пиков, на диаграмме появится более мелка "рябь".Несмотря на оптимизацию, функция CalculateCRC, по прежнему идет "впереди планеты всей", отхватывая более 50% всего времени исполнения программы. Но теперь самой горячей точной становится пара команд:
mov edi, DWORD PTR [eax+esi]
add edx, edi
Хм! Что же в них такого особенного? Ну да, тут налицо обращение к памяти (x += *(int *)((int)pswd + a)), но ведь тестируемый пароль по идее должен находится в кэше первого уровня, доступ к которому занимает один такт. Может быть, кто-то вытеснил эти данные из кэша? Или произошел какой-нибудь конфликт? Попробуй тут разберись! Можно бесконечно ломать голову, поскольку причина вовсе не в этом коде, а совсем в другой ветке программы…
Вот тут самое время прибегнуть к одному из мощнейших средств VTune – динамическому анализу, позволяющему не только определить куда уходят такты, но и выяснить причины этого. Причем, динамический анализ выполняется отнюдь не на "живом" процессоре, а… его программном эмуляторе. Это здорово экономит ваши финансы! Для оптимизации вовсе не обязательно приобретать всю линейку процессоров – от Intel 486 до Pentium-4, – достаточно приобрести один VTune, и можете запросто оптимизировать свои программы под Pentium-4, имея в наличии всего лишь Pentium-II или Pentium-III.
Перед началом динамического анализа, вам требуется указать какую именно часть программы вы хотите профилировать. В частности, можно анализировать как одну "горячую" точку функции Calculate CRC, так и всю функцию целиком. Поскольку, наша подопечная функция содержит множество "горячих" точек, выберем последний вариант.
Прокручивая экран вверх, переместим курсор в строку с меткой "Calculate CRC" (метки отображаются в второй слева колонке экрана). Если же такой строки не окажется, найдем на панели инструментов кнопку, с голубым треугольником, направленным вверх (Scroll to Previous Portal) и нажмем ее. Теперь установим точку входа (Dynamic Analyses Entry Pont) которая задается кнопкой с желтой стрелкой, направленной вправо. Аналогичным образом задается и точка выхода (Dynamic Analyses Exit Pont) – прокручивая экран вниз, добираемся до последней строки Calculate CRC (она состоит всего из одной команды – ret) и, пометив ее курсором, нажимаем кнопку с желтой стрелкой, направленной налево. Теперь – "Run\Dynamic Analysis Session". В появившимся диалоговом окне выбираем эмулируемую модель процессора (в нашем случае – P-III) на нажимаем "Start". Поехали!
Профилировщик вновь запустит программу и, погоняя ее минуту-другую, выдаст приблизительно следующее окно (см. рис. 0x004).

Рисунок 9 0х004 Динамический анализ программы не только определяет температуру каждой машинной инструкции, но и объясняет причины ее "нагрева"
Ага! Вот она наша горячая точка (на рисунке она отмечена курсором). Двойной щелчок мыши вызывает информационный диалог, подробно описывающий проблему :
Decoder Minimum Clocks = 0, ; // Минимальное время декодирования 0 тактов
Decoder Average Clocks = 0.7 ; // Среднее время декодирования 0.7 тактов
Decoder Maximum Clocks = 14 ; // Максимальное время декодирования 14 тактов
Retirement Minimum Clocks = 0, ; // Минимальное время завершения 0 тактов
Retirement Average Clocks = 6.9 ; // Среднее время завершения 6.9 тактов
Retirement Maximum Clocks = 104 ; // Максимальное время завершения 104 такта
Total Cycles = 20117 (35,88%) ; // Полное время исполнения 20.117 тактов (35,88%)
Micro-Ops for this instruction = 1 ; // Инструкция декодируется в одну микрооперацию
// Инструкция ждала (0, 0.1, 2) цикла пока ее операнды не были готовы
The instruction had to wait (0,0.1,2) cycles for it's sources to be ready
Warnings: 3*decode_slow:0 ; // Конфликтов декодеров – нет
Dynamic Penalty: DC_rd_miss
The operand of this load instruction was not in the data cache. The instruction stalls while the processor loads the specified address location from the L2 cache or main memory.
(Операнд этой инструкции отсутствовал в кэше данных. Инструкция ожидала пока процессор загрузит соответствующие данные из кэша второго уровня или основной памяти).
Occurrences = 1 ; // Случалось один раз
Dynamic Penalty: DC_misalign
The instruction stalls because it accessed data that was split across two data-cache lines.
(Инструкция простаивала, потому что она обращалась к данным "расщепленным" через две кэш-линии)
Occurrences = 2000 ; // Случалось 2000 раз
Dynamic Penalty: L2data_rd_miss
The operand of this load instruction was not in the L2 cache. The instruction stalls while the processor loads the specified address location from main memory.
(Операнд этой инструкции отсутствовал в кэше второго уровня. Инструкция ожидала пока процессор загрузит соответствующие данные из основной памяти).
Occurrences = 1 ; // Случалось один раз
Dynamic Penalty: No_BTB_info
The BTB does not contain information about this branch. The branch was predicted using the static branch prediction algorithm.
(BTB – Branch Target Buffer – буфер ветвлений не содержал информацию об этом ветвлении.
Ветка была предсказана статическим алгоритмов предсказаний).
Occurrences = 1 ; // Случалось один раз
Какая богатая кладезь информации! Оказывается, кэш тут действительно не причем (кэш-промах произошел всего один раз), а основной виновник – доступ к не выровненным данным, который имел место аж 2000 раз, – именно столько, сколько и прогонялась программа. Т.е. такое происшествие случалось на каждой итерации цикла – отсюда и тормоза.
Смотрим, – где в программе инициализируется указатель pswd? Ага, вот фрагмент кода из тела функции main (надеюсь, теперь вам понятно, почему статически анализ функции Calculate CRC был неспособен что-либо дать?):
pswd = (char *) malloc(512*1024);
pswd+=62;
Листинг 15 Выравнивание парольного буфера для предотвращения штрафных санкций со стороны процессора
Убираем строку "pswd += 62"
и перекомпилируем программу. Четыре с половиной миллиона паролей в секунду! Держи тигра за хвост!
восьмой. Сокращения операций обращение к памяти
Основная масса горячих точек теперь, как показывает профилировка, сосредоточена в цикле подсчета контрольной суммы пароля – на него приходится свыше 80% всего времени исполнения программы, из них 50% "съедает" условный переход, замыкающий цикл (Pentium-процессоры страсть как не любят коротких циклов и условных переходов), а остальные 50% расходуются на обращение к кэш-памяти. Тут уместно сделать небольшое пояснение.Расхожее мнение утверждает, что чтение нерасщепленных данных, находящихся в кэш-памяти занимает всего один такт, – ровно столько же, сколько и чтение содержимого регистра. Это действительно так, но при более пристальном изучении проблемы выясняется, что "одна ячейка за такт", это пропускная способность кэш памяти, а полное время загрузки данных с учетом латентности составляет как минимум три такта. При чтении зависимых данных из кэша (как в нашем случае) полное время доступа к ячейке определяется не пропускной способностью, а его латентностью. К тому, на процессорах семейства P6 установлено всего лишь одно устройство чтения данных и поэтому, даже при благоприятном стечении обстоятельств они могут загружать всего лишь одну ячейку за такт. Напротив, на данные, хранящиеся в регистрах, это ограничение не распространяется.
Таким образом, для увеличения производительности мы должны избавиться от цикла и до минимума сократить количество обращений к памяти. К сожалению, мы не можем эффективно развернуть цикл, поскольку нам заранее неизвестно количество его итераций. Аналогичная ситуация складывается и с переменными: программируя на ассемблере, мы запросто смогли бы разместить парольный буфер в регистрах общего назначения (благо 16-символьный пароль – самый длинный пароль – который реально найти перебором – размешается всего в четырех регистрах, а в остающиеся три регистра вмещаются все остальные переменные). Но для прикладных программистов, владеющих одним лишь языком высокого уровня этот путь закрыт и им приходится искать другие решения.
И такие решения действительно есть! До сих пор мы увеличивали скорость выполнения программы за счет отказа от наиболее "тяжеловесных" операций, практически не меняя базовых вычислительных алгоритмов. Этот путь привел к колоссальному увеличению производительности, но сейчас он себя исчерпал и дальнейшая оптимизация возможна лишь на алгоритмическом
уровне.
В алгоритмической же оптимизации нет и не может быть универсальных советов и общих решений, – каждый случай должен рассматриваться индивидуально, в контексте своего окружения. Возвращаясь к нашим баранам, задумается: а так ли необходимо считать контрольные суммы каждого нового пароля? В силу слабости используемого алгоритма подсчета CRC, его можно заменить другим, – более быстродействующим, но эквивалентным алгоритмом.
Действительно, поскольку младший байт пароля суммируется всего лишь один раз, то при переходе к следующему паролю его контрольная сумма в большинстве случаев увеличивается ровно на единицу. "В большинстве" – потому, что при изменении второго и последующих байтов пароля, изменяемый байт уже суммируется как минимум дважды, к тому же постоянно попадает то в один, то в другой разряд. Это немного усложняет наш алгоритм, но все же не оставляет его далеко впереди "тупой" методики постоянного подсчета контрольной суммы, используемой ранее.
Словом, финальная реализация улучшенного переборщика паролей может выглядеть так:
int gen_pswd(char *crypteddata, char *pswd, int max_iter, int validCRC)
{
int a, b, x;
int p = 0;
char *buff;
int y=0;
int k;
int length = strlen(pswd);
int mask;
x = -1;
for (b = 0; b <= length; b++)
x += *(int *)((int)pswd + b);
for(a = 0; a < max_iter ; a++)
{
if (x==validCRC)
{
buff = (char *) malloc(strlen(crypteddata));
strcpy(buff, crypteddata); DeCrypt(pswd, buff);
printf("CRC %8X: try to decrypt: \"%s\"\n", validCRC,buff);
}
y = 1;
k = 'z'-'!';
while((++pswd[p])>'z')
{
pswd[p] = '!';
// следующий символ
y = y | y << 8;
x -= k;
k = k << 8;
k += ('z'-'!');
p++;
if (!pswd[p])
{
pswd[p]='!';
pswd[p+1]=0;
length++;
x = -1;
for (b = 0; b <= length; b++)
x += *(int *)((int)pswd + b);
y = 0;
pswd[p]=' ';
}
//printf("%x\n",y);
} // end while(pswd)
p = 0;
x+=y;
} // end for(a)
return 0;
}
Листинг 19 Алгоритмическая оптимизация алгоритма просчета CRC
Какой результат дала алгоритмическая оптимизация? Ни за что не догадаетесь –восемьдесят три миллиона паролей в секунду или ~1/10 пароля за такт. Фантастика!
И это при том, что программа написана на чистом Си! И ведь самое забавное, что хороший резерв производительности по-прежнему есть!
второй. Вынос strlen за тело цикла
Повторный запуск "обновленной" программы под профилировщиком показывает, что количество "горячих" точек в ней уменьшилось с 187 до 106. Конечно, это хорошо, но ведь горячие точки все еще есть! Кликнув в окне "View" расположенным в правом верхнем углу окна "Hot Spots" по радио – кнопке "Hotspots by function" (сортировать горячие точки по функциями), мы узнаем, что ~80% времени наша программа проводит в недрах функции Calculate CRC, затем с большим отрывом следует gen_pswd – ~12% и по ~3% делят функции Check CRK и do_pswd.Ну это никуда не годится! Какая-то там задрипанная Calculate CRC без зазрения совести поглощает практически все быстродействие программы! Эх, вот бы еще узнать, какая именно часть функции в наибольшей степени влияет на производительность… И VTune позволяет это сделать!
Дважды кликнем по красному прямоугольнику, чтобы увеличить его на весь экран. Оказывается, внутри функции Calculate CRC насчитывается 18 горячих точек, три их которых наиболее горячи – ~30%, ~25% и ~10% соответственно (см. рис 0x003). Вот с первой из них мы и начнем. Дважды кликнем по самому высокому из прямоугольников и… VTune обижено пискнув сообщит, что "No source found for offset 0x69 into F:\.OPTIMIZE\src\Profil\pswd.exe. Proceed with disassembly only?" ("Исходные тексты не найдены. Продолжать с отображением только дизассемблерного текста?"). Действительно, поскольку программа откомпилирована без отладочной информации, то VTune не может знать, какой байт ассемблерного когда, какой строке соответствует, а компилятор не соглашается предоставить эту информацию в силу того, что в оптимизированной программе соответствие между исходным текстом и сгенерированным машинным кодов в общем-то не столь однозначно.
Конечно, можно профилировать и не оптимизированную программу, – но… какой в этом резон? Ведь это будет другая программа и с другими
горячими точками! По любому, качественная оптимизация без знаний ассемблера невозможна, поэтому, прогнав все страхи прочь, смело нажмем кнопочку, "ОК", то есть "Да, мы соглашаемся работать без исходных текстов непосредственно с ассемблерным кодом".

Рисунок 8 0x003 Распределение температуры внутри функции Calculate CRC (снимок сделан с высоким разрешением)
VTune тут же тыкает нас носом в инструкцию REPNE SCANB. Не нужно быть провидцем, чтобы распознать в ней ядро функции strlen. Использовали ли мы strlen в исходном тексте программы? А то как же! Смотрим сюда:
int CalculateCRC(char *pswd)
{
int a;
int x = -1; // ошибка вычисления CRC
for (a = 0; a <= strlen(pswd); a++)
x += *(int *)((int)pswd + a);
return x;
}
Листинг 13 Вызов функции strlen в заголовке цикла привел к тому, что компилятор, не распознав в ней инварианта, не вынес ее из цикла, "благодаря" чему длина одной и той же строки стала подсчитываться на каждой итерации
Судя по всему бестолковый компилятор не вынес вызов strlen за тело цикла, хотя ее аргумент – переменная pswd не модифицировалась в цикле! Хорошо, если гора не идет к Магомету, пойдем навстречу компилятору и перепишем этот участок кода так:
int length;
length=strlen(pswd);
for (a = 0; a <= length; a++)
Листинг 14 Вынос функции strlen за пределы цикла
Перекомпилировав программу, мы с удовлетворением отметим, что теперь ее быстродействие возросло до трех с половиной миллионов паролей в секунду, т.е. практически в два с половиной раза больше, чем было в предыдущем случае.
Силки для клиента или 7 таинств мистерий
Бизнес честным не бывает. Понятия "справедливости" в нем не существует. Сумма денег (пренебрегая инфляцией) величина постоянная и единственный способ заработать – отнять у других.Конечная цель любого бизнесмена – заставить клиента расстаться с деньгами, а конкурента – с клиентами. Неприкрытый грабеж встречается достаточно редко (цивилизация мы или нет?), но хитрость и обман – сплошь и рядом.
Синхронная статическая память
Синхронная статическая память выполняет все операции одновременно с тактовыми сигналами, в результате чего время доступа к ячейке укладывается в один-единственный такт. Именно на синхронной статической памяти реализуется кэш первого уровня современных процессоров.Слияние циклов
Если два цикла имеют идентичные заголовки, их можно объединить в один общий цикл. Например, пусть исходный код программы выглядел так:for(b=0;b<10;b++)
x[b]=b;
for(b=0;b<10;b++)
y[b]=b;
Очевидно, что данный код можно без потери функциональности переписать так, увеличив производительность и компактность программы:
for(b=0;b<10;b++)
{
x[b]=b;
y[b]=b;
}
К сожалению, ни один из трех рассматриваемых компиляторов сливать циклы не способен.
Сложение и вычитание
Старшие модели микропроцессоров Intel Pentium могут выполнять до двух операций целочисленного сложения (вычитания) за каждый такт, – казалось бы, все оптимизировано по самые помидоры, но оказывается, что это далеко не предел! Инструкция LEA способна вычислять за один такт сумму двух регистров и одной константы, помещая результат в любой регистр, а не обязательно в один из операндов, как это делает команда ADD.Использование LEA позволяет выполнить следующий код (int c=a+b+0x666; int d=e+f+0x777)
всего за один такт! (Конечно, при условии, что a, b, с, d, e и f – регистровые переменные). Весь фокус в том, что "официально" инструкция LEA предназначена для вычисления эффективного смещения ячейки памяти и по логике применима только к ближним (near) указателям. Но, поскольку в силу архитектурных особенностей микропроцессоров серии Intel 80x86 представление ближних указателей эквивалентно их фактическому значению, результат, возвращенный инструкцией LEA, равен алгебраической сумме ее операндов, и потому она может быть использована вместо ADD!
Все три рассматриваемых компилятора – Microsoft Visual C++, Borland C++ и WATCOM "знают" об этом трюке и активно прибегают к нему при необходимости.
Смертельная схватка: Ассемблер vs. Компилятор
"Сохраняй за собой право думать, даже неправильно - это лучше, чем не думать совсем".приписывается Гиппатии
В статье приводится сравнительный анализ качества машинной кода и ручной ассемблерной оптимизации на примере широко распространенных компиляторов Microsoft Visual C++ 6.0, Borland C++ 5.5, WATCOM С++ 10.0
"Священные войны" вокруг компиляторов бушуют очень давно. Одни обожествляют машинную кодогенерацию, другие же стремятся все делать своими руками, порой реализуя программы на чистейшем ассемблере. Конечно, всякий имеет право на выбор, но этот выбор должен делаться осмысленно, а не вслепую. Между тем, каждая из сторон, распускает совершенно неправдоподобные слухи. Приверженцы компиляторов убеждают окружающих в том, что человек физически не способен учитывать все архитектурные особенности современных процессоров эта работа якобы по плечу одному лишь оптимизатору. Их противники в качестве контраргумента обычно приводят ассемблерную реализацию канонической программы "Hello, World!", – по объему раз в триста
меньшей "самого оптимального кода", сгенерированного компиляторами.
Вот и разберись: кому же верить, а кому нет? Такая неопределенность часто нервирует начинающих программистов, пытающихся разобраться: стоит ли изучать ассемблер или же это пустая трата времени?
Чтобы там ни говорили сторонники компиляторов, машинная оптимизация всегда будет проигрывать человеку, поскольку, действует по жесткому, заранее заложенному в нее шаблону, в то время как человек же способен на качественно новые решения. Касательно же невозможности учета архитектурных способностей современных процессоров, – тем, кто это утверждает, легко возразить: не стоит, право же, судить все человечество по себе. Оптимальное планирование потока команд вполне по силам программисту средней руки, конечно, при наличии соответствующей подготовки. К тому же, техника оптимизации процессоров последнего поколения стала значительно проще, чем была лет пять тому назад.
С другой стороны, ассемблер – это не волшебная лампа Паладина; он не способен творить чудеса. Во всяком случае, реализация полиномиального алгоритма на ассемблере еще никогда не превращала его в логарифмический. За исключением особо оговариваемых случаев речь может идти лишь о количественном, но отнюдь не качественном выигрыше (пример с "Hello, World!" – как раз и есть один из таких редких случаев, и ниже он будет рассмотрен во всех подробностях). К тому же, при неумелом обращении (или применении знаний, почерпнутых их книжек по оптимизации десятилетней давности) "ручная" оптимизация становится просто посмешищем компиляторов!
В общем, здесь есть, с чем разбираться…
Соглашения об условных обозначениях и наименованиях
Расшифровку всех непонятных терминов, если таковые встретятся вам по ходу чтения книги, можно узнать в глоссарии. Но, поскольку, в глоссарий по обыкновению все равно никто не заглядывает, ниже перечислены обозначения, которые с наибольшей степенью вероятности могут вызвать затруднение.1) Под P6-процессорами понимаются все процессоры с ядром P6, построенные по архитектуре Pentium Pro. К ним принадлежат: сам Pentium Pro, Pentium-II и Pentium-III, а так же процессоры семейства CELERON.
2) Процессоры серии Pentium здесь сокращаются до первой буквы "P" и стоящей за ней суффиксом, уточняющим какая именно модель имеется ввиду. Например, "P Pro" обозначает "Pentium Pro", а "P?4" – "Pentium?4". Кстати, обратите особое внимание, что индексы "II" и "III" записываются римскими цифрами, а "4" – арабскими. Так хочет фирма Intel (она уже однажды сделала мне замечание по этому поводу), поэтому не будем ей противоречить. В конце концов, хозяин – барин.
3) Под "MS VC" или даже просто "VC" подразумевается Microsoft Visual C++ 6.0, а под "BC" – Borland C++ 5.5. Соответственно, "WPP" обозначает "WATCOM C++ 10.0".
4) Кабалистическое выражение наподобие "P-III 733/133/100/I815EP" расшифровывается так: "процессор Intel Pentium-III с тактовой частой 773 MHz, частой системной шины 133 MHz и частой памяти 100 MHz, установленный в материнскую плату, базирующуюся на чипсете Intel 815 EP".
Соответственно, "AMD Athlon 1050/100/100/VIA KT 133" обозначает: "процессор AMD Athlon с тактовой частотой 1050 MHz, частотой системной шины 100 MHz и частотой работы памяти 100 MHz, уставленный в материнскую плату, базирующуюся на чипсете VIA KT 133".
Да, чуть не забыл сказать. "Сверхоперативная память" – это русский эквивалент американского термина "cache memory". Здесь он будет использоваться вовсе не из-за самостийной гордости, а просто для того, чтобы избежать излишней тавтологии (частого повторения одних и тех же слов).
Создание таблицы переходов
Если значения ветвей выбора представляют собой арифметическую прогрессию, то можно сформировать таблицу переходов – массив, проиндексированный case-значениями и содержащий указатели на соответствующие им case-обработчики. В этом случае, сколько бы оператор switch ни содержал ветвей, – один или миллион – он выполняется за одну итерацию. Красота!Создавать таблицы переходов умеют все три рассматриваемых компилятора, и следующий пример успешно оптимизирует каждый из них.
switch (a)
{
case 1 : …;
case 2 :…;
case 3 :…;
case 4 :…;
case 5 : …;
case 6 :…;
case 7 :…;
case 8 :…;
case 9 :…;
case 10 :…;
case 11 :…;
}
Однако WATCOM плохо справляется с переупорядоченной прогрессией и совсем не справляется с несколькими независимыми прогрессиями. Компиляторы Microsoft Visual C++ и Borland C++ успешно оптимизируют следующий пример, а WATCOM создаст обычное несбалансированное логическое дерево, в худшем случае выполняющееся за одиннадцать итераций, что на порядок хуже, чем у конкурентов.
switch (a)
{
case 11 : …;
case 2 : …;
case 13 : …;
case 4 : …;
case 15 : …;
case 6 : …;
case 17 : …;
case 8 : …;
case 19 : …;
case 10 : …;
case 21 : …;
}
Сравнительная характеристика основных типов памяти
С точки зрения пользователя PC главная характеристика памяти это – скоростьили, выражаясь другими словами, ее быстродействие. Казалось бы, что может быть проще, чем измерять быстродействие? Достаточно подсчитать количество информации, выдаваемой памятью в единицу времени (скажем, мегабайт в секунду), и… ничего не получится! Ведь, как мы уже знаем, время доступа к памяти непостоянно и, в зависимости от характера обращений, варьируется в очень широких пределах. Наибольшая скорость достигается при последовательном чтении, а наименьшая – при чтении в разброс. Но и это еще не все! Современные модули памяти имеют несколько независимых банков и потому позволяют обрабатывать несколько запросов параллельно.
Если запросы следуют друг за другом непрерывным потоком, непрерывно генерируются и ответы. Несмотря на то, что задержка между поступлением запроса и выдачей соответствующего ему ответа может быть весьма велика, в данном случае это не играет никакой роли, поскольку латентность (т.е. величина данной задержки) полностью маскируется конвейеризацией и производительность памяти определяется исключительно ее пропускной способностью. Можно провести следующую аналогию: пусть сборка одного отдельного взятого Мерседеса занимает ну, скажем, целый месяц. Однако если множество машин собирается параллельно, завод может выдавать хоть по сотне Мерседесов в день и его "пропускная способность" в большей степени определяется именно количеством сборочных линий, а не временем сборки каждой машины.
В настоящее время практически все производители оперативной памяти маркируют свою продукцию именно в пропускной способности, но наблюдающийся в последнее время стремительный рост пропускной способности (см. рис. graph 30), адекватного увеличения производительности приложений, как это ни странно, не вызывает. Почему?
Основной камень преткновения – фундаментальная проблема зависимости по данным (см. "Оптимизация работы с памятью: устранение зависимости по данным"). Рассмотрим следующую ситуацию.
Пусть ячейка N1 хранит указатель на ячейку N 2, содержащую обрабатываемые данные. До тех пор, пока мы не получим содержимое ячейки N 1, мы не сможем послать запрос на чтение ячейки N 2, поскольку, еще не знаем ее адреса. Следовательно, производительность памяти в данном случае будет определяться не пропускной способностью, а латентностью. Причем, не латентностью микросхемы памяти, а латентностью всей подсистемы памяти – кэш-контроллером, системной шиной, набором системной логики… Латентность всего это хозяйства очень велика и составляет порядка 20 тактов системной шины, что многократно превышает полное время доступа к ячейке оперативной памяти. Таким образом, при обработке зависимых данных быстродействие памяти вообще не играет никакой роли – и SDRAM PC100, и RDRAM-800 покажут практически идентичный результат!
Причем, описываемый случай отнюдь не является надуманным, скорее наоборот – это типичная ситуация. Основные структуры данных (такие как деревья и списки) имеют ярко выраженную зависимость по данным, поскольку объединяют свои элементы именно посредством указателей, что "съедает" весь выигрыш от быстродействия микросхем памяти.
Таким образом, теоретическая пропускная способность памяти, заявленная производителями, совсем ничего не говорит о ее реальной производительности.
|
Тип памяти |
Рабочая частота, MHz |
Разрядность, бит |
Время доступа, нс. |
Время рабочего цикла, нс. |
Пропускная способность, Мбайт/c |
|
FPM |
25, 33 |
32 |
70, 60 |
40, 35 |
100, 132 |
|
EDO |
40, 50 |
32 |
60, 50 |
25, 20 |
160, 200 |
|
SDRAM |
66, 100, 133 |
64 |
40, 30 |
10, 7.5 |
528, 800, 1064 |
|
DDR |
100, 133 |
64 |
30, 22.5 |
5, 3.75 |
1600, 2100 |
|
RDRAM |
400, 600, 800 |
16 |
,,30 |
,,2.5 |
1600, 2400, 3200 |

Рисунок graph 30 Максимально достижимая пропускная способность основных типов памяти
Сравнительная характеристика профилировщиков
1) профилируемые приложения2) эмулируемые процессоры
3) разрешающая способность
4) диаграм левел
Сравнительный анализ оптимизирующих компиляторов языка Си\Си++
Количество Си/Си++ компиляторов огромно – какой же из них выбрать? Точнее, на какой критерий (критерии) следует обращать внимание при выборе компилятора?Стоимость? Отнюдь, – для профессиональных разработчиков ценовой фактор вторичен, – вложенные в компилятор средства они с лихвой окупают одной – максимум двумя программами, а непрофессионалы в своей массе лицензионных продуктов вообще не приобретают.
Степень соответствия ANSI стандартам Си/Си ++? Да большинство разработчиков с этими стандартными знакомы лишь понаслышке! И потом, кто из нас удерживался от использования нестандартных расширений или библиотек, специфичных исключительно для данного компилятора?
Качество оптимизации кода – один из тех критериев, значимость которого разделяет подавляющее большинство программистов. Даже существует поверье, что "крутой" компилятор способен исправить кривой от рождения код. Отчасти это действительно так, – оптимизатор устраняет многие небрежности и ляпы программиста, но вот вопрос – какие именно?
Техника оптимизации – тайна за семью печатями. Далеко не каждый разработчик знает, что конкретно умеет оптимизировать его любимый компилятор и чем именно он отличается от своих конкурентов. Штатная документация об этом обычно умалчивает, ограничиваясь рекламными лозунгами, не несущими никакой информации. Доступной литературы, посвященной вопросам оптимизации, (насколько известно автору) не существует и единственным источником информации остается машинный код, сгенерированный компилятором.
Исследование ассемблерных листингов, плюс собственный опыт создания оптимизирующих Си-компиляторов – вот что легло в основу данной статьи. Ни рекламная информация, ни слухи, собранные в программистских кулуарах, автором не использовались. Конечно, это не страхует от ошибок, упущений и заблуждений, но, во всяком случае, значительно уменьшает их число.
К сожалению, в рамках одной главы невозможно рассказать о том, как
работает оптимизатор и придется ограничиться перечислением пунктов: что именно он оптимизирует.
В тестах принимали участие три популярнейших компилятора: Microsoft Visual C++ 6.0, Borland C++ 5.0 и WATCOM С 10.0, результаты тестирования представлены ниже.
Сравнительный анализ основных компиляторов
Давайте рассмотрим три довольно полярных алгоритма: копирование блока памяти, поиск минимума среди множества чисел и пузырьковую сортировку. Предложенный выбор совсем не случаен. Операции копирования (равно как сравнения и поиска в памяти) программисты всегда предпочитали реализовывать на чистом ассемблере, ибо микропроцессоры Intel80x86 поддерживают специальные машинные инструкции, изначально ориентированные на эти цели. В частности, копирование памяти осуществляется командой REP MOVS, – своеобразным аналогом функции memcpy. К сожалению, эквивалентные ей (команде REP MOVS) конструкции в языках Си/Си++ отсутствуют. Можно, конечно, вызвать саму memcpy, но это будет нечестно – мы же договорились библиотечные функции не использовать! (тем более что memcpy за редкими исключениями пишется отнюдь не на Си, а на ассемблере). На уровне чистого языка существует лишь один путь решения данной задачи – поэлементное копирование массива в цикле. Проблема в том, что компиляторы еще не научились понимать физический смысл компилируемой программы, и даже такой очевидный алгоритм копирования ни один из известных мне оптимизаторов ни в жизнь не распознает. Компилятор дословно переведет программу на язык машинного кода, сохранив при этом и сам цикл, и все временные переменные, используемые для пересылки данных. Как следствие – полученный код будет далеко не самым оптимальным, проигрывая ассемблеру и по скорости, и по размеру, да и по времени, затраченному на его создание, кстати. Поэтому, очень интересно знать: насколько эффективной окажется компиляция заведомо неоптимального кода. Данный пример позволяет оценить целесообразность использования ассемблера для решения задач, имеющих аппаратную поддержку со стороны процессора, в отсутствии эквивалентных конструкций в языке высокого уровня.Поиск минимума – достаточно тривиальный алгоритм, элементарно укладывающийся в несколько строк как на ассемблере, так и на языке высокого уровня. Столь малое пространство не дает оптимизатору развернуться и показать себя во все красе.
Да этого нам, собственно, и не нужно! Напротив, представляет интерес установить: какое количество избыточного кода воткнул сюда компилятор! Благодаря тому, что объем полезного кода в нем очень мал, такой пример оказывается весьма чувствительным даже к микроскопическим порциям "мусора".
Наконец, сортировка представляет собой довольно типичный пример программистского кода, и по ней вполне можно судить о "средневзвешенном" качестве оптимизации конкретных компиляторов.
Сравнивать различные компиляторы между собой – проще всего. Сравнение же компилятора с человеком осуществить сложнее, поскольку сразу же возникает неопределенность: с каким именно человеком его сравнивать. С профессионалом экстра класса? Но будет ли показательным такое сравнение? Мы же говорим не о теоретическом превосходстве человеческого интеллекта над машинным, а о практических путях решения задач, стоящих перед рядовыми программистами. Может ли рядовой программист рассчитывать на помощь такого экстра профессионала? Вряд ли! Скорее всего, засучив рукава, ему придется взяться за кодирование самому.
Поэтому, приведенные ниже ассемблерные примеры умышленно составлены как средний по качеству и далеко не идеальный код. Причем, поскольку целевой процессор заранее не оговорен, при их подготовке использовались лишь самые общие приемы оптимизации, без учета оптимального планирования потока команд. Тем не менее, это действительно оптимизированный ассемблерный код, приблизительно соответствующий уровню программиста средней руки. (Кстати, интересно: кто из читателей сможет улучить качество кода хотя бы на процент?).
Хорошо, с человеком мы разобрались. Теперь дело – за компиляторами. Какие же из них выбрать? Давайте остановимся на следующем "джентльменском наборе": Microsoft Visual C++ 6.0, Borland C++ 5.5, WATCOM C++ 10.0.
Стратегия оптимального выравнивания
Если обращение к данным, пересекающим кэш-строку, происходит лишь эпизодически– никакого "криминала" в этом нет, и накладными расходами на дополнительную задержку можно смело пренебречь. Никакой осязаемой выгоды от выравнивания таких переменных мы все равно не получим.Интенсивно используемые переменные
(например, счетчики цикла) – дело другое. В этом случае потери производительности скорее всего окажутся весьма велики, и по меньшей мере неразумно закрывать на это глаза! Чтобы окончательно убедиться в этом, запустим следующий контрольный пример, использующий в качестве счетчика цикла 32-разрядную переменную, смещенную на 62 байта от начала, гарантируя тем самым ее расщепление и на P?II/P-III, и на AMD Athlon.
#define N_ITER 1000 // кол-во итераций цикла
#define
_MAX_CACHE_LINE_SIZE 64 //максимально возможный размер кэш-линии
#define UN_FOX (*(int*)((int)fox + _MAX_CACHE_LINE_SIZE - sizeof(int)/2))
#define FOX (*fox) // определение выровненного (FOX) и
// не выровненного (UN_FOX) счетчиков
// выделяем память
fox = (int *) _malloc32(MAX_CACHE_LINE_SIZE*2);
/*------------------------------------------------------------------------
*
* ОПТИМИЗИРОВАННЫЙ ВАРИАНТ
* (счетчик цикла не разбивает кэш-строку)
*
------------------------------------------------------------------------*/
for(FOX = 0; FOX < N_ITER; FOX+=1) c++;
/*------------------------------------------------------------------------
*
* ПЕССИМИЗИРОВАННЫЙ ВАРИАНТ
* (счетчик цикла разбивает кэш-строку)
*
------------------------------------------------------------------------*/
for(UN_FOX = 0; UN_FOX < N_ITER; UN_FOX+=1) c++;
Листинг 5 [Cache/align.for.c] Демонстрация последствий использования расщепленного счетчика цикла
Прогон программы показывает (см. рис graph 0x009), что на P-III 733 расщепленный счетчик цикла снижает производительность практически в пять раз, а на AMD Athlon падение производительности не достигает и двух крат, подтверждая тем самым что Athlon – чрезвычайно непритязательный к выравниванию процессор.
И, если бы мир ограничивался одним Athlon'ом – данные можно было бы вообще не выравнивать (шутка).

Рисунок 25 graph 0x009 Снижение производительности при обработке расщемленных данных
Обработка массивов. Несколько иначе обстоят дела с последовательной обработкой массивов данных. Этот вопрос мы уже рассматривали в первой части настоящей книги, но тогда речь шла о взаимодействии с оперативной памятью, а время загрузки ячейки из основной оперативной памяти намного превышает штрафное пенальти конфликта кэш-линий, поэтому, мы, как помнится, пришли к выводу, что потоковые данные выравнивать бессмысленно.
Если же данные целиком умещаются в кэш-памяти первого (второго) уровня, то пагубное влияние кэш-конфликтов достигает весьма значительных величин, но… только на неразвернутых циклах. Воспользуемся несколько модернизированной программой Memory/aling.c чтобы изучить этот вопрос поподробнее. Уменьшим размер блока до 8-16 Кб и запустим программу…
На неразвернутом цикле чтения (см. рис. graph 0x007) даже P-III показал всего лишь 7% падение производительности, чего и следовало ожидать, т.к. во время кэш-конфликта процессор не простаивает, а обрабатывает команды, составляющие тело цикла, практически полностью маскируя задержку.
А вот неразвернутый цикл записи… ой-ой-ой! Четырехкратное отставание по скорости это вам не тигру хвост оторвать. Чем же вызвано это…. ну там мягко скажем безобразие? А вот чем: хитрый компилятор Microsoft Visual C++ заменил цикл записи всего одной машинной командой REP STOSD, чем к минимуму свел накладные расходы на организацию цикла. Кроме того, поскольку длина буферов записи равна длине кэш-линейке, совмещение начального адреса записи с адресом начала кэш линейки, обеспечивает эффективную трансляцию адресов, позволяя выгружать весь 32-байтный буфер всего за один такт.
На фоне этого 100% результат AMD Athlon выглядит весьма сильно, правда, при развороте циклов он все же начинает сдавать, отставая от выровненного варианта на 24% при чтении и на 48% при записи данных.Впрочем, P-III выглядит ее слабее: +74% и +133% соответственно.

Рисунок 26 graph 0x07 Влияние выравнивания данных на производительность
Задержка окажется еще большей, если запрошенные данные в L1-кэше отсутствуют – тогда потребуется считать из L2-кэша обе кэш-линии, на что в среднем уйдет от 8 до 12 тактов. Если же и в L2-кэше этих злощасных данных нет, - придется ожидать загрузки аж 512 бит (64 байт) из основной памяти. А это в лучшем случае полсотни тактов процессора!
Впрочем,
Стратегия распределения данных по DRAM-банкам
Сформулированное в конце предыдущего параграфа правило ориентировано исключительно на потоковые алгоритмы, обращающиеся к каждой ячейке (и странице!) памяти всего один раз. А если обращение к некоторым страницам памяти происходит неоднократно, – имеет ли значение порядок их обработки или нет? На первый взгляд, если обрабатываемые страницы уже находятся в TLB, – шаг чтения данных никакого значения не имеет. А вот давайте проверим! Для этого добавим в нашу тестирующую программу Memory/list.step.c цикл for(i = 0; i <= BLOCK_SIZE; i += 4*K) x += *(int *)((int)p + i + 32), заставляющий систему спроецировать все страницы и загрузить их в TLB до начала замера времени исполнения. (Читателям достаточно лишь раскомментировать определение UNTLB). В результате получится следующее (см. рис. graph 11).
Рисунок 28 graph 11 График, иллюстрирующий время обработки блока данных в зависимости от шага чтения при отсутствии и присутствии страниц в TLB. Обратите внимание на "волнистость" нижней кривой. Это – прямое следствие регенерации DRAM банков оперативной памяти.
Смотри-ка, а ведь время трассировки списка и, правда, многократно уменьшилось! Зависимость скорости обработки от величины шага на первый взгляд как будто бы исчезла, но при внимательном изучении графика на нем все же обнаруживается мелкая "рябь". Строгая периодичность "волн" говорит нам о том, что это не ошибка эксперимента, а некая закономерность. Весь вопрос в том, – какая? Падение производительности на гребнях волны достигает ~30% и списывать эту величину на "мелкие брызги" побочных эффектов, – было бы с нашей стороны откровенной наглостью.
Характер кривой объясняется особенностями организации оперативной памяти. Как нам уже известно, время доступа к памяти непостоянно и зависит от множества факторов. Максимальная производительность достигается когда: а) запрашиваемые ячейки находятся в открытых страницах DRAM; б) ни страница, ни банк памяти в момент обращения к ним не находится на регенерации.
Исходя из пунктов а) и б) попробуйте ответить на вопрос: как изменяется время доступа к ячейкам памяти в зависимости от шага чтения?
Так – так – так… (глубоко затянувшись и откинувшись на спинку стула). Начнем с пункта а). Из самых общих рассуждений следует, что по мере увеличения шага время доступа будет пропорционально расти за счет более частого открытия страниц. Так будет продолжаться до тех пор, пока длина шага не достигнет длины одной DRAM-страницы (типичная длина которой 1, 2 и 4 Кб), после чего открытия страниц станут происходить при каждом доступе к ячейке и кривая, достигнув максимума насыщения, перейдет в горизонтальное положение. Попробуем проверить наше предположение на практике, запустив следующий тест (см. Memory/memory.hit.miss.c), поскольку масштаб графика graph 11 не очень-то подходит для точных измерений.
// шапка для Word'а
printf("STEP");
for(pg_size=STEP_SIZE;pg_size<=MAX_PG_SIZE;pg_size+=(STEP_SIZE*STEP_FACTOR))
printf("\t%d",pg_size);printf("\nTime");
// основной цикл, различные шаги чтения
for(pg_size=STEP_SIZE;pg_size<=MAX_PG_SIZE;pg_size+=(STEP_SIZE*STEP_FACTOR))
{
A_BEGIN(0) /* начало замера времени выполнения */
// цикл, читающий память с заданным шагом
// поскольку, количество физической памяти ограничено,
// а при большом шаге чтения все читаемые ячейки поместятся в кэше
// приходится хитрить и представлять память как кольцевой массив
// в общем, этот алгоритм нагляднее описывается кодом, чем словами
for (b=0;b
for(a=b;a
x+=p[a];
A_END(0) /* конец замера времени выполнения */
printf("\t%d",Ax_GET(0)); /* печать времени выполнения */
PRINT_PROGRESS(100*pg_size/MAX_PG_SIZE);
}
Листинг 17 [Memory/DRAM.wave.c] Фрагмент программы, предназначенной для детального изучения "волн" DRAM-памяти
Смотрите (см. рис. 0х29), – восходящая ветвь кривой действительно ведет себя в соответствии с нашими предположениями, но не достигает максимума насыщения, а переходит в чрезвычайно испещренную пиками и рытвинами местность. Время доступа к ячейкам с ростом шага меняется непрерывно, но график – к счастью! – обнаруживает строгую периодичность, что вселяет надежду на возможность его расшифровки. Собственно, это не график даже, а кладезь информации, раскрывающая секреты внутренней структуры и организации микросхем памяти. Итак, заправляемся пивом и начинаем…
Закон №1 (возьмите себе на заметку) где есть периодичность, там всегда есть структура. Значит, память, – это не просто длинная цепочка ячеек, а нечто более сложное и неоднородное. Да, мы знаем, что память состоит из страниц, но одного этого факта явно недостаточно для объяснения формы кривой. Требуется что-то еще… Не замешено ли здесь расслоение памяти на банки и их регенерация? Давайте прикинем. Время доступа к ячейке без регенерации занимает 2 (RAS to CAS Delay) + 2 (CAS Delay) + 1 + 1 + 1 == 7 тактов, а с регенерацией: 2 (RAS Precharge) + 2 (RAS to CAS Delay) + 2 (CAS Delay) + 1 + 1 + 1 == 9 тактов, что в 1.225 раз больше. Согласно графику 0x29 время обработки блока на "вершине" горы составляет 22.892.645 тактов, а у ее "подножия" – 18.610.240 тактов, что соответствует отношению в 1.23 раз. Цифры фантастическим образом сходятся! Значит, наше предположение на счет регенерации верно! (Точнее, как осторожные исследователи, мы должны сказать "скорее всего верно", но… это было бы слишком скучно)
Вообще-то, на этом можно было бы и остановится, поскольку закладываться на физическую организацию оперативной памяти столь же безрассудно, как и вкладывать свои ваучеры в МММ. С другой стороны: вполне можно выделить ряд рекомендаций, общих для всех или, по крайней мере, большинства моделей памяти. Особого прироста производительности их соблюдение, впрочем, не принесет, но вот избежать
более чем 20%-30% падения производительности – поможет.
Тонкая оптимизация для "гурманов" В первую очередь нас будет интересовать схема отображения физических ячеек памяти на адресное пространство процессора (см. "Отображение физических DRAM-адресов на логические"), а так же количество и размер банков, длина страниц и все остальные подробности.
Достаточно очевидно, что гребень волны соответствует той ситуации, когда каждое обращение к памяти попадает в регенерирующийся банк. Отсюда: расстояние между двумя соседними гребнями равно произведению длины одной DRAM страницы на количество банков (в нашем случае – 8 Кб), что удовлетворяет следующим комбинациям: 1 Кб (длина DRAM-страницы) x 8 (банков), 2 x 4, 4 x 8 и 8 x 1. Можно ли определить какой именно тип памяти используется в действительности? Да, можно и ключ к этому дает ширина склона "горы" (на рис. 29 она обозначена символом d). Легко доказать, что ширина склона "горы" в точности соответствует длине одной DRAM-страницы. Действительно, путь c – длина одной страницы, а N – количество банков. Тогда, расстояние между концом одной и началом другой страницы всякого банка равно (N-1) * c /* 1 */, соответственно, расстояние между двумя страницами одного банка равно Nc /* 2 */, а расстояние между началом одной и концом другой страниц – (N + 1) * c /* 3 */.
Ситуация /* 1 */ соответствует самому подножию горы, – если шаг чтения меньше чем (N ? 1) * c, то, независимо от смещения ячейки в странице, следующая запрашиваемая ячейка гарантированно не находится в том же самом банке. Но что произойдет, если шаг возрастет хотя бы на единицу? Правильно, при чтении последней ячейки одной DRAM-страницы, очередная ячейка придется на… следующую страницу того же самого банка! (Если не совсем понимаете, о чем идет речь, попробуйте изобразить это графически). Дальнейшее увеличение шага будет захватывать все больше и больше ячеек, вызывая неуклонное снижение производительности.
Так будет продолжаться до тех пор, пока шаг чтения не сравняется с /* 2 */, после чего начнется обратный процесс. Теперь, все больше и больше ячеек будут "вылетать" в страницы соседнего банка, уже готового к обработке запроса…
Отсюда: ширина склона "горы" равна N*c – (N ? 1)*c. Раскрываем скобки и получаем: N*c – N*c + c == c, что и требовалось доказать. Зная же c, вычислить N будет совсем не сложно: количество банков равно отношению расстояния между двумя пиками к ширине склона одной "горы", т.е. приведенный график соответствует четырех банковой памяти с длиной страницы в 2 Кб.
Бесспорно, эта методика весьма перспективна в плане создания тестирующей программы (надо же знать какое оборудование вам подсунули продавцы), но поможет ли она нам в оптимизации программ? Ввиду большого количества "разношерстных" моделей памяти, величина оптимального (неоптимального) шага чтения памяти на стадии разработки программы неизвестна
и должна определятся динамически на этапе исполнения, "затачиваясь" под конкретную аппаратную конфигурацию.
Причем, сказанное относится не только к трассировке списков и других ссылочных структур, но и к параллельной обработке нескольких потоков данных. Рассмотрим, например, такой код:
int *p1, int p2;
p1=malloc(BLOCK_SIZE*sizeof(int));
p2= malloc(BLOCK_SIZE*sizeof(int));
…
if (memcmp(p1,p2, BLOCK_SIZE*sizeof(int)) …
…
Листинг 18 Пример неоптимального программного кода, – оба блока памяти могут начинаться с одного и того же DRAM банка, вследствие чего их параллельная обработка окажется невозможна.
Как вы думаете: можно ли считать этот фрагмент программы оптимальным? А вот и нет! Ведь никто не гарантирует, что блоки памяти, возвращенные malloc-ом, не начинаются с одного и того же банка. Скорее, нам гарантированно обратное. Если размер блока превышает 512 Кб (а про обработку вот таких гигантских массивов памяти мы и говорим!), он не может быть размещен в куче и необходимую память приходится выделять с помощью win32 API?функции VirtualAlloc.
К несчастью, возвращаемый ей адрес всегда выравнивается на границу 64 Кб, т.е. величину, кратную любым разумным сочетаниям Nc. Т.е. попросту говоря, все выделяемые malloc-ом блоки памяти начинаются с одного и того же банка! А это не есть хорошо!!! Точнее – это просто кретинизм! Неужели разработчики системы проглядели такой ляп? Что ж, это не сложно проверить, – достаточно написать программку, выделяющую блоки памяти разного размера и анализирующую значения 11 и 12 битов (эти биты отвечают за выбор DRAM-банка).
#define N_ITER 9 // кол-во итераций
#define STEP_FACTOR (100*1024) // шаг приращения шага размера памяти
#define MAX_MEM_SIZE (1024*1024)
#define _one_bit 11 // эти биты отвечают...
#define _two_bit 12 // ...за выбор банка
#define MASK ((1<<_one_bit)+(1<<_two_bit))
#define zzz(a) (((int) malloc(a) & MASK) >> _one_bit)
main()
{
int a,b;
PRINT(_TEXT("= = = Определение номеров DRAM-банков = = =\n"));
PRINT_TITLE;
printf("BLOCK\n SIZE - - - n BANK - - -\n");
printf("----!----------------------------------------------------------\n");
for(a = STEP_FACTOR; a < MAX_MEM_SIZE; a += STEP_FACTOR)
{
printf("%04d:",a/1024);
for (b = 0; b < N_ITER; b++)
printf("\t%x",zzz(a));
printf("\n");
}
}
Листинг 19 [Memory/bank.malloc.c] Фрагмент программы, определяющий номера банков с которых начинаются блоки памяти, выделяемые функцией malloc
Результат ее работы будет следующим:
BLOCK
SIZE (Кб) - - - n BANK - - -
----!------------------------------------------------------------------
0100: 0 2 0 2 0 2 0 2 0
0200: 0 0 0 0 0 0 0 0 0
0300: 0 2 0 2 0 2 0 2 0
0400: 2 2 2 0 0 0 0 0 0
0500: 2 0 2 0 2 0 2 0 2
0600: 0 0 0 0 0 0 0 0 0
0700: 0 0 0 0 0 0 0 0 0
0800: 0 0 0 0 0 0 0 0 0
0900: 0 0 0 0 0 0 0 0 0
1000: 0 0 0 0 0 0 0 0 0
Смотрите, – до тех пор, пока размер выделяемых блоков не достиг 512 Кб, нам попадались как удачные, так и неудачные комбинации, но, начиная с 512 Кб, все выделенные блоки, действительно, начинаются с одного и того же банка.
Что же делать? Писать собственную реализацию malloc? Конечно же, нет! Достаточно динамически определив N и c, проверить соответствующие биты в адресах, возращенных malloc. И, если они вдруг окажутся равны, – увеличить любой из адресов на величину c (естественно, размер запрашиваемого блока необходимо заблаговременно увеличить на эту же самую величину). В результате мы теряем от 1- до 4 Кб, но получаем взамен, по крайней мере, 20%-30% выигрыш в производительности.
Это тот редкий случай, когда знание приемов "черной магии" позволяет совершенно непостижимым для окружающих способом увеличить быстродействие чужой программы, добавив в нее всего одну строку, даже без анализа исходных текстов. Маленькое лирическое отступление. Однажды поручили мне в порядке конкурсного задания оптимизацию одной программы, уже и без того оптимизированной – по понятиям заказчика – "по самые помидоры". Собственно, проблема заключалась в том, что львиная доля процессорного времени уходила не на вычисления, а на обмен с оперативной памятью, причем размер обрабатываемых данных был действительно сокращен до предела и Заказчик пребывал в абсолютной уверенности, что оптимизировать тут более нечего… Каково же было его удивление когда я прямо на его глазах, бегло пробежался контекстным поиском по исходным тестам и, даже не удосужившись запустить профилировщик, просто воткнул в нескольких местах код a'la p += 4*1024, после чего программа заработала приблизительно на треть быстрее.
Он (Заказчик) долго и недоверчиво спрашивал: уверен ли я, что это не случайность и под всяким ли железом и операционной системе это будет работать? Нет, не случайность. Уверен. Будет работать под любой OC семейства Windows и почти на любом железе: микросхемах памяти с организацией 2 x 4, 4 x 2, 4 x 2, 4 x 4, а на чипсетах серии Intel 815 (и других подобным им) вообще на всех
типах памяти. Эти чипсеты отображают 8- и 9-биты номера столбца на 25- и 26-биты линейного адреса, а вовсе не на 11- и 12-биты, как того следовало ожидать. Попросту говоря, программная длина DRAM-страниц на таких чипсетах всегда равна 2 Кб, потому на всех модулях памяти, имеющих по крайней мере четырех банковую организацию (а все модули емкостью от 64 и более мегабайт такую организацию и имеют), сдвиг указателя на 4 Кб гарантированно исключает попадание в тот же самый DRAM-банк.
Конечно, закладываться на такие особенности аппаратуры – дурной тон и в программах, рассчитанных на долговременное использование, настоятельно рекомендуется не лениться, а определять длину DRAM-страниц автоматически или, на худой конец, позволять изменять эту величину опционально.

Рисунок 29 0x029 / graph 0x004 "Волны" памяти несут чрезвычайно богатую информацию о конструктивных особенностях используемых микросхем памяти. Вот только основные характеристики: а. – расстояние между концом одной и началом следующей DRAM-страницы того же самого DRAM-банка; b. – расстояние между началом одной и началом другой DRAM-страницы того же самого DRAM-банка (т.е. размер всех DRAM-банков); c. и d. – размер одной DRAM-страницы. Кол-во DRAM банков равно b/d
Стратегия распределения данных по кэш-банкам
В выравнивании данных есть еще один далеко неочевидный момент, по непонятным обстоятельствам, упущенный составителями оригинальных руководств по оптимизации под процессоры Pentium и AMD, а потому практически не известный программистской общественности.Руководство по оптимизации под Pentium MMX – Pentium-II (Order Number 242816-003) содержало лишь следующую скудную информацию: "The data cache consists of eight banks interleaved on four-byte boundaries. On Pentium processors with MMX technology, the data cache can be accessed simultaneously from both pipes, as long as the references are to different cache banks. On the P6-family processors, the data cache can be accessed simultaneously by a load instruction and a store instruction, as long as the references are to different cache banks….
…If both instructions access the same data-cache memory bank then the second request (V-pipe) must wait for the first request to complete. A bank conflict occurs when bits 2 through 4 are the same in the two physical addresses. A bank conflict incurs a one clock penalty on the V-pipe instruction"
("Кэш данных состоит из восьми банков, чередующихся по четырех байтовым адресам. Кэш данных процессоров Pentium MMX одновременно доступен с обоих конвейеров при условии, что они обращаются к различным кэш-банкам. На процессорах семейства P6 (Pentium Pro, Pentium-II), кэш данных доступен и на чтение, и на запись, при условии, что обращение происходит к различным кэш-банкам…
…если обе инструкции [речь идет о спариваемых инструкциях – КК] обращаются к одному и тому же банку кэш-памяти, следующей запрос (V-труба) будет вынужден дожидаться завершения выполнения предыдущего. Конфликт банков происходит всякий раз, когда биты со второго по четвертый в двух физических адресах совпадают. Конфликт банков возлагает штрафную задержку в один такт, задерживающую выполнение инструкции находящейся в V-трубе"
В руководстве оптимизации под P-III второй абзац загадочным образом исчезает.
Но это еще что, – "Intel Pentium 4 Processor Optimization Reference Manual P-4" о кэш-банках вообще не обмолвливается ни словом! Ничуть не разговорчивее оказывается и "AMD Athlon Processor x86 Code Optimization Guide", уделяющее этому вопросу едва ли не дюжину слов: "The data cache and instruction cache are both two-way set-associative and 64-Kbytes in size. It is divided into 8 banks where each bank is 8 bytes wide" ("Кэш данных и кэш инструкций двух ассоциативны и имеют размер по 64 Кб каждый. Они поделены на 8 банков, шириной по 8 байт"). Понять последнее предложение может только тот, кто хорошо знаком с архитектурой расслоенной памяти и хотя бы в общих чертах представляет себе как на аппаратном уровне устроен и работает кэш. К тому же возникает досадная терминологическая путаница: разновидностей кэш-банков насчитывается по меньшей мере две.
Ассоциативный кэш делится на независимые области, называемые банками, число которых и определяет его ассоциативность. На физическом уровне, эти банки состоят из нескольких матриц статической памяти, так же именуемых банками. Расслоение памяти подробно разбиралось на страницах данной книги (см. "Часть I. Оперативная память. Устройство и принципы функционирования оперативной памяти. SDRAM (Synchronous DRAM) – синхронная DRAM") и внимательным читателям навряд ли стоило большого труда догадаться какой именно "банк" составители руководства имели ввиду. Да! Но насколько хреново приходится тем, кто только начинает изучать программирование!
А ведь когда-то фирма AMD славилась качеством совей документации. Откроем, например, замечательное руководство "AMD-K5 Processor Technical Reference Manual", которое я частенько перелистываю перед сном, поскольку это гораздо больше, чем просто руководство по безнадежно устаревшему процессору. Это – исчерпывающее описание архитектуры, к которой вплоть до последних времен не добавилось ничего революционно нового.
Даже супер современный Hammer основан на тех же самых принципах и почти том же самом ядре.
В частности, организация и назначение кэш-банков объясняются так: "The data cache overcomes load/store bottlenecks by supporting simultaneous accesses to two lines in a single clock, if the lines are in separate banks. Each of the four cache banks contains eight bytes, or one-fourth of a 32-byte cache line. They are interleaved on a four-byte boundary. One instruction can be accessing bank 0 (bytes 0-3 and 16-19), while another instruction is accessing bank 1, 2, or 3 (bytes 4-7 and 20-23, 8-11 and 24-27, and 12-15 and 28-31 respectively)".
("Кэш данных преодолевает заторы чтения/записи путем поддержки возможности одновременной записи двух кэш-линеек за один такт, при условии, что эти линейки расположены в различных банках. Каждый из четырех кэш-банков состоит из восьми байтов, или другими словами говоря, одной четвертной длины 32?байтной кэш-линейки. Они (банки) чередуются с четырех байтовым диапазоном. Одна инструкция может обращаться к банку 0 (байты 0-3 и 16-19), в то время как другая инструкция может параллельно обращаться к банку 1, 2 или 3 (байты 4 – 7 и 20 – 23, 8 – 11, и 24 – 27, и 12 – 15, и 28 – 31 соответственно").
Вот теперь все более или менее ясно. Остается лишь вопрос: а для чего такие извращения? Ответ: реализация двух портовой матрицы статической памяти обходится дорого, т.к. требует для своего создания восьми CMOS-транзисторов вместо шести. Поэтому, конструкторы поступили проще: разбили статическую память на несколько независимых банков и подцепили к ней двух портовый интерфейс. Таким образом, на 64 Кб кэше экономится порядка миллиона транзисторов, правда временами такая экономия оборачивается дорогой ценой, – ведь двух портовое ядро памяти способно одновременно обрабатывать два любых запроса, а кэш с двух портовым интерфейсом может распараллеливать только те запросы, которые направляются в различные банки.
Итак, для достижения наивысшей скорости обработки данных, мы должны соблюдать ряд определенных предписаний и планировать поток данных с таким расчетом, чтобы не возникало паразитных задержек за счет по парного обращения к одним и тем же кэш-банкам.

Рисунок 27 0х021 Схема проецирования матриц кэш-памяти на кэш-линейки
Итак, кэш-линейка не представляет собой изотропное целое, а состоит их четырех или восьми независимых 32-, 64- или 128-битных банков (см. рис 0х021). Их независимость выражается в том, что чтение/запись в каждый из банков может происходить параллельно в течение одного такта процессора. Степень параллелизма зависит от количества функциональных устройств, подцепленных к исполнительным конвейерам микропроцессора и количества портов самого кэша. В частности, микропроцессоры P-II могут выполнить одну запись и одну чтение двух различных банков за каждый такт. Более подробные сведения об особенностях реализации кэш-банках в популярных моделях процессоров можно подчеркнуть из таблицы….
Таким образом, если у нас имеются две 32-битовых переменных, каждая из которых расположена в "своем" банке, операция присвоения одной переменной другой может быть выполнена всего за один такт! Напротив, если переменные пересекают границы банков – как показано на рис. 0х022 – возникает задержка: процессор не может писать в тот банк, который в данный момент обрабатывает запрос на чтение. Величина задержки варьируется в зависимости от модели процессора, например, на P-II составляет пять тактов.
Но вернемся к нашим баранам (оптимальной стратегии выравнивания данных). В свете новых воззрений, все данные (включая переменные размером в байт) лучше располагать по адресам кратным по меньшей мере четырем, тем самым обеспечивая возможность их параллельной обработки, т.к. каждая переменная будет "монопольно" владеть соответствующим ей банком. Правда, помимо собственно самого выравнивания еще потребуется убедиться, что биты, "ответственные" за смещение данных в кэш-линейке у параллельно обрабатываемых ячеек не равны, иначе они с неизбежностью попадут в одну и ту же матрицу статической памяти, хотя и будут находиться в различных линейках кэша. (Замечание: данное ограничение не распространяется на операцию чтения, последующую за записью, – в этом случае записываемые данные направляются в буфер записи и к кэш-памяти происходит только одно обращение, да и то, лишь когда считываемых данных не окажется в буфере, – подробнее см.
"Буфера записи").
С другой стороны: такое выравнивание приводит к "разрыхлению" упаковки данных и требует значительного большего количества памяти, в результате чего вполне может статься так, что данные просто не влезут в кэш первого (а то и второго) уровня. Тем самым мы рискуем вместо ожидаемого увеличения скорости нарваться на большие тормоза, съедающие весь выигрыш параллельной обработки!

Рисунок 28 0х022 Чтение/запись ячеек, расположенных в различных кэш-банках (т.е. в различных матрицах статической памяти) осуществляется за один такт. В противном случае, каждая переменная будет обрабатываться последовательно, что потребует вдвое больше тактов.
Насколько значительными будут последствия конфликта кэш-банков? А вот сейчас и увидим! Чтобы выяснить это мы напишем следующую программу, учитывая при ее проектировании следующие обстоятельства:
а) на P-III конфликт кэш-банков приводит к падению производительности только
в случае комбинирования операций чтения с операциями записи (т.е. на P-III всего одно устройство чтения, загрузить две ячейки за такт даже из двух различных банков он увы не сможет);
б) цикл обработки памяти должен быть развернут минимум на четыре итерации, т.к. в противном случае буферизация записи позволит переждать задержки, вызванные конфликтом банков и отложить выгрузку данных "до лучших времен", а единственной инструкции записи просто будет не с чем спариваться!
Хорошо! Давайте же вызовем джина из бутыли. "Ало, джин ибн Конфликт Кэш Банков" появись!" Как вы думаете, он появится?
//============================================================================
// НЕТ КОНФЛИКТОВ
//----------------------------------------------------------------------------
// Распределение переменных по кэш-банкам на P-III
// ===============================================
// !
// !0 1 2 3!4 5 6 7!8 9 0 1!2 3 4 5!6 7 8 9!0 1 2 3!4 5 6 7!8 9 0 1!<- offset
// !-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!
// !*-*-*-*! ! ! ! ! ! <<-- ((int)_p32 + a + 0);
// ! !*-*-*-*! ! ! ! ! <<-- ((int)_p32 + a + 4);
// ! ! ! !*-*-*-*! ! ! <<-- ((int)_p32 + a +12);
// ! ! ! ! !*-*-*-*! ! <<-- ((int)_p32 + a +16);
//============================================================================
//
// Распределение переменных по кэш-банкам на AMD Athlon
// ====================================================
// !<-- bank 0 -->!<-- bank 1 -->!<-- bank 2 -->!<-- bank 3 -->!...
// !0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ...
// !-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!...
// !*-*-*-* ! ! ! <<-- ((int)_p32 + a + 2);
// ! *-*-*-*! ! ! <<-- ((int)_p32 + a + 6);
// ! ! *-*-*-*! ! <<-- ((int)_p32 + a +12);
// ! ! !*-*-*-* ! <<-- ((int)_p32 + a +16);
//============================================================================
optimize(int *_p32)
{
int a;
int _tmp32 = 0;
for(a = 0; a < BLOCK_SIZE; a += 32)
{
_tmp32 += *(int *)((int)_p32 + a + 0); // bank 0 [Athlon: bank 0]
*(int *)((int)_p32 + a +12) = _tmp32; // bank 3 [Athlon: bank 1]
_tmp32 += *(int *)((int)_p32 + a + 4); // bank 1 [Athlon: bank 0]
*(int *)((int)_p32 + a +16) = _tmp32; // bank 4 [Athlon: bank 2]
}
}
//============================================================================
// ДЕМОНСТРАЦИЯ КОНФЛИКТА БАНКОВ
//----------------------------------------------------------------------------
// Распределение переменных по кэш-банкам на P-III
// ===============================================
// !
// !0 1 2 3!4 5 6 7!8 9 0 1!2 3 4 5!6 7 8 9!0 1 2 3!4 5 6 7! 8 9 0 1 <- offset
// !-+-+-+-+-+-+-+-!-+-+-+-+-+-+-+-!-+-+-+-+-+-+-+-!-+-+-+-+-+-+-+-
// ! *-*-*-* ! ! ! ! ! <<-- ((int)_p32 + a + 2);
// ! !^ *-*-*-* ! ! ! ! <<-- ((int)_p32 + a + 6);
// ! !| !^ !*-*-*-*! ! ! <<-- ((int)_p32 + a +12);
// ! !| !| ! !*-*-*-*! ! <<-- ((int)_p32 + a +16);
// | |
// +-------+--- <- КОНФЛИКТ
//============================================================================
//
// Распределение переменных по кэш-банкам на AMD Athlon
// ====================================================
// !<-- bank 0 -->!<-- bank 1 -->!<-- bank 2 -->!<-- bank 3 -->!
// !0 1 2 3 4 5 6 7!8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
// !-+-+-+-+-+-+-+-!-+-+-+-+-+-+-+-!-+-+-+-+-+-+-+-!-+-+-+-+-+-+-+-+
// ! *-*-*-* ! ! ! <<-- ((int)_p32 + a + 2);
// ! *-*-*-* ! ! <<-- ((int)_p32 + a + 6);
// ! ^ *-*-*-*! ! <<-- ((int)_p32 + a +12);
// ! | !*-*-*-* ! <<-- ((int)_p32 + a +16);
// ! КОНФЛИКТ ! !
//
//============================================================================
conflict(int *_p32)
{
int a;
int _tmp32 = 0;
for(a = 0; a < BLOCK_SIZE; a += 32)
{
_tmp32 += *(int *)((int)_p32+a+2); // bank 0 + 1 [Athlon: bank 0]
*(int *)((int)_p32+a+12) = _tmp32; // bank 3 * [Athlon: bank 1]
// *
// "*" MARK BANKS CONFLICT
// * *
_tmp32 += *(int *)((int)_p32+a+6); // bank 1 + 2 [Athlon: bank 0 + 1]
*(int *)((int)_p32+a+16) = _tmp32; // bank 4 [Athlon: bank 2]
}
}
Листинг 6 [Cache/banks.c] Демонстрация последствий конфликта кэш-банков
На P-III "джин" появляется, да какой джин (см. рис. graph 0x008)! Чтением двух смежных 32- разрядных ячеек, смещенных относительно начала первого банка на 16 бит, мы вызвали более чем трех кратное падение производительности, что и неудивительно, т.к. в этом случае чтение двух ячеек происходит не за два такта как это должно быть, а за 2 + penalty
тактов. Поскольку на P-III величина пенальти составляет пять тактов, мы проигрываем = 350%, что вполне близко к экспериментальному значению – 320% (завышенная оценка объясняется тем, что мы не учли накладных расходов на запись и организацию цикла).
На AMD Athlon последствия конфликта кэш-банков оказались гораздо меньшими, всего на 9% увеличив время выполнения программы. Такое преимущество над P-III объясняется тем, что благодаря двойному превосходству в ширине кэш-банков, мы перекрыли всего лишь два из них и вместо полного затора (как на P-III) на Athlon'e образовалась лишь маленькая, быстро рассасывающая пробка. Разумеется, это отнюдь не означает, что на AMD Athlon вызвать затор невозможно, – возможно, еще как! Достаточно добиться перекрытия сразу нескольких банков, но для этого понадобится совсем другая программа, которая окажется непригодной для тестирования P-III. Тем не менее, вероятность спонтанного конфликта кэш-банков на AMD Athlon все-таки ниже.

Рисунок 29 graph 0x008 Демонстрация конфликта кэш-банков на P-III и AMD Athlon
Сводная характеристика инструкций предвыборки различных процессоров
Для облегчения ориентирования среди множества команд предвыборки и особенностей их поведения на различных моделях процессоров, все основные характеристики собраны в следующей таблице:| Инструкция | Характеристика | ||||||||
| K6 | C3-VIA | Athlon | P-III | P-4 | |||||
| prefetch | Загружает 32 байта в кэш-уровни всех кэш-иерархий и определяет состояние строки как исключительное | Загружает 64 байта в кэш-уровни всех кэш-иерархий и определяет состояние строки как исключительное | не поддерживается | ||||||
| prefetchw | Загружает 32 байта в кэш-уровни всех кэш-иерархий и определяет состояние строки как модифицируемое | Загружает 64 байта в кэш-уровни всех кэш-иерархий и определяет состояние строки как модифицируемое | |||||||
| prefetchnta | не поддерживается | Загружает 64 байта в L1 кэш | Загружает 32 байта в L1 кэш | Загружает 128 байт в первый банка L2 кэша | |||||
| prefetcht0 | Загружает 64 байта в L1 и L2 кэш | Загружает 32 байта в L1 и L2 кэш | Загружает 128 байт в кэш второго уровня | ||||||
| prefetcht1 | Загружает 64 байта в L2 кэш | Загружает 32 байта в L2 кэш | |||||||
| prefetcht2 | Загружает 64 байта в L3 кэш (если есть) | ||||||||
Таблица 5 Сводная характеристика инструкций предвыборки различных процессоров
Сводная характеристика качества оптимизации штатных Си функций и функций ОС для работы с памятью
Качество оптимизации штатных функций, поставляемых вместе с компилятором, – весьма актуальный вопрос, поскольку от этого напрямую зависит производительность откомпилированной программы.Вообще же, отношения к штатным функциям у программистов самые разнообразные: от полного нежелания использовать что-либо стандартное (стандартные вещи редко бывают хорошими), до безоговорочного их обожания (не надо думать, что разработчики компилятора глупее нас с вами).
Как же действительно обстоят дела на практике? Об этом можно узнать из таблицы ???3, приведенной ниже и описывающие ключевые особенности оптимизации базовых memory-функций популярных компиляторов и операционной системы Windows 2000. (Операционная система приведена лишь в качестве примера, т.к. использование функций семейства RtlxxxMemory, как было показано выше нецелесообразно – см. "Особое замечание по функциями Win32 API"):
| memcpy/CopyMemory | |||||||||||
| Microsoft Visual C++ 6.0 | Borland C++ 5.5 | WATCOM C++ 10 | Windows 2000 | ||||||||
| LIB | intrinsic | ||||||||||
| копирование | DWORD | DWORD | DWORD | DWORD | –– | ||||||
| выравнивание адресов источника и/или приемника | выравнивает адрес приемника по границе 4 байт | не выравнивает | не выравнивает | не выравнивает | –– |
| memmove/MoveMemory | |||||||||||||
| Microsoft Visual C++ 6.0 | Borland C++ 5.5 | WATCOM C++ 10 | Windows 2000 | ||||||||||
| LIB | intrinsic | ||||||||||||
| копирование не перекрывающихся блоков памяти | DWORD, в прямом направлении | – | DWORD, в прямом направлении | DWORD, в прямом направлении | DWORD, в прямом направлении | ||||||||
| копирование перекрывающихся блоков памяти пр | src < dst | DWORD, в обратном направлении | – | DWORD, в обратном направлении | BYTE?, в обратном направлении | DWORD, в обратном направлении | |||||||
| src > dst | DWORD, в прямом направлении | – | DWORD, в прямом направлении | DWORD, в прямом направлении | DWORD, в прямом направлении | ||||||||
| выравнивание адресов источника и/или приемника | выравнивает адрес приемника по границе 4 байт | – | не выравнивает | не выравнивает | не выравнивает |
В таблицах, приведенных ниже, содержится краткая характеристика качества оптимизации штатных Си-функций и функций операционной системы для работы со строками. Обратите внимание: ни операционная система, ни библиотеки распространенных компиляторов не являются полностью оптимальными и резерв для повышения быстродействия еще есть!
Поэтому, целесообразнее пользоваться вашими собственными реализациями строковых функций, вылизанных "по самые помидоры". Если этого не сделаете вы, никто не оптимизирует вашу программу за вас!
| strlen/lstrlenA | |||||||||||||
| Microsoft Visual C++ 6.0 | Borland C++ 5.5 | WATCOM C++ 10 | Windows 2000 | ||||||||||
| LIB | intrinsic | ||||||||||||
| определение длины | align | unalign | BYTE, в прямом направлении | DWORD, в прямом направлении | BYTE, в прямом направлении | BYTE, в прямом направлении | |||||||
| dword | byte | ||||||||||||
| выравнивание адресов источника и/или приемника | не выравнивает | не выравнивает | выравнивает по границе 4 байт | не выравнивает | не выравнивает | ||||||||
| степень разворота цикла (итераций) | 1 | 1 | 1 | 1 | 1 |
| strcpy/lstrcpyA | |||||||||||||
| Microsoft Visual C++ 6.0 | Borland C++ 5.5 | WATCOM C++ 10 | Windows 2000 | ||||||||||
| LIB | intrinsic | ||||||||||||
| копирование | определение длины источника | не определяет | BYTE | BYTE | BYTE | BYTE | |||||||
| перенос строки | align | unalign | DWORD | DWORD | DWORD | ||||||||
| dword | byte | ||||||||||||
| выравнивание адресов источника и/или приемника | не выравнивает | не выравнивает | не выравнивает | не выравнивает | не выравнивает | ||||||||
| степень разворота цикла (итераций) | 1 | 1 | 1 | 2 | 1 |
| strcat/lstrcatA | |||||||||||||
| Microsoft Visual C++ 6.0 | Borland C++ 5.5 | WATCOM C++ 10 | Windows 2000 | ||||||||||
| LIB | intrinsic | ||||||||||||
| копирование | определение длины источника | не определяет | BYTE | BYTE | BYTE | BYTE | |||||||
| определение длины приемника | align | unalign | BYTE | BYTE | не определяет | BYTE | |||||||
| dword | byte | ||||||||||||
| перенос строки | align | unalign | DWORD | DWORD | BYTE | DWORD, в прямом направлении | |||||||
| dword | byte | ||||||||||||
| выравнивание адресов источника и/или приемника | не выравнивает | не выравнивает | не выравнивает | не выравнивает | не выравнивает | ||||||||
| степень разворота цикла (итераций) | 1 | 1 | 1 | 2 | 1 |
|
memset/FillMemory/ZeroMemory |
|||||
|
Microsoft Visual C++ 6.0 |
Borland C++ 5.5 |
WATCOM C++ 10 |
Windows 2000 |
||
|
LIB |
intrinsic |
||||
|
заполнение памяти |
DWORD, в прямом направлении |
DWORD, в прямом направлении |
DWORD, в прямом направлении |
DWORD, в прямом направлении |
DWORD, в прямом направлении |
|
выравнивание адреса приемника |
выравнивает адрес приемника по границе 4 байт |
не выравнивает |
не выравнивает |
не выравнивает |
не выравнивает |
|
memcmp/CompareMemory |
|||||
|
Microsoft Visual C++ 6.0 |
Borland C++ 5.5 |
WATCOM C++ 10 |
Windows 2000 |
||
|
LIB |
intrinsic |
||||
|
сравнение памяти |
BYTE, в прямом направлении |
BYTE, в прямом направлении |
BYTE, в прямом направлении |
BYTE, в прямом направлении |
DWORD, в прямом направлении |
|
выравнивание адресов источника и/или приемника |
не выравнивает |
не выравнивает |
не выравнивает |
не выравнивает |
не выравнивает |
Что полезного можно почерпнуть из этой таблицы? Первое, что сразу бросается в глаза: крайне небрежная оптимизация штатных функций в компиляторах от Borland и WATCOM. Создается впечатление, что их разработчики вообще не ставили перед собой задачу достичь если не максимальной, то хотя бы приемлемой производительности.
Гораздо качественнее оптимизированы memory-функции штатной библиотеки компилятора Microsoft Visual C++, которые выгодно отличаются тем, что выравнивают адрес приемника на границу 4 байт, что в ряде случаев значительно увеличивает производительность (правда, как было показано в главе "Выравнивание данных" гораздо предпочтительнее выравнивать адрес источника, а не приемника). Тем не менее Microsoft Visual C++ не использует никаких прогрессивных алгоритмов оптимизации, описанных в главе "Оптимизация штатных Си-функций для работы с памятью", а функции memcmp он не оптимизирует вообще!
Словом, если вам нужна скорость – используйте собственные реализации функций для работы с памятью!
|
strcmp/lstrcmpA |
|||||
|
Microsoft Visual C++ 6.0 |
Borland C++ 5.5 |
WATCOM C++ 10 |
Windows 2000 |
||
|
LIB |
intrinsic |
||||
|
проверка |
BYTE, в прямом направлении |
BYTE |
BYTE |
DWORD, в прямом направлении |
BYTE, в прямом направлении |
|
выравнивание адресов источника и/или приемника |
не выравнивает |
не выравнивает |
не выравнивает |
не выравнивает |
не выравнивает |
|
степень разворота цикла (итераций) |
2 |
2 |
2 |
8 (!) |
1 |
|
strstr/--- |
|||||
|
Microsoft Visual C++ 6.0 |
Borland C++ 5.5 |
WATCOM C++ 10 |
Windows 2000 |
||
|
LIB |
intrinsic |
||||
|
сравнение |
BYTE, в прямом направлении |
– |
DWORD, в прямом направлении |
BYTE, в прямом направлении |
– |
|
выравнивание адресов источника и/или приемника |
не выравнивает |
– |
не выравнивает |
не выравнивает |
– |
|
степень разворота цикла (итераций) |
1 |
– |
1 |
1 |
– |
Сводная таблица
| компилятор действие | Microsoft Visual C++ 6 | Borland C++ 5 | WATCOM C 10 | ||||
| размножение констант | всегда размножает | никогда не размножает | всегда размножает | ||||
| свертка констант | всегда сворачивает | никогда не сворачивает | всегда сворачивает | ||||
| вычисление константных выражений | вычисляет | вычисляет | вычисляет | ||||
| свертка функций | никогда не сворачивает | никогда не сворачивает | никогда не сворачивает | ||||
| удаление неиспользуемых переменных | удаляет с все неявно неиспользуемые отслеживанием генетических связей | удаляет лишь явно неиспользуемые переменные. генетические связи не отслеживает | удаляет с все неявно неиспользуемые отслеживанием генетических связей | ||||
| удаление неиспользуемых присвоений | удаляет | удаляет | удаляет | ||||
| удаление копий переменных | удаляет | не удаляет | удаляет | ||||
| удаление лишних присвоение | удаляет | не удаляет | удаляет | ||||
| удаление лишних вызовов функций | не удаляет | не удаляет | не удаляет | ||||
| выполнение алгебраических упрощений | частично | не выполняет | не выполняет | ||||
| оптимизация подвыражений | вычислят идентичные выражения (с учетом перегруппировки) лишь раз | частично оптимизирует без учета перегруппировки | вычислят идентичные выражения (с учетом перегруппировки) лишь раз | ||||
| замена деления сдвигом | заменяет | заменяет | заменяет | ||||
| замена деления умножением | заменяет | не заменяет | не заменяет | ||||
| замена оператора взятия остатка битовыми операциями | заменяет | заменяет | заменяет | ||||
| быстрое вычисление остатка | не поддерживает | не поддерживает | не поддерживает | ||||
| замена умножения сдвигом | заменяет | заменяет | заменяет | ||||
| замена умножения сложением | заменяет | заменяет | не заменяет | ||||
| использование LEA для быстрого сложения (умножения, вычитания) | использует | использует | использует | ||||
| замена условных переходов арифметическими операциями | не заменяет | не заменяет | не заменяет | ||||
| удаление лишних условий | удаляет | не удаляет | не удаляет | ||||
| удаление заведомо ложных условий | частично | не удаляет | не удаляет | ||||
| балансировка дерева case-переходов | балансирует | балансирует | не балансирует | ||||
| создание таблицы переходов | создает | создает | частично | ||||
| разворот циклов | не разворачивает | не разворачивает | не разворачивает | ||||
| слияние циклов | не сливает | не сливает | не сливает | ||||
| вынос инвариантного кода за пределы цикла | выносит | не выносит | выносит | ||||
| замена циклов с предусловием на циклы с постусловием | заменяет | не заменяет | не заменяет | ||||
| замена возрастающих циклов на убывающие циклы | заменяет | не заменяет | не заменяет | ||||
| удаление ветвлений из цикла | удаляет | не удаляет | не удаляет | ||||
| учет частоты использования переменных при помещении их в регистр | не учитывает | учитывает | учитывает | ||||
| передача аргументов в регистрах по умолчанию | нет | да | да | ||||
| количество регистров, выделенных для передачи аргументов функции | 2 | 3 | 4 | ||||
| адресация локальных переменных через ESP | да | да | да | ||||
| оптимизация инициализации константных строк | да | нет | нет | ||||
| удаление "мертвого" кода | да | нет | частично | ||||
| оптимизация константных условий | да | нет | да |
Таблица 1 Поддержка основных методов оптимизации компиляторами Microsoft Visual C++, Borland C++ и WATCOM C++. Серым цветом для облегчения ориентации закрашены не поддерживаемые пункты
Сжатие файлов под Windows 9x\NT
Наступил девяносто пятый год – мир медленно, но неотвратимо пересаживался на новую операционную систему – Windows95. Пользователи осторожно осваивали мышь и графический интерфейс, а программисты тем временем лихорадочного переносили старое программное обеспечение на новую платформу. Объемы винчестеров к тому времени выросли настолько, что разработчики могли забыть слово "оптимизация", да они, судя по размеру современных приложений, его и забыли. Сто мегабайт – туда, триста сюда – эдак никаких гигабайт не хватит!Вот тут-то и вспомнили о распаковке исполняемых файлов "на лету". На рынке появилось несколько программ - компрессоров, из которых наибольшую популярность завоевал ASPack, умеющий сжимать и разжимать не только "экзешники", но и динамические библиотеки. А в состав самой Windows 95 вошла динамическая библиотека "LZEXPAND.DLL", поддерживающая базовые операции упаковки-распаковки и "прозрачную" работу со сжатыми файлами. Пользователи и программисты не замедлили воспользоваться новыми средствами, но…
…в отличие от старушки MS-DOS, в Windows 9x\NT за автоматическую распаковку приходится платить больше, чем получать взамен. Вспомним, как в MS-DOS происходила загрузка исполняемых модулей? Файл целиком считывался с диска и копировался в оперативную память, причем наиболее узким местом была именно операция чтения с диска. Упаковка даже ускоряла загрузку, ибо физически читался меньший объем данных, а их распаковка занимала пренебрежительно короткое время.
В Windows же загрузчик читает лишь заголовок и таблицу импорта файла, а затем проецирует его на адресное пространство процесса так, будто бы файл является частью виртуальной памяти, хранящейся на диске. (Вообще-то, все происходит намного сложнее, но не будем вдаваться в не относящиеся к делу подробности). Подкачка с диска происходит динамически – по мере обращения к соответствующим страницам памяти, причем загружаются только те их них, что действительно нужны.
Например, если в текстовом редакторе есть модуль работы с таблицами, он не будет загружен с диска до тех пор, пока пользователь не захочет создать (или отобразить) свою таблицу. Причем неважно – находится ли этот модуль в динамической библиотеке или в основном файле! (Вот и попробуйте после этого сказать, что Windows глупые люди писали!). Загрузка таких "монстров" как Microsoft Visual Studio и Word как бы "размазывается" во времени и к работе с приложением можно приступать практически сразу же после его запуска. А что произойдет, если файл упаковать? Правильно, - он будет должен считаться с диска целиком (!) и затем – опять-таки, целиком – распаковаться в оперативную память.
Стоп! Откуда у нас столько оперативной памяти? Ее явно не хватит и распакованные такой ценой страницы придется вновь скидывать на диск! Как говорится, за что боролись, на то и напоролись. Причем, если при проецировании неупакованного exe - файла оперативная память не выделяется, (ну, во всяком случае, до тех пор, пока в ней не возникнет необходимость), распаковщику без памяти никак не обойтись! А поскольку оперативная память никогда не бывает в избытке (ну разве что у вас установлено 128-256 мегабайт RAM), она может быть выделена лишь за счет других приложений! Отметим также, что в силу конструктивных особенностей железа и архитектуры операционной системы, операция записи на диск заметно медленнее операции чтения.
Важно понять: Windows никогда не сбрасывает на диск не модифицированные страницы проецируемого файла. Зачем ей это? Ведь в любой момент их можно вновь считать с оригинального файла. Но ведь при распаковке модифицируются все страницы файла! Значит, система будет вынуждена "гонять" их между диском и памятью, что существенно снизит общую производительность всех приложений в целом.
Еще большие накладные расходы влечет за собой сжатие динамических библиотек. Для экономии памяти, страницы, занятые динамической библиотекой совместно используются всеми процессами, загрузившими эту DLL.
Но как только один из процессов пытается что-то записать в память, занятую DLL, система мгновенно создает копию модифицируемой страницы и предоставляет ее в "монопольное" распоряжение процесса-писателя. Поскольку, распаковка динамических библиотек происходит в контексте процесса, загрузившего эту DLL, система вынуждена многократно дублировать все страницы памяти, выделенные динамической библиотеке, фактически предоставляя каждому процессору свой собственный экземпляр DLL. Предположим, одна DLL размером в мегабайт, была загружена десятью процессами, - посчитайте: сколько памяти напрасно потеряется, если она сжата!
Таким образом, под Windows 9x\NT сжимать исполняемые файлы нецелесообразно, - вы платите гораздо больше, чем выручаете. Что же касается защиты от дизассемблирования… Да, когда ASPack только появился, он отвадил от взлома очень многих неквалифицированных хакеров, но – Woozl! – ненадолго! Сегодня в сети только слепой не найдет руководство по ручному снятию ASPack-а. Существует и масса готового инструментария – от автоматических распаковщиков до плагинов к дизассемблеру IDA Pro, позволяющих ему дизассемблировать сжатые файлы. Поэтому, надеяться, что ASPack спасет вашу программу от взлома несколько наивно.
Техника оптимизации программ Подсистема оперативной памяти ЭНЦИКЛОПЕДИЯ
Крис Касперски"Память определяет быстродействие"
Фон-Нейман
"Самый медленный верблюд определяет скорость каравана"
Арабское народное
"Время работы программы определяется ее самой медленной частью"
Закон Амдала

Рисунок 1 mcap1 "Караван"

Рисунок 2 logo4.gif Память… Миллиарды битовых ячеек, упакованных в крошечную керамическую пластинку, свободно умещающуюся на ладони… вот как выглядит современная оперативная память!
ТЕХНИКА ОПТИМИЗАЦИИ ПРОГРАММ ТОМ
Крис Касперскиkpnc@programme.ru
kpnc@itech.ru
TEMPORA MUTANTUR, ET NOS MUTAMUR IN ILLIS
(Времена меняются и мы меняемся с ними лат.)
"Когда караван поворачивает назад, самый медленный верблюд оказывается впереди".
Арабское народное
Крис Касперски
kpnc@programme.ru
kpnc@itech.ru


Рисунок 1 0x000.gif 0x0025.gif DEUS EX MACHINA…
Типы статической памяти
Существует как минимум три типа статической памяти: асинхронная (только что рассмотренная выше), синхронная и конвейерная. Все они практически ничем не отличаются от соответствующих им типов динамической памяти (см. "Часть I Оперативная память: Устройство и принципы функционирования оперативной памяти"), поэтому, во избежание никому не нужного повторения ниже приведено лишь краткое их описание.Том I Оперативная память
Оптимизация на уровне структур данных и алгоритмов их обработки. Ориентирована на прикладных и системных программистов.Настоящая книга включает лишь половину запланированного материала первого тома. Остающаяся либо выйдет отдельной книгой, либо (что более вероятно) появится во втором издании настоящей книги.
Предполагаемый срок выхода: начало 2003 года
Том II Процессор
Оптимизация на уровне планирования потоков машинных команд. Ориентирована на системных программистов и прикладных программистов, владеющих ассемблеромПредполагаемый срок выхода: начало 2004 года
Том III Автоматическая кодогенерация
Устройство оптимизирующих компиляторов, алгоритмы оптимизации, сравнение качества кодогенерации различных компиляторов, как "помочь" оптимизатору. Ориентирована на прикладных и системных программистов.Предполагаемый срок выхода: начало 2005 года
Том IV Ввод/вывод
Оптимизация работы с периферией (как-то жесткие и лазерные диски, коммуникации, параллельные и последовательные порты, видео подсистема) Ориентирована на прикладных и системных программистов.Предполагаемый срок выхода: начало 2006 года
Том V Параллельные вычисления и суперкомпьютеры
Ориентирована на прикладных программистов. Вся информация уникальна, а многая даже засекречена. Поэтому книга будет читаться как самый настоящий детектив про разведчиков.Предполагаемый срок выхода: не известен
Традиции vs надежность
Народная мудрость и здравый смысл утверждают, "если все очень хорошо, то что-то тут не так". Применительно к описанной ситуации – если описанные авторов приемы программирования столь хороши, почему же они не получили массового распространения? Видимо, на практике не все так хорошо, как на бумаге.На самом деле основной "камень преткновения" – верность традициям. В сложившейся культуре программирования признаком хорошего тона считается использование везде, где только возможно, стандартных функций самого языка, а не специфических возможностей операционной системы, "привязывающих" продукт к одной платформе. Какой бы небесспорной эта рекомендация ни была, многие разработчики слепо следуют ей едва ли не с фанатичной приверженностью.
Но что лучше – мобильный, но нестабильно работающий и небезопасный код или– плохо переносимое (в худшем случае вообще непереносимое), зато устойчивое и безопасное приложение? Если отказ от использования стандартных библиотек позволит значительно уменьшить количество ошибок в приложении и многократно повысить его безопасность, стоит ли этим пренебрегать?
Удивительно, но существует и такое мнение, что непереносимость – более тяжкий грех, чем ошибки от которых, как водится, никто не застрахован. Аргументы: дескать, ошибки – явление временное и теоретически устранимое, а непереносимость – это навсегда.
Можно возразить – использование в своей программе функций, специфичных для какой-то одной операционной системы, не является непреодолимым препятствием для ее портирования на платформы, где этих функций нет, - достаточно лишь реализовать их самостоятельно (трудно, конечно, но в принципе осуществимо).
Другая причина не распространенности описанных выше приемов программирования – непопулярность обработки структурных исключений вообще. Несмотря на все усилия Microsoft, эта технология так и не получила массового распространения, а жаль! Ведь при возникновении нештатной ситуации любое приложение может если не исправить положение, то, по крайней мере, записать все не сохраненные данные на диск и затем корректно завершить свою работу. Напротив, если возникшее исключение не обрабатывается приложением, операционная система аварийно завершает его работу, а пользователь теряет все не сохраненные данные.
Не существует никаких объективных причин, препятствующих активному использованию структурной обработке исключений в ваших приложениях кроме желания держаться за старые традиции, игнорируя все новые технологии. Обработка структурных исключений – очень полезная возможность, области применения которой ограничены разве что фантазией разработчика. И предложенные выше приемы программирования – лучшее тому подтверждение.
Учет ограниченной ассоциативности кэша
До тех пор, пока в процессорах не появятся полностью ассоциативные кэши, оптимальная организация данных останется прерогативой программистов. В главе "Кэш – принципы функционирования: Организация кэша" было показано, что каждая ячейка кэшируемой памяти может претендовать не на любую, а всего лишь на несколько кэш-строк, число которых и определяется ассоциативностью кэша. Поскольку, ассоциативность кэш-памяти первого уровня обычно очень невелика (так, на P-II/P-III она равна четырем, а на AMDAthlon и вовсе двум), становится очевидным, что неудачная организация данных способна сократить размер кэш-памяти на один-два порядка, а то и более того!Если установочные биты кэшируемых ячеек равны, они отображаются на одну и ту же кэш-строку, поэтому таких ситуаций следует избегать. Как вычисляются установочные адреса? Для этого можно воспользоваться следующей формулой. set.address = my.address & ((mask.bank – 1) & ~(mask.line-1)), где mask.line (маска кэш линей) определяется так: 2mask.line = CACHE.LINE.SIZE, а mask.bank (маска банка) определяется так: 2mask.bank == BANK.SIZE. В частности, 4х ассоциативный 16 Кб кэш процессоров P-II/P-III задействует под установочные адреса биты с 11 по 4 линейного адреса памяти.
Таким образом, обработка ячеек памяти с шагом равным или кратным размеру кэш банка крайне непроизводительна и этого любой ценой следует избегать. Касательно размера кэш-банков – позвольте поделиться наблюдением: на всех известных мне процессорах он равен или кратен 4 Кб. Эта цифра, конечно, не догма (см. "Определение ассоциативности"), но как рабочий вариант вполне сойдет.
А теперь скажите, как вы думаете, можно ли назвать следующий код оптимальным кодом?
for(a = 0; a < googol; a++)
{
// …
a1 += bar[4096*1];
a2 += bar[4096*2];
a3 += bar[4096*3];
a4 += bar[4096*4];
a5 += bar[4096*5];
// …
}
Листинг 7 Пример, демонстрирующий конфликт кэш-линеек за счет ограниченной степени ассоциативности
Увы, оптимальность здесь и не ночевала. Смотрите, что происходит: при исполнении программы под P-II/P-III: в первом проходе цикла for ячейки bar[4096]
еще не содержится в сверхоперативной памяти первого уровня и возникает задержка в два-четыре такта, на время загрузки данных из кэша второго уровня (а в худшем случае – из оперативной памяти). Поскольку установочный адрес ячейки равен нулю (условимся считать, что массив bar выровнено по 4 Кб границе, хотя это и не критично), кэш контроллер помещает считанные 32 байта в нулевую кэш-строку первого (точнее, условного первого) кэш-банка. Идем далее: установочный адрес ячейки bar[4096*2]
тоже равен нулю и очередная порция считанных данных также претендуют на нулевую кэш-строку! Поскольку, нулевая строка первого кэш-банк уже занята, кэш-контроллер задействует второй банк…
Но ведь количество кэш-банков не безгранично! Напротив, – оно очень мало, и на момент чтения пятой по счету ячейки – bar[4096*5]
нулевые кэш-строки всех четырех кэш- банков уже заняты, а данная ячейка претендует именно на нулевую кэш-строку! Кэш-контроллер (а что ему еще остается делать?) безжалостно "прибивает" наиболее "дряхлую" нулевую строку первого кэш-банка (к ней дольше всего не было обращения) и записывает в нее "свежие" данные. Как следствие – в следующем проходе цикла for ячейки bar[4096]
вновь не оказывается в сверхоперативной памяти первого уровня, и вновь возникнет задержка на время ее загрузки! Кэш контроллер, обнаружив, что свободных банков совсем нет (помните как в том анекдоте про бензин "Как нет? Совсем нет, да!"), прибивает нулевую строку теперь уже второго банка – ту, что хранила ячейку bar[4096*2]… Чувствуете, что происходит? Правильно, кэш работает на 100% вхолостую, не обеспечивая ни одного кэш-попадания – одни лишь промахи. А ведь обрабатываемых ячеек памяти всего лишь пять…
Во избежании падения производительности, обрабатываемые данные необходимо реорганизовать так, чтобы читаемые ячейки попадали в различные кэш-линии, а, если это невозможно, то обрабатывать их не в одном, а в нескольких последовательных циклах, например, так:
for(a = 0; a < googol; a++)
{
// …
a1=bar[4096];
a2=bar[4096*2];
a3=bar[4096*3];
// …
}
for(a = 0; a < googol; a++)
{
// …
a4=bar[4096*4];
a5=bar[4096*5];
// …
}
Листинг 8 Оптимизированный вариант программы, учитывающий особенности архитектуры наборно-ассоциативной кэш памяти
А теперь разберем как будет исполняться следующий код на процессоре AMD Athlon. На первый взгляд, все произойдет по сценарию, описанному выше, с той лишь разницей, что двух ассоциативный кэш Athlon'а "завалится" уже на третий итерации.
Точнее, мы думаем, что он "завалится", на самом же деле, никакого сколь ни будь заметного падения производительности не наблюдается.
for(a = 0; a < googol; a++)
{
// …
a1 += bar[4096];
a2 += bar[4096*2];
a3 += bar[4096*3];
a4 += bar[4096*4];
a5 += bar[4096*5];
// …
}
Листинг 9 Пример кода, вопреки "здравому смыслу" оптимальное для процессора AMD Athlon
Первое впечатление: Вот это номер! Ай да Athlon! Второе впечатление (немного остынув от щенячьего восторга, мы задаемся известным вопросом): но почему? Как это реализовано? Прочитав руководство по оптимизации от начала до конца, в приложении "А" мы находим междустрочный ответ на вопрос. Оказывается в процессорах AMD Athlon имеется очередь чтения/записи на 12 входов, которая временно сохраняет данные, считанные из кэша первого уровня. Конфликты кэш-линий все-таки возникают, но они маскируются системой буферизации данных. Выходит, что при оптимизации программы исключительно под Athlon нехватки ассоциативности можно не опасаться? Увы, не все так радужно… В данном случае падения производительности не наблюдается лишь только потому, что количество одновременно обрабатываемых ячеек не превышает двенадцати.
Особенно щепетильным следует быть при выборе величины шага чтения при обработке блочных алгоритмов. Если она окажется кратной размеру кэш-банка (или хотя бы производной от него), – многократного падения производительности не избежать.
Рассмотрим следующий пример:
#define N_ITER 466 // кол-во итераций
// теоретически будет востребовано
// LINE_SIZE*N_ITER байт кэш-памяти,
// т.е. в данном случае 466*64 = ~30 Kb
#define CACHE_BANK_SIZE (4*K ) // размер кэш-банка
#define LINE_SIZE 64 // максимально возможный размер кэш-линий
#define BLOCK_SIZE ((CACHE_BANK_SIZE+LINE_SIZE)*N_ITER) // размер бурка
/*----------------------------------------------------------------------------
*
* ВАРИАНТ, ИЛЛЮСТРИРУЮЩИЙ КОНФЛИКТЫ КЭШ-ЛИНИЙ
*
----------------------------------------------------------------------------*/
int over_assoc(int *p)
{
int a;
volatile int x=0;
// внимание: top-level цикл поскипан, поскольку профайлер
// и без того прокрутит этот цикл 10 раз
for(a=0; a < N_ITER; a++)
// читаем память с шагом 4 Kb, в результате
// и на P-II/P-III/P-4 и на AMD Athlon
// быстро наступает насыщение кэша и идет
// его перегруз;
// поскольку, обрабатывается более 12 ячеек
// буферизация чтения на Athlon положения
// уже не спасает
x+=*(int *)((int)p + a*CACHE_BANK_SIZE);
return x;
}
Листинг 10 [Cache/L1.overassoc] Пример, демонстрирующий возникновение конфликтов кэш-линий и их влияние на производительность
При шаге чтения, равным четырем килобайтам, данная программа будет "буксовать" на всех процессорах, поскольку в кэш-память не поместится вообще ни одной ячейки! (Чу! Слышу голоса: "почему ни одной ячейки? Ведь должны же сохранится четыре ячейки на P-II/P-III и две – на Athlon, в соответствии с величиной их ассоциативности". Увы! При очередном проходе цикла последние сохраненные ячейки будут выкинуты из кэша).
Как можно предотвратить кэш-конфликты? Для этого мы должны реструктурировать обрабатываемый массив с таким расчетом, чтобы установочные адреса всех загружаемых ячеек были бы различны. Один из возможных путей решения – увеличить величину шага на размер кэш-линейки. (Конечно, для этого необходимо изменить и сам массив, т.к. при этом будут читаться уже другие
данные).
Оптимизированный пример может выглядеть приблизительно так:
/*----------------------------------------------------------------------------
*
* ВАРИАНТ, ИСПОЛНЯЮЩИЙСЯ БЕЗ КОНФЛИКТОВ
*
----------------------------------------------------------------------------*/
int optimize(int *p)
{
int a=0;
volatile int x=0;
// внимание: top-level цикл поскипан, поскольку профайлер
// и без того прокрутит этот цикл 10 раз
for(a=0; a < N_ITER; a++)
{
// читаем паять с шагом CACHE_BANK_SIZE+LINE_SIZE
// т.е. в данном случае 4096+64=4160 байт;
// поскольку установочные адреса всех ячеек различны
// мы не имеем конфликтов и использует емкость
// кэш-память на все 100%
x+=*(int *)((int)p + a*(CACHE_BANK_SIZE+LINE_SIZE));
}
return x;
}
Листинг 11 [Cache/L1.overassoc.c] Оптимизированный пример, устраняющий конфликты кэш-линий
Прогон программы показывает, что в результате оптимизации ее скорость увеличилась более, чем в шесть раз на процессоре P-III 733 и более чем в пять раз на процессоре AMD Athlon 1050. Согласитесь, очень неплохая прибавка к производительности и это при том, что мы только читали конфликтующие ячейки, но не модифицировали их. Конфликт же записи обойдется вам приблизительно в полтора раза "дороже"!
Поэтому, если ваша программа не смотря на крошечный размер обрабатываемых данных работает на удивление медленно, – в первую очередь проверьте, – не замешен ли здесь конфликт кэш-линеек.

Рисунок 30 graph 0x010 Демонстрация падения производительности при попадании загружаемых данных в одну и ту же кэш-линейку. Конфликт записи обходится приблизительно в полтора раза дороже.
Удаление копий переменных
Если две и более переменных имеют одно и то же значение, – можно оставить лишь одну из них, а остальные удалить, заодно избавляясь от лишних присвоений. Рассмотрим следующий пример:int a=b;
printf("%x %x \n",a, b);
Логично, что переменная 'a' совершенно не нужна и программа может работать и без нее, достаточно переписать ее так:
int a=b;
printf("%x %x \n",b, b);
Компиляторы Microsoft Visual C++ и WATCOM успешно справляются с удалением копий переменных, а вот Borland C++ этого не умеет.
Удаление лишних присвоений
Операция присвоения одной переменной другой переменной (т.е. что-то вроде a=b) бессмысленна – от нее всегда можно избавиться, заменив копию переменной ее оригиналом. Например, пусть не оптимизированный код выглядел так:int a=b;
printf("%x %x \n",a, b);
a=a+1;
printf("%x %x \n",a, b);
Очевидно: переменная 'a' не является копией 'b' и не может быть удалена, но вот присвоение a=b удалить можно, смотрите:
int a=b;
printf("%x %x \n",b, b);
a=b+1;
printf("%x %x \n",a, b);
Компиляторы Microsoft Visual C++ и WATCOM всегда избавляются от лишних присвоений, а Borland C++ делать этого не умеет.
Удаление лишних условий
Упрощение логических условий чем-то сродни алгебраическим упрощения. Рассмотрим следующий пример:if (a>0 && a<0x666 && a!=0) …
Очевидно, что проверка (a!=0) лишняя – т.к. если 'a' больше нуля, оно заведомо не равно нулю! Компилятор Microsoft Visual C++ умеет распознавать такие ситуации, избавляясь от избыточных проверок, а вот Borland C++ и WATCOM на это не способны.
Удаление лишних выражений[2]
Если результат вычисления некоторого выражения никак не используется в программе, то, очевидно, выполнять вычисление выражение нет никакой нужды. Как же возникает такой бессмысленный код? Чаще всего – из-за небрежности программиста, привыкшего сначала программировать, а потом думать, что он запрограммировал.Рассмотрим следующий пример:
c = a/b;
c = a*b;
printf("%x\n",c);
Поскольку, результат вычислений выражения (a/b) никак не используется в программе, его можно удалить, избавляясь тем самым от одной операции деления и присвоения, смотрите:
c = a/b;
c = a*b;
printf("%x\n",c);
Все три рассматриваемых компилятора с лихвой справляются с удалением лишних выражений.
Удаление лишних вызовов функций
Удаление лишних выражений ни в коем случае нельзя распространять на удаление функций! (Во всяком случае, без дополнительных ухищрений, разговор о которых выход за рамки этой статьи).Рассмотрим следующий пример:
c=func_1(0x666,0x777);
c=func_2(0);
printf("%x\n", c);
На первый взгляд, от вызова функции func_1 можно безболезненно избавиться – возращенное ею значение никак не используется, и переменной 'c' тут же присваивается результат работы func_2, который и выводится на экран. Но задумайтесь: что произойдет, если функция func_1
помимо возращения значения делает и другую работу? Ну, скажем, записывает переданные ей аргументы в дисковый файл. Правильно, - удаление этой функции нарушит нормальную работу программы!
Таким образом, оптимизирующий компилятор не должен сокращать вызовы функций, однако, он может (и должен) не присваивать возращенное значение переменной, если оно действительно нигде не используются (см. "Удаление лишних присвоений").
Ни один из трех рассматриваемых компилятором не удаляет "лишние" вызовы функций.
Удаление неиспользуемых переменных
Довольно часто программист объявляет переменную, но никак не использует ее в программе. Особенно актуальна эта проблема для языка Си, не поддерживающего (в отличие от его старшего собрата Си++) объявления переменных по месту использования. Чтобы не метаться между разными частями программы, опытные разработчики объявляют переменные загодя, с "запасом". А когда же выясняется, что можно обойтись и меньшим количеством переменных, удалить "излишки" как всегда забывают.Оптимизирующие компиляторы, стремясь сэкономить память, автоматически удаляют такие переменные, зачастую сопровождая эту процедуру "ворчанием" – выдачей предупреждающих сообщений на экран. Действительно, не использование переменной может являться следствием ошибки или описки. Никакой опасности эти предупреждения не представляют, но все же лучше от них избавиться, соответствующим образом скорректировав код. Причем, инициализация переменной или присвоение ей некоторого значения (результата арифметических вычислений или результата работы функции) не "трудоустраивают" ее! Присвоенное переменной значение хотя бы раз должно использоваться в программе!
Рассмотрим следующий пример:
int a=0;
int b;
b=a;
Несмотря на то, что значение, присвоенное переменной 'a', передается переменной 'b', переменная 'a' все равно считается неиспользуемой, т.к. результат ее присвоения 'b' никак не используется в программе!
Компиляторы Microsoft Visual C++ и WATCOM – прекрасно справляются с удалением неиспользуемых переменных, выбрасывая в данном примере и переменную 'a', и переменную 'b'. Компилятор Borland C++ не умеет отслеживать генетические связи между переменными, поэтому, неявно неиспользуемые переменные удалять неспособен. В данном примере он удалит переменную 'b', т.к. присваиваемое ей значение никак не используется, но сохранит 'a', т.к. ее значение копируется в 'b'.
Удаление неиспользуемых присвоений
Если значение, присвоенное переменной, никак не используется в программе, то выполнять операцию присвоения бессмысленно.Рассмотрим следующий пример: аккуратный программист, следуя советам популярных руководств, явно инициализировал все переменные в момент их объявления, и получилось вот что:
int *p=0;
int a=sizeof(int);
p=malloc(a*1024)
Никто, конечно, не спорит, что инициализация переменных (особенно указателей!) при объявлении – хороший тон программирования. Если разработчик случайно забудет о вызове malloc, нулевой указатель при первом же обращении выбросит исключение, демаскируя ошибку. Напротив, неинициализированные указатели порождают трудноуловимую "блуждающую" ошибку – программа может работать, но нестабильно, искажая "чужие" данные, возможно, при каждом запуске разные. Попробуй-ка, разберись – где зарыт жучок!
Платой за надежность становится "разбухание" и снижение производительности программы, поэтому, оптимизирующие компиляторы, обнаружив, что значение '0', присвоенное переменной 'p', никак не используется в программе, удаляют его:
int *p=0;
int a=sizeof(int);
p=malloc(a*1024)
С этой задачей сполна справляются все три рассматриваемых компилятора – Microsoft Visual C++, Borland C++ и WATCOM.
Удаление текста
В первую очередь хотелось бы обратить внимание на команду "CutAppendNext", позволяющую добавлять вырезаемые фрагменты в конец буфера обмена без затирания его содержимого. По умолчанию она не связана ни с какой "горячей" клавишей и назначить ее вы должны самостоятельно ("Tools à Customize à Keyboard à Category Edit à CutAppendNext"). В работе с ней есть одна хитрость – сама CutAppendNext не вырезает текст в буфер, но говорит редактору, что следующая операция вырезания (Cutting) текста должна не перезаписывать (overwrite) содержимое буфера, а дописываться (append) к нему.Другая полезная команда "DeleteBlankLines" удаляет все пустые строки, расположенные под курсором, что бывает полезно при "окультуривании" листинга. По умолчанию она так же не связана ни с какой горячей клавишей.
Ее сестра – команда "DeleteHorizontalSpace" удаляет все пробелы и символы табуляции, расположенные по обе стороны от курсора. Это бывает полезно, в частности, при удалении лишних отступов при переформатировании листинга. Как уже, вероятно, догадался читатель, назначать горячую клавишу на эту команду ему придется самостоятельно.
"Горячая" клавиша <Ctrl-L> вырезает в буфер обмена текущую строку целиком, а ее "сестра" – <Shift-Ctrl-L> просто удаляет строку, сохраняя содержимое буфера обмена неизменным.
Две других команды "LineDeleteToEnd" и "LineDeleteToStart" удаляют "хвост" строки справа и слева от курсора соответственно.
Удаление ветвлений
Ветвления внутри циклов всегда нежелательны, а на старших моделях микропроцессоров Intel – особенно (кстати, большинство Си компиляторов платформы CONVEX вообще отказываются компилировать программы с ветвлениями внутри циклов).Уникальной особенностью компилятора Microsoft Visual C++ является его умение избавляться от некоторых типов внутрицикловых ветвлений. Алгоритм подобной оптимизации слишком сложен, чтобы быть описанным в рамках журнальной статьи, поэтому, просто рассмотрим пример кода до и после оптимизации, а любопытных отошлем к книгам "Техника дизассемблирования программ" и "Техника оптимизации программ" Криса Касперски.
Рассмотрим типичный цикл с условием в середине:
do
{
printf("1й оператор\n");
if (--a<0) break;
printf("2й оператор\n");
}while(1);
Компилятор Microsoft Visual C++ не долго думая транслирует его в цикл с постусловием, удаляя тем самым оператор break.
printf("1й оператор\n");
a--;
if
(a>=0)
{
a++;
do
{
printf("1й оператор\n");
printf("2й оператор\n");
} while(–-a<0);
}
Платой за быстродействие становиться некоторое увеличение размеров программы, т.к. операторы цикла, предшествующие ветвлению break теперь встречаются дважды.
Заметим еще раз –из всех трех рассматриваемых компиляторов удалять внутрицикловые ветвления умеет один лишь Microsoft Visual C++ – ни Borland C++, ни WATCOM на это не способны.
__циклы которые никогда не выполняются
Удаление заведомо ложных условий
Иногда программисты допускают ошибки, создавая заведомо ложные условия, как, например, это:if (a!=0 && a==0) …
Ясно, что если 'a' не равно нулю, то равно нулю оно быть никак не может, - вот компилятор Microsoft Visual C++ и не генерирует для него код, опуская всю ветку "IF – THEN", правда, почему-то не выдавая никаких предупреждений. А зря! Ведь ясно, что это – программистская ошибка. Компиляторы же Borland C++ и WATCOM вообще не оптимизируют такой код, понимая его буквально – как есть.
Впрочем, и Microsoft Visual C++ не всегда распознает заведомую ложность условий. В частности, со следующим примером он уже не справляется:
if (a<0 && a>0x666) …
Удельное время выполнения
Если время выполнения некоторой точки программы не постоянно, а варьируется в тех или иных пределах (например, в зависимости от рода обрабатываемых данных), то трактовка результатов профилировки становится неоднозначной, а сам результат – ненадежным. Для более достоверного анализа требуется: а) действительно ли в программе присутствуют подобные "плавающие" точки и если да, то: б) определить время их исполнения в лучшем, худшем и среднем случаях.Очень немногие профилировщики могут похвастаться способностью засекать удельное время выполнения машинных команд (еще называемое растактовкой). К счастью, VTune это умеет! Обратимся к сгенерированному им протоколу динамического анализа. Быть может, он поможет нам разрешить загадку "неповоротливости" загрузки указателя pswd?
Line Instructions Dyn-Retirement Cycles
107 pswd[p] = '!';
107 mov edx, DWORD PTR [ebp+0ch] 13 ************
107 ; ^ загрузить в регистр EDX указатель pswd
107 add edx, DWORD PTR [ebp-4] 2 **
107 ; ^ сложить регистр EDX с переменной p
107 mov BYTE PTR [edx], 021h 3 ***
107 ; ^ записать в *(pswd+p) значение '!'
109 y = y | y << 8;
109 mov eax, DWORD PTR [ebp-28] 2 **
109 ; ^ загрузить в регистр EAX переменную y
109 shl eax, 08h 1 *
109 ; ^ сдвинуть EAX на 8 позиций влево
109 mov ecx, DWORD PTR [ebp-28] (0,7.3,80)
109 ; ^ загрузить в регистр ECX переменную y *******
109 or ecx, eax 1 *
109 ; ^ ECX = ECX | EAX (tmp = y | y)
109 mov DWORD PTR [ebp-28], ecx 1 *
109 ; ^ записать полученный результат в y
110 x -= k;
110 mov edx, DWORD PTR [ebp-24] 0
110 ; ^ загрузить в регистр EDX переменную x
110 sub edx, DWORD PTR [ebp-36] 1 *
110 ; ^ вычесть из регистра EDX переменную k
110 mov DWORD PTR [ebp-24], edx 1 *
110 ; ^ записать полученный результат в x
Листинг 3 Удельное время выполнения машинных команд внутри профилируемого фрагмента программы
Ну вот опять, – все команды, как команды, а загрузка указателя pswd разлеглась прямо как объевшаяся свинья, сожравшая целых тринадцать тактов, в то время как остальные свободно укалываются в один-два такта, а некоторые и вовсе занимают ноль, ухитряясь завершится одновременно с предыдущей инструкций.
За исключением команды, загружающей содержимое переменной y в регистр ECX, время выполнения всех остальных команд строго постоянно и не меняется от случая к случаю. Наша же "подопечная" в зависимости от еще не выясненных обстоятельств, может отъедать аж восемьдесят тактов, что на время делает ее самой горячей точкой данного фрагмента программы. Восемьдесят тактов – это вообще полный беспредел! И пускай среднеарифметическое время ее выполнения составляет всего лишь семь тактов, а минимальное – и вовсе ноль, мы не успокоимся пока не выясним: на что и при каких именно обстоятельствах уходит такое количество тактов?
Уменьшение размера структур данных
Пусть у нас имеется некоторая структура данных (для определенности возьмем список), содержащая фиксированное количество элементов. Вопрос: имеет ли значение шаг чтения памяти, при условии, что каждый элемент обрабатывается однократно? Поскольку, минимальная порция обмена с памятью составляет, по меньшей мере, 32байта, а размеры элементов списка зачастую много меньше этой величины, становится очевидным, что скорость обработки обратно пропорциональна шагу обработки. Действительно, при чтении памяти через байт (слово, двойное слово) к половине загруженных ячеек вообще не происходит обращения, а при чтении памяти через четыре байта (слова, двойных слова) реально задействуется лишь 25% ячеек, а остальные загружаются "вхолостую". Отсюда следует, что данные в памяти следует располагать так плотно, как это только возможно. (см. так же "Оперативная память: выравнивание данных" и "Кэш: выравнивание данных").Раздельные (separated) структуры данных.
Вернемся к списку. Классическое представление списка (см. рис. 0х33) – крайне не оптимально с точки зрения подсистемы памяти IBM PC. Почему? Да ведь при трассировке списка (трассировка – операция прохождения по списку без обращения к данным [значениям] его элементов) процессор вынужден загружать все ячейки, а не только ссылки на следующие элементы. Если операция трассировки выполняется неоднократно, – потери производительности могут оказаться весьма значительными.
Давайте реорганизуем нашу структуру данных, – указатели на следующий элемент поместим в один, а содержимое элементов – в другой массив. Теперь при трассировке списка (и большинстве других типовых операций с ним как то: определения количества элементов, поиск последнего элемента, замыкание и размыкание списков) окажутся востребованными все загруженные ячейки и, следовательно, эффективность обработки возрастет.
Обратите внимание: как в этом случае изменяется обращение к элементам списка. Если доступ к "классическому" списку осуществляется приблизительно так: _list[element].next=xxx; _list[element].val=xxx; то после его модернизации так: _mylist.next[element]=xxx; _mylist.val[element]=xxx.
Т.е. квадратные скобки сместились на одно слово вправо! Это не создает никаких неудобств (ну разве что с непривычки), но способно запутать начинающих программистов, не имеющих опыта работы с Си.

Рисунок 21 0х33 Устройство "классического" списка. При трассировке процессор вынужден загружать и ссылки, и значения, несмотря на то, что нас интересуют одни лишь ссылки

Рисунок 22 0х40 Устройство оптимизированного списка. Теперь при трассировке процессор загружает лишь те ячейки, к которым происходит реальное обращение, что значительно увеличивает производительность системы
В данном случае расщепление списка list в два раза сокращает объем памяти, загружаемый при его трассировке. И это еще не предел! Если количество элементов списка меньше полусотни тысяч (как чаще всего и бывает), разумно отказаться от 32-битных указателей и перейти на 16-битные индексы (см. рис. 34). Конечно, это не слишком-то продвинутый алгоритм, но его легко усовершенствовать! Выровняв все элементы в памяти по четным адресам, мы сможем задействовать младший бит указателя элемента под "производственные нужды". Скажем, если он равен нулю, то длина указателя 32 бит, а если единице, – то для представления адреса используется компактный 16 битный относительный указатель. Поскольку, расстояние между соседними элементами списка, как правило, невелико, нет нужды постоянно обращаться к ним по полному адресу, что экономит 16 бит на каждый элемент.
Развивая мысль дальше, можно ввести поддержку ситуаций "следующий элемент находится непосредственно за концом текущего" или "следующий элемент находится в другом списке". Конечная цель во всех этих случаях одна: вместо указателей с фиксированной разрядностью использовать указатели с "плавающей" разрядностью, занимающие минимальное возможное количество бит.

Рисунок 23 0х034. Отводите указателям минимально возможное количество бит, – это здорово сократит объем занимаемой памяти
Совместное использование обоих этих приемов (разделения списка вкупе с усечением разрядности указателей) сокращает размер ссылочного массива в sizeof(struct list)/sizeof(index_next) раз. Т.е. в данном случае – в четыре раза. Неплохо? А во сколько раз это повышает производительность? Давайте проверим! Рассмотрим следующую программу, реализующую "классический" и оптимизированный варианты списков и сравнивающую время их обработки.
/* -----------------------------------------------------------------------
*
* обработка классического списка
*
------------------------------------------------------------------------ */
struct list{ // КЛАССИЧЕСКИЙ СПИСОК
struct list *next; // Указатель на следующий узел
int val; // Значение
};
struct list *classic_list,*tmp_list;
// инициализация списка
for (a = 0; a < N_ELEM; a++)
{
classic_list[a].next= classic_list + a+1;
classic_list[a].val = a;
} classic_list[N_ELEM-1].next=0;
// трассировка списка
tmp_list=classic_list;
while(tmp_list = tmp_list[0].next);
/* ----------------------------------------------------------------------
*
* обработка оптимизированного раздельного списка
*
----------------------------------------------------------------------- */
struct mylist{ // ОПТИМИЗИРОВАННЫЙ РАЗДЕЛЬНЫЙ СПИСОК
short int *next; // Массив указателей на следующий узел
int *val; // Массив значений
};
struct mylist separated_list;
// инициализация списка
for (a=0;a
{
separated_list.next[a] = a+1;
/* ^^^ обратите внимание где находятся
квадратные скобки */
separated_list.val[a] = a;
} separated_list.next[N_ELEM-1]=0;
// трассировка списка
while(b=separated_list.next[b]);
Листинг 13 [Memory/list.separated] Фрагмент программы, демонстрирующий эффективность трассировки разделенных списков
Результаты ее работы должны быть приблизительно следующими:

Рисунок 24 graph 0x08 Демонстрация эффективности обработки раздельных списков с указателями усеченной разрядности. Как видно, это значительно сокращает время трассировки списков, причем трехкратный выигрыш производительности (достигнутый в данном случае) – далеко не предел!
И впрямь, оптимизированный вариант оказался намного быстрее! Правда, не в четыре раза – как ожидалось – а всего лишь в три с половиной (обработка 16-разрядных значений на современных процессорах неэффективна), но и этим результатом по праву можно гордится! К тому же, если развернуть цикл и обрабатывать ссылки параллельно (см. "Параллельная обработка данных"), выигрыш будет еще большим!
Сложные случаи и капризы раздельной оптимизации.
Хорошо, а если у нас имеется структура вида (14), включающая в себя тело некоторого объекта (obj_body) и атрибуты объекта (obj_name). Пусть тело объекта занимает несколько килобайт, а его атрибуты – с пол сотни байт. Допустим так же, что нам необходимо написать функцию, обрабатывающую атрибуты всех объектов, но не трогающую тела этих самих объектов.
struct list_of_obj {
struct list_of_obj *next;
int obj_attr[14];
int obj_body[8000];
}
Листинг 14. Не оптимизированная структура
Имеет ли смысл помещать элементы такой структуры в раздельные массивы? Давайте подсчитаем. Длина пакетного цикла обмена в подавляющем большинстве случаев составляет 32 (K6/P-II/P-III) или 64 байта (Athlon). Сумма длин полей obj_attr и obj_body – 60 байт. Следовательно, всего лишь ~10% загруженных ячеек остаются невостребованными. Это весьма незначительная потеря и ей, по идее, можно безболезненно пренебречь. Но, прежде чем делать окончательный вывод, не поленимся, а сравним оптимизированный и не оптимизированный варианты на практике.
/* -----------------------------------------------------------------------
*
* обработка классического списка
*
------------------------------------------------------------------------ */
struct LIST_OF_OBJ { // НЕОПТИМИЗИРОВАННЫЙ СПИСОК
struct LIST_OF_OBJ *next; // указатель на следующий объект
int obj_attr[ATTR_SIZE]; // атрибуты объекта /нечто компактное
int obj_body[BODY_SIZE]; // тело объекта /нечто монстровое
};
struct LIST_OF_OBJ *list_of_obj, *tmp_list_of_obj;
// выделение памяти
list_of_obj = (struct LIST_OF_OBJ*)
_malloc32(N_ELEM*sizeof(struct LIST_OF_OBJ));
// инициализация списка
for (a = 0; a < N_ELEM; a++)
list_of_obj[a].next = list_of_obj + a + 1; list_of_obj[N_ELEM-1].next = 0;
// трассировка списка
tmp_list_of_obj = list_of_obj;
do{
for(attr = 0; attr < ATTR_SIZE; attr++)
x += tmp_list_of_obj[0].obj_attr[attr];
} while(tmp_list_of_obj = tmp_list_of_obj[0].next);
/* ----------------------------------------------------------------------
*
* Обработка оптимизированного раздельного списка
*
----------------------------------------------------------------------- */
struct LIST_OF_OBJ_OPTIMIZED { // ОПТИМИЗИРОВАННЫЙ СПИСОК
struct LIST_OF_OBJ_OPTIMIZED *next; // указатель на следующий объект
#ifdef PESSIMIZE
int *obj_attr; // указатель на атрибуты (это плохо!)
#else
int obj_attr[ATTR_SIZE]; // атрибуты объекта (это хорошо!)
#endif
int *obj_body; // указатель на тело объекта
};
struct LIST_OF_OBJ_OPTIMIZED *list_of_obj_optimized, *tmp_list_of_obj_optimized;
// выделение памяти
list_of_obj_optimized = (struct LIST_OF_OBJ_OPTIMIZED*)
_malloc32(N_ELEM*sizeof(struct LIST_OF_OBJ_OPTIMIZED));
// инициализация списка
for
(a = 0; a
< N_ELEM ;a++)
{
list_of_obj_optimized[a].next = list_of_obj_optimized + a + 1;
#ifdef PESSIMIZE
list_of_obj_optimized[a].obj_attr = malloc(sizeof(int)*ATTR_SIZE);
#endif
list_of_obj_optimized[a].obj_body = malloc(sizeof(int)*BODY_SIZE);
} list_of_obj_optimized[N_ELEM-1].next = 0;
// трассировка списка
tmp_list_of_obj_optimized = list_of_obj_optimized;
do{
for(attr = 0; attr < ATTR_SIZE; attr++)
x+ = tmp_list_of_obj_optimized[0].obj_attr[attr];
} while(tmp_list_of_obj_optimized = tmp_list_of_obj_optimized[0].next);
Листинг 15 [Memory/list.obj.c] Фрагмент программы, демонстрирующий эффективность оптимизации структуры (14)
Ого! Оптимизированный вариант обгоняет своего "классического" коллегу в скорости на целых 60% (см. рис. graph 9), – весьма существенный прирост производительности, не так ли? К сожалению, оптимизированный вариант весьма капризен. Если заменить оптимизированную структуру (14):
struct list_of_obj_optimized {
struct list_of_obj_optimized *next;
int obj_attr[ATTR_SIZE];
int *obj_body;
};
на ее ближайший аналог:
struct list_of_obj_optimized {
struct list_of_obj_optimized *next;
int *obj_attr;
int *obj_body;
};
…то разница между оптимизированным и не оптимизированным вариантом составит всего лишь 30%!!! (см. рис. graph 9). Совершенно непонятно: чем же int *obj_attr хуже, чем int obj_attr[ATTR_SIZE]?! И тем более непонятно, за счет чего в последнем случае достигается такая производительность. Мистика прям какая-то! Ведь, исходя из самых общих соображений, ясно: количество загруженных ячеек памяти во всех случаях должно быть одинаково!

Рисунок 25 graph 0x09 Демонстрация эффективности различных подходов к оптимизации структуры (14) Учет особенностей станичной организации памяти (см. ниже) позволяет более чем в два раза сократить время обработки списка
Учитывайте станичную организацию памяти. Ненадолго оторвемся от этой маленькой головоломки и исследуем зависимость времени трассировки списка от шага чтения памяти. Казалось бы, какие тут могут быть сюрпризы? А вот попробуйте с ходу объяснить форму кривой, полученной с помощью программы list.step.c.
// перебор различных значений шага чтения памяти
for(a = STEP_FACTOR; a < MAX_STEP_SIZE; a += STEP_FACTOR)
{
#ifdef UNTLB
// загрузка страниц в TLB
for (i=0;i<=BLOCK_SIZE;i+=4*K) x += *(int *)((int)p + i+32);
#endif
L_BEGIN(0); // <-- начало замера времени выполнения
i=0;
// чтение памяти с шагом a
for(b = 0; b < MAX_ITER; b++)
{
x += *(int *)((int)p + i);
i
+= a;
}
L_END(0); // <-- конец замера времени выполнения
}
Листинг 16 [Memory/list.step.c] Фрагмент программы, демонстрирующий зависимость времени трассировки списка от шага чтения памяти
Кривая (см. рис. graph 0x007) и впрямь ведет себя очень интересно. Время трассировки – вопреки всем прогнозам – не остается постоянным, а увеличивается вместе с шагом! Правда, это увеличение не бесконечно, – при достижении отметки в 32 Кб для P-II\P-III и 64 Кб для AMD Athlon, кривая достигает максимума насыщения и переходит в горизонтальное "плато", превышающее "подножье" по высоте более чем в пять раз! Это слишком большая величина, и мы не можем просто взять и проигнорировать ее, но… чем же, черт побери, вызван рост времени обработки?! Конечно, оперативная память неоднородна и время доступа к ней непостоянно, но чтобы она была настолько
неоднородна.…
Какой физической реальности соответствует отметка в 32 Кб (а на Athlon и вовсе в 64 Кб)? Структур такого размера в памяти (насколько мы знаем память) заведомо нет. Между тем, время обработки при увеличении шага растет.
Почему? Можно конечно, отступиться от этой проблемы ("знаете, процессор – эта такая сложная вещь…" как ответили автору в службе технической поддержки), но ведь она так и останется "занозой" в теле и будет ныть. Нет уж, разбираться – так разбираться!
Если немного доработать программу, заставив ее выводить время доступа к каждой ячейке, то обнаружиться (см. [Memory/memory.way.c]), что через каждые четыре килобайта кривая внезапно изгибается в неприступный зубец (см. рис. graph 10), "отъедающий" десятки тысяч тактов процессора! Нет, это не типографская ошибка. Все так и должно быть, – ведь практически все современные операционные системы (и Windows с UNIX в том числе) используют страничную организацию памяти. Не останавливаясь подробно на этом вопросе (см. "Intel Architecture Software Developer's Manual Volume 3 System Programming Guide", §3.6 PAGING") отметим лишь тот факт, что с каждой страницей связана специальная 32?битная структура данных, содержащая атрибуты страницы и ее базовый адрес. При первом обращении к странице процессор считывает эти данные из физической памяти в свой внутренний буфер (именуемый TLB – Translation Look aside Buffer) и последующие обращения к той же самой станице происходят без задержек вплоть до вытеснения этой информации из TLB. Так вот где собака порылась! Оказывается, минимальной порцией обмена с памятью является отнюдь не 32-байтный пакетный цикл, а целая страница! Время задержки, возникающей при первом обращении к странице, лишь в полтора-два раза уступают времени чтения всей этой страницы, – т.е. независимо от количества реально прочитанных байт, обработка страницы занимает столько времени, как если она была прочитана целиком. Отсюда, – потоковые алгоритмы должны обращаться как можно к меньшему количеству страниц, т.е. в пределах одной страницы располагайте данные максимально плотно, не оставляя пустот.
Но постойте, постойте! Ведь если это предположение верно (а оно верно), насыщение должно наступать уже при четырех килобайтом шаге.
Действительно, чем шаг в восемь или двенадцать килобайт хуже четырех? Во всех трех случаях загрузка атрибутов страниц происходит на каждой итерации, т.е. цикл выполняется максимально неэффективно. Между тем, кривая, перевалив за четыре килобайта, пусть и уменьшает свой наклон (на P-III уменьшение наклона практически незаметно, но все же есть) и упорно продолжает свой рост. Почему?!
Дело в том, что атрибуты страниц не разбросаны хаотично по всей памяти, а объединены в особые структуры – Page Table (Страничные Таблицы или Таблицы страниц). Каждый элемент страничной таблицы занимает одно двойное слово, но, так как минимальная порция обмена с памятью превышает эту величину, то при обращении к одной странице процессор загружает из памяти атрибуты еще семи (пятнадцати – в Athlon) страниц, сохраняя эту информацию в сверхоперативной памяти. Вот вам и ответ на вопрос! Умножение восьми (пятнадцати) элементов на четыре (размер одной страницы в килобайтах) как раз и дает те загадочные 32 (64 на Athlon) килобайта, ограничивающие рост кривой.
Теперь становится понятно, почему вынос obj_body в отельный массив значительно ускорил обработку списка, – элементы *next и obj_attr стали располагаться плотнее и уместились в меньшее количество страниц.
Почему вынос obj_attr ухудшил производительность – понять несколько сложнее. Количество страниц, правда, в последнем случае будет несколько больше, но ненамного. Давайте подсчитаем. Первый вариант структуры занимает (sizeof(*next) + sizeof (int) * ATTR_SIZE + sizeof(*obj_body)) * N_ELEM = 64 Кб (16 страниц). Второй вариант: (sizeof(*next) + sizeof(*obj_attr) + sizeof(*obj_body)) * N_ELEM + sizeof(int) * ALIGN_ATTR_SIZE * N_ELEM = 76 Кб (19 страниц). Ну, пусть за счет округления набежит еще пара страниц. Тогда 21/16 = 1.3, в то время как соотношения производительности первого и второго вариантов составляет 1.4 для Athlon и аж 1.8 для P-III. Куда уходят такты?! А вот куда. Стратегия выделения памяти функции malloc такова, что при запросе маленьких блоков каждый последующий выделенный блок имеет меньший адрес, чем предыдущий.
Т.е. элементы страничного каталога читаются в обратном направлении, а пакетный цикл памяти – в прямом. Как следствие, процессору приходится дольше ожидать получения атрибутов "своей" страницы, – стоит ли удивляться снижению производительности?
Подытоживая все вышесказанное, сформулируем следующее правило: для достижения максимальной производительности все станицы, к которым происходит обращение, следует использовать целиком. Причем, страницы должны запрашивается в порядке возрастания их линейных адресов.

Рисунок 26 graph 0х007 График, иллюстрирующий время обработки блока данных в зависимости от шага чтения. На P-III насыщение наступает на 32 Кб шаге чтение данных, а на AMD Athlon – на 64 Кб. Причем, на участке (0; 4Кб] происходит резкий "влет" кривой, особенно хорошо заметный на AMD Athlon.

Рисунок 27 graph 10 График, иллюстрирующий зависимость времени доступа к ячейке от адреса этой ячейки при последовательном обращении к данным. Смотрите, – при первом обращении к странице возникает пауза в десятки тысяч тактов!
Умножение
Вообще-то, умножение – достаточно быстрая операция и особой необходимости в ее оптимизации нет. Но, как говорится, копейка рубль бережет. Вот компиляторы и борются за каждый такт времени процессора!Если один из сомножителей представляет собой степень двойки – в ход идут битовые сдвиги. Это очевидно, но не все знают: как быстро выполнить умножение на числа 3, 5, 6, 7, 9, 10 и т.д. Оказывается, в этих случаях на помощь приходит сложение – в самом деле, (a*3) можно записать как: ((a>><<1)+a), что с легкостью укладывается в один такт (LEA может не только складывать, но и умножать один из регистров на числа 2, 4 и 8).
Компиляторы Microsoft Visual C++ и Borland C++ умело заменяют умножение битовыми сдвигами, при необходимости комбинируя их с операцией сложения, а вот WATCOM предпочитает обходиться без LEA, проигрывая своим конкурентам один такт (точнее, с учетом спаривая, даже полтора такта).
Упорядочивание обращения к памяти
Задержки при доступе в память возникают в следующих случаях:Чтение большой порции данных из памяти следует за записью малой порции по адресу из той же 32-байтной выровненной области, или в область памяти, перекрывающейся с той, откуда идет чтение.
Чтение малой порции следует за записью большой порции по другому адресу, и области памяти перекрываются. Если адреса относятся к одной 32-байтной выровненной области, а области памяти не перекрываются, то задержка не возникает.
Данные одного размера вначале записываются, затем загружаются с другого адреса; области памяти перекрываются и пересекают границу 32-байтной выровненной области.
Для предотвращения таких задержек следует:
Использовать выровненные данные одного размера в коде, который производит чтение и запись по одному и тому же адресу.
Размещать операции чтения и записи в одну и ту же область памяти как можно дальше друг от друга.
Управление кэшированием в x86 процессорах старших поколений
Программному управлению кэшированием просто не повезло. Концепция "прозрачного" кэша, активно продвигаемая фирмой Intel, абстрагировала программистов от подробностей аппаратной реализации кэш-контроллера и не предоставила им никаких рычагов управления. Впрочем, для достижения полной абстракции, интеллектуальности первых кэш-контроллеров все равно хватало и для системных программистов пришлось оставить крохотную лазейку, позволяя им в частности запрещать кэширование страниц памяти, принадлежащих периферийным устройствам.До тех пор, пока подавляющее большинство приложений перемалывало компактные, многократно обрабатываемые структуры данных, стратегия загрузки кэш-линеек по первому требованию вполне справлялась со своей задачей, но с появлением мультимедийных приложений стала "буксовать". Резко возросший объем обрабатываемых данных вкупе с нашествием потоковых алгоритмов, обращающихся к каждой ячейке памяти всего лишь раз, привел к постоянным перезагрузкам кэша, ограничивая тем самым производительность системы не быстродействием процессора, а пропускной способностью оперативной памяти.
Первой этой проблеме бросила вызов фирма AMD, включив в состав набора команд 3D Now! инструкцию prefetch, позволяющую программисту заблаговременно загружать в кэш ячейки памяти, к которым он рассчитывает обратиться в ближайшем будущем. Причем, загрузка данных осуществляется без участия и остановки вычислительного конвейера! Это убивает двух зайцев сразу: во-первых, "ручное" управление кэш-контроллером позволяет выбрать оптимальную стратегию упреждающей загрузки данных, что существенно уменьшает количество кэш-промахов, а, во-вторых, с предвыборкой становится возможным загружать очередную порцию данных параллельно с обработкой предыдущей, маскируя тем самым латентность тормозов оперативной памяти!
Следом за K6, предвыборка (естественно, в усовершенствованном варианте) появилась и в процессоре Pentium-III, да не одна, а с целой свитой команд "ручного" управления кэшированием, – Intel явно не хотела отставать от конкурентов!
Совершенствование управления подсистемой памяти продолжилось и в Pentium-4. Помимо множества новых команд (таких как….), в нем реализован воистину уникальный (с появлением Athlon XP уже, увы, не уникальный) на сегодняшний день механизм аппаратной предвыборки
с интеллектуальным алгоритмом упреждающей загрузки. Анализируя порядок, в котором приложение запрашивает данные из оперативной памяти, процессор пытается предсказать (приблизительно так же, как предсказывает направление условных переходов) адрес следующей обрабатываемой ячейки, чтобы спекулятивно загрузить ее в кэш еще до того, как в ней возникнет необходимость. Естественно, при всей прозрачности аппаратной предвыборки, организовать структуры данных желательно так, чтобы процессор пореже ошибался в своих предсказаниях (а в идеале – не ошибался вообще).
При грамотном обращении команды управления кэшированием (равно как и аппаратная предвыборка) ускоряют типовые операции с памятью, по крайне мере, в три-пять раз, а в некоторых случаях и более того! К сожалению, оптимизацию кэширования невозможно возложить на плечи компилятора. Она осуществляется на уровне структур данных и алгоритмов их обработки, а оптимизировать алгоритмы компиляторы еще не научились (и маловероятно, чтобы научились в обозримом будущем). Поэтому, эту работу программистам приходится выполнять самостоятельно.
Устранение зависимостей по данным
Если запрашиваемые ячейки оперативной памяти имеют адресную зависимость по данным (т.е. попросту говоря, одна ячейка содержит адрес другой), процессор не может обрабатывать их параллельно, и вынужден простаивать в ожидании поступления адресов. Рассмотрим это на следующем примере: while(next=p[next]). До тех пор, пока процессор не узнает значение переменной next, он не сможет приступить к загрузке следующей ячейки, т.к. еще не знает ее адреса. Время выполнения такого цикла определяется в основном латентностью подсистемы памяти и практически не зависит от ее пропускной способности. И SDRAM, и DDR-SDRAM, и даже сверхпроизводительная RDRAM покажут практически одинаковый результат, над которым посмеялась бы и EDO-DRAM, будь одна до сих пор жива. Латентность же подсистемы памяти на современных компьютерах весьма велика и составляет приблизительно 20 тактов системной шины, что соответствует полному времени доступа в 200 нс.Прямую противоположность этому составляет цикл вида: while(a=p[next++]). Процессор, отправив чипсету запрос на загрузку ячейки p[next], немедленно увеличивает next на единицу и, не дожидаясь ответа (зачем? ведь адрес следующей ячейки известен), посылает чипсету еще один запрос. Потом еще один, и еще.… Так продолжается до тех пор, пока количество необработанных запросов не достигает своего максимально допустимого значения (для P6 – четырех). Поскольку, запросы следуют друг за другом с минимальным интервалом, в первом приближении можно считать, что они обрабатываются параллельно. И это действительно так! Если время загрузки N зависимых ячеек в общем случае равно: t = N(Tch + Tmem), где Tch – латентность чипсета, а Tmem – латентность памяти, то такое же количество независимых ячеек будут загружены за время , где C – пропускная способность подсистемы памяти.
Таким образом, при обработке независимых данных пагубное влияние латентности подсистемы памяти в значительной мере ослабляется, и производительность определяется исключительно пропускной способностью.
Правда, достичь заявленного производителем количества мегабайт в секунду таким способом все равно не удастся (ведь число одновременно обрабатываемых запросов ограничено и полного параллелизма в этой схеме не достигается), но полученный результат будет, по крайней мере, не худшего порядка.
Наглядно сравнить скорость обработки зависимых и независимых данных позволяет следующая программа:
/* ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
*
* цикл чтения зависимых данных
* (не оптимизированный вариант)
* ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
for (a=0; a < BLOCK_SIZE; a += 32)
{ /* ^^^^^ мы разворачиваем цикл для более быстрой обработки */
// читаем ячейку
x = *(int *)((int)p1 + a + 0);
// адрес следующей ячейки вычисляется на основе значения предыдущей
// поэтому, процессор не может послать очередной запрос чипсету до тех пор,
// пока не получит эту ячейку в свое распоряжение
a += x;
// дальше - аналогично...
y = *(int *)((int)p1 + a + 4);
a += y;
x = *(int *)((int)p1 + a + 8);
a += x;
y = *(int *)((int)p1 + a + 12);
a += y;
x = *(int *)((int)p1 + a + 16);
a += x;
y = *(int *)((int)p1 + a + 20);
a += y;
x = *(int *)((int)p1 + a + 24);
a += x;
y = *(int *)((int)p1 + a + 28);
a
+= y;
}
/* ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
*
* цикл чтения независимых данных
* (оптимизированный вариант)
* ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
for (a=0; a
{
// теперь процессор может посылать очередной запрос чипсету,
// не дожидаясь завершения предыдущего, т.к. адрес ячейки
// никак не связан с обрабатываемыми данными
x += *(int *)((int)p1 + a + 0);
y += *(int *)((int)p1 + a + 4);
x += *(int *)((int)p1 + a + 8);
y += *(int *)((int)p1 + a + 12);
x += *(int *)((int)p1 + a + 16);
y += *(int *)((int)p1 + a + 20);
x += *(int *)((int)p1 + a + 24);
y += *(int *)((int)p1 + a + 28);
}
Листинг 9 [Memory/dependence.c] Фрагмент программы, демонстрирующий эффективность обработки независимых данных
Результат тестирования двух компьютеров, имеющихся в распоряжении автора, представлен на рис. graph 1. Первое, что сразу бросается в глаза – значительный разрыв во времени обработки зависимых и независимых данных. В частности, на P?III/733/133/100 цикл чтения независимых данных выполняется в два с половиной раза быстрее! Несколько худший результат показывает AMD Athlon?1050/100/133/VIA KT 133, что объясняется серьезными конструктивными недоработками этого чипсета (см. "Вычисление полного времени доступа"). Недостаточная пропускная способность канала между контроллером памяти и блоком интерфейса с шиной (оба смонтированы в северном мосте чипсета) приводит к образованию постоянных заторов, и как следствие – ограничению количества одновременно обрабатываемых запросов. Тем не менее, даже в этом случае чтение независимых данных осуществляется намного эффективнее и весь вопрос в том, как именно следует их обрабатывать.
Линейное (оно же – последовательное) чтение ячеек памяти – не самая удачная идея, что и демонстрирует рис. graph 1. На P-III мы не достигли и 60% от расчетной пропускной способности, а на AMD Athlon и того меньше, – всего лишь немногим более 30%. "Потрясающая" производительность, не правда ли? Это что же такое получается?! Неужели архитектура PC настолько крива, что не позволяет справиться даже с такой простой штукой как оперативная память? Кстати, мы не первые, кому эта "здравая" мысль пришла в голову.У большинства прикладных разработчиков существует весьма устойчивое убеждение, что PC – это тормоз. По жизни. Но не спешите пересаживаться на Cray…

Рисунок 18 graph 0x001 Тест пропускной способности оперативной памяти при линейном чтении зависимых и независимых данных. На "правильном" чипсете Intel 815EP независимые данные обрабатываются в два с половиной раза быстрее. На чипсете VIA KT133 (за счет его высокой латентности) различие в производительности намного меньше и составляет всего 1,7 крат. Но, как бы так ни было, на любой системе обрабатывать зависимые данные крайне невыгодно. Обратите также внимание, что при линейном чтении данных заявленная пропускная способность (800 Мб/сек. для данного типа памяти) не достигается
Устройство интерфейсной обвязки
По своему устройству, интерфейсная обвязка матрицы статической памяти, практически ничем не отличается от аналогичной ей обвязки матрицы динамической памяти (см. "Часть I. Оперативная память: устройство и принципы функционирования оперативной памяти. Conventional DRAM Page Mode DRAM – обычная DRAM") Поэтому, не будем подробно останавливаться на этом вопросе и рассмотрим его лишь в общих чертах.Пожалуй, единственное различие в интерфейсах статической и динамической памяти заключается в том, что микросхемы статической памяти имея значительно меньшую емкость (а, следовательно – и меньшее количество адресных линий) и геометрически располагаясь гораздо ближе к процессору, могут позволить себе роскошь не прибегать к мультиплексированию. И потому, для достижения наивысшей производительности, номера строк и столбцов чаще всего передаются одновременно.
Если статическая память выполнена в виде самостоятельной микросхемы, а не располагается непосредственно на кристалле процессора, линии ее входа зачастую объединяют с линиями выхода, и требуемый режим работы приходится определять по состоянию специального вывода WE (Write Enable). Высокое состояние вывода WE
готовит микросхему к чтению данных, а низкое – к записи. Статическая память, размещенную на одном кристалле вместе с процессором, обычно не мультиплексирует, и в этом случае содержимое одной ячейки можно читать параллельно с записью другой (линии входа и выхода ведь раздельные!).
Номера столбцов и строк поступают на декодеры столбца и строки соответственно (см. рис. 0х007). После декодирования расшифрованный номер строки поступает на дополнительный декодер, вычисляющий, принадлежащую ей матрицу. Оттуда он попадает непосредственно на выборщик строки, который открывает "защелки" требуемой страницы. В зависимости от выбранного режима работы чувствительный усилитель, подсоединенный к битовым линейкам матрицы, либо считывает состояние триггеров соответствующей raw-линейки, либо "перещелкает" их согласно записываемой информации.

Рисунок 7 0х007 Устройство типовой микросхемы SRAM-памяти
Устройство элемента "НЕ" (инвертора)
Как устроен элемент "НЕ"? На этот вопрос нельзя ответить однозначно. В зависимости от имеющейся у нас элементарной базы, конечная реализация варьируется в очень широких пределах.Ниже в качестве примера приведена принципиальная схема простейшего инвертора, сконструированного из двух последовательно соединенных комплементарых /* взаимно дополняемых */ CMOS-транзисторов – p- и n- канального (см. рис. 0х004).
Если на затворы подается нулевой уровень, то открывается только p-канал, а n-канал остается разомкнутым. В результате, на выходе мы имеем питающее напряжение (т.е. высокий уровень). Напротив, если на затворы подается высокий уровень, размыкается n-канал, а p-канал – замыкается. Выход оказывается закорочен на массу и на нем устанавливается нулевое напряжение (т.е. низкий уровень).

Рисунок 3 0х004 Устройство элемента НЕ (инвертора)
Устройство матрицы статической памяти
Подобно ячейкам динамической памяти (см. "ЧастьI. Оперативная память: устройство и принципы функционирования оперативной памяти. Conventional DRAM Page Mode DRAM – "обычная" DRAM"), триггеры объединяются в единую матрицу, состоящую из строк (row) и столбцов (column), последние из которых так же называются битами (bit).В отличии от ячейки динамической памяти, для управления которой достаточно всего одного ключевого транзистора, ячейка статической памяти управляется как минимум двумя. Это не покажется удивительным, если вспомнить, что триггер, в отличии от конденсатора, имеет раздельные входы для записи логического нуля и единицы соответственно. Таким образом, на ячейку статической памяти расходуется целых восемь транзисторов (см. рис. 0х005) – четыре идут, собственно, на сам триггер и еще два – на управляющие "защелки".

Рисунок 4 0х005 Устройство 6-транзистроной одно-портовой ячейки SRAM-памяти
Причем, шесть транзисторов на ячейку – это еще не предел! Существуют и более сложные конструкции! Основной недостаток шести транзисторной ячейки заключается в том, что в каждый момент времени может обрабатываться всего лишь одна строка матрицы памяти. Параллельное чтение ячеек, расположенных в различных строках одного и того же банка невозможно, равно как невозможно и чтение одной ячейки одновременно с записью другой.
Этого ограничения лишена многопортовая память. Каждая ячейка многопортовой памяти содержит один-единственный триггер, но имеет несколько комплектов управляющих транзисторов, каждый из которых подключен к "своим" линиям ROW и BIT, благодаря чему различные ячейки матрицы могут обрабатываться независимо. Такой подход намного более прогрессивен, чем деление памяти на банки. Ведь, в последнем случае параллелизм достигается лишь при обращении к ячейкам различных банков, что не всегда выполнимо, а много портовая память допускает одновременную обработку любых
ячеек, избавляя программиста от необходимости вникать в особенности ее архитектуры. (Замечание: печально, но кэш-память x86-процессор не истинно многопортовая, а состоит из восьми одно-портовых матриц, подключенных к двух портовой интерфейсной обвязке; см.
так же "Оптимизация обращения к памяти и кэшу. Стратегия распределения данных по кэш-банкам")
Наиболее часто встречается двух - портовая память, устройство ячейки которой изображено на рис. 0х006. (внимание! это совсем не та память которая, в частности, применяется в кэше первого уровня микропроцессоров Intel Pentium). Нетрудно подсчитать, что для создания одной ячейки двух – портовой памяти расходуется аж восемь транзисторов. Пусть емкость кэш-памяти составляет 32 Кб, тогда только на одно ядро уйдет свыше двух миллионов транзисторов!

Рисунок 5 0х006 Устройство 8-транзистроной двух портовой ячейки SRAM-памяти


Рисунок 6 sram.dev3 sram.top Ячейка динамической памяти воплощенная в кристалле
Устройство триггера
В основе всех триггеров лежит кольцо из двух логических элементов "НЕ" (инверторов), соединенных по типу "защелки" (см. рис. 0х002). Рассмотрим, как он работает. Если подать на линию Q сигнал, соответствующий единице, то, пройдя сквозь элемент D.D1 он обратится в ноль. Но, поступив на вход следующего элемента, – D.D2 – этот ноль вновь превратится в единицу. Поскольку, выход элемента D.D2 подключен ко входу элемента D.D1, то даже после исчезновения сигнала с линии Q, он будет поддерживать себя самостоятельно, т.е. триггер перейдет в устойчивое состояние. Образно это можно уподобить дракону, кусающему себя за хвост.Естественно, если на линию Q подать сигнал, соответствующий логическому нулю,– все будет происходить точно так же, но наоборот!


Рисунок 2 0х002, lesiem_cd2.TIF Устройство простейшего триггера (слева). Образно это можно представить драконом, кусающим свой хвост
Увеличение эффективности предвыборки.
Предотвращение "холостого" хода. Сдвиг предвыборки на несколько итераций приводит к возникновению "холостого" хода – неэффективному исполнению первых psd проходов цикла, ввиду отсутствия запрашиваемых данных в кэше и вытекающей отсюда необходимостью ожидания их загрузки из медленной основной памяти. Если цикл исполняется многократно (скажем, сто или даже сто тысяч раз), накладные расходы настолько невелики, что вряд ли кому придет в голову брать их в расчет. Если же цикл исполняется несколько десятков раз, то время его выполнения практически не сказывается на производительности системы, и им можно вновь пренебречь. Однако если такой цикл вызывается неоднократно (скажем, из другого цикла), то потери от холостого хода окажутся весьма внушительными.Рассмотрим следующий пример:
for (a = 0; a < N; a++)
{
for (b = 0; b < BLOCK_SIZE; b+=STEP_SIZE)
{
// Для переноса примера на K6\Athlon замените
// нижеследующую инструкцию на prefetch
_prefetchnta (p[a][b+STEP_SIZE]);
computation (a[a][b]);
}
}
Листинг 24 Пример, демонстрирующий "холостой" ход предвыборки
Поскольку, дистанция предвыборки цикла B равна единице, то первый проход цикла всегда исполняется неэффективно – "вхолостую". При небольшом значении BLOCK_SIZE и внушительном N с этим трудно смириться! А при дистанции предвыборки сравнимой с количеством итераций цикла B ее эффективность и вовсе стремится к нулю. Точно такая же ситуация наблюдается и на P-4, механизм аппаратной предвыборки которого не настолько интеллектуален, чтобы справиться с вложенными циклами.
Как, не сильно усложнив алгоритм, увеличить его производительность? Да очень просто – достаточно лишь в последней итерации цикла B осуществить предвыборку следующей обрабатываемой ячейки. В данном случае ею будет ячейка p[a+1][0].
Оптимизированный вариант кода может выглядеть, например, так:
for (a = 0; a < N; a++)
{
for (b = 0; b
if (b==(BLOCK_SIZE-STEP-SIZE))
_prefetchnta (p[a+1][0]);
else
_prefetchnta (p[a][b+STEP_SIZE]);
computation (p[a][b]);
}
}
Листинг 25 Черновая демонстрация удаление "холостого" хода предвыборки
Однако использование ветвлений в теле цикла не самым лучшим образом сказывается на его производительности, поэтому условный переход следует выкинуть, переписав код, например, так:
for (a = 0; a < N; a++)
{
for (b = 0; b <(BLOCK_SIZE-STEP_SIZE); b+=STEP_SIZE)
{
_prefetchnta (p[a][b+STEP_SIZE]);
computation (p[a][b]);
}
_prefetchnta (p[a+1][0]);
computation (p[a][b]);
}
Листинг 26 Финальная демонстрация удаления "холостого" хода предвыборки
После модернизации программы, останется не устраненным всего лишь один холостой проход, возникающий при первом выполнении цикла В (точнее, не устраненными останутся psd проходов), что даже при небольшом N практически не сказывается на производительности.
Уменьшение количества инструкций предвыборки. Все предыдущие рассуждения молчаливо опирались на предположение, что шаг цикла равен размеру кэш-линейки, а в реальной жизни так бывает далеко не всегда. Наглядной демонстрацией тому следует следующий пример:
int p[N];
#define computation (x) zzz+=(x)*0x666; zzz+=p[x];
for (a = 0; a < N; a+=sizeof(int))
{
_prefetchnta (p[a + 32*3]);
computation (a);
}
Листинг 27 Демонстрация чрезмерного злоупотребления предвыборкой
Поскольку каждый элемент массива p занимает всего лишь 8 байт, а размер кэш-линеек процессора в зависимости от модели составляет от 32 до 128 байт, в большинстве итераций цикла команда предвыборки будет выполняться вхолостую, т.к. запрошенные данные уже находятся в кэше, и загружать из оперативной памяти их не требуется. В такой ситуации команда предвыборка ведет себя аналогично инструкции NOP, однако накладные расходы на ее выполнение отнюдь не равны нулю! Чрезмерное засорение цикла предвыборкой (overprefetching) не то, что не ускоряет, а даже замедляет
его работу. В частности, пример??? 8 при исключении предвыборки исполняется на 10%-15% быстрее! (см. рис. 0х019). (Следует так же учесть накладные расходы на передачу аргументов функции предвыборки).
Решение проблемы заключается в разворачивании цикла с подгонкой величины его шага к размеру кэш-линий. Это снизит накладные расходы на выполнение цикла и удалит все лишние предвыборки, в результате чего скорость выполнения кода значительно возрастет. Так, оптимизация предыдущего примера увеличивает скорость его выполнения на 80%, т.е. более чем в три раза:
for (a = 0; a < N; a+=32)
{
_prefetchnta (p[a + 32*3]);
computation (a+0);
computation (a+4);
computation (a+8);
computation (a+12);
computation (a+16);
computation (a+20);
computation (a+24);
computation (a+28);
}
Листинг 28 [cache_prefetch_unroll] Разворачивание цикла для исключения лишних запросов предвыборки

Рисунок 47 graph 0x019 Влияние лишних запросов предвыборки на производительность. За 100% приятно копирование памяти штатной функцией memcpy
Однако этот прием не лишен недостатков: во-первых, многократное дублирование тела цикла приводит к значительному увеличению объема исполняемого кода с вытекающим отсюда риском попросту не влезть в кэш. Во-вторых, величину шага (а, значит, и размер кэш-линеек) допустимо выбирать лишь на этапе создания программы и поменять ее "на лету" практически невозможно. Чем плоха "жесткая" прошивка? Дело в том, что, попав на процессор с иной длиной кэш-линий, программа будет исполняться недостаточно эффективно. Ориентироваться на размер в 32 байта – это хвататься за процессоры уходящего дня, но затачивать свой код под 128 байт – все равно, что бежать впереди паровоза. Рабочие станции на основе P-4 вытеснят P?III не раньше чем через несколько лет, но и тогда доля процессоров с 32 (64) байтными кэш-линейками будет весьма велика. Похоже, ничего не остается, как реализовать все критичные к быстродействию функции в нескольких вариантах и по ходу выполнения программы выбирать одну из них.
Но почему бы ни вставить в цикл тривиальный условный оператор, например, что-то вроде: if ((a % PREFETCH_CACHE_LINE_SIZE) == 0) prefetch(a+psd)? Во?первых, деление крайне
медленная операция и поэтому такой прием снизит производительность цикла на столько, что и домкратом ее не поднимешь. Во-вторых, даже если исхитриться и заменить деление битовыми операциями (или ввести в цикл дополнительный счетчик) накладные расходы будут по-прежнему довольно велики. Лучше уж остановить свой выбор на 32-байтных кэш-линейках, махнув рукой на оптимизацию под P-4 и более старшие процессоры, которые и без того быстры, а вот их младшим братьям требуется поддержка.
Мясной рулет предвыборки на инструкциях. До сих пор мы рассматривали случаи предвыборки лишь одной кэш-линейки за каждую итерацию цикла, но если параллельно обрабатывается несколько блоков памяти, расположенных в различных местах, одной операцией предвыборки уже не обойтись. Конечно, лучше всего – организовать структуру данных так, чтобы совместно используемые данные находились как можно ближе друг к другу, - в идеале вообще в пределах единой кэш-линейки. Но, увы, это не всегда возможно… Тогда, если уменьшить количество предвыборок невозможно, следует по крайней мере увеличить эффективность их выполнения.
Рассмотрим следующий пример:
for (a = 0; a < N; a+=32)
{
computation1 (p1[a]);
computation2 (p2[a]);
computation3 (p3[a]);
computation4 (p4[a]);
}
Листинг 29 Не оптимизированный пример, демонстрирующий обработку четырех различных ячеек памяти за каждую итерацию
При условии, что блоки памяти p1, p2, p3 и p4 расположены достаточно далеко друг от друга, требуется как минимум четыре инструкции предвыборки на каждую итерацию. Возникает вопрос – как лучше всего их расположить: поместить их в начало цикла или перемешать вместе с остальными инструкциями?
Вопрос не имеет однозначного ответа – каждый прием имеет свои сильные и слабые стороны, и чему отдать предпочтение зависит от конкретной ситуации.
С одной стороны, большое количество подряд идущих запросов на предвыборку, приводит к чрезмерной загруженности как системной, так и внутренней шины процессора и образованию "затора" в load- и fill-буферах. В результате, выполнение инструкций, обращающихся к данным, приостанавливается, даже если эти данные расположены в кэше первого уровня! Поэтому, предвыборку лучше перемежевать с вычислительными инструкциями, чтобы они без напряги для шины могли исполняться параллельно. С другой стороны, это утверждение верно лишь в отношении вычислительных инструкций, но не команд записи! Для достижения наивысшей производительности системы настоятельно рекомендуется до минимума сократить количество транзакций между чтением и записью данных.
Таким образом, стратегия оптимального размещения команд предвыборки значительно усложняется. В первом приближении она выглядит так: если тело цикла состоит исключительно из вычислительных инструкций, ничего не записывающих в память, команды предвыборки лучше всего равномерно перемещать вместе с остальными инструкциями. Если же в теле цикла присутствуют команды записи, то попытайтесь скомбинировать код так, чтобы транзакции записи и чтения не пересекались.
Т.е. предвыборку с инструкциями записи должно разделять, по крайней мере, 30-50, а лучше еще большее количество тактов процессора. А что делать, если это невозможно? В таком случае будет лучше сгруппировать все операции предвыборки вместе, чем позволить им перемешаться с операциями записи. Вообще-то оптимальное чередование можно подобрать и экспериментально, только помните о том, что оно системно-зависимо.
В ядре
Ядро микросхемы динамической памяти состоит из множества ячеек, каждая из которых хранит всего один бит информации. На физическом уровне ячейки объединяются в прямоугольную матрицу, горизонтальные линейки которой называются строками(ROW), а вертикальные – столбцами
(Column) или страницами (Page).
Линейки представляют собой обыкновенные проводники, на пересечении которых находится "сердце" ячейки – несложное устройство, состоящее из одного транзистора и одного конденсатора (см. рис. 0x12.4).
Конденсатору отводится роль непосредственного хранителя информации. Правда, хранит он очень немного – всего один бит. Отсутствие заряда на обкладках соответствует логическому нулю, а его наличие – логической единице. Транзистор же играет роль "ключа", удерживающего конденсатор от разряда. В спокойном состоянии транзистор закрыт, но, стоит подать на соответствующую строку матрицы электрический сигнал, как спустя мгновение-другое (конкретное время зависит от конструктивных особенностей и качества изготовления микросхемы) он откроется, соединяя обкладку конденсатора с соответствующим ей столбцом.
Чувствительный усилитель (sense amp), подключенный к каждому из столбцов матрицы, реагируя на слабый поток электронов, устремившихся через открытые транзисторы с обкладок конденсаторов, считывает всю страницу целиком. Это обстоятельство настолько важно, что последняя фраза вполне заслуживает быть выделенной курсивом. Именно страница является минимальной порцией обмена с ядром динамической памяти. Чтение/запись отдельно взятой ячейки невозможна! Действительно, открытие одной строки приводит к открытию всех, подключенных к ней транзисторов, а, следовательно, – разряду "закрепленных" за этими транзисторами конденсаторов. Хочешь – не хочешь,– а читай всю строку за раз целиком!
"…эти шары содержат информацию… Их надо переписать на пленку, потому что они разового действия. Молекулы после прослушивания снова приходят в хаос"
"Имею скафандр - готов путешествовать" Роберт Ханлайн
Чтение ячейки деструктивно по своей природе, поскольку sense amp (чувствительный усилитель) разряжает конденсатор в процессе считывания его заряда. "Благодаря" этому динамическая память представляет собой память разового действия. Разумеется, такое положение дел никого устроить не может, и потому во избежание потери информации считанную строку приходится тут же перезаписывать вновь. В зависимости от конструктивных особенностей эту миссию выполняет либо программист, либо контроллер памяти, либо сама микросхема памяти. Практически все современные микросхемы принадлежат к последней категории. Редко какая из них поручает эту обязанность контроллеру, и уж совсем никогда перезапись не возлагается на программиста.
Ввиду микроскопических размеров, а, следовательно, и емкости конденсатора записанная на нем информация хранится крайне недолго, – буквально сотые, а то тысячные доли секунды. Причина тому – саморазряд конденсатора. Несмотря на использование высококачественных диэлектриков с огромным удельным сопротивлением, заряд стекает очень быстро, ведь количество электронов, накопленных конденсатором на обкладках, относительно невелико. Для борьбы с "забывчивостью" памяти прибегают к ее регенерации
– периодическому считыванию ячеек с последующей перезаписью. В зависимости от конструктивных особенностей "регенератор" может находиться как в контроллере, так и в самой микросхеме памяти. Например, в компьютерах XT/AT регенерация оперативной памяти осуществлялась по таймерному прерыванию каждые 18 мс через специальный канал DMA (контроллера прямого доступа). И всякая попытка "замораживания" аппаратных прерываний на больший срок приводила к потере и/или искажению оперативных данных, что не очень-то радовало программистов, да к тому же снижало производительность системы, поскольку во время регенерации память была недоступна. Сегодня же регенератор чаще всего встраивается внутрь самой микросхемы, причем перед регенерацией содержимое обновляемой строки копируется в специальный буфер, что предотвращает блокировку доступа к информации.

Рисунок 4 core 1024-битное ядро памяти компьютера UNIVAC-11015. Действительный размер этого ядра составляет 5.5 см. Похожее на грубо обработанную мешковину, оно, в действительности, состоит из многочисленных ферритовых "пончиков", каждый из которых может хранить всего лишь один бит информации. По легенде, намотку ферритовых колец осуществляли… девушки, вооруженные стереоскопическим микроскопом. Мужчины, по всей видимости оказывались слишком неуклюжими и недостаточно усидчивыми для этой тонкой и чрезвычайно кропотливой работы. (Попробовал бы сегодня кто-нибудь намотать 1 гигобит ячеек памяти вручную!). Восемь таких ядер объединялись в стопку, реализуя на пересечении столбцов и колонок целый байт информации (термин "машинное слово" в те годы еще не был изобретен!).
В кэше первого уровня
Смотрите (см. graph 1): до тех пор, пока размер обрабатываемого блока не превышает размера кэш-памяти первого уровня (32Кб для AMD K6), все четыре графика идут практически горизонтально, – т.е. скоростной показатель остается практически неизменным, причем сейчас, после удаления линейной составляющей, это видно наглядно и нам уже не приходится вычислять коэффициент пропорциональности с линейкой и калькулятором в руках.Обратите внимание на небольшой "завал", расположенный в начале всех четырех графиков. Как бы вы его интерпретировали? Конечно же, удельное время обработки блока не уменьшается с ростом его размера, – это сказываются неучтенные накладные расходы на замер времени выполнения цикла.
Помимо этого, увеличенный масштаб графика (ведь мы собственноручно растянули его в сто раз по вертикали – см. 100*Ax_GET) позволяет безо всякого труда установить, что запись ячеек памяти происходит в три раза быстрее чтения и в четыре раза быстрее чтения, последующим за записью. Виною тому – накладные расходы на организацию цикла. Компилятор Microsoft Visual C++ 6.0, которым транслировалась тестовая программа, сумел распознать цикл записи и заменил его всего одной машинной командой: REP STOSD, в то время как цикл чтения занял несколько машинных команд.
В зависимости от микро архитектуры процессора, обращение к кэш-памяти может занимать от одного до трех тактов, а максимальное количество одновременно обрабатываемых ячеек варьируется от двух до четырех. Характеристики некоторых, наиболее популярных процессоров, приведены в таблице 3.
| процессор | латентность | пропускная способность | макс. операций | ||||||
| K5 | чтение | 1 | 2 | R+R | R+W | |||||
| запись | 1 | ||||||||
| K6 | чтение | 2 | 1 | R + W | |||||
| запись | 1 | 1 | |||||||
| Athlon | чтение | ? | 2 | R+R+W+W | |||||
| запись | ? | 2 | |||||||
| P-II, P-III | чтение | 3 | 1 | R+W | |||||
| запись | 1 | 1 | |||||||
| P-4 | чтение | 2 | 1 | R+W | |||||
| запись | 2 | 1 |
Таблица 3 Основные характеристики кэш памяти некоторых моделей процессоров.
Как рассчитать реальное время доступа к ячейке? Давайте сделаем это на примере процессора AMD K6. Только сначала уточним, что именно мы условимся понимать под "реальным временем доступа". Графа "латентность" сообщает количество тактов, прошедших с момента обращения к ячейке, до завершения операции чтения (записи). В частности, операция чтения ячейки осуществляется за два этапа (так же называемых стадиями): вычисление эффективного адреса и загрузка данных. На AMD K6 каждый из этих этапов выполняется за один такт, и поэтому полное время чтения ячейки составляет два такта.
Тем не менее, при отсутствии зависимости по данным, процессор может приступать к загрузке следующей ячейке не через два такта, а всего лишь через один, ведь устройство вычисления эффективного адреса уже освободилось и к началу следующего такта вновь готово к работе! Таким образом, N независимых ячеек могут быть прочитаны за N*Throughput + Latency
тактов. Очевидно, что при большом N, величиной латентности можно полностью пренебречь, приняв среднее время доступа к ячейке в 1 такт на K6 и 0.5 таков на Athlon (Athlon способен загружать две 32 битных ячейки за такт).
Правда тут есть одно "но". Параллельное выполнение команд осуществляется как минимум при наличии этих самых команд. В свернутом цикле вида for(a=0; a < BLOCK_SIZE; a++) x+=p[a]; происходит лишь одно обращение к памяти за каждую итерацию (при условии, что переменные a
и x
расположены в регистрах, конечно), поэтому время выполнения данного цикла окажется значительно больше, чем N*Throughput + Latency. Решение проблемы состоит в развороте цикла по крайней мере на две итерации. Подробнее этот вопрос уже рассмотрен в ("Часть I. Оптимизация работы с памятью. Разворачивание циклов") сейчас же нас больше интересует другой аспект, а именно: как изменится производительность при выходе за кэш первого уровня.
В кэше второго уровня
Время загрузки данных из кэша второго уровня составляет порядка тактов процессора в пересчете на одну 32битную ячейку, где F.CACHE – частота работы кэша второго уровня, F.CPU – частота ядра, а N.BUS – разрядность локальной кэш-шины. Латентность кэш-контроллера и блока интерфейсов с шиной здесь не учитывается, т.к. при конвейерной обработке данный ей можно полностью пренебречь.Таким образом, на процессоре K6-333/66 чтение блока, вылезающего за пределы кэш первого уровня, но еще умещающегося в кэш-памяти второго, должно осуществляться в приблизительно в 2.5 раз медленнее. В действительности же (особенно на неразвернутых циклах) разрыв в производительности будет даже меньшим, поскольку параллельно с загрузкой данных их кэша второго уровня процессор может выполнять другие команды, частично компенсируя тем самым падение быстродействия. И правда, судя по приведенному выше графику, скорость загрузки данных из кэша второго уровня уступает кэшу первого уровня где-то в полтора раза.
Совершенно иная ситуация складывается с записью данных. Действительно, если загруженные в кэш-память строки не были модифицированы, кэш-контроллер просто замещает их новыми, иначе же он вынужден предварительно выгружать их в кэшируемую память, что практически вдвое увеличивает время доступа к ячейке. Впрочем, этой задержке можно противостоять, например, введением специального буфера, расположенным между кэшем первого уровня и блоком интерфейсов в шиной. Процессор K6, однако, не имеет такого буфера и потому в данном случае время записи данных в кэш второго уровня составляет порядка тактов процессора, что и подтверждается графиком graph 1.
Вся беда в том, что при превышении размера последовательно записываемого блока все банки кэша начинают работать вхолостую. Т.е. кэш-контроллер четырех ассоциативного кэша вынужден делать четыре лишних выгрузки на каждую кэш-линейку, не умещающуюся в кэш-памяти. Отсюда и тормоза. И это еще что! На процессоре Pentium–II кэш второго уровня работает на половинной частоте ядра, вследствие чего торможение оказывается еще большим!
В ожидании конца света
Это в наше-то время говорить о конце света? Лучше уж о прошлогоднем снеге – все-таки имеет отношение к урожаю. Во всяком случае завтра его не будет, а там… наши потомки что-нибудь да придумают! Который раз объявляют очередной день Концом, но в последний момент вновь переносят исполнение «приговора Господня» на неопределенный срок. Вот и слухи о готовящимся столкновении с астероидом «1999 AN10» в середине нашего века оказались сильно преувеличенными – уточненные расчеты показали, что даже в момент наибольшего сближения нас будут разделять по меньшей мере полмиллиона километров!А если бы не разделяли – свершились бы самые худшие опасения создателей кинофильмов «Астероид» и «Армагеддон». В момент столкновения вся кинетическая энергия астероида (e=mv2/2) мгновенно высвобождается и происходит чудовищной мощности взрыв. Приняв среднюю плотность астероидов равной 3,5х103 кг/м3, скорость – порядка 104
м/сек, а размер – километр в поперечнике, нетрудно рассчитать энергию взрыва. Но ненаглядно мереть ее джоулями – гораздо практичнее оперировать тротиловым эквивалентом. Астероид, движущийся со скоростью 4 км/сек, в момент столкновения эквивалентен такому же по массе количеству тротила, т.е. в среднем несет в себе заряд порядка 20.000.000 мегатонн. Даже в наш век атомного оружия о такой бомбе ни один полководец не смеет и мечтать (эдак всю Землю разнести недолго)! А ведь поперечник многих астероидов составляет сотни километров, а скорости столкновения доходят до пятнадцати и более километров в секунду…
Какие последствия вызовет взрыв такой силы сказать трудно – ученым до сих пор не представилось возможности проверить свои предположения на практике. Считается – наиболее благоприятно для человечества падение астероида в океан. За исключением «мелких брызг» все дело закончится гигантской морской волной, способной обойти весь Земной Шар и дважды и трижды, поднимаясь при этом на несколько километров! Но будем оптимистами – мы не сахарные, не растаем. Пока волна своим ходом до нас дойдет – переждем ее на вертолетах (только не спрашивайте: «где мы найдем столько вертолетов?»).
Правда, имеются смутные опасения насчет океанской коры, слишком тонкой, чтобы выдержать такие удары судьбы – наверняка астероид пробьет ее насквозь и… точное развитие событий геологи еще прорабатывают, но уже ясно, что по крайне мере в планетарном масштабе это ничем не грозит. А землетрясения и извержения – локальные катастрофы, никак не отражающиеся на судьбе человечества в целом.
Гораздо хуже, если астероид угораздит врезаться в какой-нибудь материк. Пыль, поднятая взрывом, будет выброшена в верхние слои атмосферы и если ее окажется много, она равномерно окутает Землю на многие годы заслонив собой Солнце.
Кинетическая энергия частичек пыли, сравнимая с силой гравитационного притяжения, позволит им долго-долго носится в воздухе, не собираясь никуда оседать. Плотность верхних слоев атмосферы недостаточно велика, чтобы столкновения с молекулами воздуха играли заметную роль. К тому же пыль недурственно поглощает солнечный свет, нагреваясь и восполняя этим потерю кинетической энергии от взаимных соударений. Тем временем, поверхность Земли охладится (по некоторым прогнозам аж до минус пятидесяти), покроется льдом и все живое на ней вымрет…
…и на долгие годы наступит «ядерная зима». В течении нескольких столетий частички пыли, сталкиваясь, будут постепенно слипаться и, потеряв подвижность, спускаться на землю… Но когда Солнце взойдет над безбрежной пустыней, боюсь, к тому времени не останется ничего живого.
Впрочем, это всего лишь модель. Ученые не могут, отдав голову на отсечение, предсказать хотя бы приближенный ход событий (а просто так болтать можно о чем угодно). Слишком много факторов вступают в игру, когда природу пытаются вывести из равновесия. Но как бы там ни было, человечеству в ядерную зиму будет очень-очень туго, поэтому возникает вопрос – а можно ли всего этого избежать? И вот тут наступает уместный момент рассказать что такое астероиды, откуда они взялись, каким макаром движутся в пространстве и как рассчитывают их траектории.
Строго определения «астероидам» не существует – астрономы относят к ним все, что меньше планеты и в отличие от комет не имеет видимого хвоста, а движется по эллипсу в одном из фокусов которого находится Солнце.
Астероиды могут захватываться планетами, становясь их спутниками, и, хотя от этого они не перестают быть астероидами, так называть их становится не принято.
Откуда взялись астероиды точно не знает никто. Зато ученные с уверенностью могут сказать откуда они взяться не могли. Популярная в середине нашего (ой, то есть уже прошлого) столетия гипотеза о взрыве гипотетической планеты Фаэтона с треском провалилась – лабораторный анализ доказал – большинство астероидов формировались в отсутствии высоких температур и никогда не претерпевали значительных давлений.
По общепринятой модели Солнечная система образовалась из газопылевого облака, а все тела в свою очередь сформировались в результате дефекта складки – давным-давно однородный газопылевой шар, окружающий Солнце, по причине осевого вращения сплющился в диск (центробежные силы в плоскости экватора диаметрально противоположны силам гравитации, в то время как у полюсов ничто не могло удержать частички газа и пыли от падения на Солнце). Из курса физики известно – в поле тяжести радиус вращения тела по круговой орбите обратно пропорционален квадрату скорости. Т.е. если бы все пылинки двигались одинаково, они бы собрались на какой-то одной орбите, сформировав одну-единственную планету. Но квадратичная зависимость многократно усиливает даже незначительную дисперсию скоростей, делая протопланетный диск гравитационно-неустойчивым, отчего он распадается на отдельные сгущения. Если коэффициент дисперсии близок к единице, порядка 2/3 всей массы диска сосредотачиваются в одной планете типа Юпитера, а остаток распределяется между всеми остальными. Мощное гравитационное поле гиганта стянуло на себя часть вещества своих соседей, отхватив приличный кусок от того, что впоследствии стало Марсом (вот почему он меньше Земли, хотя исходя из распределения дисперсии скоростей должно быть наоборот) и изрядно поистрепало строительный материал гипотетической планеты Фаэтон, оставив ей горстку мусора, по массе в сотни раз меньшую Луны. Фаэтону просто не из чего было формироваться, но даже если бы он каким-то чудом сумел образовался, тяготение Юпитера в короткие сроки вновь разрушило бы его!
Вместо Фаэтона в этой области пространства мы наблюдаем огромное количество космического щебня – «пояс астероидов». В отличие от всех остальных планет эволюция пояса еще не завершена и в настоящее время там происходят чрезвычайно любопытные процессы.
Достаточно очевидно, если два астероида столкнуться друг с другом, раздаться громкое «бабах» и во все стороны брызнут осколки. А вот то, что площадь образовавшихся осколков намного превосходит площадь исходных астероидов, слету догадается не каждый! А ведь благодаря этому вероятность взаимных столкновений экспоненциально нарастает! Ох, не зря, не зря астрономы называют пояс астероидов «каменоломней» Солнечной системы!
Чем меньше небесное тело, тем оно чувствительнее к гравитационным возмущениям, а его орбита неустойчивее. Юпитер запросто может выдернуть разлетающиеся осколки из пояса астероидов, изменив их орбиту так, что она пересечется с орбитой Земли. К счастью, вероятность столкновения с нами очень мала, но во-первых, из-за дробления астероидов со временем она неумолимо нарастает, а во-вторых, «…слон был один на всю Москву, да и того убило».
На сегодняшний день известно свыше пятисот астероидов с поперечником более километра, пересекающих орбиту Земли. Так ведь и до столкновения недалеко! И правда, статистика показывает, что такие встречи происходит приблизительно каждые 100 тысяч лет, а мелкий, неучтенный «космический мусор» падает на нас и того чаще! Но для кого «мусор», а для кого кратер на десяток-другой километров (вполне достаточно, чтобы стереть с лица земли иной уездный город попади в него астероид).
Если бы из-за различных возмущений траектории астероидов не подвергалась бы непрерывным изменениям, их вычисления давались бы без труда, а так не справляются даже современные суперкомпьютеры, – какими бы исчезающе малыми ошибки не были, неуклонно накапливаясь, они вносят все большую и большую неопределенность в положение астероида в пространстве-времени. Приходится учитывать даже такие «тонкие» физические процессы, как, например, эффект Ярковского – когда нагретая солнечными лучами поверхность вращающегося астероида уходит в тень, тепловое излучение действует подобно реактивному двигателю, чуть-чуть изменяя его орбиту.
Но даже этого «чуть-чуть» достаточно, чтобы астероид врезался в Землю или наоборот, пролетел далеко от нее!
Чтобы там ни говорили оптимисты, на сегодняшнем уровне развития науки и техники предсказать движение астероидов можно лишь на очень короткий срок (порядка нескольких десятков лет) и с посредственной точностью (плюс-минус пара сотен тысяч километров), что несравненно больше поперечника Земли.
Это только «царям природы» Земля кажется большой – в масштабах Космоса она все равно что пылинка. Нас и Солнце разделяют сто пятьдесят миллионов километров, и приблизительно в полтора раза дальше находится пояс астероидов. Погрешность ±0.01% приводит к неопределенности в расстоянии пятьдесят тысяч километров, что вчетверо больше диаметра нашего «шарика»! Поэтому, если расчетное сближение с Землей составляет менее полумиллиона километров, астрономы всерьез начинают беспокоится о столкновении (прогнозы же типа «астероид … упадает в Атлантический океан в районе острова Мадагаскар» не более чем выдумка журналистам – не верьте им, такую точность расчетов наука, в отличие от гадалок, магов и прорицателей, обеспечить ни сейчас, ни в отдаленном будущем не в состоянии!)
Но помимо известных науке астероидов, существуют огромное множество еще неоткрытых, и способных в любой момент вынырнув из космической тьмы, натворить на Земле кучу неприятностей.
С такой угрозой нельзя не считаться и небо регулярно патрулируется десятками обсерваторий – как наземными, так и космическими. Но трудно сказать, что у них все хорошо получается – многие астероиды обнаруживаются задолго после их максимального сближения с Землей или не обнаруживаются вообще!
В чем причина неудач? Во-первых, поле зрения телескопа много меньше площади небесной сферы и полный обзор требуют тысячи и тысячи экспозиций, а это – время – вполне достаточное чтобы иной астероид успел «проскользнуть».
Во-вторых, полученные снимки еще надо просматривать на предмет поиска астероидов. К счастью, они легко отличимы от звезд – ввиду значительного собственного движения их след на фотопластинке (или ПЗС-матрице – неважно) выглядит не точкой, а дугообразной линией.
Проблема в том, что астероидов в солнечной системе много больше всех вместе взятых тараканов – попробуй тут разберись – кто из них новый, а кто давным-давно открытый! Ошибки в отождествлении неизбежны и проколы случаются в обе стороны – то новый астероид проморгают, приняв его за старого знакомого, то с иным знакомым попытаются познакомится повторно.
Появление относительно дешевых сверхвысокочувствительных светоприемников позволяет снизить стоимость телескопов за счет уменьшения диаметра объектива, а современные электронные системы слежения удешевляют механическую часть – монтировку. Теперь на те же деньги можно построить значительно больше телескопов, а значит и быстрее выполнять обзор всего неба!
Хуже обстоит дело с построением приемлемо-точной теории движения малых планет – математики дымят как паровозы, но издают лишь нечленораздельное мычание – типа дайте нам еще один миллион долларов и еще один год времени…
Когда же такая теория будет построена, дело станет за малым, – вот «сталкивающийся» астероид обнаружен – как его уничтожить? Пальнуть по нем атомной ракетой? А вдруг не попадет? Подпускать засранца близко к Земле нельзя (рискованно!), а ракет, способных поразить цель на большом удалении от Земли, пока нет и в скором будущем не предвидится.
Доставить на астероид водородную бомбу космическим кораблем и разнести его в клочья к какой-то матери? А вот нет таких кораблей! Во всяком случае, стоящих на всех парах наготове, да и практики высаживания на астероид (как и сброса на него каких-нибудь девайсов) нет ни у отечественной космонавтики, ни у западной.
Пальнуть лазером? Расчеты показывают: стоит чуть-чуть подогреть поверхность астероида, как излучение, устремясь наружу подобно реактивному двигателю, заметно изменит орбиту астероида, отводя его в сторону (эффект Ярковского). Такие лазеры у землян уже есть, но применять их вряд ли рискнут – траектория-то астероида известна не точно, а с некоторой неопределенностью, как знать – не летел ли этот космический странник мимо, до тех пор, пока мы не подкорректировали его орбиту так… словом, лучше бы мы ее не корректировали.
Перечислять все остальные прожекты уничтожения астероидов – бессмысленно: до тех пор, пока они не будут проверены на практике – никакой уверенности, что хотя бы один их них сработает нет! «Мишеней» вокруг Земли летает предостаточно – так почему бы не опробовать на них хваленые высокоточные ракеты или хотя бы лазеры? Поразительно, но об этом просто «не принято» говорить, напирая на исключительную важность развития и расширения патрулирующих служб, дескать, главное заблаговременно увидеть астероид, а уж предотвратить столкновение мы как-нибудь сумеем (тут, как правило, начитается перечисление колоссального количества накопленных человечеством бомб и прочих видов взрывчатки).
Закончить статью мне бы хотелось на оптимистичной ноте. Катастрофы, как это не цинично звучит, – двигатель эволюции. Не убей что-там-у-них-было динозавров, как знать, кто бы сейчас распоряжался Землей: мы, или хвостатые пресмыкающиеся…
Биосферу Земли, как шутят учение, можно уничтожить разве что вместе с самой Землей. Так что не волнуйтесь, ничего с ней не случиться: как говорил старик Лемм – это не конец мира, это всего лишь конец нашей цивилизации…
* В поисках нуля *
Для комфортной работы всякий прибор следуетВлияние размера исполняемого кода на производительность
В целом, кодовый кэш устроен практически точно также, как и кэш данных. Даже проще, ведь машинные команды, в отличии от данных, не требуют поддержки операций записи. (Попытка модификации исполняемого кода приводит к непосредственному обновлению кэшируемой памяти и перезагрузке соответствующей стоки кодового кэша, поэтому во избежание падения производительности прибегать к самомодифицирующемуся коду в глубоко вложенных циклах категорически не следует).Если не углубляться в детали, можно сказать, что влияние размера исполняемого кода на производительность подчиняется тем же законам, что и размер читаемого (не модифицируемого!) блока данных.
Давайте проследим, как меняется скорость исполнения блока кода при увеличении его размеров. Это не такая простая задачка! Ведь, в отличии от обработки данных, мы не можем не прибегая к самомодифицирующемуся коду менять размер исполняемого блока по своему желанию. Тем не менее, выход есть и довольно элегантный. Достаточно лишь слезть с высокоуровневых языков и обратится к макроассемблеру, развитые препроцессорные средства которого позволят нам генерировать блоки исполняемого кода произвольного размера.
Для предотвращения возможных побочных эффектов мы будем оформлять каждый блок кода отдельной программой и, чтобы не задавать параметры трансляции каждый раз вручную, воспользуемся пакетным файлом.
В результате, у нас получится следующее:
; CODE_SIZE EQU ? ; // Макрос CODE_SIZE автоматически генерируется
; // пакетным файлом-транслятором
; /*--------------------------------------------------------------------------
; *
; * МАКРОС, ГЕНЕРИРУЩИЙ N машинных команд "NOP
; *
; * (команда NOP буквально обозначает "нет операции"
; * и занимает ровно один байт, т.е. N команд NOP
; * дают N байт исполняемого кода)
; *
; -------------------------------------------------------------------------*/
NOPING MACRO N ; // НАЧАЛО МАКРОСА //
_N = N ; _N := N (получаем переданный макросу аргумент)
_A = 0 ; _A -- переменная-счетчик цикла
WHILE _A NE _N ; while(_A <= _N){
NOP ; вставляем в исходный текст "NOP"
_A = _A + 1 ; _A++;
ENDM ; }
ENDM ; // КОНЕЦ МАКРОСА //
; /*--------------------------------------------------------------------------
; *
; * ВЫЗОВ МАКРОСА ДЛЯ СОЗДАНИЯ БЛОКА ИЗ CODE_SIZE KB команд NOP
; *
; --------------------------------------------------------------------------*/
NOPING CODE_SIZE*1024
Листинг 2 [Cache/ code.cache.size.xm] Ассемблерный листинг программы, генерирующей произвольное количество машинных операций NOP
#include
"code.cache.size.h" ; ßэтот файл формируется пакетным транслятором
; и содержит определение CODE_SIZE
#include
main()
{
int a;
A_BEGIN(1); ; ß начало замера времени выполнения
DoCPU(&a); ; выполняем блок из CODE_SIZE команд NOP
A_END(1); ; ß конец замера времени выполнения
// вывод результатов замера на экран
printf("%03d\t %d\n", CODE_SIZE, Ax_GET(1));
}
Листинг 3 [Cache/code.cache.size.c] Пример, демонстрирующий последствия выхода за кэш перового (второго) уровня
@ECHO OFF
IF #%1#==#MAKE_FOR# GOTO make_it
REM MAKE ALL
ECHO
= = = СБОРКА ПРИМЕРА, ДЕМОНСТРУЮЩЕГО ОПРЕДЕЛЕНИЕ РАЗМЕРА КОДОВОГО КЭША = = =
ECHO
Утилита к книге \"Техника оптимизации программ" /* название рабочее */
ECHO @ECHO OFF > CODE.CACHE.SIZE.RUN.BAT
ECHO ECHO
= = демонстрация определения размера кэша = = >> CODE.CACHE.SIZE.RUN.BAT
ECHO ECHO
Утилита к книге "Техника оптимизации программ" >> CODE.CACHE.SIZE.RUN.BAT
ECHO ECHO N NOP ...CLOCK... >> CODE.CACHE.SIZE.RUN.BAT
ECHO ECHO ------------------------------------------------- >> CODE.CACHE.SIZE.RUN.BAT
FOR %%A IN (2,4,8,16,32,64,128,256,512,1024,2048) DO CALL %0 MAKE_FOR %%A
ECHO DEL %%0 >> CODE.CACHE.SIZE.RUN.BAT
GOTO end
:make_it
ECHO /%0/%1/%2 *
SHIFT
ECHO CODE_SIZE EQU %1 > CODE.CACHE.SIZE.MOD
ECHO #define CODE_SIZE %1 > CODE.CACHE.SIZE.H
TYPE CODE.CACHE.SIZE.XM >> CODE.CACHE.SIZE.MOD
CALL CLOCK.MAKE.BAT CODE.CACHE.SIZE.C > NUL
DEL CODE.CACHE.SIZE.MOD
DEL CODE.CACHE.SIZE.H
IF NOT EXIST CODE.CACHE.SIZE.EXE GOTO err
IF EXIST CODE.CACHE.SIZE.%1.EXE DEL CODE.CACHE.SIZE.%1.EXE
REN CODE.CACHE.SIZE.EXE CODE.CACHE.SIZE.%1.EXE
ECHO CODE.CACHE.SIZE.%1.EXE >> CODE.CACHE.SIZE.RUN.BAT
ECHO DEL CODE.CACHE.SIZE.%1.EXE >> CODE.CACHE.SIZE.RUN.BAT
GOTO end
:err
ECHO -ERR ошибка
компиляции! подробнее см. CODE.CACHE.SIZE.ERR
TYPE CODE.CACHE.SIZE.ERR
EXIT
:end
Листинг 4 [Cache/code.cache.size.make.bat] Пакетный файл, выполняющий трансляцию тестовой программы
Влияние размера обрабатываемых данных на производительность
Никто не спорит, – чем меньше массив данных, тем быстрее он обрабатывается – это общеизвестно. Для достижения наивысшей производительности следует проектировать алгоритм программы так, чтобы все интенсивно обрабатываемые блоки данных целиком умещались в сверхоперативной памяти первого или, на худой конец, второго уровня. В противном случае обмен с оперативной памятью в мгновение ока сожрет все мегагерцы процессора.Но, вот вопрос, – какой именно зависимостью связан размер с производительностью? В частности: на сколько упадает быстродействие программы, если обрабатываемый блок данных вылезет за переделы кэш-памяти первого (второго) уровня, ну скажем, на один килобайт? Еще вопрос: весь ли объем кэша доступен для непосредственного использования или некоторую его часть необходимо резервировать для хранения стековых переменных, аргументов и адреса возврата из функции? Популярные руководства по оптимизации хранят гробовое молчание на этот счет, в основном ограничиваясь сухим правилом "кто не вместился в кэш – том сам себе и виноват".
Даже такой авторитет как Agner Fog в свей монографии "How to optimize for the Pentium family of microprocessors" приводит всего лишь ориентировочное количество тактов, требующихся для загрузки ячейки из различных иерархий памяти! Может быть, эта информация и полезна сама по себе, но она не дает общего представления о картине. Ведь "время доступа" как уже было показано в первой части настоящей книги (см. Части I "Оперативная память. Взаимодействие памяти и процессора. Вычисление полного времени доступа") – слишком абстрактное понятие, тесно переплетающееся с конвейером и параллелизмом. К тому же, Fog устарел не на один ледниковый период и сегодня представляет разве что исторический интерес (во всяком случае – часть, касающаяся времени доступа к памяти). Что ж! Ничего не остается как затовариться пивом (или "Напитками из Черноголовки" – это кому как во вкусу) и плотно засесть за компьютер в надежде разобраться во всем самостоятельно.
Давайте напишем следующую тестовую программку, обрабатывающую в цикле блоки все большего и большего размера, а затем выведем полученные результаты в виде графика на экран. При этом мы исследуем четыре основных комбинации обработки данных: последовательное чтение, последовательная запись, чтение ячейки с последующей модификацией и запись ячейки с последующим чтением. Конечно, не мешало бы исследовать еще и параллельную обработку (см. "Часть I. Оптимизация работы с памятью. Параллельная обработка данных"), но – боюсь – что в этом случае размер книги превысил бы все мыслимые барьеры…
#define BLOCK_SIZE (715*K) // размер обрабатываемого блока памяти
#define STEP_FACTOR (1*K) // шаг приращения перебираемого размера
#define X 1 // :'b' - для вычитания линейной составляющей
// :'1' - показывать график как есть
// выделяем память
p = malloc(BLOCK_SIZE);
// шапка
printf("---\t");
for (b = 1*K; b < BLOCK_SIZE; b += STEP_FACTOR)
printf("%d\t",b/K); printf("\n");
/*------------------------------------------------------------------------
*
* ПОСЛЕДОВАТЕЛЬНОЕ ЧТЕНИЕ
*
---------------------------------------------------------------------- */
printf("R\t"); for (b = 1*K; b < BLOCK_SIZE; b += STEP_FACTOR)
{
PRINT_PROGRESS(25*b/BLOCK_SIZE); VVV;
A_BEGIN(0)
for (c = 0; c <= b; c += sizeof(int))
tmp += *(int*)((int)p + c);
A_END(0)
printf("%d\t", 100*Ax_GET(0)/X);
}
printf("\n");
/*------------------------------------------------------------------------
*
* ПОСЛЕДОВАТЕЛЬНАЯ ЗАПИСЬ
*
---------------------------------------------------------------------- */
printf("W\t"); for (b = 1*K; b < BLOCK_SIZE; b += STEP_FACTOR)
{
PRINT_PROGRESS(25+25*b/BLOCK_SIZE); VVV;
A_BEGIN(1)
for (c = 0; c <= b; c += sizeof(int))
*(int*)((int)p + c) = tmp;
A_END(1)
printf("%d\t", 100*Ax_GET(1)/X);
}
printf("\n");
/*------------------------------------------------------------------------
*
* ПОСЛЕДОВАТЕЛЬНОЕ ЧТЕНИЕ потом ЗАПИСЬ
*
---------------------------------------------------------------------- */
printf("RW\t"); for (b = 1*K; b < BLOCK_SIZE; b += STEP_FACTOR)
{
PRINT_PROGRESS(50+25*b/BLOCK_SIZE); VVV;
A_BEGIN(2)
for (c = 0; c <= b; c += sizeof(int))
{
tmp += *(int*)((int)p + c);
*(int*)((int)p + c) = tmp;
}
A_END(2)
printf("%d\t", 100*Ax_GET(2)/X);
}
printf("\n");
/*------------------------------------------------------------------------
*
* ПОСЛЕДОВАТЕЛЬНОЕ ЧТЕНИЕ потом ЗАПИСЬ
*
---------------------------------------------------------------------- */
printf("WR\t"); for (b = 1*K; b < BLOCK_SIZE; b += STEP_FACTOR)
{
PRINT_PROGRESS(75+25*b/BLOCK_SIZE); VVV;
A_BEGIN(3)
for (c = 0; c <= b; c += sizeof(int))
{
*(int*)((int)p + c) = tmp;
tmp += *(int*)((int)p + c);
}
A_END(3)
printf("%d\t", 100*Ax_GET(3)/X);
}
printf("\n");
Листинг 1 [Cache/cache.size.c] Пример, демонстрирующий зависимость времени обработки от размера и рода обработки блока данных
Вид полученного графика может варьироваться в зависимости от архитектуры используемого процессора, но в целом должен выглядеть приблизительно так, как показано на рис. graph 0x0. Ага! Вот он потерянный хвост Иа! Если скоростная кривая чтения ячеек (синяя линия) напоминает обветшалый и разрушенный эрозией старый пологий холм, то остальные кривые являют собой молодую горную формацию, непрерывно
меняющую крутизну своих склонов.
Если присмотреться повнимательнее (впрочем, вряд ли вы что разглядите на полиграфии такого качества), то можно заметить, что первый подъем расположен в районе ~32 километра, ой, я хотел сказать килобайта. Это и есть размер сверхоперативной памяти кэша данных первого уровня микропроцессора AMD K6 (кстати, неплохого между прочим процессора).
Миновав кэш, график чтения начинает линейное восхождение вверх с коэффициентом пропорциональности близким к единице, т.е. на участке (L1.CACHE.SIE; L2.CACHE.SIZE] – время обработки блока в зависимости от его размера изменяется так: , где T – время обработки. При выходе за пределы кэша первого уровня быстродействие программы резко и значительно сокращается, – действительно, по горам лазать – это вам не по равнине налегке ходить (кэш первого уровня – это равнина).
Наклон графика чтения задирается к верху еще круче и визуально (то бишь субъективно) его коэффициент пропорциональности близко к полутора, а то и двум. Между тем, это – не более чем досадный обман зрения и формула зависимости времени обработки от размеров записываемого блока идентична формуле чтения. Не верите? Так давайте проверим! Смотрите: обработка ~89 Кб блока данных заняла 200.000 тактов процессора, а ( …приложив линейку к монитору…) блок в ~177 Кб обрабатывался бы 400.000 тактов. А теперь – 89 : 177 ==200.000 : 400.000 == 0.5, что и требовалось доказать!

Рисунок 17 graph 0 Зависимость времени обработки от размера блока (AMD K6) без вычета линейной составляющей
Увы, мы должны признать, что наглядность полученного нами графика оставит желать лучшего. Причина в том, что он состоит из двух составляющих. Линейный
рост времени обработки возникает вследствие увеличения количества операций обращения к памяти. На него наложена нелинейная
составляющая, обусловленная непостоянством скорости доступа к различным видам памяти.
Поскольку, в данном случае нас интересует именно скоростной показатель доступа к памяти, а не общее время обработки блока данных, от линейной составляющей мы должны избавится.Если не требуется большой точности вычислений, достаточно разделить результаты каждого замера на число итераций цикла. (Примечание: на самом деле, это очень большое упрощение. Время обработки блока данных равно N*(Tcycle +T
В результате, форма полученного графика должна выглядеть приблизительно так:

Рисунок 18 graph 1 Зависимость скорости обработки от размера блока на AMD K6
Вместо заключения
Задумываться о борьбе с ошибками переполнения следует до начала разработки программы, а не лихорадочно вспоминать о них на последней стадии завершения проекта. Если это не поможет гарантированно их предотвратить, то, по крайней мере, уменьшит вероятность возникновения до минимума. Напротив, возлагать решение всех проблем на beta-тестеров и надеяться, что надежно работающий продукт удастся создать с одной лишь их помощью –слишком наивно.Тем не менее именно такую тактику выбрали ведущие фирмы, – стремясь захватить рынок, они готовы распространять сырой, кишащий ошибками программный продукт, "доводимый до ума" его пользователями, сообщающими производителю об обнаруженных ими ошибках, а взамен получающих либо "заплатку", либо обещание устранить ошибку в последующих версиях.
Как показывает практика, данная стратегия работает безупречно и даже обращает ошибку в пользу, а не в убыток – достаточно веской мотивацией пользователя к приобретению новой версии зачастую становятся отнюдь не его новые функциональные возможности, а… заверения, что все (или, на худой конец, большинство) присущих ему ошибок теперь исправлено. На самом деле исправляется лишь незначительная часть от всех ошибок, и добавляется множество новых, поэтому, такую волынку можно тянуть до бесконечности, и потребитель (куда ж ему деться) будет без конца приобретать все новые и новые версии, обеспечивая компании стабильный доход и процветание.
Вы все еще хотите исправлять ошибки в своей программе? (Шутка, конечно, а то вдруг кто поймет неправильно)
Волчьи ямы опережающей записи II
Итак, как мы уже знаем, при попытке записи в ячейку, отсутствующую в кэш-памяти первого уровня, процессор временно сохраняет записываемые данные в одном из свободных буферов (конечно, если таковые есть), а затем при первой же возможности выгружает их в кэш первого и/или второго уровня.Чтение данных, находящихся в буфере, осуществляется по крайней мере на один такт быстрее, чем обращение к кэшу первого уровня, к тому же буфера имеют значительно больше портов, чем кэш и могут обрабатывать более двух запросов одновременно (хотя, буфера записи процессора AMD K5 имели всего один-единственный порт). Как это можно использовать на практике?
На P6 и K6 следующий код будет исполняться предельно быстро независимо от того, присутствует ли ячейка *p в сверхоперативной памяти или нет:
*p = a;
b = p*;
Тем не менее, использование буферов записи таит в себе одну очень коварную опасность. Рассмотрим следующий пример, на первый взгляд как будто бы полностью повторяющий предыдущий:
*p = a;
f = (sin(x) + con(y)) / z;
b = p*;
Да, команды записи и чтения данных уже не прижаты друг к другу, а разделены некотором количеством "посторонних" инструкций. Предположим, что компилятор сгенерировал наиглупейший код, сохраняющий результаты всех четырех вычислений в промежуточных переменных. Предположим, что все переменные (включая f) отсутствуют в кэше и претендуют на различные буфера записи. Тогда, между записью ячейки *p и чтением ее содержимого происходит заполнение всего лишь пяти буферов, и судя по всему *p еще находится в буфере.
А вот и нет! Кто вам это обещал?! Разработчики процессора? Отнюдь! Буфера записи, в отличии от кэш-памяти, склонны к самопроизвольному опорожнению с переносом (именно переносом, а не копированием!) своего содержимого в кэш первого и/или второго уровня. Рассматриваемый нами пример кода неустойчив, поскольку скорость его выполнения варьируется в зависимости от того, успел ли процессор выгрузить буфера или нет.
Попросту говоря, производительность такого кода определяется "настроением" процессора и различные прогоны могут показать весьма неодинаковые результаты.
Причем, если на K6 содержимое буферов выгружается в кэш первого уровня, откуда данные могут быть считаны всего за один такт, на P6 в этой ситуации возникает кэш-промах и процессор вынужден обращаться к кэшу второго уровня, что будет стоить многих тактов.
В данном случае проблемы легко избежать перегруппировкой команд, – переместив вычислительную операцию на одну строчку вверх или вниз, – мы добьемся спаривания команд записи/чтения и гарантировано избежим преждевременного вытеснения буферов. Но такое решение не всегда достижимо. Команды могут иметь зависимость по данным или вообще находится в различных функциях, а то и потоках. Как быть тогда? Откроем, например, уже упомянутое руководство по оптимизации от Ангера Фрога ("How to optimize for the Pentium family of microprocessors" by Agner Fog) и найдем в главе, посвященной кэш-памяти следующие строки:
"When you write to an address which is not in the level 1 cache, then the value will go right through to the level 2 cache or to the RAM (depending on how the level 2 cache is set up) on the PPlain and PMMX. This takes approximately 100 ns. If you write eight or more times to the same 32 byte block of memory without also reading from it, and the block is not in the level one cache, then it may be advantageous to make a dummy read from the block first to load it into a cache line. All subsequent writes to the same block will then go to the cache instead, which takes only one clock cycle. On PPlain and PMMX, there is sometimes a small penalty for writing repeatedly to the same address without reading in between.
On PPro, PII and PIII, a write miss will normally load a cache line, but it is possible to setup an area of memory to perform differently, for example video RAM (See Pentium Pro Family Developer's Manual, vol. 3: Operating System Writer's Guide").
("Когда на Pentium- просто или Pentium MMX вы записываете данные, отсутствующие в кэш-памяти первого уровня, они будут помещены в кэш второго уровня или основную оперативную память (в зависимости от того: наличествует ли кэш второго уровня или нет). Эта операция занимает приблизительно 100 нс. Если вы обращайтесь на запись восемь или более раз (именно раз, а не байт, как сказано в популярном переводе Дмитрия Померанцева) к одному и тому же 32-байтовому блоку памяти без чтения чего бы то ни было оттуда, и данный блок памяти отсутствует в кэше первого уровня, было бы недурственно предварительно прочитать любую ячейку блока, загружая тем самым его в кэш первого уровня. Все последующие операции записи данного блока будут записываться в кэш первого уровня, что займет всего один такт. На Pentium-просто и Pentium MMX при многократной записи данных по одному и тому же адресу иногда возникают небольшие задержки, если эти данные не будут востребованы.
На Pentium Pro, Pentium-II и Pentium-III промах записи обычно загружает соответствующую кэш-линейку, но если это возможно, установите область памяти для предотвращения различий, например видеопамять (см. "Семейство-Pentium Pro Справочник разработчика. Том 3. Руководство создателям операционных систем").
Выделенное курсивом предложение написано довольно неуверенным тоном (похоже Ангер Фрог и сам его не понимал). Итак, начинаем лексический анализ. "Normally load a cache line" – можно перевести двояко. "нормально загружает" (т.е. самостоятельно загружает без дураков) или же "обычно загружает" (т.е. может загрузить, а может нет). Судя по всему, Ангер Фрог подразумевал последний вариант. Действительно, в зависимости от состояния соответствующих атрибутов страницы, кэширование записи может быть как разрешено, так и нет. Вот например, в области видеопамяти оно уж точно запрещено, ведь в противном случае обновление изображения происходило бы не в момент записи, а спустя неопределенное время после вытеснения данных из кэша первого уровня, что вряд ли кого могло устроить.
Вот Фрог и советует: убедитесь, что обрабатываемая область памяти разрешает кэширование…
Между тем, это только часть истины, – Ангер Фрог совсем забыл о буферизации. На самом деле, и на P-Pro, и на P-II, и на P-III промах записи не загружает кэш линейку! (Исключение составляет запись расщепленных данных). На K6/Athlon промах записи так же не приводит к немедленной загрузке кэш-линейки, но поскольку содержимое буферов вытесняется в кэш первого уровня, с некоторой натяжкой можно сказать, что такая загрузка все-таки происходит.
Поэтому, к современным процессорам применимы те же самые рекомендации, что и к Pentium-просто и Pentium MMX. Покажем их живое воплощение на практике:
volatile trash;
trash = *p;
*p = a;
f = (sin(x) + con(y)) / z;
b = *p;
Что изменилось? Обратите внимание на выделенную жирным шрифтом строку, загружающую содержимое записываемой ячейки в неиспользуемую переменную. Такой трюк практически не снижает производительности (т.к. процессоры P6 и K6 могут дожидаться загрузки ячейки из оперативной памяти параллельно с ее записью), но гарантирует, что содержимое буферов к моменту обращения к ним, не будет вытеснено дальше кэша первого уровня. А кэш первого уровня – он всегда под рукой и его чтение не займет много времени.
Как всегда здесь не обходится без тонкостей. При загрузке данных в неиспользуемую переменную оптимизирующий компилятор может проигнорировать бессмысленное с его точки зрения присвоение и… тогда у нас ничего не получится. Один из способов запретить компилятору самовольничать – объявить переменную как volatile.
Экспериментальное подтверждение самопроизвольной выгрузки буферов. Теперь, после надлежащей теоретической подготовки, имеет смысл исследовать процесс выгрузки буферов что называется "в живую". Конкретно нас будет интересовать какой именно промежуток времени записываемые данные проводят в буферах, в каком порядке и с какой скоростью они вытесняются оттуда.
Но ведь буфера записи полностью прозрачны для программиста и нам не предоставлено абсолютно никаких рычагов управления! Хорошо, будем рассматривать буфер как "черный ящик" со входом и выходом.
Как узнать что у него внутри? Непосредственно задачу решить невозможно, но мы вполне в состоянии посылать этому ящику запросы и засекать время их выполнения. Останется лишь сопоставить несколько очевидных фактов и прийти к определенным заключениям. Попросту говоря: если данные считываются практически мгновенно – они безусловно все еще находятся в буфере. Чуть большая задержка укажет на то, что данных в буфере уже нет и их следует искать в кэше первого уровня. Наконец, резкое увеличение времени доступа означает, что данные выгружены непосредственно в кэш второго уровня.
Как мы будем действовать? Последовательно записывая все большее и большее количество ячеек с последующим обращением к первой из них, мы рано или поздно столкнемся с внезапным паданием производительности. Это и будет обозначать, что ячейка, содержимое который мы пытаемся прочесть, по тем или иным причинам, покинула застенки буферов и отошла в мир иной. Так мы узнаем стратегию выгрузки буферов: выгружаются ли они в "фоновом" режиме или выгрузка происходит лишь при переполнении буферов.
Вообще-то, тестовую программу можно было бы написать и на чистом Си, но на этом пути притаилось множество трудностей. Си не поддерживает циклических макросов, а, значит, не позволяет автоматически дублировать команды записи заданное число раз. Если же выполнять запись в цикле, мы сразу проиграем в точности измерений. Во-первых, накладные расходы на организацию цикла сравнимы со временем загрузки данных из кэша первого уровня. Во-вторых, нельзя быть уверенным, что код, сгенерированный компилятором, не содержит лишних обращений к памяти. И, в-третьих, параллельно с обработкой ветвлений могут выгружаться буфера.
Да простят меня прикладные программисты, но все-таки я остановлю свой выбор на ассемблере. К слову сказать, приведенные ниже листинги, достаточно подробно комментированы и разобраться в алгоритме их работы навряд ли будет стоить большого труда. И еще, не смейтесь, пожалуйста, но одна из частей программы реализована в виде… пакетного файла.
Да-да, не него возложена миссия
___промежуток времени, в течении которого записываемые данные еще можно надеяться обнаружить в буферах, а так же
; N_ITER EQU ? ;// <-- !auto gen!
; /*--------------------------------------------------------------------------
; *
; * макрос, автоматически дублирующий свое тело N раз
; *
; ---------------------------------------------------------------------------*/
STORE_BUFF MACRO N
_N = N
_A = 0
WHILE _A NE _N
MOV [EBX+32*_A],ECX; <- *(int *)((int)p + 32 * _A) = x;
_A = _A + 1
ENDM
ENDM
; /*--------------------------------------------------------------------------
; *
; * ДЕМОНСТРАЦИЯ ВЫГРУЗКИ БУФЕРОВ ВО ВРЕМЕЯ ЗАНЯТОСТИ ШИНЫ
; *
; ---------------------------------------------------------------------------*/
STORE_BUFF N_ITER ; *p+00 = a; <- заполняем буфера записи, записывая
; *p+32 = a; каждый раз ячейку в новый буфер
; *p+64 = a; Буфера выгружаются параллельно с
; .......... записью. Чтобы доказать это мы....
MOV EDX, [EBX] ; b = *p; <- ...мы обращаемся к самому первому
; записанному буферу; если он еще
; не выгружен, - его содержимое
; считается максимально быстро;
; в противном случае возникнет зад.
ADD EBX, 32*N_ITER; <- смещаем указатель на след. буфера
Листинг 15 [Cache/store_buf.xm] Ядро программы, демонстрирующей выгрузку одних буферов записи параллельно с заполнением других
; N_ITER EQU ? ;// <-- !auto gen!
; /*--------------------------------------------------------------------------
; *
; * макрос, автоматически дублирующий свое тело N раз
; *
; ---------------------------------------------------------------------------*/
STORE_BUFF MACRO N
_N = N
_A = 0
WHILE _A NE _N
NOP <- ТЕЛО МАКРОСА
_A = _A + 1
ENDM
ENDM
; /*--------------------------------------------------------------------------
; *
; * ДЕМОНСТРАЦИЯ ВЫГРУЗКИ БУФЕРОВ ВО ВРЕМЕНЯ ПРОСТОЯ ШИНЫ
; *
; ---------------------------------------------------------------------------*/
MOV [EBX], ECX ; *p = a; <- тут мы записываем в *p некое значение
; <- записываемое значение в первую очередь
; <- попадает в буфер записи (store buffers)
STORE_BUFF N_ITER ; ... <- один или несколько NOP
; ... параллельно с их выполнением содержимое
; ... буферов вытесняется в кэш первого (AMD)
; ... или второго (Intel) уровней
MOV EDX, [EBX] ; b = *p; <- читаем содержимое ячейки *p
; если к этому моменту соответствующий ей
; буфер еще не вытеснен, то она причтется
; максимально быстро; в противном же
; случае возникнет задержка
ADD EBX, 32 ; (int)p+32; <- смещаем указатель на след. буфер
Листинг 16 [Cache/store_buf_nop.xm] Ядро программы, демонстрирующей выгрузку буферов во время простоя шины
Результаты прогонов программы на процессорах P-III и AMD Athlon представлены на диаграмме graph 0x013. Наше обсуждение мы начнем с характера кривой зависимости времени загрузки данных от количества команд записи. Кривая P-III изображена жирной линией, выделенной синим цветом. Смотрите, – после семи команд записи время загрузки данных без всяких видимых причин возрастает с ~35 до ~150 тактов, т.е.
в четыре с небольшим раза. Это говорит о том, что первая из записанных ячеек уже покинула буфер и "отлетела" в кэш второго уровня. Она сделала это несмотря на то, что свободные буфера еще не были исчерпаны! Тем самым, мы убедительно доказали, что буфера могут выгружаться и самопроизвольно, а не только при переполнении их. Приняв за время выполнения операции записи один такт, мы сможем оценить приблизительное время выгрузки содержимого перового из буферов. Оно, как нетрудно установить составляет 7±1 тактов.
Последующие три замера показывают практически идентичное время прогона, но затем кривая делает легкий взмах вверх, образуя своеобразную ступеньку. О чем она говорит? По всей видимости, к этому моменту завершает свою выгрузку второй буфер и, вследствие занятости шины, чтение ячеек из кэша второго уровня испытывает некоторые задержки.
Следующая ступенька наблюдается на четырнадцати операциях записи, что и не удивительно, т.к. с этого момента начинается острая нехватка свободных буферов (на P-II/P-III всего 12 буферов плюс два уже освободившихся – итого четырнадцать) и каждая последующая запись обходится приблизительно в семь дополнительных тактов, требующихся для выгрузки содержимого хотя бы одного из буферов. Неудивительно, что производительность стремительно падает, прямо как рубль в печально памятные дни августовского кризиса.
Теперь запустим второй вариант программы, который выполняет всего одну-единственную запись, затем выдерживает короткую паузу, скармливая процессору некоторое количество команд-пустышек, после чего проверяет наличие записанных данных в буфере. Оказывается, как это подтверждает тонкая голубая линия, опорожнение буферов происходит и в данном случае, причем, приблизительно за тоже самое время, что и в предыдущей программе (процессоры P-II/P-III способны выполнять до трех машинных команд NOP за каждый такт, поэтому, результаты замеров следует разделить на три).
Поскольку, время записи данных в кэш второго уровня на P-III составляет всего лишь два такта, напрашивается интересный вывод: содержимое буферов выгружается отнюдь не при первой же возможности (ну да, увидел, что шина свободна и как идиот побежал), а согласно внутреннему таймеру.
Я не уверен, что продолжительность проживания данных в буферах записи на всех процессорах идентична, но во всяком случае, мы установили порядок этой величины. Как нетрудно видеть, он заметно короче времени выполнения многих вычислительных команд, поэтому, наше интуитивное предположение о нежелательности разделения команд записи и чтения, полностью подтвердилось.
Рассмотрим теперь как реализован механизм буферизации записи в процессоре AMD Athlon (коричневая кривая). Сразу же бросается в глаза, что за счет выгрузки содержимого буферов в кэш первого, а не второго (как на P-II/P-III) уровня, Athlon не имеет проблем с обвальным падением производительности. За счет этого сокращено и время выгрузки буферов. Причем, Athlon, судя по всему, не выгружает буфера вплоть до тех пор, пока в этом не возникнет несущей необходимости.
Правда, наблюдается трудно объяснимый "пик" кривой, отражающий значительное увеличение времени доступа при объединении семнадцати операций записи. Именно семнадцати! Обработка шестнадцати или восемнадцати операций записи не вызывает никаких проблем и "послушно" ложиться на гладкую кривую. Почему так происходит – трудно сказать… Требуются дополнительные исследования (быть может позже – по втором издании настоящей книги вы и встретите объяснение, пока же спишем это на ошибку разработчиков процессора).

Рисунок 37 graph 0x013 Демонстрация выгрузки буферов записи
Волчьи ямы опережающей записи
Начиная с K5 процессоры серии x86 используют прозрачную буферизацию записи (см. "Кэш – принципы функционирования. Буфера записи"), причем, буфера записи доступны не только на запись, но на чтение. То есть, результат работы команды становится доступным сразу же, как только он попадает в буфер,– дожидаться завершения его выгрузки в кэш-память нет никакой нужды! Очевидно, что такой трюк, именуемый разработчиками процессоров опережающий записью (Store-Forwarding), значительно сокращает время доступа к данным.Рассмотрим это на следующем примере. Пусть у нас имеется цикла вида.
for(a = 0; a < BLOCK_SIZE; a += sizeof(tmp32))
{
p[a] += tmp_1;
tmp_2 -= p[a];
}
Вместо того, чтобы гонять данные по "большому кругу кровообращения": вычислительное устройство à
блок записи à
кэш à
блок чтения à
вычислительное устройство, процессор AMD Athlon направляет данные по "малому кругу кровообращения" вычислительное устройство à
буфер записи à
вычислительное устройство. Как видно, малый круг намного короче! Pentium–процессоры судя по всему ведут себя точно так же, хотя ничего вразумительного на этот счет в документации не говорится.
Разумеется, буферизации записи присущи определенные ограничения и она эффективная лишь в тех случаях, когда читаются именно те данные, которые были записаны. В противном случае, процессор выставляет пенальти и быстродействие программы значительно падает.
Продемонстрируем это на следующем примере (см. рис. 0х17 слева):
*(int *)((int)p) = x; // запись данных в буфер
y = *(int *)((int)p + 2); // байтов [p+2; p+4] нет в буфере
Грубо говоря, мы записываем в буфер ячейки 0, 1, 2 и 3, а затем запрашиваем ячейки 2, 3, 4 и 5. Легко сообразить, что ячеек 4 и 5 просто нет в буфере и для их загрузки процессору необходимо обратиться к кэшу. Но ведь в кэше еще нет ячеек 2 и 3, – т.к. они не успели покинуть буфер!
Доподлинно неизвестно как процессор выходит из этой ситуации. Возможно, часть ячеек он считывает из буфера, а часть – из кэша и объединяет обе "половинки" в одну. Возможно (и более вероятно на мой взгляд), процессор сбрасывает содержимое буфера в кэш и уже оттуда безо всяких ухищрений извлекает запрошенные данные.
Но, так или иначе, все это требует дополнительных тактов, снижающих производительность. (На P-III величина пенальти составляет шесть тактов, а на AMD Athlon – десять).

Рисунок 33 0х017 Возникновение задержки при перекрытии областей чтения/записи
Другое ограничение. Даже если запрошенные данные целиком содержатся в буфере, но адреса читаемой и записываемой ячеек не совпадают, – все равно возникает задержка, т.к. процессору приходится выполнять определенные преобразования, отсекая "лишние" биты из записанного результата (см. рис. 0х17 справа).

Рисунок 34 0х018 Возникновение задержки при несоответствии разрядности данных
Наконец, если адреса ячеек совпадают, но они имеют различную разрядность, – задержки опять-таки не миновать. Логично, что если размер записываемой ячейки меньше читаемой (см. рис. 0х18 справа), – только часть запрашиваемых данных попадает в буфер, а все остальное содержится в кэше, т.е. ситуация сводится к рассмотренной выше.
Природу задержки, возникающий при записи ячейки большей разрядности (см. рис. 0х18 слева), понять сложнее. Несмотря на то, что данные непосредственно извлекаются из буфера, минуя кэш, отсечение лишних битов требует какого-то времени (по меньшей мере одного такта)…
То же самое справедливо и для нескольких коротких записей, перекрываемых последующим длинным чтением. Разберем следующий пример:
*(char *)((int)p + 0) = 'B';
*(char *)((int)p + 1) = '0';
*(char *)((int)p + 2) = 'F';
*(char *)((int)p + 3) = 'H';
BOFH
= *(int *)((int)p
+ 0); // ß задержка! читаются не те же самые данные,
// которые только что были записаны
На первый взгляд, все здесь вполне корректно, – ведь запрашиваемые данные целиком содержатся в буфере записи. Тем не менее задержка в шесть тактов все равно возникает, – ведь записываемые и читаемые ячейки имеют различную разрядность. Попросту говоря, загружаются отнюдь не те же самые данные, которые только что были записаны! Буфер записи адресуется совсем не так, как кэш-память, и процессор не может мгновенно установить: расположены ли записываемые байты в соседних ячейках или нет.
Отсюда правило. Чтение данных, следующее за их записью, должно иметь тот же самый стартовый адрес и не большую, а лучше такую же точно разрядность. Поэтому, при возможности лучше вообще не работайте со смешанными типами данных (например, байтами и двойными словами), а сводите их к единому типу наибольшей разрядности.
Если же прочесть небольшую порцию только что записанных данных просто жизненно необходимо, – воспользуйтесь, как и рекомендует Intel, битовыми операциями: "If it is necessary to extract a non-aligned portion of stored data, read out the smallest aligned portion that completely contains the data and shift/mask the data as necessary. The penalty for not doing this is much higher than the cost of the shifts".
Допустим, не оптимизированный пример выглядел так:
// не оптимизированный код
for(a = 0; a < BLOCK_SIZE; a += sizeof(int))
{
*(int *)((int)p + a) += x;
y += *(char *)((int)p + a + 2));
}
Выделенная жирным шрифтом строка навлекает страшный гнев процессора и становится самым узким местом в цикле.
Попробуем исправить проблему так:
// оптимизированный код
for(a = 0; a < BLOCK_SIZE; a += sizeof(int))
{
*(int *)((int)p + a)+= x;
tmp = *(int *)((int)p + a); // читаем во временную переменную те же самые данные
x += ((tmp & 0x00FF0000) >> 0x10); // "вручную" выкусываем нужный нам ячейку
}
Вопреки заявлениям Intel, на P- III мы получим даже худшее быстродействие по сравнению с первоначальным вариантом! Остается лишь гадать, кто ошибся: парни из Intel или мы? Быть может, на P-4 расклад вещей окажется совсем иной и ручные битовые махинации возьмут верх над не оптимизированным вариантам, но, по любому, учитывая, что ваша программа планирует исполняться не только на P-4, но и на младших моделях x86–процессоров, не слишком-то закладывайтесь на эту рекомендацию.
До сих мы говорили о записи/чтении одних и тех же данных. Однако, процессор, проверяя наличие запрашиваемых данных в буфере, анализирует не все биты адреса, а только те из них, которые "отвечают" за выбор конкретной кэш-линейке в кэш-памяти первого уровня (так называемые установочные адреса). Отсюда следует, что если адреса записываемых/читаемых ячеек кратны размеру кэш-банка (который можно вычислить поделив размер кэша на его ассоциативность), процессор дезорганизуется и не может определить: какую именно порцию данных ему следует извлекать. Возникает вынужденная задержка на время, пока "одноименные" ячейки не будут выгружены из буфера записи в кэш первого уровня, на что уходят те же самые шесть (P-III) или десть (AMD Athlon) тактов процессора.
Рассмотрим следующий пример:
*(int *)((int)p) = x;
*(int *)((int)p + L1_CACHE_SIZE/L1_CACHE_WAY_ASSOCIATIVE) = y;
z = *(int *)((int)p); // ß задержка
Поскольку, записываемые данные имеют идентичные установочные адреса, процессор не может осуществить опережающее чтение из буфера и вынужден дожидаться пока обе ячейки не попадут в кэш. Закладываясь на наименьший возможный размер кэш-банка (2 Кб на P-4), располагайте все интенсивно "передергиваемые" переменные в пределах одного килобайта.
Следует заметить, что все вышесказанное не распространяется на запись, следующую за чтением, т.е. код следующего вида будет исполняться вполне эффективно:
for(a = sizeof(int); a < BLOCK_SIZE; a += sizeof(int))
{
x += *(char *)((int)p + a - (sizeof(int)/2));
*(int *)((int)p + a) += y;
}
И в заключении главы – наш традиционный эксперимент, позволяющий количественно оценить степень падения производительности при неправильном обращении к данным. Для наглядности мы последовательно переберем все шесть комбинаций (см. листинг mem.stail.c), упомянутых в руководстве по оптимизации от Intel (кстати, руководство по оптимизации от AMD крайне поверхностно и туманно освещает эту проблему, поэтому даже если вы убежденный поклонник AMD не побрезгуйте обратится к Intel, тем более, что в этом вопросе оба процессора ведут себя одинаково).
// выделяем
память
p = (int*)_malloc32(BLOCK_SIZE);
/*------------------------------------------------------------------------
*
* ОПТИМИЗИРОВАННЫЙ ВАРИАНТ
* Long Write/Long Read(same addr)
*
----------------------------------------------------------------------- */
for(a = 0; a < BLOCK_SIZE; a += sizeof(tmp32))
{
*(int *)((int)p + a) = tmp32;
tmp32 += *(int *)((int)p + a);
}
/*------------------------------------------------------------------------
*
* НЕОПТИМИЗИРОВАННЫЙ ВАРИАНТ
* Short Write/Long Read (same addr)
*
----------------------------------------------------------------------- */
for(a = 0; a < BLOCK_SIZE; a += sizeof(tmp32))
{
*(char *)((int)p + a) = tmp8;
tmp32 += *(int *)((int)p + a);
}
/*------------------------------------------------------------------------
*
* НЕОПТИМИЗИРОВАННЫЙ ВАРИАНТ
* Short Write/Long Read (overlap space)
*
----------------------------------------------------------------------- */
for(a = sizeof(tmp32); a < BLOCK_SIZE; a += sizeof(tmp32))
{
*(char *)((int)p + a + (sizeof(tmp32)/2)) = tmp8;
tmp32 += *(int *)((int)p + a);
}
/*------------------------------------------------------------------------
*
* НЕОПТИМИЗИРОВАННЫЙ ВАРИАНТ
* Long Write/Short Read (same addr)
*
----------------------------------------------------------------------- */
for(a = 0; a < BLOCK_SIZE; a += sizeof(tmp32))
{
*(int *)((int)p + a) = tmp32;
tmp8 += *(char *)((int)p + a);
}
/*------------------------------------------------------------------------
*
* НЕОПТИМИЗИРОВАННЫЙ ВАРИАНТ
* Long Write/Short Read (overlap space)
*
----------------------------------------------------------------------- */
for(a = sizeof(tmp32); a < BLOCK_SIZE; a += sizeof(tmp32))
{
*(int *)((int)p + a) = tmp32;
tmp8 += *(char *)((int)p + a + (sizeof(tmp32)/2));
}
/*------------------------------------------------------------------------
*
* НЕОПТИМИЗИРОВАННЫЙ ВАРИАНТ
* Long Write/Long Read (overlap space)
*
----------------------------------------------------------------------- */
for(a = sizeof(tmp32); a < BLOCK_SIZE; a += sizeof(tmp32))
{
*(int *)((int)p + a) = tmp32;
tmp32 += *(int *)((int)p + a - (sizeof(tmp32)/2));
}
Листинг 14 [Cache/mem.stail.c] Демонстрация возникновения задержек памяти при записи/чтении данных различного размера
Результат прогона этой программы на процессорах AMD Athlon 1050 и P-III 733 представлен ниже. При условии, что обрабатываемый блок не превышает размера кэша первого (второго уровня), попытка чтения отсутствующих в буфера данных приводит к пяти-шести кратному падению производительности (см. рис. graph 05)!
На P-4 (если верить его разработчикам) величина пенальти еще больше. Намного больше, вот цитата из руководства: "The performance penalty from violating store-forwarding restrictions was present in the Pentium II and Pentium III processors, but the penalty is larger on the Pentium 4 processor" и дальше "
Приятное исключение составляет чтение маленькой порции данных после записи большой.
Если их адреса совпадают, время доступа к ячейке увеличивается "всего" в полтора раза.

Рисунок 35 graph 05 Возникновение задержек при обработке данных различной разрядности (в кэше первого/второго уровня)
Вне кэша. При выходе за пределы кэш-памяти второго уровня картина существенно изменяется. Штрафные санкции снижаются до не таких уж значительных полутора-трех крат, а короткое чтение после длинной записи на P-III (и – предположительно – на P-II и P-4) исполняется и вовсе без издержек! Впрочем, не стоит обольщаться, – AMD Athlon не простит вам подобных вольностей и накажет двух кратным падением производительности.
С другой стороны, преобразование данных к единому типу путем расширения их до наибольшей разрядности обернется еще большими потерями, – ведь удельное время доступа к ячейкам стремительно растет с увеличением размера обрабатываемого блока.
Таким образом: универсальной стратегии работы с разнотипными данными нет. Решайте сами, что лучше в каждом конкретном случае: мириться со штрафными задержками или возросшей потребностью в памяти.

Рисунок 36 graph 06 Возникновение задержек при обработке данных различной разрядности, находящихся в основной памяти
Вредный совет 1 Используйте табличные вычисления вместо расчетов
Когда-то давным-давно, когда процессоры еще именовались микропроцессорами, а скорость работы оперативной памяти была относительно велика, зачастую оказывалось гораздо быстрее выполнять наиболее трудоемкие вычисления всего один раз – на стадии разработки программы, а затем, поместив полученные результаты в таблицу, обращаться к ней по мере необходимости.Рассмотрим пример с вычислением синуса угла. Пусть нам необходимо знать его с точностью до одной угловой минуты, тогда в худшем случае таблица займет: 90*60*sizeof(float) = 21.6 Kb, что совсем немного даже по понятиям восьмидесятых. Используя же простейшие алгоритмы интерполяции мы, не сильно проиграв в производительности и точности, уменьшим этот размер минимум раза в два, а то и в четыре. А теперь вспомним, что вы
не выполнять вычисления каждый раз
"на лету
Вредные советы и
Временные диаграммы чтения/записи
Временные диаграммы чтения/записи статической памяти практически ничем не отличаются от аналогичных им диаграмм микросхем динамической памяти (что и неудивительно, т.к. интерфейсная обвязка в обоих случаях схожа).Цикл чтения начинается со сброса сигнала CS
(Chip Select – Выбор Чипа) в низкое состояние, давая понять тем самым микросхеме, что чип "выбран" и сейчас с ним будут работать (и работать будут, и прорабатывать!). К тому моменту, когда сигнал стабилизируется, на адресных линиях должен находиться готовый к употреблению адрес ячейки (т.е. номер строки и номер столбца), а сигнал WE должен быть переведен в высокое состояние (соответствующее операции чтения ячейки). Уровень сигнала OE (Output Enable – разрешение вывода) не играет никакой роли, т.к. на выходе пока ничего не содержится, точнее выходные линии находятся в, так называемом, высоко импедансом состоянии.
Спустя некоторое время (tAddress Access), определяемое быстродействием управляющей логики и быстротечностью переходных процессорах в инверторах, на линиях выхода появляются долгожданные данные, которые вплоть до окончания рабочего цикла (tCycle) могут быть непосредственно считаны. Обычно время доступа к ячейке статической памяти не превышает 1 – 2 нс., а зачастую бывает и меньше того!
Цикл записи происходит в обратном порядке. Сначала мы выставляем на шину адрес записываемой ячейки и одновременно с этим сбрасываем сигнал WE в низкое состояние. Затем, дождавшись, когда наш адрес декодируется, усилиться и поступит на соответствующие битовые линии, сбрасываем CS в низкий уровень, приказывая микросхеме подать сигнал высокого уровня на требуемую линию row. Защелка, удерживающая триггер, откроется и в зависимости от состоянии bit-линии, триггер переключится в то или иное состояние.

Рисунок 8 0х008 Временные диаграммы чтения/записи асинхронной статической памяти
в крошечную керамическую пластинку, свободно
Миллиарды битовых ячеек, упакованных в крошечную керамическую пластинку, свободно умещающуюся на ладони... Сегодня, когда счет оперативной памяти пошел на сотни мегабайт, мы – программисты – наконец-то избавились от "удовольствия" оптимизации своих программ по скорости и размеру одновременно. Пусть будет нужен хоть гигабайт – система выделит его за счет жесткого диска!Правда, производительность подсистемы памяти все еще оставляет желать лучшего. Причем, современная ситуация даже хуже, чем была десять-пятнадцать лет тому назад. Если персональные компьютеры конца восьмидесятых – начала девяностых оснащались микропроцессорами с тактовой частотой порядка 10MHz и оперативной памятью со временем доступа в ~200 нс., типичная конфигурация ПК ближайшего будущего: 1.000 – 2.000 MHz и 20 ns. Нетрудно подсчитать, что соотношение производительности памяти и процессора уменьшилось более чем в тысячу раз!
Несмотря на стремительный рост пропускной способности оперативной памяти, наблюдающийся в последние годы, разрыв "CPU vs Memory" растет с чудовищной быстротой. Забавно, но с этой ситуаций мы сталкиваемся отнюдь не впервые: приблизительно сорок-пятьдесят лет тому назад, – в эпоху "больших" машин с быстродействующими (по тем временам!) процессорами и жутко медленной барабанной (а позже и ферритовой) памятью, – соотношение быстродействие памяти и процессора было приблизительно тем же самым.
Интересно, как же конструкторы ЭВМ выходили из этой ситуации? Откроем, например, "Структуры ЭВМ и их математическое обеспечение" Л. Н. Королева: "Для того чтобы достичь необходимого баланса между высокой скоростью выполнения арифметических и логических действий в центральном процессоре и ограниченным быстродействием блоков оперативного ферритового запоминающего устройства (время цикла работы каждого блока - 2 мксек.), были предприняты следующие меры.
Оперативное запоминающее устройство состоит из восьми блоков, допускающих одновременную выборку информации (командных слов и операндов), что резко повышает эффективное быстродействие системы памяти.
Подряд идущие физические адреса памяти относятся к разным блокам, и если оказалось, например, так, что последовательно выбираемые операнды имеют последовательно возрастающие (убывающие) адреса, то они могут выбираться со средней скоростью, равной 2 мксек/8=0,25 мксек...
Второй структурной особенностью организации обращений к оперативному запоминающему устройству является метод буферизации, или метод накопления очереди заказов к системе памяти. В машине БЭСМ-6 существуют группы регистров, на которых хранятся запросы (адреса), называемые буферами адресов слов и команд. Разумеется, что эти буфера могут работать эффективно только в том случае, если структура машины позволяет просматривать команды "вперед", т. е. загодя готовить запросы. Устройство управления БЭСМ-6 позволяет это делать. Буфера адресов позволяют в конечном итоге сгладить неравномерность поступления запросов к памяти и тем самым повысить эффективность ее использования.
Третьей структурной особенностью БЭСМ-6 является метод использования сверхоперативной, не адресуемой из программы памяти небольшого объема, цель которого – автоматическая экономия обращений к основному оперативному запоминающему устройству. Эта сверхоперативная память управляется таким образом, что часто используемые операнды и небольшие внутренние командные циклы оказываются на быстрых регистрах и готовы к немедленному использованию в арифметическом устройстве или в системе управления машиной. Быстрые регистры в ряде случаев позволяют экономить до 60% всех обращений к памяти и уменьшают тем самым временные затраты на ожидание чисел и команд из основной памяти.
Следует еще раз подчеркнуть, что об использовании быстрых регистров заботится аппаратура самой машины и при составлении программ об экономии обращений к памяти думать нет необходимости. [Выделение мое – КК]
Эти структурные особенности БЭСМ-6 получили название водопроводного [ныне "конвейерного" – КК] принципа построения структуры машины. В самом деле, если подсчитать время от начала выполнения команды до его окончания, то для каждой команды оно будет очень велико, однако глубокий параллелизм выполнения, просмотр вперед, наличие буфера адресов, быстрых регистров приводят к тому, что "поток" команд и темп обработки информации очень высок.
Аналогия с водопроводом состоит в том, что если проследить время, за которое частица воды проходит по некоторому участку водопровода, то оно будет большим, хотя скорость на выходе потока может быть очень велика.
Четвертой структурной особенностью БЭСМ-6, имеющей очень важное значение для построения операционных систем и работы машины в мультипрограммном режиме, является принятый аппаратный способ преобразования математических, или виртуальных адресов в физические адреса машины. В машине БЭСМ-6 четко выдержано деление на физическую и математическую память, принята постраничная организация, однако способ отображения, заложенный в аппаратуру, значительно отличается от того, который был применен в машине…".
Трудно отделаться от впечатления, что перед тобой лежит не перечень ключевых концепций архитектуры P6 (Pentium Pro, Pentium-II, Pentium-III…), а описание "морально устаревшей" электронно-вычислительной машины, ценящейся сегодня разве что за драг. мет. Ан нет! Еще может старушка нас чему-то научить! Мы гордимся современной аппаратурой и пренебрежительно относимся к достижениям двадцати-тридцати летней давности, между тем это ослиная гордость. Чтобы там ни говорила реклама, невозможно не признать, что за последнее время ничего принципиально нового не придумано. Эксплуатируется сравнительно небольшое количество весьма древних идей и, если что и совершенствуется, – так это проектные нормы. БЭСМ-6 занимала целый шкаф, а процессор Pentium свободно умещается на ладони. Но в нем нет ничего такого, что в том или ином виде ни присутствовало бы в "динозаврах" первых поколений.
Обратите внимание на выделенный жирным шрифтом абзац. Идеология сверхоперативной
(или "кэш", как принято сейчас говорить) памяти изначально позиционирует ее как прозрачную и не видимую для программиста. Так утверждали и конструкторы БЭСМ, так утверждают и создатели процессоров Pentium/Krypton. Между тем, это утверждение неверно. Эффективная работа с памятью всех иерархий без учета ее физических, конструктивных и архитектурных особенностей невозможна! Как минимум программист должен позаботиться о том, чтобы интенсивно используемые данные целиком уместились в кэш, а для достижения наивысшей производительности следует тщательно согласовать запросы к памяти с "характером" всех ее подсистем.
Проблемам такого согласования, собственно, и посвящена эта книга…
Вычисление полного времени доступа
Теперь, познакомившись с механизмом взаимодействия оперативной памяти и процессора, мы можем рассчитать реальную пропускную способность при чтении зависимых данных. Итак, мысленно прокрутим процесс обмена еще раз…· получив запрос на чтение ячейки, процессор выполняет арбитраж и передает чипсету адрес и длину запрошенного блока памяти. При условии, что шина свободна, эта операция укладывается в четыре такта;
· контроллер шины, получив запрос, ставит его в очередь и, если контроллер памяти свободен, передает ему запрос с началом следующего такта;
· в течение следующего такта контроллер памяти декодирует адрес и ставит его в свою внутреннею очередь запросов на чтение памяти;
· в следующем такте запрос извлекается из очереди и контроллер, при необходимости дождавшись прихода фронта тактового импульса микросхемы памяти, передает ей адрес ячейки:
I. если соответствующая страница открыта и банк памяти не находится на регенерации, – чипсет выставляет сигнал CAS и передает сокращенный адрес ячейки. Спустя 2-3 такта частоты памяти на шине появляются первая порция считанных данных;
II. контроллер памяти считывает ее за один такт;
§ синхронный контроллер памяти с началом следующего такта передает считанные данные контролеру шины и в дальнейшем пересылка осуществляется параллельно с чтением, но с задержкой в один такт;
§ асинхронный контроллер памяти, "благодаря" расхождению частот не может передавать данные одновременно с чтением, и вынужден накапливать их во временном буфере. После завершения пакетного цикла чтения, контроллер памяти по приходу фронта следующего синхроимпульса начинает передавать содержимое временного буфера контроллеру шины на требуемой частоте.
§ примечание: Некоторые дешевые чипсеты, в частности VIA KT133/KT266, осуществляет передачу данных внутри чипсета только по фронту импульса, что полностью обесценивает все преимущества шины EV6, на которой работает Athlon, и ее эффективная часта (определяемая, как известно, самым узким местом системы) оказывается равной всего 100/133 MHz.
§ примечание: если длина запроса превышает длину пакета, то независимо от типа контроллера памяти, данные всегда передаются через временный буфер.
III. на чтение "хвоста" пакета в зависимости от его длины уходит еще три или семь тактов частоты оперативной памяти;
IV. если длина запроса превышает длину пакета, то мы возвращаемся к пункту I.
V. контроллер шины, получив считанные данные, формирует запрос на передачу данных от чипсета к процессору и ставит его в очередь, на что расходуется один такт;
VI. если в очереди не находится ничего другого, и шина никем не занята, контроллер шины извлекает запрос из очереди и "выстреливает" его в шину, передавая за один такт одну, две или четыре порции данных (на K6/P-II/P-III, Athlon и P-4 соответственно).
VII. как только запрошенная ячейка попадает в процессор, она становится немедленно доступной для обращения, даже если пакетный цикл передачи еще не завершен.
VIII. Все! Остается лишь добавить латентность кэш-контроллеров всех иерархий и латентность самого процессора, – но это уже тема другого разговора, к оперативной памяти прямого отношения не имеющая.
· если требуемая DRAM- страница закрыта, но банк не находится на регенерации, контроллер памяти передает адрес строки, вырабатывает сигнал RAS, ждет 2 или 3 такта пока микросхема его "переварит", и переходит к сценарию I.
· если же банк находится на регенерации, контролеру приходится подождать от одного до трех тактов пока она не будет завершена.
Конечно, это только приблизительная схема, не учитывающая конструктивные особенности отдельных наборов системных логик. Так, например, чипсеты от nVIDIA оснащены двумя независимыми контроллерами памяти, общающиеся со "своими" модулями памяти, в результате чего запросы от AGP-карты исполняются параллельно с запросами процессора, уменьшая тем самым латентность чипсета. Но самое интересное – Dynamic Adaptive Speculative pre-Processor /DASP/ (адаптивная система динамической упреждающей предвыборки), распознающая регулярные шаблоны обращения к памяти и заблаговременно осуществляющая предвыборку требуемых ячеек во внутренний буфер, расположенный где-то поблизости от контроллера шины (где именно – компания умалчивает, да это, собственно, и не важно).
Бессмысленно таким образом выводить общую форму вычисления латентности некоторого "обобщенного" чипсета. Потребуется, как минимум, знать его основные характеристики, которые, кстати, в документации отсутствуют. С другой стороны, все же полезно знать степень влияния тех или иных факторов на производительность системы, и мы отважимся написать такую программу, заранее оговорив ее ограниченность.
Полный исходный текст читатель найдет в файле "Memory/speed.exactly.c", в книге же ради экономии места приведен лишь ее ключевой фрагмент. Рассмотрим его (см. листинг 1).
// вычисление пропускной способности оперативной памяти с учетом латентности чипсета
C = (N /* разрядность памяти, байт */ * BRST_LEN /* длина пакета, итер */) /
(
2/FSB /* арбитраж */
+ 1/FSB /* передача адреса ячейки */
+ 1/FSB /* передача идентификатора транзакции */
+ 1/FSB /* латентность BIU */
+ 1/FSB /* декодирование MCT адреса ячейки */
+ 1/FSB /* латентность MCT */
+ Chipset_penalty/Fm /* пенальти на согласов. частот Mem/FSB*/
+ BRST_NUM*CAS_latency/Fm /* CAS Delay */
+ (fSrl?BRST_LEN/Fm:1/Fm) /* передача данных от DRAM к BUFF/BIU */
+ Chipset_penalty/FSB /* пенальти на согласование частот */
+ (fMCT2BIUparallel?BRST_LEN/FSB:1/FSB) /* передача данных от BUFF к BIU */
+ 1/FSB /* латентность BIU */
+ (fImmediately?1/FSB:BRST_LEN/Ftransf) /* передача данных от BIU к CPU */
+ CPU_latency/Fcpu /* латентность СPU */
+ X_CACHE*BRST_LEN/Fcpu /* передача данных от L2 к L1 */
+ CPU_penalty/Fcpu /* пенальти на согласов. частот CPU/FSB*/
+ RAS_latency/((LEN_page*K/(N*BRST_LEN))*Fm) /* задержка на открытие страницы памяти*/
+ (fInterleaving?0:RAS_precharge/Fm) /* задержка на перезарядку банка */
);
Листинг 1 [Memory/speed.exactly.c] Фрагмент программы измерения реальной пропускной способности памяти с учетом латентности чипсета и CPU
Сразу видно, что величина RAS to CAS Delay при последовательном доступе к ячейками на производительность практически не влияет и ей можно пренебречь. Время перезарядки банка (RAS Precharge) за счет чередования банков и вовсе маскируется, поэтому, при последовательном доступе не играет никакой роли.
Величина CAS Delay, будучи много меньше общей латентности чипсета, очень незначительно влияет на производительность системы, особенно на AMD Athlon, где одним CAS Delay считывается восемь порций данных из памяти (на P-II/P-III лишь четыре).
Таким образом основной фактор, определяющий производительность, это (за исключением архитектуры чипсета) – частота работы памяти
и частота передачи данных по системной шине
(при условии, что внутри чипсета данные перемещаются с не меньшей скоростью, – в противном случае частота системной шины утрачивает свою определяющую роль).
Вычисление значений функций на стадии компиляции ("свертка" функций)
Если все аргументы функции – константные значения, то теоретически возвращаемый ей результат можно вычислить еще на стадии компиляции. Рассмотрим следующий пример:Компиляторы Microsoft Visual C++ и WATCOM всегда выполняют свертку констант в границах одной функции, а вот передачу аргументов они отслеживать не умеют и на с
func(int a, int b)
{
return a+b;
}
main()
{
printf("%x\n",func(0x666,0x777));
}
Несмотря на его тривиальность, предвычислить значение функции func не сможет ни Microsoft C++, ни Borland C++, ни WATCOM!
Причина в том, что единицей трансляции практически всех современных компиляторов является функция. Компилятор выполняет ее синтаксический анализ и генерирует целевой код, инвариантный по отношению к остальным функциям программы. Исключение составляют встраиваемые (in-line) функции, но это – тема отдельного разговора.
Таким образом, ни один из трех используемых компиляторов на сквозную оптимизацию не способен и сворачивать функции не умеет.
Вычисление значения переменных на стадии компиляции ("свертка" констант)
"Сверткой" констант, вопреки логике и здравому смыслу, разработчики компиляторов называют процесс аналогичный их "развертке", за тем исключением, что "свертка" охватывает весь ансамбль константных выражений, а не одну константную переменную в отдельности.Логично: если все члены выражения (подвыражения) – константные переменные, то и значение выражения – тоже константа.
Например:
int a=0x666;
int b=0x777;
int c=b-a;
printf("%x\n", c);
c=a+b;
printf("%x\n", c);
Значение переменной 'c' инвариантно относительно входных данных программы и его можно вычислить еще на этапе трансляции, удалив переменные 'a' и 'b', и заменив 'c' ее фактическим значением. В результате всех преобразований оптимизированный код программы будет выглядеть так:
printf("%x\n", 0x111);
printf("%x\n", 0xDDD);
Не правда ли, здорово?! "Свертка" констант не только увеличивает компактность кода, не только избавляет от загрузки переменных из медленной оперативной памяти, не только экономит регистры, но и значительно повышает быстродействие программы, разгружая процессор от части вычислений. Выигрыш в производительности особенно заметен на свертке операций деления, умножения, взятия остатка не говоря уже об обработке вещественных значений
Компиляторы Microsoft Visual C++ и WATCOM всегда выполняют свертку констант, а вот Borland C++ этого делать не умеет.
Выделение
"Горячая" клавиша <F8> включает режим выделения текста, что равносильно удержаниюЕще интереснее комбинация <Shift-Ctrl-F8>, позволяющая выделять вертикальные блоки текста, что позволяет, в частности, одним махом удалить вертикальную строку комментариев.
Выход из кэша первого уровня
При выходе за границы кэш-памяти первого уровня, все четыре кривые лавинообразно "взлетают" (см. рис. graph 0x001), останавливаясь только тогда, когда размер обрабатываемого блока более чем в 1,5 раза превысит размер кэш-памяти первого уровня. Это обстоятельство слишком интересно, чтобы оставить его незамеченным. Почему именно полтора, а не, скажем, два или на худой конец три? На самом деле уже сам факт постепенного изменения скоростного показателя весьма знаменателен, ибо из самых общих рассуждений следует, что его градиент должен носить скачкообразный характер.Рассмотрим полностью заполненный кэш и мысленно попытаемся загрузить еще одну кэш-строку. Для освобождения свободного места стратегия LRU предписывает вытолкнуть наиболее "дряхлую" кэш-строку, к которой дольше всего не происходило обращений. При последовательной обработке блока памяти это будет самая первая строка. Да, именно та, с которой начнется обработка следующей итерации цикла!
Поэтому, в очередном проходе цикла первой кэш-строки там уже не окажется и кэш-контроллер будет вынужден вновь обращаться к кэшу второго уровня, замещая самую "древнюю", на этот раз вторую по счету, строку (ведь свободных линеек в кэше по прежнему нет!) Соответственно, при обращении к следующей ячейке памяти ее вновь не окажется в кэше и кэш контроллер будет вынужден перезагружать все кэш-строки по цепочке, работая "вхолостую". Поскольку каждое обращение к памяти сопровождается кэш-промахом, размер обрабатываемого блока уже не критичен – превосходит ли он размер кэш-памяти на одну, две или десять кэш-линеек – безразлично, ибо избыток всего в одну-единственную кэш-линейку уже не обеспечивает ни одного кэш-попадания, – худшего результата просто не бывает!
Однако, вопреки всем доводам, полученный нами график с завидным упорством демонстрирует плавный, а отнюдь не скачкообразный градиент. Что это: ошибка эксперимента или ошибка рассуждений? Ошибка в рассуждениях действительно есть.
Ведь, вследствие ограниченной ассоциативности кэша, ячейки кэшируемой памяти связаны не с любыми, а со строго определенными кэш-строками.
Легче всего понять это обстоятельство на примере кэша прямого отображения. Вообразим себе такой полностью заполненный кэш. При обращении к следующей ячейке, кэш-котроллер загружает ее в кэш-строку под номером (cache_size+1) % cache_size == 1. Затем, при следующем проходе цикла, первая ячейка вновь идет в эту же самую строку (т. к. 1 % cache_size == 1), а вовсе не в самую "дневную" кэш-строку под "номером два". Да, строка "номер один" будет работать "вхолостую", но она не затронет всех остальных. Соответственно, если размер обрабатываемого блока превышает размер кэш-памяти на две строки, то количество "холостых" кэш-линеек будет равно двум. Наконец, при обработке блока данных, вдвое превышающего размер сверхоперативной памяти, кэш будет работать полностью вхолостую.
В наборно-ассоциативном кэше "насыщение" наступает гораздо раньше. И не удивительно – ведь каждая ячейка кэшируемой памяти может претендовать на одну из нескольких кэш-строк.
Рассмотрим кэш, состоящий из двух банков. При недостатке свободного места очередная считываемая ячейка идет в первую кэш-линейку первого банка, т.к. к ней дольше всего не было обращения, поэтому, при следующем проходе цикла первой обрабатываемой ячейки в кэш-памяти уже не окажется и ее придется перезагружать за счет вытеснения первую кэш-линейки второго банка.
В результате, вхолостую будет работать уже не одна, а целых две кэш-линейки и, если размер обрабатываемого блока превысит емкость кэш-памяти в полтора раза, кэш будет крутиться полностью вхолостую. Соответственно, четырех - ассоциативный кэш целиком насыщается при превышении размера кэш-памяти в 1.25 раза, а восьми - ассоциативный и того хуже – 1.125. Выходит, что высокая ассоциативность несет в себе не только плюсы, но и минусы и максимально достижимый размер кэшируемых данных составляет Кб.
(Замечание: это правило не обходится без исключений, – см. Особенности кэш-подсистемы процессоров P-II и P-III).
Однако все же вернемся к нашим баранам (в смысле графикам). Действительно, насыщение кэша наступает при обработке блока в 48 килобайт, т.е. при превышении емкости кэш-памяти на 16 килобайт, что практически (в смысле в пределах погрешности измерений) совпадает с размером одного кэш-банка (двух ассоциативный кэш процессора K6 содержит два банка по 16 Кб каждый), что и требовалось доказать.
Возьмем другой пример (см. рис. 0х022). Четырех ассоциативный шестнадцати килобайтный кэш процессора CELERON-300A, "сдыхает" ровно отметке в двадцать килобайт, что полностью соответствует приведенной выше формуле (), подтверждая ее справедливость.

Рисунок 19 graph 0x029 Зависимость скорости обработки от размера блока на CELERON?300A
Выход из кэша второго уровня (мнимый)
Придерживаясь самых общих рассуждений о природе кэша, попытаемся определить его размер по характеру изменения скоростного показателя с ростом размера обрабатываемого блока. Первое, что сразу бросается в глаза – внезапный скачек кривой удельной скорости записи при пересечении отметки в ~128 Кб. Синхронно с ней изменяется и кривая чтения, пускай ее излом и менее ярко выражен, но он все-таки есть. Выходит, размер кэша второго уровня составляет 128 Кб? Но это не согласуется с показаниями BIOS, которая оценивает его размер в 512 Кб, что в четверо больше! Нас надули?! Или кэш работает неправильно? Можно, например, предположить, что кэш состоит из нескольких микросхем статической памяти с различным временем доступа…Но не спешите возвращать материнскую плату обратно к продавцу! Она вполне исправна и полностью соответствует заявленным характеристикам. Полученный же результат объясняется тем, что ячейки кэшируемой памяти могут соответствовать не любым, а строго определенным кэш-линейкам. Несмотря на то, что свободное место в кэше еще есть, обрабатываемый блок по мере его роста начинает претендовать на кэш-линейки, занятые другим "хозяйствующим субъектов". Это может быть и код (впрочем, в нашем случае основной цикл целиком помещается в сверхоперативной памяти первого уровня), и стек, и интенсивно используемые переменные.
Наша программа, действительно, интенсивно использует стек вызывая каждый раз функции A1 и A2 для замера временных интервалов выполнения цикла, сохраняя результат в локальной переменной buff (см. исходный текст программы). Наконец, около шести килобайт требует для своей работы функция printf, не говоря уже о системе ввода-вывода операционной системы и переключении задач. Если размер обрабатываемого блока превышает размер кэш-памяти первого уровня, то содержимое стека вместе с локальными переменными неизбежно вытиснится в кэш-память второго уровня, причем, в данном случае сложилось так, что стек вкупе с локальными переменными и обрабатываемые данные отображаются на один и те же кэш линейки, что приводит к их постоянным замещениям.
Выходит, мало уложится кэш второго уровня, необходимо еще и ухитрится эффективно распределить свободное пространство между обрабатываемыми данными, стеком и кодом! Это – одна из сложнейших задач оптимизации, в общем виде не имеющая решения. Не прибегая к анализу кода операционной системы и всех запущенных задач, невозможно определить интенсивность использования тех или иных кэш-линеек, а, значит, невозможно спланировать и оптимальную стратегию размещения обрабатываемых данных. Эффективная емкость кэша второго уровня только в исключительных случаях совпадает с его физической емкостью и чем меньше ассоциативность кэша, тем выше вероятность возникновения взаимных конфликтов.
Планируя размеры структур данных, многие программисты часто забывают, что в их распоряжении не весь кэш второго уровня, а только часть его. Другая же часть принадлежит коду программы. Если интенсивно используемые циклы не умещаются в кодовом кэше первого уровня и постоянно вытесняются обрабатываемыми ими данными из второго – производительность не замедлит упасть так, что домкратном не поднимаешь!
Впрочем, в данном случае цикл обработки вращается глубоко в кэше первого уровня, и ступенька принадлежит… стеку! Да, раз размер обрабатываемого блока превышает размер кэша данных первого уровня, ячейки памяти, принадлежащие стеку, вынуждены постоянно вытесняться в кэш второго, а, затем, по мере "распухания" обрабатываемого блока, и вовсе отправляться в основную память! Но что обозначает горизонтальная верхушка ступеньки?
Давайте подумаем: раз скоростной показатель не меняется с ростом размера обрабатываемого блока, значит, соответствующие кэш-линейки никто не использует, в противном случае наблюдалось бы нарастающие падание производительности.
Выход из кэша второго уровня (настоящий)
Изменение скоростного показателя при выходе за границы кэша второго уровня описывается теми же самыми формулами, что и выход за границы кэша первого уровня, т.е. на участке от L2.CACHE.SIZE до L2.CACHE.SIZE+ L2.CACHE.SIZE*WAY кривая чтения "взлетает" с коэффициентом пропорциональности , где F.L2.CACHE – частота работы кэша, F.MEM – частота работы памяти, N.CACHE.BUS – разрядность шины кэша второго уровня, а N.MEM.BUS разрядности шины памяти.Из этой формулы следует, что процессоры, не имеющие собственного кэша второго уровня, практически никак не реагируют на его переполнение. Действительно, кэш, расположенный на материнской плате, работает на частоте системной шины по формуле 2?1?1?1, что вполне сопоставимо со скоростью синхронной динамической памяти, работающей на тех же частотах по формуле 3?1?1?1 или даже 2?1?1?1!
Процессоры, с интегрированным кэшем второго уровня, ведут себя совершенно иначе, что и не удивительно, т.к. частота работы интегрированного кэша (даже если он размещен не на кристалле, а смонтирован на отдельном картридже) по крайней мере вдвое–вчетверо превосходит частоту системной шины и, что тоже не маловажно, кэш-контроллер обладает значительно меньшей латентностью нежели контроллер оперативной памяти. Поэтому, старшие представители семейства x86, выход за пределы кэша второго уровня переносят крайне болезненно, теряя в производительности порядка трех крат.
Кривая записи ведет себя иначе. Если не предпринять
Выход за пределы кэша первого уровня
Прогон полученной программы показывает, что выход за пределы кодового кэша первого уровня вызывает существенное снижение производительности, гораздо более существенное, нежели при обработке данных.Объясняется это тем, что многостадийные конвейеры современных процессоров крайне болезненно реагируют даже на кратковременное прерывания потока данных.
В частности, на AMDAthlon 1050 удельное время выполнения команд при выходе за пределы кэша уровня увеличивается по меньшей мере втрое (вспомним, что удельное время доступа к данным в аналогичной ситуации возрастает всего лишь на 10%, – см. рис. graph 2).
На P-III (за счет огромной ширины шины) падение быстродействия, к счастью, не столь значительно, но все-таки достигает добрых 25%, за просто так "съедая" четверть производительности. С другой стороны, размер кодового кэша составляет всего 32 Кб против 64 Кб AMD Athlon, – вот пойди разберись какой из них предпочтительнее.
Выход за пределы кэша второго уровня
Во-первых, не забывайте, что кэш второго уровня хранит не только код, но и данные. Как было показано в предыдущей главе, эффективная емкость кэша второго уровня не всегда совпадает с физической, – ведь исполняемый код и обрабатываемые данные могут претендовать на одни и те же кэш-линейки, в результате чего падение производительности начнется задолго до того, как алгебраическая сумма размеров интенсивно исполняемого кода и обрабатываемых им данных превысит размер кэша второго уровня.Причем, падание производительности будет… нет, даже не обвальным, а по настоящему, без дураков, ошеломляющим – порядка тридцати (!) крат на AMD Athlon и шести –на P-III. Так никаких мегагерц процессора не хватит! Впрочем, с выходом исполняемого кода за границы кэша второго уровня приходится сталкиваться не так уж и часто, а если и приходится – практически всегда удается разбить его на несколько циклов меньшего размера, обрабатывающихся последовательно.
Таким образом, при разработке программы стремитесь проектировать ее так, чтобы все интенсивно используемые циклы вмещались в кэш первого или по крайней мере второго уровня.

Рисунок 22 graph 04 Изменение удельного времени выполнения команд в зависимости от размера исполняемого кода
Вынесение инвариантного кода за пределы цикла
Инвариантным называется код, не изменяющийся в ходе выполнения цикла. А раз так, – то какой смысл выполнять его в каждой итерации – не лучше ли вынести такой код за пределы цикла?Рассмотрим следующий пример:
for(a=0;a<(b*2);a++)
printf("%x\n",a*(b/2));
Выражения (b*2) и (b/2) очевидно представляют собой инвариант, и оптимизированный код будет выглядеть так:
tmp_1=b*2;
tmp_2=b/2;
for(a=0;a<tmp_1;a++)
printf("%x\n",tmp_2+=tmp_2);
Это экономит одну операцию деления и две операции умножения на каждую итерацию, что очень и очень неплохо!
Компиляторы Microsoft Visual C++ и WATCOM успешно распознают инвариантный код и выносят его за пределы цикла, а вот Borland C++ увы, нет.
Выполнение алгебраических упрощений
Вычисление многих выражений можно существенно ускорить, если на этапе компиляции выполнить все возможные алгебраические упрощения. Очень показателен следующий пример, кстати, заимствованный из фирменного "хелпа" компилятора Microsoft Visual C++ (см. описание функции PreCreateWindows):cs.y = ((cs.cy * 3) - cs.cy) / 2;
cs.x = ((cs.cx * 3) - cs.cx) / 2;
Если раскрыть скобки (помните школьный кур математики?), то получится буквально следующее:
cs.y = cs.cy;
cs.x = cs.cx;
Поскольку, оба присвоения бессмысленны (см. "Удаление лишних присвоений"), то их можно сократить. В результате мы избавляемся от двух операций умножения, двух операций деления, двух операций вычитания и двух операций присвоения – совсем неплохой результат, правда?
К сожалению, даже современные оптимизаторы очень плохо справляются с задачей алгебраических упрощений. Так, приведенный пример не сумеет сократить ни один из них.
Ни Microsoft Visual C++, ни Borland C++, ни WATCOM не избавятся от операций деления и умножения в выражении (a=2*b/2), хотя его избыточность очевидна.
Самый продвинутый в этом плане – Microsoft Visual C++ – сокращает лишь простейшие выражения наподобие (a=3*b-b) или (a=b-b). А компиляторы Borland C++ и WATCOM могут похвастаться только тем, что автоматически вычисляют результат умножения (деления) на нуль или единицу. Т.е. следующий код "a=b*0; c=d/1" после оптимизации будет выглядеть так: "a=0; c=d".
Поэтому, всегда выполняйте все возможные алгебраические упрощения, – компилятор не собирается делать это за вас!
Заметим, что сказанное не имеет никакого отношения к константным выражениям вроде (2*3+4/2) – их "сворачивают" все три рассматриваемых компилятора.
Выполнение кода в стеке
Разрешение на выполнение кода в стеке объясняется тем, что исполняемый стек необходим многим программам, в том числе и самой операционной системе для выполнения некоторых системных функций. Благодаря ему упрощается генерация кода компиляторами и компилирующими интерпретаторами.Однако вместе с этим увеличивается и потенциальная угроза атаки – если выполнение кода в стеке разрешено, и ошибки реализации при определенных обстоятельствах приводят к передаче управления на данные, введенные пользователем, злоумышленник получает возможность передать и выполнить на удаленной машине свой собственный зловредный код. Для операционных систем Solaris и Linux существуют "заплатки", установка которых приводит к запрету исполнения кода в стеке, но они не имеют большого распространения, поскольку, делают невозможной работу множества программ, и большинству пользователей легче смириться с угрозой атаки, чем остаться без необходимых приложений.
Поэтому, использование стека для выполнения самомодифицирующегося кода, вполне законно и системно независимо, т.е. универсально. Помимо этого, такое решение устраняет оба недостатка функции WriteProcessMemory:
Во-первых, выявлять и отследить команды, модифицирующие заранее неизвестную ячейку памяти, чрезвычайно трудно и взломщику придется провести кропотливый анализ кода защиты без надежды на скорый успех (при условии, что сам защитный механизм реализован без грубых ошибок, облегчающих задачу хакера).
Во-вторых, приложение в любой момент может выделить столько стековой памяти, сколько ему заблагорассудится, а затем, при исчезновении потребности – ее освободить. По умолчанию система резервирует один мегабайт стекового пространства, а, если этого для решения поставленной задачи не достаточно, нужное количество можно указать при компоновке программы.
Замечательно, что для программ, выполняющихся в стеке, справедлив принцип Фон Неймана – в один момент времени текст программы может рассматриваться как данные, а в другой – как исполняемый код. Именно это необходимо для нормальной работы всех распаковщиков и расшифровщиков исполняемого кода.
Однако, программирование кода, выполняющегося в стеке, имеет ряд специфических особенностей, о которых и будет рассказано ниже.
Выравнивание данных
В силу конструктивных ограничений пакетный цикл обмена с памятью не может начинаться с произвольного адреса. В зависимости от типа процессора он автоматически выравнивается по границе 32-, 64- и 128байт на K6/P-II/P-III, Athlon и P-4 соответственно. (см. "Отображение физических DRAM-адресов на логические").Задумаемся, что произойдет, если на P-III мы запросим двойное слово, лежащее по адресу, равному, ну скажем, 30? Правильно! Для его чтения потребуется выполнить два пакетных цикла! В первом цикле будут загружены ячейки из интервала [(30 % 32); (30 % 32) + 32), т.е. [0; 32), в который входит лишь половина запрошенного нами двойного слова. Для чтения другой его половины потребуется совершить еще один цикл, – [32; 64). В результате, – время доступа к ячейке возрастет как минимум вдвое.
Но не стоит упрекать создателей Pentium'а в кретинизме, – многие процессоры, в отличии от него, вообще запрещают доступ по не выровненным адресам, генерируя при этом исключение! Однако и на Pentium'ах таких ситуации все же рекомендуются избегать. И вот тут самое время рассказать о широко распространенном заблуждении, связанном с выравниванием данных.
Подавляющее большинство руководств по оптимизации (равно как и техническая документация от производителей процессоров) настоятельно рекомендуют всегда выравнивать данные независимо то того, по каким адресам они лежат. На самом же деле, если запрошенные данные целиком умещаются в один пакетный цикл, то величина выравнивания не играет никакой роли! Чтение двойного слова, начинающегося с адреса 0x40001, осуществляется безо всяких задержек (no penalty!), поскольку оно гарантированно не пересекает пакетный цикл. Действительно, 0x20 – (0x40001 % 0x20) == 0x1F, а 0x1F > sizeof(DWORD) /* в смысле расстояние до правой границы пакетного цикла превышает размер читаемых данных */! Следовательно, чтение двойного слова с адреса 0х40001 вполне законно, хотя он (адрес) отнюдь и не кратен 4.
Подробный разговор о проблемах выравнивания мы отложим на потом (см. "Кэш. Выравнивание данных"), поскольку он больше относится к кэшу, чем к подсистеме оперативной памяти, здесь же мы рассмотрим лишь влияние начального адреса на скорость обработки больших блоков памяти (см. [Memory/align.c]).
Достаточно очевидно, что при линейной обработке памяти кратность выравнивания начального адреса играет второстепенную роль. Действительно, если очередная порция запрошенных данных "вылетит" за пределы пакетного цикла и процессор будет вынужден инициировать еще один цикл обмена, – мы не слишком огорчимся этим обстоятельством, поскольку эти данные нам все равно предстоит загружать. Так какая разница, – случится это раньше или позже?
На самом деле, некоторая разница все же есть. При чтении данных, пересекающих пакетный цикл, процессор вынужден тратить по крайней мере один такт, на склеивание двух половинок данных, что несколько ухудшит производительность. Впрочем, ненамного: на ~10% на P-III, и на ~20 на AMD Athlon (см. рис. graph 34). В подавляющем большинстве случаев этой величиной можно безболезненно пренебречь. Тем нее менее, если вы хотите достичь максимальной производительности, соответствующим образов выравнивайте адреса (см. таблицу 2 ниже).
|
Размер данных |
Граница |
|
1 байтов (8 битов) |
Произвольная |
|
2 байтов (16 битов) |
Кратная 2 байтам |
|
4 байтов (32 бита) |
Кратная 4 байтам |
|
8 байтов (64 битов) |
Кратная 8 байтам |
|
10 байтов (80 битов) |
Кратная 16 байтам |
|
16 байтов (128 битов) |
Кратная 16 байтам |
Совершенно иная ситуация с записью данных. Механизм отложенной записи, реализованный в процессорах Pentium и AMD Athlon, предотвращает падание производительности вызванное несовпадением размером записываемых данных с границами пакетных циклов. На P?III запись не выровненных данных выполняется всего лишь на 3% медленнее, а на AMD Athlon даже на 35% быстрее! Нет, это не ошибка! В силу конструктивных особенностей процессора Athlon и северного моста чипсета VIA KT133, запись не выровненных данных действительно осуществляется значительно быстрее.
Однако данный эффект наблюдается исключительно при записи данных в оперативную память. Не выровненная запись в кэш-память несет значительные издержки, многократно снижая производительность (см. "Кэш. Выравнивание данных"), поэтому пренебрегать выравниванием допускается только
при обработке огромных блоков памяти (от 1 Mб и выше), многократно превосходящих в объеме емкость кэшей всех уровней.

Рисунок 35 graph 026 Эффективность выравнивания начального адреса при обработке больших массивов данных. Не выровненный начальный адрес читаемого потока памяти несет ~15% издержки производительности. Записывание данных, напротив, не требует выравнивания, а на AMD Athlon не выровненные данные обрабатываются даже быстрее
В заключении рассмотрим какое влияние на производительность оказывает выбор адресов источника и приемника при копировании больших блоков памяти (см. [memory/align.memcpy]. Как нетрудно догадаться, выравнивание адреса-приемника не имеет решающего значения, а вот неудачный выбор адреса-источника заметно снижает быстродействие программы. И это действительно так! (см. рис. graph 27).

Рисунок 36 graph 27 Влияние на производительность выравнивания адресов источника и приемника при копировании памяти. Главное – выровнять источник. Выравниванием же начального адреса приемника можно пренебречь
Техника ручного выравнивания данных
Штанных средств выравнивания данных (подробнее см. "Кэш. Выравнивание") очень часто оказывается недостаточно. Во-первых, они действуют только на элементы структур, а локальные и глобальные переменные компилятор всегда выравнивает по собственному усмотрению, и, во-вторых, максимально допустимая степень выравнивания обычно (читай у Microsoft Visual C++ и Borland C++) ограничена кратностью в 16 байт. Т.е. выровнять массив по границе кэш-линий (32 байта для P–III и 64 байта для Athlon) не удастся! /* ну почему же не удастся? еще как удастся – см. "Кеш.
Выравнивание" */ Некоторые руководства (в том числе и довольно авторитетные) рекомендуют в этой ситуации прибегать к ассемблеру. Что ж, ассемблер – действительно, великая вещь, но как быть тем, кто им не владеет? (А не владеют ассемблером большинство прикладных программистов)
Однако для решения этой задачи вполне достаточно штатных средств языка Си. Поскольку, 32-битные near-указатели представляют собой по сути 32-битные целые, – весь математический аппарат к услугам программиста. Фактически решение задачи сводится к следующему: есть число X, из него надо получить число Y, максимально близкое к X и кратное N. Первое, что приходит на ум: Y = (X / N)*N. Если N не любое число, а кратное степени двойки, то от медленной операции деления можно отказаться, вручную сбрасывая соответствующий двоичный разряд с помощью логического AND. Так же, будет необходимо увеличить "усеченный" указатель на величину, равную степени выравнивания, т.к. при выравнивании происходит уменьшение указателя, в результате чего он залезает в чужую область. Естественно, количество выделяемой памяти придется увеличить на величину округления. Таким образом, законченное решение будет выглядеть приблизительно так:
char p;
p = (char*) malloc(need_size + (align_powr – 1));
p = (char *) (((int)p + align_power – 1) & ~(align_power - 1));
где align_power – требуемая степень выравнивания, а "char*" – тип указателя (естественно, не обязательно именно char* – это может быть и int*)
Тот же трюк может использоваться и при выравнивании массивов, расположенных в стеке или сегменте данных. Например:
#define array_size 1024
#define align_power 64
int a[array_size + align_power –1];
int *p;
p = (int *) (((int)&a + align_power – 1) & ~(align_power-1));
Указатель 'p' будет указывать на начало массива, выровненного по 64-байтной границе (такое выравнивание обеспечивает наиболее эффективную обработку данных – см. "Кэш.
Использование упреждающего чтения").
Кстати, при желании массив можно использовать и для хранения разнотипных данных, выравнивая их так, как это заблагорассудится. Например:
char array[9];
#define a array[0]
#define b (int *array[sizeof(char)])[0]
#define c (int *array[sizeof(char)+sizeof(int)])[0]
#define d (int *array[sizeof(int)*3])[0]
Этот, пускай и не очень изящный, трюк позволяет хранить переменные вплотную друг к другу, без зазоров и контролировать их порядок размещения в памяти, что позволяет, например, рассовать совместно используемые переменные по разным банкам. Дело в том, что локальные переменные размещаются в стеке не в порядке их объявления в программе, а как это заблагорассудиться компилятору.
Выравнивание потоков данных. Если с выравниванием блоков памяти, возвращенных malloc, все более или менее понятно (действительно, это очень простая задача), то выравнивание адресов, получаемых функцией извне, несколько сложнее. Рассмотрим следующий пример: пусть нам необходимо посчитать сумму всех элементов некоторого массива. Проще всего это сделать приблизительно так:
int sum(int *array, int n)
{
int a, x = 0;
for(a = 0; a < n; a++)
x+=array[a];
return x;
}
Листинг 24 Не оптимизированный пример реализации функции. *array может быть не выровнен!
Недостаток предложенной реализации в том, что она никак не заботится о выравнивании данных, молчаливо перекладывая эту заботу на плечи вызывающего ее кода. А это весьма рискованное допущение! Даже если мы явно оговорим целесообразность выравнивания в спецификации функции, использующий ее программист может просто пренебречь (забыть, упустить) этой рекомендацией. К тому же не всегда возможно выровнять передаваемый функции адрес (скажем, программист сам получил его не выровненным извне).
Поэтому, заботиться о выравнивании адресов вызываемая функции должна самостоятельно. В состоянии ли она это сделать? На первый взгляд нет.
Легко доказать, что если (x & 3) != 0, то и ((x + sizeof(int)*k) & 3) != 0. Действительно, это так, но… в том-то и все и дело, что штрафные такты за доступ по не выровненным данным даются лишь в том, и только в том случае когда они пересекают границы пакетных циклов обмена. А вот на этом можно и сыграть!
Читаем память двойными словами до тех пор, пока текущий адрес (((p % BRST_LEN) + sizeof(DWORD)) < BRST_LEN), – т.е. пока мы не дойдем до двойного слова, пересекающего границы пакетных циклов обмена. "Опасный" участок трассы проходим маленькими – побайтовыми – шажками, что гарантированно страхует нас от почетной "награды" в виде пенальти. Остается лишь собрать эти четыре байта в одно слово, что элементарно осуществляется битовыми операции сдвига. Затем описанный цикл чтения вновь повторяется (см. рис. 45). И так происходит до тех пор, пока не будет достигнут конец обрабатываемого блока памяти.

Рисунок 37 45 Техника эффективной обработки не выровненного двухсловного потока данных
Одна из возможных реализаций предложенного алгоритма показана ниже. Обратите внимание: общих решений поставленная задача не имеет. Максимальное количество двойных слов в типичном пакете всего восемь, а при работе с не выровненными адресами и того меньше! Цикл из нескольких интеракций – крайне медленная штука и во избежание падения производительности он должен быть развернут целиком. В этом-то и заключается основная проблема: поскольку количество итераций зависит от величины смещения указателя в пакетном цикле обмена, нам необходимо создать BRST_LEN – BRST_LEN/sizeof(DWORD) различных циклов, каждый из которых будет обрабатывать "свое" смещение указателя. Для экономии места в книге ### приведен лишь один вариант, поскольку остальные реализуются практически аналогично.
// -[Посечет суммы массива]----------------------------------------------------
//
// ARG:
// array - указатель на массив
// n - кол-во элементов для сортировки
//
// README:
// Функция справляется с не выровненными массивами, и сама выравнивает их
// (правда, было бы лучше, если бы она это не делала)
//----------------------------------------------------------------------------
int sum_align(int *array, int n)
{
int a, x = 0;
char supra_bytes[4];
// внимание: это решения для _частного_ случая
// когда array & 15== 1, т.е. попросту говоря,
// указатель смещен на 1 байт вправо относительно
// выровненного по границе 32 байт адреса
// общее решение данной задачи без использования
// циклов (циклы - снижают производительность)
// невозможно!
// единственный вариант - вручную создать свой
// "обработчик" для каждой ситуации
// всего их будет 32 - 32/4 = 24, что слишком
// громоздко для книжного варианта
if (((int)array & 15)!=1)
ERROR("-ERR: Недопустимое выравнивание\n");
for(a = 0; a < n; a += 8)
{
// копируем все двойные слова, которые
// не пересекают границы пакетных циклов
// обмена
x+=array[a + 0];
x+=array[a + 1];
x+=array[a + 2];
x+=array[a + 3];
x+=array[a + 4];
x+=array[a + 5];
x+=array[a + 6];
// двойное слово, пересекающие пакетный цикл
// копируем во временный буфер по б а й т а м
supra_bytes[0]=*((char *) array + (a+7)*sizeof(int) + 0);
supra_bytes[1]=*((char *) array + (a+7)*sizeof(int) + 1);
supra_bytes[2]=*((char *) array + (a+7)*sizeof(int) + 2);
supra_bytes[3]=*((char *) array + (a+7)*sizeof(int) + 3);
// извлекаем supra-байты и обрабатываем их как двойное слово
x+=*(int *)supra_bytes;
}
return x;
}
Листинг 25 [Memory/align.dwstream.c] Пример реализации эффективной обработки не выровненного двухсловного потока данных
И вот тут нас ждет неожиданный и весьма неприятный сюрприз. "Оптимизированная" программа выполняется намного медленнее
первоначального варианта! На P?III 733/133/100/I815EP падение производительности составляет ~15%, а на AMD Athlon 1050/100/100/VIA KT133… аж ~130%!!! Ничего себе "оптимизация"! В чем же причина? Дело в том, что предотвращение пересечения пакетных циклов обмена достается нам дорогой ценой, а именно – увеличением количества обращений к памяти. Это-то и снижает производительность!
Тем не менее, автор отнюдь не собирается выбрасывать предложенный алгоритм на помойку. Тут еще есть над чем подумать! "А почему бы нам ни подумать вместе?" (с) Котенок Гав.

Рисунок 38 graph 34 Описанная методика "эффективной" обработки не выровненного двухсловного потока данных, на самом деле снижает производительность. Самое интересное, что не выровненный поток на AMD Athlon обрабатывается даже быстрее, чем выровненный!
Выравнивание байтовых потоков данных.
Эффективная обработка байтовых потоков данных двойными словами (см. "Обработка памяти байтами, двойными и четвертными словами") возможна лишь в том случае, если адрес начала блока выровнен по границе четырех байт. А так, к сожалению, бывает не всегда. Ведь байтовые потоки не требуют выравнивания по определению и компилятор (или программист) может располагать их по любому месту в памяти – где ему больше заблагорассудиться. К счастью, самостоятельно выровнять такой поток вызываемой функции не составит большого труда!
Поскольку байтовый поток однороден мы можем начать читать его с любого места. Вычисляем адрес ближайшей границы пакетного цикла (в общем случае он равен p + (BRST_LEN ? ((int) p % BRST_LEN))) и гоним двойными словами в полную силу, не забывая, конечно, о том, что размер блока не всегда бывает кратен sizeof(DWORD).
Постойте! А как же первые (BRST_LEN ? ((int) p % BRST_LEN)) байтов?! Ведь они остались необработанными! Что ж, добавить дополнительный цикл обработки – не проблема! (см. рис. 46)

Рисунок 39 46 Техника выравнивания байтовых потоков данных. В отличии от выравнивая двухсловных потоков, она действительно эффективна
Возвращаясь к листингу 23, реализующему простейший шифровальный алгоритм, давайте научим его выравнивать обрабатываемый блок данных. Один из возможных способов приведен ниже:
// -[Simple byte-crypt]-------------------------------------------------------
//
// ARG:
// src - указатель на шифруемый блок
// mask - маска шифрования (байт)
//
// DEPENCE:
// unalign_crypt
//
// README:
// функция самостоятельно выравнивает шифруемые данные
//----------------------------------------------------------------------------
void align_crypt(char *src, int n, int mask)
{
int a;
char *x;
int n_ualign;
// вычисляем величину на которую следует "догнать" блок
// чтобы он стал выровненным блоком
n_ualign= 32 - ((int) src & 15);
// шифруем пока не достигнем границы пакетного цикла обмена
unalign_crypt(src, n_ualign, mask);
// смело шифруем все остальное
// т.к. src+n_ualign - гарантированно выровненный указатель!
unalign_crypt(src+n_ualign, n-n_ualign, mask);
/* не забываем уменьшить ^^^^^^^^^^^^ ко-во шифруемых байтов */
}
Листинг 26 [Memory/align.bstream.c] Пример реализации функции, самостоятельно выравнивающий байтовый поток данных
Прогон программы показывает, что оптимизированный вариант с одинаковой эффективностью обрабатывает как выровненные так и не выровненные блоки данных, в то время как не оптимизированный теряет на не выровненных блоках от ~17% до 69% производительности (на P-III 733/133/100/I815EP и AMD Athlon 1050/100/100/VIA KT 133 соответственно).
Кстати, функция align_crypt (как вы, наверное, уже обратили внимание) представляет собой не более чем "обертку" для unalign_crypt, т.е. она легко может быть адоптированная под ваши собственные нужды. Для этого достаточно лишь заменить вызов "unalign_crypt" на вызов вашей функции.

Рисунок 40 graph 35 Демонстрация эффективности выравнивания байтовых потоков данных. Легко видеть, что предложенная техника выравнивания на 100% эффективна
Выравнивание команд
Выравнивание команд выходит за рамки данной книги и будет подробно рассмотрено в следующем томе настоящей серии.Исполняемые файлы лучше не паковать.
1) Исполняемые файлы лучше не паковать. В крайнем случае используйте для упаковки/распаковки функции операционной системы (LZInit, LZOpenFile, LZRead, LZSeek, LZClose, LZCopy) динамически распаковывая в специально выделенный буфер только те части файла, которые действительно нужны в данный момент для работы.2) Динамические библиотеки вообще не следует паковать, ибо это ведет к чудовищному расходу и физической, и виртуальной памяти и извращает саму концепцию DLL – один модуль – всем процессам.
3) Кстати, о динамических библиотеках: не стремитесь кромсать свое приложение на множество DLL – страницы исполняемого файла не требуют физической памяти до тех пор, пока к ним не происходит обращений. Поэтому – смело помещайте весь код программы в один файл.
Взаимодействие памяти и процессора
Вопреки распространенному заблуждению процессор взаимодействует с оперативной памятью не напрямую, а через специальный контроллер, подключенный к системной шине процессора приблизительно так же, как и остальные контроллеры периферийных устройств. Причем, механизм обращения к портам ввода/вывода и к ячейкам оперативной памяти с точки зрения процессора практически идентичен. Процессор сначала выставляет на адресную шину требуемый адрес и в следующем такте уточнят тип запроса: происходит ли обращение к памяти, портам ввода/вывода или подтверждение прерывания. В некотором смысле оперативную память можно рассматривать как совокупность регистров ввода/вывода, каждый из которых хранит некоторое значение.Обработка запросов процессора ложится на набор системной логики (так же называемый чипсетом) среди прочего включающий в себя и контроллер памяти. Контроллер памяти полностью "прозрачен" для программиста, однако знание его архитектурных особенностей существенно облегчает оптимизацию обмена с памятью.
Рассмотрим механизм взаимодействия памяти и процессора на примере чипсета Intel 815. Когда процессору требуется получить содержимое ячейки оперативной памяти он, дождавшись освобождения шины, через механизм арбитража захватывает шину в свое владение (что занимает один такт) и в следующем такте передает адрес искомой ячейки. Еще один такт уходит на уточнение типа запроса, назначение уникального идентификатора транзакции, сообщение длины запроса и маскировку байтов шины. Подробнее об этом можно прочитать в спецификациях на шины P6 и EV6, здесь же достаточно отметить, что эта фаза запроса осуществляется за три такта системной шины.
Независимо от размера читаемой ячейки (байт, слово, двойное слово) длина запроса всегда равна размеру линейки L2-кэша (подробнее об устройстве кэша мы поговорим в одноименной главе), что составляет 32 байта для процессоров K6/P-II/P-III, 64 байта – для AMD Athlon и 128 байт – для P-4. Такое решение значительно увеличивает производительность памяти при последовательном чтении ячеек, и практически не уменьшает ее при чтении ячеек вразброс, что и неудивительно, т.к.
латентность чипсета в несколько раз превышает реальное время передачи данных и им можно пренебречь.
Контроллер шины (BIU – Bus Interface Init), "вживленный" в северный мост чипсета, получив запрос от процессора, в зависимости от ситуации либо передает его соответствующему агенту (в нашем случае – контроллеру памяти), либо ставит запрос в очередь, если агент в этот момент чем-то занят. Потребность в очереди объясняется тем, что процессор может посылать очередной запрос, не дожидаясь завершения обработки предыдущего, а раз так – запросы приходится где-то хранить.
Но, так или иначе, наш запрос оказывается у контроллера памяти (MCT – Memory Controller). В течение одного такта он декодирует полученный адрес в физический номер строки/столбца ячейки и передает его модулю памяти по сценарию, описанному в главе "Устройство и принципы функционирования оперативной памяти".
В зависимости от архитектуры контроллера памяти он работает с памятью либо только на частоте системной шины (синхронный контроллер), либо поддерживает память любой другой частоты (асинхронный контроллер). Синхронный контролеры ограничивают пользователей ПК в выборе модулей памяти, но, с другой стороны, асинхронные контроллеры менее производительны. Почему? Во-первых, в силу несоответствия частот, читаемые данные не могут быть непосредственно переданы на контроллер шины, и их приходится сначала складывать в промежуточный буфер, откуда шинный контроллер сможет их извлекать с нужной ему скоростью. (Аналогичная ситуация наблюдается и с записью). Во-вторых, если частота системной шины и частота памяти не соотносятся как целые числа, то перед началом обмена приходится дожидаться завершения текущего тактового импульса. Таких задержек (в просторечии пенальти) возникает две: одна – при передаче микросхеме памяти адреса требуемой ячейки, вторая – при передаче считанных данных шинному контроллеру. Все это значительно увеличивает латентность подсистемы памяти – т.е. промежутка времени с момента посылки запроса до получения данных.
Таким образом, асинхронный контроллер, работающий с памятью SDRAM PC-133 на 100 MHz системной шине, проигрывает своему синхронному собрату, работающему на той же шине с памятью SDRAM PC-100.
Контроллер шины, получив от контроллера памяти уведомление о том, что запрошенные данные готовы, дожидается освобождения шины и передает их процессору в пакетном режиме. В зависимости от типа шины за один такт может передаваться от одной до четырех порций данных. Так, в процессорах K6, P-II и P-III осуществляется одна передача за такт, в процессоре Athlon – две, а в процессоре P-4 – четыре.
Все! С этого момент данные поступают в кэш и становятся доступными процессору.
>>>>> Врезка.
Большинство наборов системных логик состоит из двух микросхем – северного
и южного мостов. Северный мост (названный так за свое традиционное расположение на чертежах) включает в себя контроллер системной шины процессора, контроллер памяти, факультативно контроллер порта AGP, PCI-контроллер или контроллер внутренней шины для общения с южным мостом. Южный мост отвечает за ввод/вывод и включает в себя контроллер DMA, контроллер прерываний, таймер, контроллеры жестких и гибких дисков, последовательных-, параллельных- и USB-портов.
<<<<<

Рисунок 11 0х27 Устройство серверного моста чипсета Intel 815EP, содержащего (среди всего прочего) контроллер памяти

Рисунок 12 815ep_chipset_photo.jpg Внешний вид чипсета Intel 815EP

Рисунок 13 845-northbridge.jpg Этот же чипсет на материнской плате. Северный мост традиционно украшен радиатором
До сих пор мы рассматривали и шинный контроллер, и контроллер памяти как черные ящики. Сейчас же настало время снять с них крышку и изучить их внутренности. Дабы автора не объявили в излишней любви к Intel, сделаем это на примере чипсета AMD 750, попутно отметив, что по качеству документации собственных чипсетов AMD значительно превосходит своих конкурентов.
Контроллер системной шины, отвечающий за обработку запросов и перемещение данных между процессором и чипсетом, состоит из следующих функциональных компонентов: трансфера данных
(Processor Source Synch Clock Transceiver), планировщика запросов (command queue CQ), контроллера очередей запросов (control system queue CSQ) и агента транзакций (transaction combiner agent XCA). Остальные компоненты контроллера шины, присутствующие на рис. 0х28 необходимы для поддержки зондовой отладки, которая к обсуждаемой теме не относится, а потому здесь не рассматривается.
Трансфер данных – в каком-то высшем смысле представляет собой "голый" контроллер шины, понимающий шинный протокол и берущий на себя все заботы по общению с процессором. Полученные от процессора запросы передаются планировщику запросов, откуда они отправляются соответствующим агентам по мере их освобождения.
Ответы агентов сохраняются в трех раздельных очередях: очереди чтения (SysDC Read Queue SRQ), очереди записи памяти (Memory Write Queue) и очереди записи шины PCI (PCI/A-PCI Write Queue AWQ). Обратите внимание: в данном случае речь идет о записи/чтении в процессор, а не наоборот! Т.е. очередь записи памяти хранит данные, передаваемые из памяти в процессор, но не записываемые процессором в память!
Агент транзакций (transaction combiner agent XCA) извлекает содержимое очередей и преобразует их в командные пакеты, которые передаются трансферу данных для отправки в процессор. Если же все очереди пусты, процессору передается команда NOP.
Планировщик запросов памяти (Memory Request Organizer MRO) принимает заказы на чтение/запись памяти сразу от трех устройств: контроллера шины, шины PCI и порта AGP, и стремиться обслужить каждого из своих клиентов максимально эффективно, что совсем не просто (память-то одна!).
Арбитр очереди памяти (Memory Queue Arbiter MQA) помещает всех клиентов в кольцевую очередь (round-robin RBN) и обрабатывает по одной транзакции за такт, в дополнение к этому преобразуя физический адрес ячейки в тройку чисел: банк DRAM, номер строки и колонки. Обработанные транзакции помещаются в одну из нескольких очередей. В чипсете AMD 760 их пять – четыре очереди по четыре элемента на чтение (MRQ0 – MRQ3) и одна на шесть элементов (MWQ) – на запись.
В данном случае под "чтением" имеется в виду чтение из памяти, а под "записью", соответственно, запись в память.
Каждая из очередей чтения хранит запросы, предназначенные исключительно для "своего" банка памяти, благодаря чему при циклической выборке из очередей (этим занимается агент RBN), регенерация банков выполняется параллельно с обработкой других запросов.
Контроллер памяти (Memory Controller MCT) отвечает за физическую поддержку модулей оперативной памяти, установленных на компьютере (в чипсете AMD 760 этим занимается SDRAM Memory Controller – SMC, более поздние чипсеты умеют работать с DDR и Rambus-памятью). Он же отвечает за инициализацию, регенерацию микросхем памяти и ее конфигурирование – установку задержек RAS to CAS Delay, CAS Delay, RAS Precharge, выбор рабочей тактовой частоты и др.
Арбитр запросов к памяти (Memory Request Arbiter MRA) – принимает запросы на чтение/запись памяти, поступающие от MRO и AGP, и передает их SMC. Передача одного запроса занимает один такт.
Данные, записываемые в память, извлекаются из очереди SRQ контроллера системной шины, а данные, читаемые из памяти отправляются в очередь MWQ, откуда они в последствии передаются процессору.

Рисунок 14 0x28 Устройство механизма взаимодействия с памятью в чипсете AMD 750
Взятие остатка
Вычисление остатка происходит ничуть не быстрее деления (что и не удивительно, т.к. на машинном уровне она посредством деления и осуществляется), поэтому было бы неплохо ускорить этот процесс. Если делитель представляет собой степень двойки (2N = b), а делимое – беззнаковое число, то остаток будет равен N младшим битам делимого числа. Если же делимое – знаковое, необходимо установить все биты, кроме первых N, равными знаковому биту для сохранения знака числа. Причем, если N первых битов равно нулю, все биты результата должны быть сброшены независимо от значения знакового бита.Таким образом, если делимое – беззнаковое число, то выражение (a % 2N) транслируется в конструкцию: (AND a, 2n-1N), в противном случае трансляция становится неоднозначна – компилятор может вставлять явную проверку на равенство нулю с ветвлением, а может использовать хитрые математические алгоритмы, самый популярный из которых выглядит так: DEC x\ OR x, -N\ INC x. Весь фокус в том, что если первые N бит числа x равны нулю, то все биты результата кроме старшего, знакового бита, будут гарантированно равны одному, а (OR x, - N) принудительно установит в единицу и старший бит, т.е. получится значение, равное, –1. А (INC –1) даст ноль! Напротив, если хотя бы один из N младших битов равен одному, заема из старших битов не происходит и (INC x) возвращает значению первоначальный результат.
А можно ли вычислять остаток посредством умножения и битовых сдвигов? Теоретически возможно, но не для всех делителей. Делитель обязательно должен быть кратен , где k и t – некоторые целые числа. Тогда остаток будет можно вычислить по следующей формуле:
К сожалению, ни один из трех рассматриваемых компиляторов не использует этот трюк для оптимизации кода, но выполнять быстрый поиск остатка для делителей, кратных степени двойки, умеет каждый из них (невелика премудрость).
Заблуждение I За меня все оптимизирует мой компилятор!
Вера в могущество компиляторов в своем коре счету абсолютно безосновательна. Хороший оптимизирующий компилятор по большому счету может похвастаться лишь своим умением эффективно транслировать грамотно спроектированный код, т.е. если он не сильно ухудшает исходную программу – уже за это его разработчикам следует сказать "спасибо".Изначально кривой код не исправит никакой компилятор, и оптимизирующий – в том числе. Не спихивайте все заботы по эффективности на транслятор! Лучше – постарайтесь в меру своих сил и возможностей ему помогать. Как именно помогать, – это тема отдельного большого разговора, которому планируется посвятить третий том настоящей серии. Краткий же перечень возможностей машинной оптимизации содержится в третей части данной книги.
ЗаблуждениеII Максимальная эффективность
Перенос программы на ассемблер только в исключительных случаях увеличивает ее эффективность. При трансляции качественного исходного кода, оптимизирующие компиляторы отстают от идеальной ручной оптимизации не более чем на 10%-20%. Конечно, это весьма ощутимая величина, но все же не настолько, чтобы оправдать трудоемкость программирования на чистом ассемблере!Подробнее о сравнении качества машинной и ручной оптимизации см. "Часть III. Ассемблер vs Компилятор".
ЗаблуждениеIII Человек, в отличии
Вообще говоря, кроме компиляторов, разрабатываемых Intel, никакие другие компиляторы не умеют генерировать оптимальный с точки зрения процессора код. Несколькими страницами позднее (см. "Практический сеанс профилировки с VTune") вы собственноручно сможете убедиться в этом, а пока же просто поверьте автору на слово.Тем не менее, современные процессоры с одной стороны достаточно умны и самостоятельно оптимизируют переданный им на выполнение код, а с другой – кода, оптимального для всех процессоров, все равно не существуют и архитектурные особенности процессоров P-II, P-4, AMD K6 и Athlon отличаются друг от друга столь разительно, что все позывы к ручной оптимизации гибнут прямо на корю.
Исключение составляет небольшой круг весьма специфичных задач (например, парольных переборщиков), требования которых к производительности более чем критичны. В этом случае ручная оптимизация действительно рвет компилятор, как Тузик грелку.
ЗаблуждениеIV Процессоры семейства
Как гласит народная мудрость "Хорошо там – где нас нет" Сам я, правда, ничего не оптимизирую под PowerPC, но знаком с людьми, разрабатывающими под него оптимизирующие компиляторы. И могу сказать, что они далеко не в восторге от его "закидонов", коих у него, поверьте уж, предостаточно.Да, у серии x86 присущи многие проблемы и ограничения. Но это ничуть не оправдывает программистов, пишущих уродливых код и палец о палец не ударяющих, чтобы хоть как-то его улучшить.
А "язык" x86 процессоров между прочим очень интересен. На сегодняшний день они имеют едва ли не самую сложную систему команд, дающую системным программистам безграничные возможности для самовыражения. Прикладные программисты даже не догадываются сколько красок мира у них украли компиляторы!
Закладки
Visual Studio поддерживает два типа закладок. Одни перечисляются в диалоговом окне "Bookmark", доступном из меню "~Edit\Bookmarks…", а другие отмечаются голубым квадратиком слева от "заложенной" строки (см. рис.1). Причем первые и последние никак не связаны между собой – добавление закладки "голубого квадратика" не изменяет содержимого списка "Bookmark" и наоборот. Более того, управление закладками "голубых квадратиков" вообще не доступно из системы меню! (Правда, закладками можно управлять через панель инструментов "Edit", но по умолчанию она на экране не отображается). Чтобы установить такую закладку подведите курсор к соответствующей строке и нажмите "горячую" клавишу <Ctrl-F2>. Повторное нажатие удаляет закладку.Для удаления всех закладок "голубых квадратиков" предусмотрена команда BookmarckClearAll, доступная через комбинацию клавиш <Shift-Ctrl-F2>. Закладки, назначенные через меню "Edit\Bookmarks…" (<Alt-F2>) при этом не удаляются.
Для быстрого поиска закладок можно воспользоваться как листанием вперед: клавиша <F2> перемещает курсор к следующей закладке (при этом курсор не обязательно должен находится на закладке), так и листанием назад: нажатие клавиш <Shift-F2> перемещают курсор к предыдущей закладке.
Кстати, при контекстном поиске подстроки можно автоматически помечать все, содержащие ее строки, "голубыми" закладками. Для этого в диалоговом окне "Find" (<Ctrl-F>) вместо "ОК" нажмите кнопку "Mark All". Повторный поиск не удаляет старых закладок, но добавляет к ним новые. Причем, удалить закладки, пользуясь одной лишь системой меню, невозможно! Но мы-то с вами уже знаем, что такие закладки удаляются либо нажатием <Ctrl-F2>, либо нажатием <Shift-Ctrl- F2>.
Секреты закладок на этом не заканчиваются. Если вас раздражает, что для добавления новой Bookmark–закладки необходимо выполнять целый ряд операций (нажимать
Это можно сделать, например, так. В меню "Tools" выберите пункт "Customize", в открывшемся диалоговом окне перейдите к закладке "Keyboard" и в ниспадающем боксе "Category" выберите категорию "Edit". Теперь в окне "Commands" найдите команду "BookmarkDrop(Epsilon)", переместите курсор в окно "Press new shortcut key" и нажмите комбинацию клавиш, которой вам будет удобно выполнять эту команду. Если эта комбинация уже используется – в поле "Currently assigned" появится название ее владельца, в противном случае – "unassigned". Для подтверждения назначения новой клавишной комбинации нажмите кнопку "Assign".
Теперь при нажатии вашей "горячей" клавиши в "Bookmark" будут автоматически добавляться закладки, нумеруемые в прядке возрастания от нуля до девяти. Если возникнет необходимость установить закладку с каким-нибудь конкретным номером, воспользуйтесь командами "BoolmarkDrop1 (Brief)", "BoolmarkDrop2 (Brief)"… "BoolmarkDrop10 (Brief)". По умолчанию им так же не соответствует никакая клавишная комбинация, и вы должны назначить ее самостоятельно.
Очень полезна команда "BookmarkJumpToLast (Epsilon)", циклически пролистывающая Bookmark-закладки в порядке убывания их номеров. Ей, как и предыдущим командам, назначать "горячую" клавишу приходится самостоятельно.

Рисунок 1 0x001 Закладка "голубого квадратика"
Конечно же, это далеко не
Конечно же, это далеко не полный перечень скрытых возможностей Visual Studio, – эта среда разработки настолько мощна, что для полного ее освоения потребовалась бы целая жизнь. Поэтому, не поленитесь, и тщательно проштудируйте прилагаемую к ней документацию. Затраченное время с лихвой окупится ускорением написания и отладки программ.Что же делать потребителям? Как не попасться на удочку? О! Это очень просто. Достаточно забыть два слова "вера" и "доказательство", оставив лишь "скептицизм". Не верьте ни в какие доказательства. При желании можно доказать, что Земля – плоская и держится на трех китах. Бизнесмен никогда не работает на благо клиента. За каждым его шагом стоит личная выгода (деньги – не обязательно, но выгода – наверняка).
Так какой же компилятор лучше всех? Пальму первенства по праву, безусловно, заслуживает Microsoft Visual C++. За ним, существенно отставая, идет WATCOM, а Borland C++ плетется в самом хвосте, показывая на удивление низкий результат – и за что только его оптимизирующим компилятором называют?
"А где же количественные тесты?" – спросит придирчивый читатель. Их нет в этой статье. Нет, потому что выигрыш, даваемый оптимизатором, очень сильно зависит от рода компилируемого кода и более чем на порядок варьируется от одной программы к другой.
Вердикт. Ассемблер жил, ассемблер жив, ассемблер будет жить. Наблюдаемое засилье высокоуровневых языков и визуальных средств разработки – явление временное. Это – затишье перед бурей. А буря грянет – можете не сомневаться. Не сегодня – завтра перед программистами встанут новые задачи, под чистую съедающие все вычислительные мощности и требующие еще. Главное – быть готовым к этому и вовремя предложить свои знания, умения и навыки, дождавшись момента острой нехватки ассемблерных специалистов. (Мимоходом: программисты, знающие практически забытый ныне Фортран, с руками отрываются на Запад, ибо там половина научных приложений написана на Фортране, но ныне их некому сопровождать – старые кадры уходят на пенсию, а новое поколение выбирает пепСи).
Если же вы органически не приемлите наживу и бизнес, – программируйте на ассемблере из спортивного интереса. Последние поколения Pentium'ов в этом отношении – просто клад и там, поверьте, есть чему поучиться!
Программируйте! Удачи вам и… побольше сложностей от жизни!
Замена циклов с предусловием на циклы с постусловием
Существуют три основных типа цикла: циклы с условием вначале(см. рис. 2 слева), циклы с условием в конце
(см. рис. 2 в центре) и циклы с условием в середине (см. рис. 2 справа). Комбинированные циклы имеют несколько условий в разных местах, например, в начале и в конце одновременно. Как видно, цикл с постусловием содержит всего одно ветвление, в то время как все его "собратья" – два!

Рисунок 2 0х002 Логическое дерево цикла с условием вначале (слева) и условием в конец (справа).
Компилятор Microsoft Visual C++ всегда заменяет циклы с предусловием на циклы с постусловием, а его конкуренты – Borland C++ и WATCOM – нет. Понятное дело – такая замена не может быть осуществлена без коррекции тела цикла, что представляет собой отнюдь не тривиальную задачу. Но, к сожалению, в рамки журнальной статьи слишком тесны для описания этой технологии и мне ничего не остается, как отослать заинтересованных читателей к книге "Техника дизассемблирования программ" Крис Касперски – там вопрос трансляции циклов изложен во всех подробностях.
Замена инкремента цикла на декремент
Поскольку, машинные инструкции декремента (уменьшения значения переменной) автоматически выбрасывают Zero-флаг при достижении нуля, сравнивать уменьшаемую переменную с нулем не требуется – это автоматически делает сам процессор. Отсюда: цикл for (a=10;a>0;a--)транслируется в более компактный и быстродействующий код, нежели цикл for (a=0;a<10;a++).
Если аргумент цикла не используется в самом цикле, то компилятор Microsoft Visual C++ всегда транслирует его в цикл с декрементом. Например, пусть исходный код программы выглядел так:
for(a=0;a<10;a++)
printf("Hello, Sailor!\n");
Поскольку, переменная 'a' не используется в теле цикла, заголовок цикла можно безболезненно переписать так:
for(a=10;a>0;a--)
Из всех трех рассматриваемых компиляторов на такой трюк способен один лишь Microsoft Visual C++, – ни Borland C++, ни WATCOM этого делать не умеют.
Замена переменных константными значениями ("размножение" констант)
На жаргоне разработчиков оптимизирующих компиляторов замена переменных их непосредственными значениями называется "размножением" констант. Понять суть этого приема поможет следующий пример:int a=0x666;
if (b > a) b=a;
Поскольку, непосредственное сравнение (равно как и присвоение) двух переменных невозможно, переменную 'a' предпочтительнее заменить ее непосредственным значением (обратите внимание: попутно это экономит одну операцию пересылки):
int a=0x666;
if (b > 0x666) b=0x666;
Размножать константы – дело не хитрое и это умеют делать практически все компиляторы, даже не имеющие титула "оптимизирующих". Но лишь немногие их них делают это правильно. Как уже было показано выше, наиболее оптимальная стратегия сочетает в себе использование регистров с непосредственными значениями.
Так вот, компиляторы Microsoft Visual C++ и WATCOM всегда, когда это возможно, заменяют переменные непосредственными значениями, не "подозревая" о том, что в некоторых случаях их целесообразнее помещать в регистры. Компилятор же Borland C++ вообще не способен размножать константы.
Замена условных переходов арифметическими операциями
Суперконвейерные процессоры, коими как раз и являются все старшие представители серии Intel 80x86, крайне болезненно относятся к ветвлениям. При нормальном ходе исполнения программы в то время, покуда обрабатывается текущий код, блок упреждающей выборки успевает считать и декодировать следующую партию инструкций, не допуская простоя шины памяти. Ветвления же отправляют всю эту работу насмарку, очищая конвейер. А конвейер у поздних моделей микропроцессоров Pentium очень длинный и быстро его не заполнишь – на это может уйти не один десяток тактов процессора, в течение которых вся "кухня" будет простаивать. Нехорошо! Программа, критичная к производительности, должна содержать минимум ветвлений.Сказать-то просто, – трудно сделать. Как, скажем, избавиться от ветвлений в следующем примере: "if (a>b) a=b"? Непосредственно ликвидировать условный переход невозможно, попытка переписать код так: "a =((a>b)?b:a)" ничего не даст – оператор "?" с точки зрения компилятора – точно такой же условный переход, как и "if". Но вот спустившись на уровень ассемблера, мы можем кое-что предпринять (увы, в данном случае обращение к языку ассемблера – вынужденное, да пусть простят меня те, кто с ним не в ладах):
SUB b, a
; Отнять от содержимого 'b' значение 'a', записав результат в 'b'
; Если a > b, то процессор установит флаг заема в единицу
SBB c, c
; Отнять от содержимого 'c' значение 'c' с учетом флага заема,
; записав результат обратно в 'c' ('c' – временная переменная)
; Если a <= b, то флаг заема сброшен, и 'c' будет равно 0,
; Если a > b, то флаг заема установлен и 'c' будет равно –1.
AND c, b
; Выполнить битовую операцию (c & b), записав результат в 'c'
; Если a <= b, то флаг заема равен нулю, 'c' равно 0,
; значит, с =(c & b) == 0, в противном случае: c == b - a
;
ADD a, c
; Выполнить сложение содержимого 'a' со значением 'c', записав
; результат в 'a'.
; Если a <= b, то c = 0 и a = a
; Если a > b, то c = b - a, и a = a + (b-a) == b
Таким образом, данный код находит наименьшее из двух чисел, прекрасно обходясь без ветвлений. Аналогичным образом решаются и другие задачи. Специально для этой цели в старших процессорах серии Intel 80x86 был введен ряд команд, упрощающих программирование без условных переходов и уменьшающих количество математических преобразований.
К сожалению, ни один из трех рассматриваемых компиляторов не умеет избавляться от ветвлений и потому код, критичный к производительности, приходится вычищать от условных переходов вручную.
Бизнес: Предпринимательство - Малый бизнес - Управление
- Бизнес
- Разновидности бизнеса
- Планирование бизнеса
- Управление бизнесом
- Предпринимательство
- Русское предпринимательство
- Управление и предпринимательство
- Малый бизнес
- Виды малого бизнеса
- Русский малый бизнес
- Управление малым бизнесом
- Posix для малого бизнеса
- Телефония как малый бизнес
- Телефония на Java для малого бизнеса