Ускорена обработка на Variant данни в Delphi

Ускоряване на динамичното изпращане на повиквания в статично въведени езици на примера за работа с Variant в Delphi

Автор: Максим Гумеров Източник: Списание RSDN #4-2004
Въведение в проблема Причини и решения Пример за внедряване Казус от практиката и оценка на ефективността Връзки
Variant

Въведение в проблема

Вариантните данни са изключително бавни за работа. По този начин системата за помощ на Borland Delphi 6.0 заявява, че (свободно преведено) "Вариантните данни осигуряват повече гъвкавост, но заемат повече памет от обикновените променливи и операциите върху тях са по-бавни, отколкото върху статично въведени данни." В същия смисъл А.Я. Архангелски в книгата "Програмиране в Delphi 6" - той обаче напълно забравя да спомене индикацията, съдържаща се в помощната услуга едно изречение по-нататък, че в Delphi 6 се е появил принципно нов тип Variant - Variant, дефиниран от програмиста (персонализиран вариант). Не става дума обаче за него.

Такова пренебрегване на този елемент от езика не е напълно оправдано. Както ще бъде показано по-долу, работата с данни от тип Variant може да бъде ускорена в някои случаи. От друга страна, има редица ситуации, в които използването на Variant би било много полезно, ако може да се постигне такова ускорение. In particular, given the lack of templates in Delphi (it seems that this will change soon - Delphi is moving in full swing towards .NET, and all languages ​​​​\u200b\u200bof the second version of .NET will be required to support generic programming - ed.), it would be possible to describe in a universal form an algorithm for calculating, say, the average value in an array - moreover, the data in the array can be both integer andреален или персонализиран вариант (например комплексни числа). Като цяло, задачата за обработка на потоци от данни е възможна - добавяне на два вектора, изчисляване на средната стойност - когато конкретният тип данни е неизвестен по време на писане на манипулаторната функция или може дори да е различен.

Причината за бавната обработка на стойности от типа Variant е, че по време на компилация на някаква операция върху стойността, действителният тип на последната (т.е. какво точно е поставено във Variant) е неизвестен и следователно компилаторът генерира код, който анализира информацията за типа, съдържаща се в стойността, и в зависимост от резултата извиква подходящата операция. И така, за двойка цели числа ще се извърши събиране на цели числа, а за двойка низове (нека ви напомня, че Delphi ви позволява да поставите във Variant, в допълнение към BSTR, обичайния Delphi AnsiString, т.е. низове с 8-битово кодиране на знаци, брояч на дължина на низове, брояч на референтни номера и нулев терминатор) - тяхното конкатениране.

Повторното прилагане на такъв анализ не може да не повлияе на изпълнението на извършените операции: всъщност действителното събиране на цели числа на числа, поставени в два варианта, е по-бързо от предишната проверка на типа на аргумента. Това се потвърждава от експерименталните резултати, представени в тази статия.

Причини и решения

Лидерството на 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], извикването на метода на диспечера с директно извикване на желаната процедура, която извършва добавяне за два варианта на данни от специфични типове (типът на първия се определя от тип A[i], вторият - от тип B[i]).

Извикването на DisableBinding казва на подсистемата за директно свързване да възстанови първоначалното състояние на кода, тъй като следващото изпълнение на операцията може вече да се случи с други типове операнди.

И компилаторът на Delphi, с който разполага, генерира същия код за операцията „+“ на Variants, която, за съжаление, не е подходяща за модификация по време на изпълнение. Следователно добавянето ще трябва да бъде заменено с по-малко естествена нотация, която ви позволява да вземете предвид директното свързване във вашата собствена функция -посредник VarAdd:

В един раздел на кода може да има независими групи от операции с варианти и типът на обработените стойности за всяка група може да е различен. В следващия пример едната група е операции върху A[i] и B[i], другата група е операции върху VarString1 и VarString2. Има смисъл да се съхранява информация за обвързването на определена група операции в специален обект:

ЗАБЕЛЕЖКА

Variant
Фигура 1. Механизъм за заместване на команда за повикване.

