Делюсь способами оптимизации и хаками.
Почему case плох
Даже начинающие delphi-программисты знают как выглядит блок case. Однако не все знают, что для нашего процессора, он выглядит как множество if блоков.
Вот что видит программист:
procedure case_test(Index:Integer);
begin
case Index of
0: writeln('Hello');
1: writeln('Habr!');
end;
end;
А вот что видит процессор:
procedure case_test(Index:Integer);
begin
Index:=0;
if Index = 0 then
writeln('Hello')
else
if Index = 1 then
writeln('Habr!');
end;
К сожалению никакой магии за словом case не оказалось. Более того, слишком активное использование case может замедлить выполнение кода. Например если у Вас не 2 варианта проверок, а 50, 250 или ещё больше. Худшим решением для вас будет блок case.
Чем заменить case
Решение у этой проблемы есть. Само строение блока case подсказывает нам, что наши варианты должны быть достаточно прибраны, чтобы поместиться в перечисляемом типе данных например: Integer, Word, Byte, Enum или Char.
В случае когда мы используем индекс для обращения к данным через case — всё просто. Вам необходимо записать данные в массив и подставлять индекс не в case, а в массив.
const
Data:Array[0..1] of String = ('Hello', 'Habr!');
procedure case_test(Index:Integer);
begin
writeln(Data[Index]);
end;
Это работает когда в действиях внутри блока case меняется только один параметр. Но что делать если параметров несколько?
Чем заменить сложный case
Для случаев когда действия в блоке case отличаются сразу по нескольким параметрам, Вы можете расширить тип данных массива до структуры, чтобы в нём поместилось больше параметров.
type
TMyTextWord = record
Text:String;
NeedLinebreak:Boolean;
end;
const
Data:Array[0..2] of TMyTextWord = (
(Text:'Hello'; NeedLinebreak:False),
(Text:' '; NeedLinebreak:False),
(Text:'Habr!'; NeedLinebreak:True)
);
procedure case_test(Index:Integer);
var
MyTextWord:TMyTextWord;
begin
MyTextWord:=Data[Index];
write(MyTextWord.Text);
if MyTextWord.NeedLinebreak then writeln;
end;
Здесь мы заменили блок case, который мог выглядеть вот так
procedure case_test(Index:Integer);
begin
case Index of
0: write('Hello');
1: write(' ');
2:
begin
write('Habr!');
writeln;
end;
end;
end;
Таким образом мы сводим количество действий для выполнения любого из случаев в блоке case до минимума, одинакового для всех случаев. Хотя здесь не сильно видна разница т.к. один набор из 3-ех действий заменился другим. Но подумайте, что выполнится быстрее: «50 раз проверить, является ли переменная одним из чисел?» или «Получить по индексу из массива 1 параметр из 50 возможных?». Ответ очевиден.
И так мы пришли к тому что case не далеко ушёл от известного нам if.
А раз мы научились оптимизировать case почему бы не пойти дальше?
Чем заменить if
Допустим у нас case не использует настроек, которые можно было бы записать в обычный массив. Например такой case:
class procedure ActiveRecord<T>.SetFields(Fields: TArray<TField>;
Data: Pointer);
var
I:Integer;
PRec:Pointer;
begin
PRec:=@Data;
for I:=0 to Length(Fields)-1 do
begin
case Fields[I].Kind of
tkUString,
tkWideString: PString(PRec)^:=PString(Fields[I].Data)^;
tkInteger: PInteger(PRec)^:=PInteger(Fields[I].Data)^;
tkInt64: PInt64(PRec)^:=PInt64(Fields[I].Data)^;
tkFloat: PDouble(PRec)^:=PDouble(Fields[I].Data)^;
tkEnumeration: PWord(PRec)^:=PWord(Fields[I].Data)^;
end;
IncPtr(PRec,Fields[I].Size);
end;
end;
Но перед главным решением я сделаю отступление, чтобы описать ход своих мыслей.
Допустим у нас есть некая процедура содержащая несколько блоков кода.
И единственное чем похожи эти блоки это номером случая, когда их нужно вызвать.
Мы знаем заведомо простое решение этой проблемы: перечислить в массиве процедуры, вот так
procedure SetString(A,B:Pointer); inline;
procedure SetInt(A,B:Pointer); inline;
procedure SetInt64(A,B:Pointer); inline;
procedure SetDouble(A,B:Pointer); inline;
procedure SetBool(A,B:Pointer); inline;
implementation
procedure SetString(A,B:Pointer); begin PString(A)^:=PString(B)^; end;
procedure SetInt(A,B:Pointer); begin PInteger(A)^:=PInteger(B)^; end;
procedure SetInt64(A,B:Pointer); begin PInt64(A)^:=PInt64(B)^; end;
procedure SetDouble(A,B:Pointer); begin PDouble(A)^:=PDouble(B)^; end;
procedure SetBool(A,B:Pointer); begin PBoolean(A)^:=PBoolean(B)^; end;
type
TTypeHandlerProc = reference to procedure (A,B:Pointer);
var
TypeHandlers:Array[TTypeKind] of TTypeHandlerProc;
class procedure ActiveRecord<T>.SetFields(Fields: TArray<TField>;
Data: Pointer);
var
I:Integer;
PRec:Pointer;
begin
PRec:=@Data;
for I:=0 to Length(Fields)-1 do
begin
TypeHandlers[Fields[I].Kind](PRec, Fields[I].Data);
IncPtr(PRec,Fields[I].Size);
end;
end;
initialization
TypeHandlers[tkUString]:=SetString;
TypeHandlers[tkWideString]:=SetString;
TypeHandlers[tkInteger]:=SetInt;
TypeHandlers[tkInt64]:=SetInt64;
TypeHandlers[tkFloat]:=SetDouble;
TypeHandlers[tkEnumeration]:=SetBool;
end.
Здесь мы создали массив с inline процедурами, т.е. вместо вызова этих процедур компилятор подставит их код в строку, из которой мы к ним обращаемся. А в остальном всё как раньше, обращаемся к массиву по индексу операции, и выполняем операцию указывая в круглых скобках параметры. Обработчик представлен в сокращенном варианте, т.к. обработка всех действий с типом TTypeKind выглядит громоздкой.
Как, наверное многие уже догадались, это не последний вариант решения проблемы.
Допустим, что мы не хотим выходить за рамки одной функции, и создавать вокруг неё ворох вспомогательных процедур, тип данных для них, и инициализировать массив с процедурами в конструкторе класса или при инициализации модуля.
Если вы уверенный в себе программист и хотите максимальной оптимизации, но не хотите засорять модуль кучей примочек. Я представляю вам решение прямиком из тёмной стороны кодинга. Все кодеры вокруг говорят, что использовать goto не безопасно, что сам оператор устарел и в 90% случаях существуют решения без goto. Говорят что использование ассемблерных вставок доступно только злым хакерам. Но что будет, если мы пустимся во все тяжкие?
Я давно мечтал о таком способе переключения между блоками кода, чтобы хранить в массиве тип label, для осуществления прыжка goto. Таким образом я бы мог перемещаться между кусками одной процедуры при этом использовать для этого не case, а индекс по массиву адресов прыжка. Но возможно ли такое решение? Оказалось что да.
К сожалению в generic методах и классах нельзя использовать ассемблерные вставки, по этому решение пришлось переместить в метод другого объекта, хотя на самом решении это не отразилось. Решение представлено для x32 мода.
procedure TTestClass.Test(Fields: TArray<TField>; Data: Pointer);
label
LS,LI,LI64,LF,LE,FIN;
var
I:Integer;
PRec:Pointer;
ADR:Cardinal;
Types:Array[TTypeKind] of Cardinal;
begin
FillChar(Types,Length(Types)*4,#0);
asm
lea EDX, [EAX].Types
mov [EAX-$4C+tkUString*4], offset LS
mov [EAX-$4C+tkWideString*4], offset LS
mov [EAX-$4C+tkInteger*4], offset LI
mov [EAX-$4C+tkInt64*4], offset LI64
mov [EAX-$4C+tkFloat*4], offset LF
mov [EAX-$4C+tkEnumeration*4], offset LE
end;
PRec:=Data;
for I:=0 to Length(Fields)-1 do
begin
ADR:=Types[Fields[I].Kind];
asm jmp ADR end;
LS: PString(PRec)^:=PString(Fields[I].Data)^; goto FIN;
LI: PInteger(PRec)^:=PInteger(Fields[I].Data)^; goto FIN;
LI64: PInt64(PRec)^:=PInt64(Fields[I].Data)^; goto FIN;
LF: PDouble(PRec)^:=PDouble(Fields[I].Data)^; goto FIN;
LE: PByte(PRec)^:=PByte(Fields[I].Data)^; goto FIN;
FIN:
IncPtr(PRec,Fields[I].Size);
end;
end;
Трюк этого способа заключается в том, что компилятор delphi сам хранит адреса всех label и в ассемблерных вставках дает к ним доступ. Решение нашлось неожиданно просто[1]. Когда я искал как считать регистр EIP, в котором хранится адрес текущей исполняемой команды. Оказалось, что регистр считать нельзя, а вот адрес label'а как раз можно.
Ну а дальше всё просто, на ассемблере заполняем массив адресами нужных нам label'ов.
В цикле по массиву, берется каждый элемент. Тип элемента = индекс адреса его обработчика. Берем по индексу адрес обработчика, прыгаем на адрес, профит.
ADR:=Types[Fields[I].Kind];
asm jmp ADR end;
Остались чисто формальности: после каждого блока ставим прыжок вконец цикла «goto FIN;», чтобы не попадать на следующие label блоки.
Константа $4C — это количество байт до начала блока с памятью массива, чтобы её вычислить можете записать в массив заполненный нулями «mov [EAX], 1» и посмотреть в дебагере какая ячейка приняла это значение, количество ячеек от начала до неё * 4 и будет ваша константа.
Пишите своё мнение, и правки к статье в комментариях. Желаю успехов с оптимизацией кода.
References:
1. Хак с адресом EIP через label
edo1h
что за статья про оптимизацию без единого замера производительности сравниваемых решений?
если я правильно понял, то это ужасно грязный хак, которому не место в реальных программах
pda0
Даже без ассемблерного dump'а. Помню чуть ли не для DOS Pascal хвалились, что case оптимизируется таблицами переходов.