Articolo infruttuoso sull'accelerazione della riflessione

Spiego subito il titolo dell’articolo. L'idea originale era quella di dare consigli validi e affidabili su come accelerare l'uso della riflessione utilizzando un esempio semplice ma realistico, ma durante il benchmarking si è scoperto che la riflessione non è così lenta come pensavo, LINQ è più lento che nei miei incubi. Ma alla fine si è scoperto che avevo sbagliato anche le misure... I dettagli di questa storia di vita sono sotto il taglio e nei commenti. Poiché l'esempio è abbastanza banale e implementato in linea di principio come di solito viene fatto in un'impresa, si è rivelato piuttosto interessante, come mi sembra, una dimostrazione della vita: l'impatto sulla velocità dell'argomento principale dell'articolo è stato non evidente a causa della logica esterna: Moq, Autofac, EF Core e altri "banding".

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

Come puoi vedere, l'autore suggerisce di utilizzare delegati compilati invece di chiamare direttamente metodi di tipo riflessione come un ottimo modo per velocizzare notevolmente l'applicazione. Ovviamente esiste l'emissione IL, ma vorrei evitarla, poiché questo è il modo più laborioso per eseguire l'attività, che è irto di errori.

Considerando che ho sempre avuto un’opinione simile riguardo alla velocità di riflessione, non avevo particolarmente intenzione di mettere in discussione le conclusioni dell’autore.

Spesso incontro un uso ingenuo della riflessione nell'impresa. Il tipo è preso. Vengono prese informazioni sulla proprietà. Viene chiamato il metodo SetValue e tutti esultano. Il valore è arrivato nel campo obiettivo, tutti contenti. Persone molto intelligenti - anziani e team leader - scrivono le loro estensioni per obiettare, basandosi su un'implementazione così ingenua di mappatori "universali" di un tipo all'altro. L'essenza è solitamente questa: prendiamo tutti i campi, prendiamo tutte le proprietà, iteriamo su di essi: se i nomi dei membri del tipo corrispondono, eseguiamo SetValue. Di tanto in tanto ci imbattiamo in eccezioni dovute a errori in cui non abbiamo trovato qualche proprietà in uno dei tipi, ma anche qui esiste una via d'uscita che migliora le prestazioni. Prova a prendere.

Ho visto persone reinventare parser e mappatori senza essere completamente armati di informazioni su come funzionano le macchine che li hanno preceduti. Ho visto persone nascondere le loro ingenue implementazioni dietro strategie, dietro interfacce, dietro iniezioni, come se questo giustificasse i successivi baccanali. Ho storto il naso davanti a tali realizzazioni. In effetti, non ho misurato la reale perdita di prestazioni e, se possibile, ho semplicemente modificato l'implementazione in una più "ottimale" se potevo metterci le mani sopra. Pertanto, le prime misurazioni discusse di seguito mi hanno seriamente confuso.

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

La chiamata alla riflessione costringe il CLR a esaminare gli assembly per trovare quello di cui ha bisogno, estrarre i metadati, analizzarli, ecc. Inoltre, la riflessione durante l'attraversamento delle sequenze porta all'allocazione di una grande quantità di memoria. Stiamo esaurendo la memoria, CLR scopre il GC e iniziano i fregi. Dovrebbe essere notevolmente lento, credimi. Le enormi quantità di memoria sui moderni server di produzione o sulle macchine cloud non impediscono elevati ritardi di elaborazione. Infatti, maggiore è la memoria, maggiore è la probabilità di NOTARE come funziona il GC. La riflessione è, in teoria, per lui uno straccio rosso in più.

Tuttavia, utilizziamo tutti contenitori IoC e mappatori di date, il cui principio di funzionamento si basa anch'esso sulla riflessione, ma di solito non ci sono dubbi sulle loro prestazioni. No, non perché l’introduzione di dipendenze e l’astrazione da modelli esterni a contesto limitato siano così necessarie da dover sacrificare in ogni caso le prestazioni. Tutto è più semplice: in realtà non influisce molto sulle prestazioni.

Il fatto è che i framework più comuni basati sulla tecnologia di riflessione utilizzano tutti i tipi di trucchi per lavorarci in modo più ottimale. Di solito questa è una cache. In genere si tratta di espressioni e delegati compilati dall'albero delle espressioni. Lo stesso automapper mantiene un dizionario competitivo che abbina i tipi a funzioni che possono convertirli l'uno nell'altro senza chiamare la riflessione.

Come si ottiene questo risultato? In sostanza, questo non è diverso dalla logica utilizzata dalla piattaforma stessa per generare il codice JIT. Quando un metodo viene chiamato per la prima volta, viene compilato (e, sì, questo processo non è veloce); nelle chiamate successive, il controllo viene trasferito al metodo già compilato e non ci saranno perdite significative di prestazioni.

Nel nostro caso, puoi anche utilizzare la compilazione JIT e quindi utilizzare il comportamento compilato con le stesse prestazioni delle sue controparti AOT. Le espressioni verranno in nostro aiuto in questo caso.

Il principio in questione può essere brevemente formulato come segue:
È necessario memorizzare nella cache il risultato finale della riflessione come delegato contenente la funzione compilata. Ha anche senso memorizzare nella cache tutti gli oggetti necessari con le informazioni sul tipo nei campi del tuo tipo, il lavoratore, che sono archiviati all'esterno degli oggetti.

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

Guardando al futuro, va detto che la cache nel lavorare con la riflessione ha i suoi vantaggi, anche se non si utilizza il metodo proposto per compilare le espressioni. In realtà qui mi limito a ripetere le tesi dell'autore dell'articolo a cui mi riferisco sopra.