Трябва да се отбележи, че осезаеми ползи от използването на директно обвързване не могат да бъдат получени във всички случаи. Така че работата с низове или извикването чрез IDispatch е малко вероятно да се ускори значително - в крайна сметка основното изчислително натоварване тук се създава от самата обработка на стойността, а не от изпращането. Но някои действия вероятно има смисъл да се ускоряват - например аритметични операции върху цели и реални операнди. Ако съществуващата реализация на ускорение не поддържа операцията за добавяне за операнди от типовете, с които се извиква VarAdd, тогава заместването не се извършва. В този случай няма да има печалба от директно обвързване и режийни разходи за чекове и т.н. остават - въпреки че, както показва експериментът, загубата не е толкова голяма.

Когато прави предположението, че типовете операнди са неизменни, програмистът трябва да бъде внимателен. Например, възможни са ситуации, когато някоя променлива, участваща в изчисленията и разположена вътре в цикъла, променя типа си от итерация на итерация. Такъв може да е случаят по-специално при използване на акумулираща променлива.

Нека масивът Arr съдържа стойности от двоен тип, пакетирани във Variant. Помислете за цикъл:

Той изглежда достатъчно безобиден. Въпреки това, ако при първата итерация на цикъла Sum съхранява цяло число, то при втората -вече реално! Освен това, ако функцията, съдържаща цикъла, е предназначена да бъде универсална, едва ли си струва да се разчита, че 0 винаги ще бъде адекватна начална стойност за акумулатора: какво ще стане, ако например масивът се състои от текстови низове?

Някои от тези вредни ефекти могат да бъдат избегнати, като направите:

Но тук има друга опасност: типът на резултата от операцията върху акумулатора и следващия елемент на дадена итерация може да не съвпада с типа на елементите на масива, което ще доведе до неправилна обработка на следващата итерация. Така че, ако акумулаторът натрупа не сумата, а резултата от разделянето на определено число на произведението на целочислените елементи на масива, рано или късно той може да приеме реална стойност.

Освен това, тъй като е невъзможно предварително да се предскаже поведението на други типове данни, които могат да се поберат във вариант, като потребителски варианти, има смисъл да се напишат две версии на функционалната логика - оптимизирана и стандартна. В началото на работата функцията проверява типа на изходните данни и ако съвпада с един от очакваните типове, тогава се изпълнява оптимизираната версия на тялото на функцията, в противен случай се изпълнява вариантът без оптимизация. В допълнение, този подход ще ви позволи правилно да се справите със ситуации, когато директното обвързване по една или друга причина се оказа невъзможно:

Пример за изпълнение

За да спестим време, за varInt и varDouble, както и за техните препратки, се прилага само операцията по добавяне. По избор можете да промените поведението на функциите за внедряване, така че вместо да извикат диспечера, те да хвърлят някакъв вид изключение, което извикващият код може да обработи, подобно на грешка при модификация, като извикат неговата резервна версия, която не използва директно свързване.

Казус и оценка на изпълнението

Кодът по-долу е предназначен да реши три проблема едновременно: първо, да демонстрира жизнеспособността на обсъжданата тема и нейната практическа полезност; второ, потвърждаване на изпълнението на предложеното в предишния раздел изпълнение; трето, оценка на ефекта от директното свързване при работа с Variant.

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

Функцията RDTSC се използва за измерване на производителността. NewInt е помощно средство за разпределяне на дадена целочислена стойност в динамична памет, което се използва в един от тестовете.

Има общо четири теста: в първия, данните за масива са от тип varInteger, във втория, също varInteger, но съхранени по референция (тук се използва NewInt), в третия, varDouble и в четвъртия, varString (при работа с който в предложената реализация не се извършва директно обвързване, но поради факта, че се извиква диспечерът, функциите за добавяне в Sum ще доведат до допълнителни времеви разходи).

При тестване на оператора се показва за всеки тест времето на работа на Sum и Sum_std, както и резултатите, върнати от тях.

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

По този начин можем да заключим, че използването на Variant за писанеобобщени алгоритми и извършването на определени видове изчисления не е толкова безнадеждно, колкото може да изглежда на пръв поглед.