Enums, switch statements en SOLID - deel 5

SOLID en performance

Vorige week refactorde ik een stuk code om meer in lijn te zijn met het Open-closed principe. In plaats van een harde afhankelijkheid te coderen in de class die een ClaimProvider teruggeeft, gebruikten we reflection om deze classes op runtime te vinden en terug te geven.

De class die we daarvoor aanmaakten, was een flink stuk groter dan de oorspronkelijke method:

Maar het grootste probleem van deze class is niet het aantal regels code, want dat is met 29 regels nog altijd zeer bescheiden. Het grootste probleem is de performance-impact die de keuze voor reflection met zich meebrengt.

Het op runtime-niveau uitvragen van de eigenschappen van een class is niet gratis. Er is relatief veel processorkracht voor nodig. En onze class is op dit moment zo opgezet dat deze dure operatie elke keer wordt uitgevoerd als een ClaimProvider wordt opgehaald. Voor één gebruiker met lees-, schrijf- en verwijderrechten zou deze method al drie keer worden aangeroepen. Dat is al drie keer een performance-penalty.

Stel je nu eens voor dat de ClaimsHelper wordt aangeroepen een een foreach-loop met 100 of 1.000 of misschien wel 10.000 gebruikers. Oei.

Langzamer, maar hoeveel langzamer?

Wat te doen?

Allereerst moeten we inzichtelijk maken hoe groot de performance penalty is die onze nieuwe opzet met zich meebrengt. Hiervoor gebruik ik een open source project genaamd BenchmarkDotNet.1 Deze tool roept de verschillende versies van GetClaimsForUser() aan die we in de loop van deze reeks hebben gemaakt, en houdt de snelheid van elke implementatie bij.

(De applicatie kan ook het geheugengebruik bijhouden, maar dat laat ik voor deze blog buiten beschouwing. Wie daar toch benieuwd naar is, kan de code via GitHub binnenhalen en de tool zelf runnen. Het project is zodanig geconfigureerd dat het geheugengebruik automatisch mee wordt genomen in de analyse.)

Het is belangrijk om over dit soort gegevens te beschikken vóórdat je gaat refactoren om de performance van je code te verbeteren. Dat reflection een impact heeft op de snelheid van je applicatie is aannemelijk, maar pas als je weet hoe groot die impact is, kun je een geïnformeerde keuze maken over de vraag of die refactorslag de moeite waard is.

Een kanttekening daarbij is dat de performance-penalty waar we het hier over hebben zich in termen van nanoseconden bevindt. De kans is groot dat dat verlies wegvalt tegenover veel duurdere operaties, zoals het aanspreken van een database. Desalniettemin, dat ontslaat je als ontwikkelaar niet van de plicht om een evenwicht te vinden tussen onderhoudbare en performante code.

Onze tool levert de volgende informatie op, gesorteerd van de snelste implementatie naar de langzaamste:

MethodGem.FoutmargeStandaarddeviatieMediaan
GetClaimsForUserV021.134 us0.0226 us0.0390 us1.136 us
GetClaimsForUserV001.194 us0.0237 us0.0633 us1.183 us
GetClaimsForUserV011.218 us0.0271 us0.0755 us1.199 us
GetClaimsForUserV039.231 us0.1828 us0.5096 us9.151 us

(V00 is de originele code. De oplopende versies zijn gerefactord: V01 op basis van het Single responsibility principe; V02 de op basis van het Dependency inversion-principe, en V03 op basis van het Open closed-principe.)

Zoals je ziet, ontlopen V01 en V02 van de ClaimsHelper het origineel nauwelijks. V02 lijkt zelfs nog iets sneller te zijn, maar dat verschil valt binnen de foutmarge en is dus verwaarloosbaar.

V03 is daarentegen bijna acht keer langzamer dan de rest. Oei!

Langzaamaan steeds iets beter

In wat volgt zal ik de stappen laten zien die ik heb ondernomen in een poging de performance van de ClaimsProviderFactory te verbeteren.

Maar eerst nog een kanttekening. Ondanks dat ik toch al een paar jaar programmeer, was refactoren op basis van performanceoverwegingen een relatief nieuwe ervaring voor me. Performancevraagstukken hebben in mijn dagelijks ontwikkelwerk eigenlijk nauwelijks een rol gespeeld.

Voor een deel is dat misschien geluk of luxe geweest. Maar het zegt denk ik ook iets over het belang van performance ten opzichte van lees- en onderhoudbaarheid. Deze eigenschappen van de code moeten op orde zijn vóórdat je kunt gaan optimaliseren voor performance.

Poging #1

Dat gezegd hebbende, mijn eerste ingeving was om de lijst ClaimProviders (of liever: de lijst van types die IProvideClaims implementeren) te vangen in een globale variabele. Deze lijst verschilt immers niet van aanroep tot aanroep en kan dus in het geheugen bewaard blijven.

Maar helemaal tevreden over deze aanpak was ik niet. _claimProviderTypes heeft hier de functie van een instance variable, terwijl de class static is en dus geen instanties heeft. Dit ontwerp wil twee dingen tegelijkertijd.

(De class is overigens static, omdat de winst van het bewaren van de variabelen verloren gaat als er elke keer een nieuwe instantie van moet worden gemaakt. Althans, ik denk dat dat de reden is waarom ik hier een static class van heb gemaakt. Om eerlijk te zijn maakte ik die keuze met mijn onderbuik. Als ik onzin blijk te praten, dan hoor ik het graag.)

