Няўдалы артыкул пра паскарэнне рэфлексіі

Адразу растлумачу назву артыкула. Першапачаткова планавалася даць добрую, надзейную параду па паскарэнні выкарыстання рэфлекшэна на простым, але рэалістычным прыкладзе, аднак падчас бенчмаркетынгу высветлілася, што рэфлексія працуе не так марудна, як я думаў, LINQ працуе павольней, чым снілася ў кашмарах. А ў выніку аказалася, што мной яшчэ і была дапушчана памылка ў замерах… Падрабязнасці гэтай жыццёвай гісторыі пад катом і ў каментарах. Так як прыклад дастаткова бытавой і рэалізаваны ў прынцыпе як звычайна робіцца ў энтэрпрайзе, атрымалася дастаткова цікавая, як мне здаецца, дэманстрацыя жыцця: уплыву на хуткасць працы асноўнага прадмета артыкула было не прыкметна з-за знешняй логікі: Moq, Autofac, EF Core і іншай абвязкі .

Пачаў я працу пад уражаннем ад гэтага артыкула: Why is Reflection slow

Як відаць, аўтар прапануе выкарыстоўваць скампіляваныя дэлегаты замест прамога звароту да метадаў тыпаў адлюстравання як выдатны спосаб моцна паскорыць працу прыкладання. Ёсць тамака яшчэ, вядома, IL эмісія, але яе жадалася бы пазбегнуць, бо гэта самы працаёмкі спосаб выканання задачы, які багаты памылкамі.

Улічваючы, што я прытрымліваўся заўсёды аналагічнага меркавання аб хуткасці рэфлексіі, асоба ставіць пад сумневы высновы аўтара я не збіраўся.

Я не рэдка сустракаюся з наіўным выкарыстаннем рэфлексіі ў энтэрпрайзе. Бярэцца тып. Бярэцца інфармацыя аб уласцівасці. Выклікаецца метад SetValue, і ўсё цешацца. Значэнне прыляцела ў мэтавае поле, усе задаволены. Людзі вельмі недурныя - сініёры і тымліды - пішуць свае пашырэнні на object, засноўваючы на ​​такой наіўнай рэалізацыі "універсальныя" мапперы аднаго тыпу ў іншы. Сутнасць такая звычайна: бярэм усе палі, бярэм усе ўласцівасці, итерируем па іх: пры супадзенні імёнаў чальцоў тыпу выконваем SetValue. Перыядычна ловім выключэнні на промахах там, дзе не знайшлі нейкую ўласцівасць у аднаго з тыпаў, але і тут ёсць выйсце, які дамагае прадукцыйнасць. Try/catch.

Я бачыў, як людзі перанаходзяць парсеры і мапперы, не будучы цалкам узброенымі інфармацыяй аб тым, як працуюць вынайдзеныя да іх ровары. Я бачыў, як людзі хаваюць свае наіўныя рэалізацыі за стратэгіямі, за інтэрфейсамі, за ін'екцыямі, як быццам гэта выбачыць наступную вэрхал. Ад такіх рэалізацый я адварочваў нос. Па факце, рэальную ўцечку прадукцыйнасці я не замяраў, і пры магчымасці проста мяняў рэалізацыю на больш «аптымальную», калі рукі даходзілі. Таму першыя замеры, пра якія ідзе гаворка ніжэй, мяне сур'ёзна збянтэжылі.

Думаю, многія з вас, чытаючы Рыхтэра або іншых ідэолагаў, сутыкаліся са цалкам справядлівым сцвярджэннем, што рэфлексія ў кодзе - гэта з'ява вельмі негатыўна адбіваецца на перформансе прыкладання.

Выклік адлюстравання змушае CLR абыходзіць зборкі ў пошуках патрэбнай, падцягваць іх метададзеныя, парсіць іх і г.д. Акрамя таго, рэфлексія падчас абыходу паслядоўнасцяў прыводзіць да алакацыі вялікага аб'ёму памяці. Расходуем памяць, CLR расчахляе ГЦ і панесліся фрызы. Гэта павінна быць прыкметна павольна, паверце. Велізарныя аб'ёмы памяці сучасных прадакшэн сервераў або хмарных машын не ратуюць ад высокіх затрымак у апрацоўцы. Фактычна, чым больш памяці, тым вышэй верагоднасць, што вы заўважыце, як працуе ГЦ. Рэфлексія - гэта, па ідэі, лішняя чырвоная ануча для яго.

Тым не менш, усе мы выкарыстоўваем і IoC кантэйнеры, і дата мапперы, прынцып працы якіх гэтак жа заснаваны на рэфлексіі, аднак пытанняў да іх прадукцыйнасці звычайна не ўзнікае. Не, не таму што ўкараненне залежнасцяў і абстрагаванні ад мадэляў вонкавага абмежаванага кантэксту гэтак неабходныя рэчы, што перформансам нам даводзіцца ахвяраваць у любым выпадку. Усё прасцей - гэта сапраўды не моцна адбіваюцца на прадукцыйнасці.

