Articolo infruttuoso sull'accelerazione della riflessione

Vorrei subito spiegare il titolo dell'articolo. Inizialmente, avevo intenzione di fornire consigli validi e affidabili su come accelerare la riflessione usando un esempio semplice ma realistico. Tuttavia, durante il benchmarking, ho scoperto che la riflessione non era così lenta come pensavo, e LINQ era più lento di quanto avessi mai immaginato. E ho anche scoperto di aver commesso un errore di misurazione... I dettagli di questa storia vera sono riportati più avanti e nei commenti. Poiché l'esempio è piuttosto banale e implementato in un modo tipicamente aziendale, si è rivelato una dimostrazione piuttosto interessante, a mio parere: l'impatto sulle prestazioni dell'argomento principale dell'articolo non è stato evidente a causa di logica esterna: Moq, Autofac, EF Core e altri "binding".

Ho iniziato a lavorare sotto l'impressione di questo articolo: Perché la riflessione è lenta

Come potete vedere, l'autore suggerisce di utilizzare delegati compilati invece di accedere direttamente ai metodi di tipo reflection come un ottimo modo per accelerare significativamente le prestazioni dell'applicazione. Naturalmente, esiste anche l'emissione IL, ma è meglio evitarla, poiché è il modo più laborioso e soggetto a errori per portare a termine l'attività.

Considerando che ho sempre avuto un’opinione simile sulla velocità della riflessione, non avevo intenzione di mettere in discussione in modo particolare le conclusioni dell’autore.

Mi imbatto spesso in usi ingenui della riflessione in ambito aziendale. Viene preso un tipo. Vengono recuperate le informazioni sulle proprietà. Viene chiamato il metodo SetValue e tutti sono contenti. Il valore è arrivato nel campo di destinazione, tutti sono contenti. Persone intelligenti – senior e team leader – scrivono le proprie estensioni per gli oggetti, basando i loro mappatori "universali" da un tipo all'altro su questa implementazione ingenua. Il succo è solitamente questo: prendi tutti i campi, prendi tutte le proprietà, itera su di esse: se i nomi dei membri del tipo corrispondono, eseguiamo SetValue. Periodicamente intercettiamo delle eccezioni quando una proprietà non viene trovata per uno dei tipi, ma anche in questo caso esiste un modo per migliorare le prestazioni: Try/catch.

Ho visto persone reinventare parser e mappatori senza essere pienamente informate su come funzionassero le ruote che erano state inventate prima di loro. Ho visto persone nascondere le loro implementazioni ingenue dietro strategie, interfacce e iniezioni, come se ciò potesse giustificare il caos che ne è seguito. Ho storto il naso di fronte a tali implementazioni. In realtà, non ho misurato l'effettivo consumo di prestazioni e, quando possibile, ho semplicemente sostituito l'implementazione con una più "ottimale" quando ne avevo il tempo. Quindi le misurazioni iniziali, discusse di seguito, mi hanno seriamente lasciato perplesso.

Penso che molti di voi, leggendo Richter o altri ideologi, si siano imbattuti nell'affermazione del tutto corretta secondo cui la riflessione nel codice è un fenomeno che ha un impatto estremamente negativo sulle prestazioni di un'applicazione.

Chiamare la reflection obbliga il CLR ad attraversare gli assembly per trovare quello giusto, recuperarne i metadati, analizzarli e così via. Inoltre, la reflection durante l'attraversamento delle sequenze comporta grandi allocazioni di memoria. Noi consumiamo memoria, il CLR decomprime il GC e di conseguenza si blocca. Questo dovrebbe essere notevolmente lento, credetemi. Le enormi quantità di memoria nei moderni server di produzione o nelle macchine cloud non impediscono elevate latenze di elaborazione. Anzi, più memoria si ha, maggiore è la probabilità di NOTARE le prestazioni del GC. La reflection è, in teoria, un inutile campanello d'allarme.

Tuttavia, utilizziamo tutti sia contenitori IoC che data mapper, che si basano anch'essi sulla riflessione, ma le loro prestazioni di solito non ne risentono. Non è perché l'iniezione di dipendenza e l'astrazione da modelli di contesto vincolato esterni siano così necessarie da dover sacrificare le prestazioni in ogni caso. È più semplice: non hanno davvero un impatto significativo sulle prestazioni.

