Прискорена обробка даних типу Variant у Delphi

Прискорення динамічної диспетчеризації дзвінків у статично типізованих мовах на прикладі роботи з Variant у Delphi

Автор: Maksim Gumerov Джерело: RSDN Magazine #4-2004
Введення в проблему Причини та рішення Приклад реалізації Приклад практичного застосування та оцінка ефективності Посилання
даних

Введення у проблему

Загальновідомо, що з даними типу Variant виконується повільно. Так, довідкова система Borland Delphi 6.0 стверджує, що (вільний переклад) "Дані типу Variant забезпечують більшу гнучкість, але займають більше пам'яті, ніж звичайні змінні, і операції над ними виконуються повільніше, ніж статично типізованими даними". У тому сенсі висловлюється і А.Я. Архангельський у книзі «Програмування в Delphi 6» - щоправда, начисто забуває згадати що міститься у довідковій службі однією пропозицією далі вказівку, що у Delphi 6 з'явився принципово новий вид Variant'ів – Variant, який визначається програмістом (custom variant). Втім, тут мова піде не про нього.

Така зневага до цього елемента мови не цілком виправдана. Як буде показано далі, робота з даними типу Variant у деяких випадках може бути прискорена. З іншого боку, існує низка ситуацій, в яких використання Variant виявилося б дуже доречним, якби такого прискорення вдалося досягти. Зокрема, з урахуванням відсутності в Delphi шаблонів (схоже, це скоро зміниться – Delphi повним ходом рухається у напрямку .NET, а всі мови другої версії .NET повинні будуть підтримувати узагальнене програмування – прим. ред.), можна було б описати в універсальному Як алгоритм обчислення, скажімо, середнього значення в масиві – при тому, що дані в масиві можуть бути як цілими, так іречовими, або custom variant (наприклад, комплексними числами). Взагалі, можливе завдання обробки потоків даних - складання двох векторів, обчислення середнього значення - коли конкретний тип даних невідомий на момент написання функції-обробника або взагалі може бути різним.

Причина повільної обробки значень типу Variant в тому, що на момент компіляції деякої операції над значенням фактичний тип останнього (тобто що саме було поміщено в Variant) невідомий, а тому компілятор генерує код, що аналізує інформацію про тип, що міститься в значенні, і Залежно від результату викликає відповідну операцію. Так, для пари цілих чисел буде виконано цілісне додавання, а для пари рядків (нагадаю, що Delphi дозволяє поміщати у Variant, крім BSTR, і звичайні для Delphi AnsiString, тобто рядки з 8-бітовим кодуванням символів, лічильником довжини рядка, лічильником посилань та нульовим термінатором) – їх конкатенація.

Багаторазове здійснення такого аналізу не може не позначитися на продуктивності виконуваних операцій: насправді, власне ціле додавання чисел, вміщених у два Variant, виконується швидше, ніж попередня йому перевірка типів аргументів. Це підтверджують результати експериментів, що наводяться в цій статті.

Причини та рішення

Лідерство SmallTalk пояснюється тим, що для зменшення втрат продуктивності при динамічній диспетчеризації (яка у SmallTalk більш трудомістка, ніж у C++ або Delphi) у сучасних реалізаціях SmallTalk використовується технологія Polymorphic Inline Caches [3]. Виникає природне питання: що заважає реалізувати подібний підхід, наприклад, у Delphi?

Зі сказаного вище ясно, що пропоноване рішення ґрунтується на самомодифікуєтьсякоді, тобто. коді, який у ході свого виконання змінює окремі свої фрагменти, змінюючи таким чином і свою майбутню поведінку – порівняно з поведінкою оригінального варіанта коду.

Слід зазначити, що використання коду, що самодифікується, не завжди прийнятно в сучасних багатопоточних ОС: всі потоки одного процесу в MS Windows використовують єдину область пам'яті. Отже, якщо фрагмент коду модифікується в одному потоці тоді, коли він виконується в іншому потоці, нормальна робота цього останнього потоку, взагалі кажучи, порушується. Тому, якщо код може виконуватися в контексті різних потоків, від модифікації краще відмовитись – втрати часу через синхронізацію потоків (очікування одним потоком моменту, коли інший закінчить виконання коду, який передбачається модифікувати), особливо на багатопроцесорних системах, можуть переважити виграш від прямої прив'язки.

