Написание оптимального кода под Delphi

В данной статье рассмотрены принципы, помогающие компилятору Delphi генерировать более оптимальный с точки зрения скорости код. Если Вы не хотите вникать в подробности, в конце статьи есть "свод правил", которые рекомендуется соблюдать при написании программ. Компилятор Delphi относится к разряду оптимизирующих. Но насколько качественно проводится оптимизация? Как "помочь" компилятору создать более быстрый код? Давайте разберемся с этим на экспериментах.

Оптимизация константных выражений

Пример 1:

const z = 15616; ... var a,b: integer; ... a := $abcd6123; b := z+a; ....

С точки зрения оптимизации код можно упростить еще на этапе компиляции до

b:=15616+$abcd6123;

или того проще:

b:=$ABCD9E23;

Но написанный выше листинг преобразуется в

mov eax, $abcd6123
lea ebx, [eax + $00003D00]

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

mov ebx, $ABCD9E23 

в любом случае быстрее и короче.

Пример 2:

... b:=random(maxint); // b - заведомо не константа ! a:=$abcd6123; if b>a then b:=a; ...

Скомпилированный код будет выглядеть

mov eax, $7fffffff   // MaxInt  
call @RandInt
mov ebx,eax
mov eax, $abcd6123
cmp eax, ebx
jnl +$02
...

А ведь значение, присвоенной переменной "а" являлось константой и наш пример можно было бы переписать как:

b:=random(maxint);
a:=$abcd6123;
if b>$abcd6123 then b:= $abcd6123;
Пример 3:

... a:=$abcd6123; b:=$abc34233; c:=b-a; ...

После компиляции получаем:

mov eax, $abcd6123
mov ebx, $abc34233
mov ebx, edx
sub ebx, eax

Т.е. компилятор преобразовал код так, как он был написан, а ведь можно было бы просто записать:

mov ebx, $fff5e110
т.е. c:= $fff5e110;

Оптимизация алгебраических выражений

Пример 4:

... var a,b,c,d: integer; p: pointer; begin p:=nil; a:=0; ... {далее по коду эти присвоения не используются}

После компиляции эти переменные будут удалены, причем с предупреждением

Value assigned to ... never used
Пример 5:

... a:=0; b:=a; showmessage(inttostr(b)); ...

Код скомпилируется как есть! Таким образом мы обманули компилятор псевдо использованием переменных. Delphi не исправляет нашей "кривости", поэтому эта задача ложится исключительно на плечи программиста.

Пример 6:

... b:=random(maxint); a:=b; func(a,b); a:=a+1; func(a,b); ...

Данный код можно оптимизировать до

...
b:=random(maxint);
func(b,b);
a:=b+1;
func(a,b);
...

И этого Delphi за нас не сделает.

Пример 7:

... c:=a div b; c:=a*b; ...

В данном примере первую строчку можно безболезненно удалить, что Delphi делать умеет.

Пример 8:

if ((a*b)<$3d00) and (a*b)>0)) then ...

В данном случае можно избавится от одной операции умножения, присвоив значение выражения a*b временной переменной. Анализ ассемблерного листинга показывает, что компилятор именно так и поступает. Тем не менее, поменяв второе подвыражение на ((b*a)>0), компилятор принимает выражения за разные и генерирует умножение для обоих случаев, не смотря на то, что результат одинаков.

Оптимизация арифметических операций

Сложение и вычитание

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

Деление

Операция деления требует гораздо больше тактов процессора, нежели умножение, поэтому замена деления на умножение может значительно ускорить работу. Существуют формулы, позволяющие выполнять такое преобразование. Тем не менее, Delphi не использует такую оптимизацию. Деление на степень двойки можно заменять сдвигом вправо на n бит, но даже в этом случае получаем следующий код:

mov esi,edi
sar esi,1
jns +$03
adc esi, $00

Здесь учитывается особенность самой операции div - округление в большую сторону. Поэтому, если можно пренебрегать округлением, используйте c:=a shr 1 вместо с:=a div 2.

Умножение

Умножение на степень двойки можно заменять сдвигами битов. Delphi заменяет умножение сдвигами при умножении на 4,8,16 итд. При умножении на 2 производится суммированием переменной с собой.

Умножать на 3,5,6,7,8,10 и т. д. можно и без операции умножения - расписав выражение по формуле (a shl n)+a, где n - показатель степени двойки. Например, при умножении на 3 n=1. Delphi при возможности прибегает к этому трюку. Заметим, операнд LEA умеет умножать регистр на 2,4,8, что также при возможности используется компилятором. Например, умножение на 3 преобразуется в инструкцию

lea esi, [ebx + ebx*2]
Оптимизация case of

Анализ скомпилированного кода показывает, что Delphi проводит утрамбовку дерева. Т.е. значения case сортируются и выбор нужного элемента производится при помощи двоичного поиска.

