Блог → Секреты C++: о важности точного следования инструкциям

Мы уже говорили об эффективности работы с индексами и указателями на языке C. Теперь мы двинемся дальше, и текущую заметку я хотел бы целиком посвятить другой теме, а именно - анализу различий производительности программ, полученных с помощью различных компиляторов языка C++. Для этого нам понадобится более пристальный взгляд на работу компилятора вообще, а в качестве примеров, я возьму три некогда популярных продукта: Borland C++, Microsoft С 6.0а и Watcom С 8.0. Итак, приступим!

Процесс преобразования исходного текста в исполняемый модуль (СОМ- или ЕХЕ-файл) состоит из нескольких последовательных шагов. Сначала C-препроцессор вставляет include-файлы и осуществляет макроподстановку. Затем лексический анализатор "разбирает" программу на наименьшие единицы компиляции - лексемы (правда, в литературе по языку C++ они обычно называются токены, от англ. "tokens"). После этого наступает очередь синтаксического анализатора, который пытается понять, что означают эти токены именно здесь, и наконец, начинается непосредственно генерация исполняемого кода.

При этом, современные компиляторы не генерируют код сразу! Первым делом они изучают код и (если вы их попросили) пытаются его оптимизировать. Под оптимизацией понимается не только использование коротких переходов и вызовов подпрограмм там, где это возможно, но и более интеллектуальная деятельность. Так, например, компилятор может вынести инвариант цикла за скобки, перегруппировать цепочку вычислений, чтобы не перевычислять общие подвыражения и т.д.

Следовательно, для понимания причины различия во времени выполнения одной и той же программы, полученной с помощью различных компиляторов, необходимо посмотреть на код, который они сгенерировали. Все компиляторы использовались с ключами максимальной оптимизации. В общем случае это приводит к тому, что код тяжело отождествить с программой, поскольку оптимизатор пытается его улучшить. К счастью, компиляторы фирм Borland и Microsoft предоставляют возможность вывести полученный код в отдельный файл, а с компилятором Watcom поставляется утилита, преобразующая OBJ-файл в ассемблеровский листинг. Для экономии места я не буду приводить полные листинги программ, лишь ту часть, которая иллюстрирует предмет сегодняшней заметки.

Код для инициализации large-массива с помощью указателя (Microsoft С 6.0). Обратите внимание: cnt и p_large существуют только в регистрах. Cnt, вообще говоря, есть в неявной форме в цикле loop, который декрементирует регистр СХ SIZE раз. То есть компилятор догадался произвести обратный отсчет.



Следующий пример, это инициализация huge-массива с помощью указателя (Microsoft С 6.0). Как вы видите, cnt и здесь не хранится, но работа немного осложнилась нормализацией ( выделено * - звёздочкой). _AHINCR - внутренняя константа компилятора, равная 0fffh. Со смещением ничего не делают, поскольку оно и так обнулится при переходе через 0ffffh.



Идём дальше. Инициализация large-массива с помощью индекса (Microsoft С 6.0).



Можно задаться вопросом: почему этот вариант компактнее, чем первый код, а работает медленнее? Ответ прост - там используется цикл loop, работающий быстрее, чем последовательность inc… сшр… jb…

Не буду приводить участок кода Microsoft С, инициализирующий huge-массив с помощью индекса. Скажу лишь, что и там нормализация осуществляется непосредственно при модификации указателя. А теперь взглянем на код, который генерирует Watcom С 8.0 (инициализация large-массива с помощью указателя).



Во внутреннем цикле неявно присутствует cnt (в регистре ах), кроме того, i реализована как локальная переменная в оперативной памяти. Это и служит главной причиной отставания Watcom-кода от Microsoft-кода. Кроме того, несмотря на то, что заранее известно, сколько раз будет выполняться цикл по cnt, всё равно генерируется стандартная форма цикла с предусловием. Обратите внимание: Microsoft этого не делает!

Теперь посмотрим на Borland и попробуем понять, почему с индексом он работает быстрее. Пример кода для инициализации large-массива, с помощью указателя (Borland C++ 2.0).



Код очень простой, оптимизации просто нет. Все временные переменные хранятся в памяти. Все циклы выполняются с проверкой предусловия. Вероятно, Borland предполагает ручную оптимизацию, например, мы могли написать, что i и cnt - register. Теперь посмотрим, почему с использованием индекса работа идёт быстрее.

Пример кода для инициализации large-массиаа с помощью индекса (Borland С++ 2.0).



Оказывается, использование индекса освободило Borland от необходимости обращаться к указателю и, следовательно, дало выигрыш в скорости. Причина аномального отставания Borland от Watcom на huge-модели заключается в слепом следовании инструкции: в языке Си параметры подпрограммам передаются на стеке. Но это не одна из заповедей, да и подпрограмма нормализации - внутреннее, глубоко интимное дело компилятора. Тем не менее, Borland аккуратно "заталкивает" длинный указатель в стек и вызывает подпрограмму нормализации, в то время как более сообразительный Watcom передает указатель в регистрах, а умница Microsoft просто вставляет код нормализации. При этом, окончательные выводы делать рано, поскольку на момент написания заметки уже существуют Microsoft С 7.0, Watcom С 8.5 и Borland C++ 3.0. Следовательно, спор между компиляторами ещё не окончен.