Правильна послідовність дій з модифікації включає очищення кешу інструкцій після модифікації коду: інакше процесор може не розпізнати зміну комірки пам'яті, що вже збережена у внутрішньому кеші інструкцій, і продовжувати використовувати старий варіант коду; очищення кеша дещо сповільнює виконання коду через необхідність його повторного завантаження в кеш.

Крім того, якщо модифікований код знаходиться в dll, що використовується декількома процесами, в лінійці Windows NT за замовчуванням модифікація спричинить фізичне дублювання сторінки пам'яті, що містить фрагмент, що модифікується (для даного процесу її вміст зміниться, для інших - залишиться колишнім); в Windows 9x/ME ж зміна відіб'ється на всіх процесах, що використовують код, викликаючи ті ж проблеми, що і багатопоточність, тільки ще більш важко розв'язувані.

Нарешті, останній аргумент"проти": пропонована схема модифікації коду не переноситься на процесори, несумісні з Intel. Прийде писати спеціалізований код модифікації для кожної окремої платформи.

По-друге, якщо повторних перевірок типу не виконується, доведеться покласти обов'язки з контролю типів на програміста: він повинен сам вказати, в який момент більше не можна бути впевненим, що типи операндів операції, що замінюється, при наступних викликах будуть тими ж, що і в останній разів.