Il fatto è che i framework più comuni basati sulla reflection impiegano ogni sorta di accorgimento per ottimizzarne l'uso. In genere, questo implica una cache. Sono comuni anche espressioni e delegati compilati da alberi di espressioni. L'automapper, ad esempio, mantiene un dizionario concorrente che mappa i tipi in funzioni che possono essere convertite l'una nell'altra senza ricorrere alla reflection.

Come si ottiene questo risultato? In sostanza, non è diverso dalla logica utilizzata dalla piattaforma stessa per generare codice JIT. La prima volta che un metodo viene chiamato, viene compilato (e sì, questo processo non è veloce). Le chiamate successive trasferiscono il controllo al metodo compilato e non si verificherà alcun calo significativo delle prestazioni.

Nel nostro caso, possiamo anche sfruttare la compilazione JIT e quindi utilizzare il comportamento compilato con le stesse prestazioni delle controparti AOT. In questo caso, le espressioni ci verranno in soccorso.

Il principio in questione può essere brevemente formulato come segue:
Il risultato finale della riflessione dovrebbe essere memorizzato nella cache come delegato contenente la funzione compilata. È inoltre opportuno memorizzare nella cache tutti gli oggetti necessari con informazioni sul tipo nei campi del tipo di worker memorizzati esternamente.

C'è una logica in tutto questo. Il buon senso ci dice che se qualcosa può essere compilato e memorizzato nella cache, allora dovrebbe esserlo.

Guardando al futuro, va notato che il caching presenta i suoi vantaggi quando si lavora con la reflection, anche senza utilizzare il metodo di compilazione delle espressioni proposto. In effetti, qui mi limito a ripetere i punti dell'autore dell'articolo a cui ho fatto riferimento sopra.

Passiamo ora al codice. Diamo un'occhiata a un esempio basato su un problema che ho riscontrato di recente in un importante ambiente di produzione presso un importante istituto finanziario. Tutte le entità sono fittizie, quindi nessuno potrà indovinare.

C'è una certa entità. Chiamiamola Contatto. Ci sono email con corpi standardizzati, da cui il parser e l'idratatore creano questi contatti. Arriva un'email, la leggiamo, la analizziamo in coppie chiave-valore, creiamo un contatto e lo salviamo nel database.

È semplice. Supponiamo che un contatto abbia proprietà come nome completo, età e numero di telefono. Questi dati vengono trasmessi nell'e-mail. L'azienda desidera anche che il supporto sia in grado di aggiungere rapidamente nuove chiavi per mappare le proprietà dell'entità alle coppie nel corpo dell'e-mail. Questo nel caso in cui qualcuno commetta un errore di battitura nel modello o se la mappatura debba essere lanciata urgentemente da un nuovo partner prima del rilascio, adattandosi a un nuovo formato. A questo punto possiamo aggiungere una nuova correlazione di mappatura come correzione economica dei dati. Quindi, questo è un esempio concreto.

Lo stiamo implementando, creando dei test. Funziona.

Non mostrerò il codice: c'è molto codice sorgente ed è disponibile su GitHub tramite il link alla fine dell'articolo. Potete scaricarlo, modificarlo fino a renderlo irriconoscibile e valutare l'impatto che avrebbe sul vostro caso. Mostrerò solo il codice di due metodi template che differenziano l'idratatore che avrebbe dovuto essere veloce da quello che avrebbe dovuto essere lento.

La logica è la seguente: il metodo template riceve le coppie generate dalla logica di base del parser. Il livello LINQ è la logica di base del parser e dell'idratatore, che interroga il contesto del database e confronta le chiavi con le coppie del parser (per queste funzioni è disponibile codice non LINQ per il confronto). Le coppie vengono quindi passate al metodo di idratazione principale e i valori delle coppie vengono impostati sulle proprietà dell'entità corrispondenti.

"Veloce" (prefisso Veloce nei benchmark):

 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;
        }

Come possiamo vedere, viene utilizzata una collezione statica con setter di proprietà, ovvero lambda compilate che richiamano il setter dell'entità. Vengono create con il seguente codice:

        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();
        }