Справа ў тым, што найболей распаўсюджаныя фрэймворкі, якія заснаваныя на тэхналогіі рэфлексіі, выкарыстаюць разнастайныя хітрыкі для больш аптымальнай працы з ёй. Звычайна гэта кэш. Звычайна - гэта Expressions і скампіляваныя з дрэва выразаў дэлегаты. Той жа аўтамапер трымае пад сабой канкурэнтны слоўнік, які супастаўляе тыпы з функцыямі, якія могуць адзін у іншы сканвертаваць ужо без выкліку рэфлексіі.

Як гэта робіцца? Па сутнасці, гэта не адрозніваецца ад логікі, якой карыстаецца сама платформа для генерацыі кода JIT. Пры першым выкліку метаду, той кампілюецца (і, так, гэты працэс не хуткі), пры наступных выкліках кіраванне перадаецца ўжо скампіляванага метаду, і тут асаблівых прасадак прадукцыйнасці не будзе.

У нашым выпадку гэтак жа можна скарыстацца JIT кампіляцыяй і потым выкарыстоўваць скампіляваныя паводзіны з той жа прадукцыйнасцю, што і яго AOT аналогі. На дапамогу нам у дадзеным выпадку прыйдуць выразы.

Коратка можна сфармуляваць прынцып, аб якім ідзе гаворка, наступным чынам:
Варта кэшаваць канчатковы вынік працы рэфлексіі ў выглядзе дэлегата, утрымоўвальнага скампіляваную функцыю. Усе неабходныя аб'екты са звесткамі аб тыпах таксама мае сэнс кэшаваць у захоўваюцца па-за аб'ектамі палях вашага тыпу - воркера.

Логіка ў гэтым ёсць. Разумны сэнс нам кажа аб тым, што калі нешта можна скампіляваць і закэшаваць, то гэта варта зрабіць.

Забягаючы наперад, варта сказаць, што кэш у працы з рэфлексіяй мае свае перавагі, нават калі не выкарыстоўваць прапанаваны метад кампіляцыі выразаў. Уласна, тут я просты паўтараю тэзы аўтара артыкула, на які спасылаюся вышэй.

Зараз аб кодзе. Давайце разгледзім прыклад, які заснаваны на маім нядаўнім болі, з якой прыйшлося сутыкнуцца ў сур'ёзным прадакшэне сур'ёзнай крэдытнай арганізацыі. Усе энтыці выдуманыя, каб ніхто не здагадаўся.

Ёсць нейкая сутнасць. Няхай будзе Contact. Ёсць лісты са стандартызаваным целам, з якіх парсер і гідтар ствараюць гэтыя самыя кантакты. Прыйшоў ліст, мы яго прачыталі, разабралі на пары ключ-значэнне, стварылі кантакт, захавалі ў бд.

Гэта элемэнтарна. Дапушчальны, у кантакту ёсць уласцівасці ПІБ, Узрост і кантактны тэлефон. Гэтыя дадзеныя і перадаюцца ў лісце. Гэтак жа бізнэс жадае, каб саппорты маглі аператыўна дадаваць новыя ключы для мапінга ўласцівасцяў вентылі на пары ў целе ліста. На той выпадак, калі хтосьці апячатаўся ў шаблоне ці калі да рэлізу трэба будзе тэрмінова запусціць мапінг ад новага партнёра, падбудаваўшыся пад новы фармат. Тады новую карэляцыю мапінгу мы зможам дадаць як танны датафікс. Гэта значыць, прыклад жыццёвы.

Рэалізуем, ствараем тэсты. Працуе.

Код я прыводзіць не буду: зыходнікаў атрымалася шмат, і яны даступныя на GitHub па спасылцы ў канцы артыкула. Вы можаце іх загрузіць, замучыць да непазнавальнасці і замерыць, як гэта б мовілася ў вашым выпадку. Прывяду толькі код двух шаблонных метадаў, якімі адрозніваюцца гідратар, які павінен быў быць хуткім ад гідратара, які павінен быў быць павольным.

Логіка наступная: шаблонны метад атрымлівае пары, сфарміраваныя базавай логікай парсера. Узровень LINQ - гэта парсер і базавая логіка гідратара, якая робіць запыт да кантэксту бд і супастаўляльная ключы з парамі ад парсера (для гэтых функцый ёсць код без LINQ для параўнання). Далей пары перадаюцца ў асноўны метад гідрацыі і значэнні пар усталёўваюцца ў адпаведныя ўласцівасці Энціці.

Хуткі (Прэфікс Fast у бенчмарках):

 protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
        {
            var contact = new Contact();
            foreach (var setterMapItem in _proprtySettersMap)
            {
                var correlation = correlations.FirstOrDefault(x => x.PropertyName == setterMapItem.Key);
                setterMapItem.Value(contact, correlation?.Value);
            }
            return contact;
        }