Це може виглядати так: якщо він упевнений, що дана операція протягом деякого часу оброблятиме лише дані певного фактичного типу (тобто викликати методи об'єктів одного класу, або складати два Variant-значення щоразу одних і тих самих типів), він може повідомити про цю підсистему прямої прив'язки. Схема може бути такою:

Диспетчер викликів (у даному випадку – диспетчер Variant'ів) при першому запуску операції в циклі перевіряє, чи дозволена пряма прив'язка, і якщо так – замінює у виконуваному коді, породженому при компіляції операції A[i] + B[i], виклик методу -диспетчера прямим викликом потрібної процедури, що виконує додавання двох Variant'ов даних конкретних типів (тип першого визначається за типом A[i], другого – за типом B[i]).

Виклик DisableBinding говорить підсистемі прямої прив'язки, що потрібно відновити вихідний стан коду, оскільки наступний запуск операції може відбутися вже за інших типів операндів.

А наявний у його розпорядженні компілятор Delphi генерує для операції “+” над Variant'ами той самий код, на жаль, погано підходить для модифікації під час виконання. Отже, додавання доведеться замінити менш природним записом, що дозволяє врахувати пряму прив'язку всередині своєї функції-посередника VarAdd:

В одній ділянці коду можуть існувати незалежні групи операцій з Variant-ами, і тип значень, що обробляються, для кожної групи може бути своїм. У прикладі одна група – операції над A[i] і B[i], інша – над VarString1 і VarString2. Має сенс зберігати інформацію про прив'язку якоїсь групи операцій у спеціальному об'єкті:

ПРИМІТКА

даних
Малюнок 1. Механізм заміни команди виклику.

Слід зауважити, що відчутну вигоду від прямої прив'язки можна отримати далеко не у всіх випадках. Так, робота з рядками чи виклик через IDispatch навряд чи відчутно прискоряться – адже основне обчислювальне навантаження тут створюється самою обробкою значень, а не диспетчеризацією. Але деякі дії, мабуть, має сенс прискорити – наприклад, арифметичні операції над цілими та речовими операндами. Якщо ж наявна реалізація прискорення не підтримує операцію додавання для операндів таких типів, з якими викликана VarAdd, підміна не виконується. При цьому жодного виграшу від прямої прив'язки не буде, а накладні витрати на перевірки тощо. залишаються - хоча, як показує експеримент, програш не такий вже й великий.

Роблячи припущення про незмінність типів операндів, програміст повинен бути уважним. Можливі, наприклад, ситуації, коли деяка змінна, яка бере участь у розрахунках і розташована всередині циклу, змінює свій тип від ітерації до ітерації. Так може бути, зокрема, при використанні змінної акумулятора.

Нехай масив Arr містить значення типу double, упаковані у Variant. Розглянемо цикл:

Він виглядає досить нешкідливо. Однак якщо на першій ітерації циклу в Sum зберігається ціле значення, то на другій –вже речове! Крім того, якщо функція, що містить цикл, задумана як універсальна, навряд чи варто розраховувати, що 0 завжди буде адекватним початковим значенням акумулятора: раптом, наприклад, масив складається з текстових рядків?

Почасти цих шкідливих ефектів можна уникнути, виконавши:

Але тут існує інша небезпека: тип результату операції над акумулятором та черговим елементом на деякій ітерації може не збігтися з типом елементів масиву, що призведе до неправильної обробки наступної ітерації. Так, якби акумулятор накопичував не суму, а результат поділу деякого числа на добуток цілісних елементів масиву, рано чи пізно він міг би прийняти речове значення.

Крім того, оскільки передбачити поведінку інших типів даних, які можуть бути розміщені в Variant, наприклад, Custom Variants, заздалегідь неможливо, має сенс написати дві версії логіки функції – оптимізовану та стандартну. На початку роботи функція перевіряє тип вихідних даних, і якщо він збігається з одним з очікуваних типів, то виконується оптимізована версія тіла функції, інакше варіант без оптимізації. Крім того, такий підхід дозволить коректно обробляти ситуації, коли пряма прив'язка з тієї чи іншої причини виявилася неможливою:

Приклад реалізації

З метою економії часу реалізована тільки операція додавання для varInt і varDouble, а також посилань на них. За бажанням, можна змінити поведінку функцій реалізації таким чином, щоб замість виклику диспетчера вони генерували якийсь виняток, який код, що викликає, може обробляти за аналогією з помилкою модифікації, викликаючи свою резервну версію, що не використовує прямої прив'язки.

Приклад практичного застосування та оцінка ефективності

Наведений нижче код призначений для вирішення відразу трьох завдань: по-перше, демонстрації спроможності теми, що обговорюється, і її практичної корисності; по-друге, підтвердження працездатності запропонованої у попередньому розділі реалізації; по-третє, оцінки ефекту від прямої прив'язки під час роботи з Variant.

Функції Sum та Sum_std приймають масив значень типу Variant. Перша намагається провести обчислення з використанням прямої прив'язки (якщо це не вдається, викликає другу), друга без неї.

Функція RDTSC використовується для вимірювання продуктивності. NewInt – допоміжний засіб для розподілу динамічної пам'яті заданого значення типу integer, що у одному з тестів.

Усього тестів чотири: у першому дані масиву мають тип varInteger, у другому - теж varInteger, але що зберігаються за посиланням (тут і застосовується NewInt), у третьому - varDouble і в четвертому - varString (при роботі з яким у запропонованій реалізації пряма прив'язка не проводиться , але внаслідок того, що викликається диспетчер, функції додавання в Sum буде призводити до додаткових витрат часу).

Під час тестування оператору виводиться для кожного тесту час роботи Sum та Sum_std, а також повернені ними результати.

В результаті тестування на обчислювальній системі в конфігурації AMD Athlon64 3000+, 512Мб PC3200 (вільно 320Mб), MS Windows 2003 Server, «чиста» система відразу після встановлення, зменшення часових витрат при прямій прив'язці для перших трьох тестів склало, відповідно, 81% 89% та 83%. Четвертий тест показав, як і слід очікувати, зниження продуктивності Sum порівняно з Sum_std, проте не надто значне: близько 10%.

Таким чином, можна зробити висновок, що використання Variant для написанняузагальнених алгоритмів та виконання деяких видів обчислень не так безнадійно, як може здатися з першого погляду.