In generale, è chiaro. Esaminiamo le proprietà, creiamo i delegati per ciascuna di esse, chiamiamo i setter e li salviamo. Quindi li richiamiamo quando necessario.

"Slow" (prefisso Slow nei benchmark):

        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;
        }

Qui ignoriamo immediatamente le proprietà e chiamiamo direttamente SetValue.

Per chiarezza e come parametro di riferimento, ho implementato un metodo ingenuo che scrive i valori delle coppie di correlazione direttamente nei campi entità. Il prefisso è Manual.

Ora prendiamo BenchmarkDotNet e testiamo le sue prestazioni. E all'improvviso... (attenzione, spoiler: questo è un risultato errato; dettagli di seguito)

Articolo infruttuoso sull'accelerazione della riflessione

Cosa vediamo qui? I metodi che portano trionfalmente il prefisso Fast sono più lenti dei metodi con il prefisso Slow in quasi tutti i passaggi. Questo vale sia per la velocità di allocazione che per quella di esecuzione. D'altra parte, un'implementazione di mapping elegante e raffinata, che utilizza metodi LINQ quando possibile, riduce significativamente le prestazioni. La differenza è di ordini di grandezza. Questa tendenza non cambia con un numero diverso di passaggi. L'unica differenza è la scala. Con LINQ, è da 4 a 200 volte più lento, con all'incirca la stessa quantità di garbage.

AGGIORNAMENTO

Non potevo credere ai miei occhi, ma cosa più importante, il nostro collega non credeva né ai miei occhi né al mio codice: Dmitrij Tichonov 0x1000000Dopo aver ritestato la mia soluzione, ha brillantemente individuato e segnalato un errore che mi era sfuggito a causa di diverse modifiche nell'implementazione iniziale. Dopo aver corretto il bug scoperto nella configurazione di Moq, tutti i risultati sono tornati alla normalità. I ​​risultati del ritestato mostrano che la tendenza principale rimane invariata: LINQ ha ancora un impatto maggiore sulle prestazioni rispetto alla riflessione. Tuttavia, è bello vedere che il lavoro di compilazione delle espressioni è utile e i risultati sono visibili sia nel tempo di allocazione che in quello di esecuzione. La prima esecuzione, quando vengono inizializzati i campi statici, è naturalmente più lenta per il metodo "fast", ma poi la situazione cambia.

Ecco il risultato del nuovo test:

Articolo infruttuoso sull'accelerazione della riflessione

Conclusione: quando si utilizza la reflection in ambito aziendale, non è necessario ricorrere a trucchi: LINQ consumerà significativamente le prestazioni. Tuttavia, nei metodi ad alto carico che richiedono ottimizzazione, la reflection può essere preservata sotto forma di inizializzatori e compilatori delegati, che forniranno quindi una logica "veloce". In questo modo, è possibile preservare sia la flessibilità della reflection sia la velocità dell'applicazione.

Il codice di riferimento è disponibile qui. Chiunque fosse interessato può ricontrollare le mie affermazioni:
HabraReflectionTests

P.S.: Il codice nei test utilizza IoC, mentre i benchmark utilizzano un costrutto esplicito. Questo perché nell'implementazione finale ho eliminato tutti i fattori che avrebbero potuto influire sulle prestazioni e distorcere i risultati.

PPS: Grazie all'utente Dmitrij Tikhonov @0x1000000 Per aver individuato il mio errore nella configurazione del Moq, che ha influenzato le misurazioni iniziali. Se qualche lettore ha abbastanza karma, per favore metta un "Mi piace". Qualcuno si è fermato, qualcuno ha letto attentamente, qualcuno ha ricontrollato e ha segnalato l'errore. Penso che questo meriti rispetto e comprensione.

PPPS: Grazie al lettore meticoloso che ha approfondito lo stile e l'impaginazione. Sono assolutamente a favore della coerenza e della praticità. La diplomazia della presentazione lascia molto a desiderare, ma ho tenuto conto delle critiche. Per favore, mettetevi al lavoro.

Fonte: habr.com

Acquista hosting affidabile per siti con protezione DDoS, server VPS VDS 🔥 Acquista un hosting web affidabile con protezione DDoS, server VPS e VDS | ProHoster