Як мы бачым, выкарыстоўваецца статычная калекцыя з сетэрамі пропертей - скампіляваныя лямбдамі, якія выклікаюць сетэр Энціці. Ствараюцца наступным кодам:

        static FastContactHydrator()
        {
            var type = typeof(Contact);
            foreach (var property in type.GetProperties())
            {
                _proprtySettersMap[property.Name] = GetSetterAction(property);
            }
        }

        private static Action<Contact, string> GetSetterAction(PropertyInfo property)
        {
            var setterInfo = property.GetSetMethod();
            var paramValueOriginal = Expression.Parameter(property.PropertyType, "value");
            var paramEntity = Expression.Parameter(typeof(Contact), "entity");
            var setterExp = Expression.Call(paramEntity, setterInfo, paramValueOriginal).Reduce();
            
            var lambda = (Expression<Action<Contact, string>>)Expression.Lambda(setterExp, paramEntity, paramValueOriginal);

            return lambda.Compile();
        }

У цэлым зразумела. Абыходзім уласцівасці, ствараем па іх дэлегаты, якія выклікаюць сетэры, захоўваем. Потым выклікаем, калі трэба.

"Павольны" (Прэфікс Slow у бенчмарках):

        protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
        {
            var contact = new Contact();
            foreach (var property in _properties)
            {
                var correlation = correlations.FirstOrDefault(x => x.PropertyName == property.Name);
                if (correlation?.Value == null)
                    continue;

                property.SetValue(contact, correlation.Value);
            }
            return contact;
        }

Тут адразу абыходзім уласцівасці і выкліканы напроста SetValue.

Для навочнасці і ў якасці ўзору рэалізаваў наіўны метад, які піша значэння іх пар карэляцыі напрамую ў палі Энціці. Прэфікс - Manual.

Цяпер бярэм BenchmarkDotNet і даследуем прадукцыйнасць. І раптам… (спойлер – гэта не правільны вынік, падрабязнасці – ніжэй)

Няўдалы артыкул пра паскарэнне рэфлексіі

Што мы тут бачым? Метады, пераможна якія носяць прэфікс Fast, амаль пры ўсіх праходах апыняюцца павольней, чым метады з прэфіксам Slow. Гэта справядліва і для алакацыі, і для хуткасці працы. З іншага ж боку прыгожая і элегантная рэалізацыя мапінгу з выкарыстаннем усюды, дзе можна, прызначаных для гэтага метадаў LINQ, наадварот, моцна аджырае прадукцыйнасць. Розніца ў парадкі. Тэндэнцыя не мяняецца з рознай колькасцю праходаў. Розніца толькі ў маштабах. З LINQ у 4 - 200 разоў павольней, смецця больш прыкладна ў такіх жа маштабах.

АБНОЎЛЕНА

Я не паверыў сваім вачам, але што важней, ні маім вачам, ні майму коду не паверыў наш калега - Dmitry Tikhonov 0x1000000. Пераправерыўшы мой солюшн ён бліскуча выявіў і паказаў на памылку, якую я з-за шэрагу змен у рэалізацыі ён пачатковай да канчатковай выпусціў. Пасля выпраўлення знойдзенага бага ў наладзе Moq, усе вынікі ўсталі на свае месцы. Па выніках рэтэсту асноўная тэндэнцыя не мяняецца – LINQ уплывае на прадукцыйнасць усё роўна мацней, чым рэфлексія. Аднак прыемна, што праца з кампіляцыяй Expression'ов робіцца нездарма, і вынік бачны і па алакацыі, і па часе выканання. Першы запуск, калі ініцыялізуюцца статычныя палі, заканамерна павольней у "хуткага" метаду, але далей сітуацыя мяняецца.

Вось вынік рэтэсту:

Няўдалы артыкул пра паскарэнне рэфлексіі

Выснова: пры выкарыстанні ў энтерпрайзе рэфлексіі звяртацца да хітрыкаў асабліва не патрабуецца – LINQ зжарэ прадукцыйнасць мацней. Тым не менш, у высоканагружаных метадах, якія патрабуюць аптымізацыі, можна захаваць рэфлексію ў выглядзе ініцыялізатараў і кампілятараў дэлегатаў, якія будуць потым забяспечваць "хуткую" логіку. Так Вы можаце захаваць і гнуткасць рэфлексіі, і хуткасць працы прыкладання.

Код з бенчмаркам даступны тут. Усе жадаючыя могуць пераправерыць мае словы:
HabraReflectionTests

PS: код у тэстах выкарыстоўвае IoC, а ў бенчмарках - відавочную канструкцыю. Справа ў тым, што ў канчатковай рэалізацыі я адсек усе здольныя адбіцца на прадукцыйнасці і зашуміць вынік фактары.

PPS: Дзякуй карыстальніку Dmitry Tikhonov @ 0x1000000 за выяўленне маёй памылкі ў наладзе Moq, якая адбілася на першых замерах. Калі ў кагосьці з чытачоў будзе дастатковая карма, лайкніце яго, калі ласка. Чалавек спыніўся, чалавек учытаўся, чалавек пераправерыў і ўказаў на памылку. Я лічу, што гэта годна павагі і сімпатыі.

PPPS: дзякуй таму скурпулому чытачу, які дакапаўся да стылістыкі і афармлення. Я за аднастайнасць і зручнасць. Дыпламатычнасць падачы вымушае жадаць лепшага, але крытыку я ўлічыў. Прашу да снарада.

Крыніца: habr.com

Дадаць каментар