Невдала стаття про прискорення рефлексії

Відразу поясню назву статті. Спочатку планувалося дати хорошу, надійну пораду щодо прискорення використання рефлекшену на простому, але реалістичному прикладі, проте в ході бенчмаркінгу з'ясувалося, що рефлексія працює не так повільно, як я думав, 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

Додати коментар або відгук