О паскале и объектах #2

Я написал предыдущий пост, и знакомый меня справедливо спросил, а какие же именно функции вызываются при создании объекта? Проверить несложно!
tmp := TObject.Create;
tmp.Destroy;

Оказывается, вот полный список всех вызовов при создании объекта: (далее)

Create
@ClassCreate
TObject.NewInstance
TObject.InstanceSize
@GetMem
TObject.InitInstance
@AfterConstruction
TObject.AfterConstruction

Из них TObject.InstanceSize и TObject.AfterConstruction не делают вообще ничего серьёзного, остальные не слишком много (20-30 инструкций).

Выглядит довольно безобидно! Как же на самом деле? Чтобы посмотреть, насколько быстро создаются объекты, я написал простенькую программу (pastebin). В ней в цикле создаётся и уничтожается 50 миллионов объектов и рекордов.
Для рекордов я использовал два варианта создания/удаления: GetMem/FreeMem и New/Dispose. Последний отличается тем, что Дельфи автоматически генерирует код, инициализирующий и очищающий так называемые “сложные” поля – строки и указатели на интерфейсы. Разумеется, Dispose не может быть быстрее FreeMem, поскольку в конечном счёте его же и вызывает!

Итак, пустой объект и пустой рекорд, результаты в миллисекундах:
Objects: 5469
Records through new/dispose: 344
Records through GetMem/FreeMem: 343

Неплохо! Объекты в десяток раз медленней! Однако у этого есть причина, которая станет ясна, если мы повторим эксперимент, добавив в объект и в рекорд по одному полю типа integer.

Теперь мы получаем:
Objects: 5453
Records through new/dispose: 1094
Records through GetMem/FreeMem: 1109

Откуда такой прирост у рекордов? Дело в том, что размер рекорда в прошлом эксперименте был равен нулю. GetMem/FreeMem просто игнорировала эти пустые запросы. У объекта же существуют скрытые поля. Если мы запросим размер объекта

tmp := TObject.Create;
writeln(‘Size: ‘+IntToStr(tmp.InstanceSize));

То получим:
Size: 4
Как только мы добавили поле и в рекорд, оптимизация GetMem перестала работать, и время создания записи подскочило в три раза. Но это ещё не всё! Добавим в рекорд поле типа string, чтобы проиллюстрировать разницу между New и Dispose.

Получаем:
Objects: 6188
Records through new/dispose: 3687
Records through GetMem/FreeMem: 1094

“Корректное” создание рекордов уже всего в два раза медленней объектов! Рекорды через GetMem/FreeMem работают с прежней скоростью, поскольку размер объекта не изменился: переменная типа string занимает те же четыре байта, что и integer.

Примерно то же получится, если добавить в рекорд динамический массив: он тоже требует финализации. А вот статические массивы не требуют: память для них выделяется за один запрос, вместе с памятью записи:
FField: array[0..40] of integer;
Objects: 6844
Records through new/dispose: 1188
Records through GetMem/FreeMem: 1187

Казалось бы, я совершенно напрасно ругал объекты! Ведь любой сколь-либо сложный набор данных в рекорде создаётся всего в два раза быстрее объекта. Ну, два раза для таких быстрых операций – это ерунда. Я уже готов был придти к такому выводу, как решил посмотреть, сколько занимает сложение строк:
s := s + ‘test’;
if Length(s) > 10000 then s := ”; //Чтобы не разбухала

1500 микросекунд! А разница между New и GetMem в примере со строкой была 2500!
Иными словами, расширение места под строку и копирование слова “test” занимает меньше времени, чем инициализация/финализация пустого поля типа string! Да как такое может быть?
Оказывается, вот как. Оказывается, для инициализации и финализации полей Дельфи вставляет не сам код, а вызов внутренних функций @New/@Dispose с параметром, в котором зашифровано, что именно удалять. Внутри этих функций довольно громоздкий процесс разборы параметра на части.

Попробуем сделать всё вручную! Напишем:
GetMem(rec, SizeOf(rec^))
pointer(rec.FField) := 0; //инициализация строки
rec.FField := ”; //финализация строки
FreeMem(rec);

Во-первых, дельфи могла бы догадаться, что на момент присваивания пустой строки rec.FField и так пустой. Но Дельфи этого, слава богу, не делает, и честно проверяет “if rec.FField<>nil then @LStrClr”, образно говоря. Таким образом, мы выполняем все операции, необходимые для создания/очистки рекорда со строковым полем.
Время? ~1300. Меньше, чем на 100 миллисекунд отличается от простого GetMem/FreeMem. Остальные 2400 микросекунд уходят на шатания по функциям @New/@Dispose с выяснением в рантайме вещей, которые и так известны на момент компиляции.

Теперь сделать решительный вывод опять стало сложно. Получается, если делать всё действительно оптимально – то есть, вручную – то рекорды примерно в шесть раз быстрее объектов – и этот разрыв будет расти с ростом сложности! Шестикратное замедление – это уже вполне значительная разница.
С другой стороны, если пользоваться для инициализации рекордов средствами Delphi (New/Dispose), то разница всего лишь в два раза, и она будет уменьшаться с ростом сложности! Ведь чем сложнее объект, тем большую часть создания занимает инициализация, а она общая у объектов и у New/Dispose рекордов.

Во всяком случае, вывод надо сделать такой: с рекордами не стоит использовать New/Dispose, это убивает весь их выигрыш в скорости. Если же вы используете New/Dispose, то уже не очень жалко превратить рекорды в объекты. Это уже мало (в пару раз) замедлит дело. Совершенно неожиданный вывод, поскольку я всегда считал New/Dispose быстрыми обёртками над GetMem/FreeMem.

Вообще же говоря, чтобы оценить порядок временных затрат: время на создание пустого TObject примерно равно одной десятитысячной микросекунды. В общем, я скорее всё же был неправ, обвиняя объекты в медлительности. Разумеется, было бы лучше, чтобы объекты создавались без дополнительной суеты, но её не так много, чтобы это делало их использование при большой нагрузке непрактичным.

Напишите комментарий:

Если хотите, можно залогиниться.

*