Ora riguardo al codice. Facciamo un esempio che si basa sul mio recente dolore che ho dovuto affrontare in una seria produzione di un serio istituto di credito. Tutte le entità sono fittizie in modo che nessuno possa indovinare.

C'è una certa essenza. Lascia che ci sia il Contatto. Esistono lettere con un corpo standardizzato, da cui il parser e l'idratatore creano gli stessi contatti. È arrivata una lettera, l'abbiamo letta, l'abbiamo analizzata in coppie chiave-valore, creato un contatto e salvato nel database.

È elementare. Supponiamo che un contatto abbia le proprietà Nome completo, Età e Telefono del contatto. Questi dati vengono trasmessi nella lettera. L'azienda desidera inoltre supporto per poter aggiungere rapidamente nuove chiavi per mappare le proprietà dell'entità in coppie nel corpo della lettera. Nel caso qualcuno abbia commesso un errore di battitura nel template o se prima del rilascio sia necessario lanciare urgentemente la mappatura da un nuovo partner, adattandosi al nuovo formato. Quindi possiamo aggiungere una nuova correlazione di mappatura come correzione dati economica. Cioè, un esempio di vita.

Implementiamo, creiamo test. Lavori.

Non fornirò il codice: i sorgenti sono moltissimi e sono disponibili su GitHub tramite il link a fine articolo. Puoi caricarli, torturarli fino a renderli irriconoscibili e misurarli, come inciderebbe nel tuo caso. Darò solo il codice di due metodi template che distinguono l'idratore, che doveva essere veloce, dall'idratore, che doveva essere lento.

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

“Fast” (prefisso Fast 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 raccolta statica con proprietà setter: lambda compilate che chiamano l'entità setter. Creato dal 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. Attraversiamo le proprietà, creiamo delegati per loro che chiamano setter e li salviamo. Poi chiamiamo quando necessario.

“Lento” (prefisso Lento 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 riferimento, ho implementato un metodo ingenuo che scrive i valori delle loro coppie di correlazione direttamente nei campi dell'entità. Prefisso – Manuale.

Ora prendiamo BenchmarkDotNet ed esaminiamo le prestazioni. E all'improvviso... (spoiler - questo non è il risultato corretto, i dettagli sono sotto)

Articolo infruttuoso sull'accelerazione della riflessione

Cosa vediamo qui? I metodi che portano trionfalmente il prefisso Fast risultano essere più lenti in quasi tutti i passaggi rispetto ai metodi con il prefisso Slow. Ciò vale sia per l'allocazione che per la velocità del lavoro. D'altra parte, un'implementazione bella ed elegante della mappatura utilizzando metodi LINQ destinati a questo scopo, ove possibile, al contrario, riduce notevolmente la produttività. La differenza è d'ordine. La tendenza non cambia al variare del numero di passaggi. L'unica differenza è nella scala. Con LINQ è da 4 a 200 volte più lento, c'è più spazzatura all'incirca nella stessa scala.

AGGIORNAMENTO

Non credevo ai miei occhi ma, cosa ancora più importante, il nostro collega non credeva né ai miei occhi né al mio codice: Dmitry Tikhonov 0x1000000. Dopo aver ricontrollato la mia soluzione, ha scoperto e segnalato brillantemente un errore che non avevo notato a causa di una serie di modifiche nell'implementazione, dall'inizio alla fine. Dopo aver corretto il bug riscontrato nella configurazione di Moq, tutti i risultati sono andati a posto. Secondo i risultati del nuovo test, la tendenza principale non cambia: LINQ continua ad influenzare le prestazioni più della riflessione. Tuttavia, è bello che il lavoro con la compilazione delle espressioni non sia stato svolto invano e che il risultato sia visibile sia nell'allocazione che nel tempo di esecuzione. Il primo lancio, quando vengono inizializzati i campi statici, è naturalmente più lento per il metodo “veloce”, ma poi la situazione cambia.

Ecco il risultato del nuovo test:

Articolo infruttuoso sull'accelerazione della riflessione

Conclusione: quando si utilizza la riflessione in un'impresa, non è necessario ricorrere a trucchi: LINQ consumerà maggiormente la produttività. Tuttavia, nei metodi a carico elevato che richiedono ottimizzazione, è possibile salvare la riflessione sotto forma di inizializzatori e compilatori delegati, che forniranno quindi una logica "veloce". In questo modo è possibile mantenere sia la flessibilità di riflessione che la velocità dell'applicazione.

Il codice benchmark è disponibile qui. Chiunque può ricontrollare le mie parole:
HabraReflectionTest

PS: il codice nei test utilizza IoC e nei benchmark utilizza un costrutto esplicito. Il fatto è che nell'implementazione finale ho eliminato tutti i fattori che potrebbero influenzare le prestazioni e rendere rumoroso il risultato.

PPS: Grazie all'utente Dmitry Tikhonov @0x1000000 per aver scoperto un mio errore nell'impostazione di Moq, che ha influito sulle prime misurazioni. Se qualcuno dei lettori ha karma sufficiente, metti mi piace. L'uomo si fermò, l'uomo lesse, l'uomo ricontrollò e fece notare l'errore. Penso che questo sia degno di rispetto e simpatia.

PPPS: grazie al meticoloso lettore che è arrivato fino in fondo allo stile e al design. Sono per l'uniformità e la comodità. La diplomazia della presentazione lascia molto a desiderare, ma ho tenuto conto delle critiche. Chiedo il proiettile.

Fonte: habr.com

Aggiungi un commento