Posing #2

Ik vroeg me af hoe ik de statische aard van de class kon rijmen met de noodzaak van een geïnstantieerde variabele. Het antwoord: het singleton-patroon.

Met een singleton zorg je ervoor dat je aanroepende code telkens dezelfde instantie van een object aanroept. Dit is een ideaal patroon voor wanneer je de performance impact van de instantiatie een object wil beperken.

Voor deze specifieke use case is dit patroon zelfs misschien wat teveel van het goede. De complexiteit van de code neemt ten opzichte van de performancewinst dusdanig toe (denk erom: we hebben het over nanoseconden), dat ik wel twee keer erover na zou denken om deze code in productie te nemen. Maar dit is een oefening in het opkrikken van performance, dus hier is het op zijn plek.

Een tussenstand

Laten we eens kijken hoe onze pogingen tot nu toe zich verhouden tot de oorspronkelijke metingen:

MethodGem.FoutmargeStandaarddeviatieMediaan
GetClaimsForUserV054.211 us0.1071 us0.3124 us4.128 us
GetClaimsForUserV044.575 us0.0913 us0.1624 us4.551 us
GetClaimsForUserV039.231 us0.1828 us0.5096 us9.151 us

(V04 is de versie met de static variabele; V05 de singleton-implementatie.)

De performance is in V04 al gehalveerd ten opzichte van V03! Dat is een enorme winst voor zo’n kleine wijziging! Vergeleken daarbij is de winst van V05 bescheiden. Een echte verrassing hoeft dat niet te zijn: V05 is een doorontwikkeling van V04, maar geen volledig nieuwe implementatie.

Desalniettemin, beide implementaties zijn nog steeds vier keer zo langzaam als de snelste versie. Kunnen we echt niet beter dan dit?

Is dat echt nodig?

Vrees niet: dat kunnen we! De truc zit ’m in het nagaan waar er reflection wordt gebruikt, en jezelf af te vragen of deze daar nodig is. Als je kijkt naar de refactorslag van V03 naar V04 en V05, dan hebben we niet meer gedaan dan het aantal aanroepen van GetAllImplementationsOfIProvideClaims() terugbrengen naar één.

GetClaimProviderForPermission() maakt echter ook gebruik van reflection, namelijk om de Permission-property uit te lezen. Deze method wordt nog steeds elke keer aangeroepen wanneer we een call naar GetClaimProvider() maken. Is dit echt nodig?

Poging #3

Nou, nee. We kunnen de ClaimProviders en de bijbehorende Permissions bij de eerste aanroep van de singleton opslaan in een dictionary. Deze kunnen we uitlezen wanneer we een ClaimsProvider nodig hebben, zonder gebruik te hoeven maken van reflection.

Qua performance is een dictionary een goede keuze. Deze heeft namelijk een waarde van O(1). Dat wil zeggen dat het aantal stappen dat nodig is om de juiste entry in een dictionary te vinden, niet verandert met het aantal entries. Of er nu 3 ClaimProviders zijn of 3000, het is niet van invloed op hoe snel we de juiste te pakken hebben, als we eenmaal weten wat de bijbehorende Permission is.

De uiteindelijke implementatie is als volgt:

De eindstand

Dit is het moment van de waarheid. Heeft het gebruik van een dictionary de performance van deze code voldoende opgekrikt om te kunnen wedijveren met de oorspronkelijke implementaties?

Dit is de eindstand:

MethodGem.FoutmargeStandaarddeviatieMediaan
GetClaimsForUserV021.134 us0.0226 us0.0390 us1.136 us
GetClaimsForUserV001.194 us0.0237 us0.0633 us1.183 us
GetClaimsForUserV011.218 us0.0271 us0.0755 us1.199 us
GetClaimsForUserV061.388 us0.0276 us0.0526 us1.380 us
GetClaimsForUserV054.211 us0.1071 us0.3124 us4.128 us
GetClaimsForUserV044.575 us0.0913 us0.1624 us4.551 us
GetClaimsForUserV039.231 us0.1828 us0.5096 us9.151 us

V06 is drie keer zo snel dan V05 en komt dicht in de buurt van V01! Het verschil tussen die twee is klein genoeg om in de praktijk onmerkbaar te zijn voor een eindgebruiker.

Het is ons gelukt: we hebben een stuk code gerefactord aan de hand van SOLID-principes, zonder een merkbare impact op performance te hebben.

What’s next?

De voordelen van code die SOLID-principes volgt, zijn legio. En zoals we in deze blog hebben gezien, hoeven die principes maar nauwelijks impact te hebben op performance. Betekent dit dat je morgen meteen moet beginnen met het refactoren van je oude code? Dat is een vraag die ik volgende week zal behandelen.

Wie de code vóór die tijd nog eens na wil lopen, kan terecht op GitHub.

Meer in deze reeks

  1. De casus
  2. Het Single-Responsibility principe
  3. Het Dependency inversion principe
  4. Het Open-closed principe
  5. SOLID en performance
  6. Conclusie
  7. Addendum: Slimmere Enums

  1. Met dank aan Nick Chapsas, die de werking van het project in deze video beter uitlegt dan ik in tekst zou kunnen doen. ↩︎

clean code · enums · performance · refactoren · solid · switch statements