В случае, если элементы case of выстраиваются в арифметической прогрессии, компилятор формирует таблицу переходов. Т.е. создается массив указателей с индексами элементов, поэтому выбор нужно элемента выполняется за одну итерацию независимо от количества элементов.

Оптимизация циклов

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

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

Вынесение инвариантного кода за пределы цикла - не выносится. Наиболее распространенный недочет - условие цикла записывается как:

for i:=0 to memo1.lines.count - 1 do...

Delphi будет при каждой итерации вызывать метод count, вычитать из результата 1 и потом уже сверять. Настоятельно рекомендуется переписывать подобный код как

lin := .lines.count - 1;
for i:=0 to lin do...

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

Замена циклов с предусловием на циклы с постусловием - производится. Циклы с постусловием имеют главное преимущество над другими видами циклов (с предусловием и с условием в середине) - они содержат всего одно ветвление. Delphi производит такую замену.

Замена инкремента на декремент - не производится. Более того, даже декрементный цикл компилируется в неоптимальный код, т.к. не используется флаг ZF. Вместо этого происходит сравнивание значения регистра с 0.

Удаление ветвлений - не производится.

Вывод:

  1. Не используйте переменные для временного хранения констант или обязательно объявляйте "магические" числа как const, либо подставляйте в код непосредственные значения
  2. Неиспользуемыми объявлениями и присвоениями можно безболезненно пренебрегать - Delphi умеет их вычищать.
  3. Внимательно следите за использованием переменных, в частности лишним присвоениям их значений друг другу. Такого рода оптимизации Delphi делать не умеет.
  4. Используйте свернутые математические выражения. (например, (3*a - a) /2 упрощается до a). Delphi не умеет упрощать математические выражения. (Да и что говорить, даже MathCAD не всегда грамотно умеет делать такие преобразования).
  5. Не используйте конструкции типа a:=10*sin(45*pi/180); Delphi не вычислит эту константу на этапе компиляции, напротив, будет послушно вызывать sin и pi по ходу выполнения программы! В случае, если угол является переменной, по крайней мере pi можно заменить константой 3,1415...
  6. Delphi прекрасно справляется с выражениями, полностью составленных из констант - они вычисляются на этапе компиляции.
  7. Внимательно следите за условиями и их границами. Компилятор Delphi не умеет обнаруживать заведомо ложных условий. Также он не умеет удалять заведомо лишние условия. Например, (a>0) and (a<15616) and (a<>0)
  8. Если в условии несколько раз проверяется одно и тоже выражение, следите, чтобы оно было выражено во всех конструкциях одинаково. В противном случае скомпилированный код будет не оптимален. Например, if ((a*b)>0) and ((a*b)<1024) then... При перестановке во втором случае b*a смысл выражения не изменится, но код будет иметь уже на одну операцию умножения, а две. Можно временно присвоить проверяемое выражение временной переменной, а затем уже проверять полученное значение.
  9. Сообщение "Combining signed and unsigned types - widened both operands" сообщает не только о потенциальной ошибке - также вследствие преобразования мы теряем производительность. Например, z - объявлена как ineteger. условие if z>$abcd6123 then z:= $abcd6123; несмотря на его правильность вызовет данное предупреждение. Сгенерированный код будет, выполнять преобразования величин до 64-х бит, и дальнейшее уже сравнение 64-х битных операндов. Если изменить тип z на cardinal, мы избавимся от предупреждения и получим 3 строки кода, вместо 8 !
  10. Delphi умеет оптимизировать сложение, умножение и частично деление. При делении на степень двойки, если не важно округление до большего, рекомендуется пользоваться shr 1 вместо div 2.
  11. В case of при возможности используйте элементы, расположенные в арифметической прогрессии. Тем не менее, даже при невыполнении данного условия мы получим качественный код после утрамбовки дерева.
  12. Выносите инвариантный код за тело цикла. Наиболее частая ошибка - for i:=1 to length(str) do... Дело в том, что при каждой итерации будет вызываться функция length, что пагубно скажется на производительности. Рекомендуется длину строки заранее присвоить переменной. Также не включайте в тело цикла код, заведомо не зависящий от изменения итерационной переменной.

Сравнивая Delphi с компиляторами Visual C++, WATCOM, Borland C++ (тестирование данных компиляторов приведено в [1]) приходим к выводу, что Delphi по своим оптимизирующим свойствам аналогичен Borland C++ (а кто сомневался? ;) ). Учитывая, что Borland C++ по итогам сравнения оказался последним, делаем несложный вывод. Весьма печален и тот факт, что большинство кода VCL написано с точки зрения "красоты" кода, а не его оптимальности с точки зрения скорости. Например, не соблюдается правило 12.

 


Страница сайта http://silicontaiga.ru
Оригинал находится по адресу http://silicontaiga.ru/home.asp?artId=5604