Ga naar hoofdinhoud

Python-cache: twee eenvoudige methoden

Leer decorators zoals @functools.lru_cache of @functools.cache gebruiken om functies in Python te cachen.
· 12 min lezen

Verken met AI

In dit artikel leren we over caching in Python. We kijken wat het is en hoe je het effectief gebruikt.

Caching is een techniek om de prestaties van applicaties te verbeteren door resultaten die het programma heeft berekend tijdelijk op te slaan, zodat je ze later kunt hergebruiken.

In deze tutorial behandelen we verschillende technieken voor caching in Python, waaronder de decorators @lru_cache en @cache in de module functools.

Voor wie haast heeft: we beginnen met een heel korte caching-implementatie en gaan daarna verder met meer details.

Kort antwoord: implementatie van caching in Python

Om een cache in Python te maken, kunnen we de decorator @cache uit de module functools gebruiken. In onderstaande code zie je dat de functie print() maar één keer wordt uitgevoerd:

import functools

@functools.cache
def square(n):
    print(f"Calculating square of {n}")
    return n * n

# Testing the cached function
print(square(4))
print(square(4))

# Calculating square of 4
# 16
# 16

Wat is caching in Python?

Stel dat je een wiskundig probleem moet oplossen en er een uur over doet om het juiste antwoord te vinden. Als je de volgende dag hetzelfde probleem opnieuw moet oplossen, is het handig om je eerdere werk te hergebruiken in plaats van helemaal opnieuw te beginnen.

Caching in Python volgt een vergelijkbaar principe: waardes die binnen functyaanroepen worden berekend, worden opgeslagen zodat ze opnieuw gebruikt kunnen worden wanneer dat nodig is. Dit soort caching wordt ook wel memoization genoemd.

Laten we een kort voorbeeld bekijken dat de som van een groot bereik aan getallen twee keer uitrekent:

output = sum(range(100_000_001))
print(output)
output = sum(range(100_000_001))
print(output)

# 5000000050000000
# 5000000050000000

Het programma moet de som elke keer opnieuw uitrekenen. We kunnen dit bevestigen door de twee aanroepen te timen:

import timeit

print(
    timeit.timeit(
        "sum(range(100_000_001))",
        globals=globals(),
        number=1,
    )
)

print(
    timeit.timeit(
        "sum(range(100_000_001))",
        globals=globals(),
        number=1,
    )
)

De uitvoer laat zien dat beide aanroepen ongeveer evenveel tijd kosten (afhankelijk van je setup kunnen de tijden sneller of langzamer zijn).

We kunnen echter een cache gebruiken om te voorkomen dat we dezelfde waarde vaker dan één keer uitrekenen. We kunnen de naam sum herdefiniëren met behulp van de functie cache() in de ingebouwde module functools:

import functools
import timeit

sum = functools.cache(sum)

print(
    timeit.timeit(
        "sum(range(100_000_001))",
        globals=globals(),
        number=1,
    )
)

print(
    timeit.timeit(
        "sum(range(100_000_001))",
        globals=globals(),
        number=1,
    )
)

De tweede aanroep duurt nu slechts enkele microseconden in plaats van meer dan een seconde, omdat het resultaat van de som van de getallen van 0 tot en met 100.000.000 al is berekend en gecached—de tweede aanroep gebruikt de eerder berekende en opgeslagen waarde.

Hierboven gebruiken we de decorator functools.cache() om een cache toe te voegen aan de ingebouwde functie sum(). Terzijde: een decorator in Python is een functie die het gedrag van een andere functie aanpast zonder de code permanent te veranderen. Meer over decorators leer je in deze Python Decorators Tutorial.

De decorator functools.cache() is toegevoegd in Python 3.9, maar voor oudere versies kunnen we functools.lru_cache() gebruiken. In de volgende sectie bekijken we beide manieren om een cache te maken, inclusief het gebruik van de vaker gebruikte decoratienotatie zoals @cache.

Caching in Python: verschillende methoden

De module functools in Python heeft twee decorators om caching op functies toe te passen. Laten we functools.lru_cache() en functools.cache() verkennen met een voorbeeld.

Laten we een functie sum_digits() schrijven die een reeks getallen binnenkrijgt en de som van de cijfers van die getallen retourneert. Als we bijvoorbeeld de tuple (23, 43, 8) als invoer gebruiken, dan geldt:

  • De som van de cijfers van 23 is vijf.
  • De som van de cijfers van 43 is zeven.
  • De som van de cijfers van 8 is acht.
  • De totale som is dus 20.

Dit is één manier waarop we onze functie sum_digits() kunnen schrijven:

def sum_digits(numbers):
    return sum(
        int(digit) for number in numbers for digit in str(number)
    )

numbers = 23, 43, 8

print(sum_digits(numbers))

# 20

Laten we deze functie gebruiken om verschillende manieren om een cache te maken te verkennen.

Handmatig cachen in Python

Laten we eerst de cache handmatig maken. Hoewel we dit ook makkelijk kunnen automatiseren, helpt handmatig cachen om het proces te begrijpen.

We maken een dictionary en voegen sleutel-waardeparen toe telkens wanneer we de functie met een nieuwe waarde aanroepen, zodat we de resultaten opslaan. Als we de functie aanroepen met een waarde die al in deze dictionary staat, retourneert de functie de opgeslagen waarde zonder het opnieuw uit te rekenen:

import random
import timeit

def sum_digits(numbers):
    if numbers not in sum_digits.my_cache:
        sum_digits.my_cache[numbers] = sum(
            int(digit) for number in numbers for digit in str(number)
        )
    return sum_digits.my_cache[numbers]
sum_digits.my_cache = {}

numbers = tuple(random.randint(1, 1000) for _ in range(1_000_000))

print(
    timeit.timeit(
        "sum_digits(numbers)",
        globals=globals(),
        number=1
    )
)

print(
    timeit.timeit(
        "sum_digits(numbers)",
        globals=globals(),
        number=1
    )
)

De tweede aanroep van sum_digits(numbers) is veel sneller dan de eerste, omdat deze de gecachete waarde gebruikt.

Laten we de bovenstaande code nu in meer detail uitleggen. Merk eerst op dat we de dictionary sum_digits.my_cache maken nadat we de functie hebben gedefinieerd, ook al gebruiken we hem in de functiedefinitie.

De functie sum_digits() controleert of het argument dat aan de functie is doorgegeven al één van de sleutels is in de dictionary sum_digits.my_cache. De som van alle cijfers wordt alleen berekend als het argument nog niet in de cache staat.

Omdat het argument dat we bij het aanroepen van de functie gebruiken, als sleutel in de dictionary dient, moet het een hashbaar datatype zijn. Een list is niet hashbaar, dus die kunnen we niet als sleutel in een dictionary gebruiken. Laten we bijvoorbeeld numbers vervangen door een list in plaats van een tuple—dit zal een TypeError opleveren:

# ...

numbers = [random.randint(1, 1000) for _ in range(1_000_000)]

# ...
Traceback (most recent call last):
...
TypeError: unhashable type: 'list'

Handmatig een cache maken is prima voor leerdoeleinden, maar laten we nu snellere manieren bekijken.

Cachen in Python met functools.lru_cache()

Python heeft sinds versie 3.2 de decorator lru_cache(). De “lru” aan het begin van de functienaam staat voor "least recently used" (minst recent gebruikt). Je kunt een cache zien als een doos om vaak gebruikte dingen in op te slaan—als die vol raakt, gooit de LRU-strategie het item weg dat we het langst niet hebben gebruikt om ruimte te maken voor iets nieuws.

Laten we onze functie sum_digits() decoreren met @functools.lru_cache:

import functools
import random
import timeit

@functools.lru_cache
def sum_digits(numbers):
    return sum(
        int(digit) for number in numbers for digit in str(number)
    )

numbers = tuple(random.randint(1, 1000) for _ in range(1_000_000))

print(
    timeit.timeit(
        "sum_digits(numbers)",
        globals=globals(),
        number=1
    )
)

print(
    timeit.timeit(
        "sum_digits(numbers)",
        globals=globals(),
        number=1
    )
)

Dankzij caching kost de tweede aanroep aanzienlijk minder tijd.

Standaard slaat de cache de eerste 128 berekende waardes op. Zodra alle 128 plekken vol zijn, verwijdert het algoritme de minst recent gebruikte (LRU) waarde om ruimte te maken voor nieuwe waardes.

We kunnen een andere maximale cachegrootte instellen wanneer we de functie decoreren met de parameter maxsize:

import functools
import random
import timeit

@functools.lru_cache(maxsize=5)
def sum_digits(numbers):
    return sum(
        int(digit) for number in numbers for digit in str(number)
    )

# ...

In dit geval slaat de cache maar vijf waardes op. We kunnen het argument maxsize ook op None zetten als we de cachegrootte niet willen beperken.

Cachen in Python met functools.cache()

Python 3.9 bevat een eenvoudigere en snellere caching-decorator—functools.cache(). Deze decorator heeft twee belangrijke kenmerken:

  • Hij heeft geen maximale grootte—het is vergelijkbaar met functools.lru_cache(maxsize=None).
  • Hij slaat alle functieroepen en hun resultaten op (hij gebruikt niet de LRU-strategie). Dit is geschikt voor functies met relatief kleine outputs of wanneer we ons geen zorgen hoeven te maken over beperkingen in de cachegrootte.

Laten we de decorator @functools.cache gebruiken op de functie sum_digits():

import functools
import random
import timeit

@functools.cache
def sum_digits(numbers):
    return sum(
        int(digit) for number in numbers for digit in str(number)
    )

numbers = tuple(random.randint(1, 1000) for _ in range(1_000_000))

print(
    timeit.timeit(
        "sum_digits(numbers)",
        globals=globals(),
        number=1
    )
)

print(
    timeit.timeit(
        "sum_digits(numbers)",
        globals=globals(),
        number=1
    )
)

Het decoreren van sum_digits() met @functools.cache is equivalent aan het toekennen van sum_digits aan functools.cache():

# ...

def sum_digits(numbers):
    return sum(
        int(digit) for number in numbers for digit in str(number)
    )

sum_digits = functools.cache(sum_digits)

Merk op dat we ook een andere importstijl kunnen gebruiken:

from functools import cache

Zo kunnen we onze functies decoreren met alleen @cache.

Andere cachingstrategieën

De eigen tools van Python implementeren de LRU-cachingstrategie, waarbij de minst recent gebruikte items worden verwijderd om ruimte te maken voor nieuwe waardes.

Laten we nog een paar andere cachingstrategieën bekijken:

  • First-in, first-out (FIFO): Wanneer de cache vol is, wordt het eerste toegevoegde item verwijderd om ruimte te maken voor nieuwe waardes. Het verschil tussen LRU en FIFO is dat LRU recent gebruikte items in de cache houdt, terwijl FIFO het oudste item weggooit, ongeacht gebruik.
  • Last-in, first-out (LIFO): Het meest recent toegevoegde item wordt verwijderd wanneer de cache vol is. Denk aan een stapel borden in een kantine. Het bord dat we het laatst op de stapel hebben gelegd (last in), pakken we er als eerste af (first out).
  • Most-recently used (MRU): De waarde die het meest recent is gebruikt, wordt weggegooid wanneer er ruimte nodig is in de cache.
  • Random replacement (RR): Deze strategie verwijdert willekeurig een item om ruimte te maken voor een nieuw item.

Deze strategieën kunnen ook worden gecombineerd met maatstaven voor de geldige levensduur—dit verwijst naar hoe lang data in de cache als geldig of relevant wordt beschouwd. Stel je een nieuwsartikel in een cache voor. Het kan vaak worden geraadpleegd (LRU zou het bewaren), maar na een week is het nieuws misschien verouderd.

Caching in Python: veelvoorkomende use-cases

Tot nu toe hebben we eenvoudige voorbeelden gebruikt om te leren. Caching kent echter veel toepassingen in de praktijk.

In data science voeren we vaak herhaalde bewerkingen op grote datasets uit. Door gecachete resultaten te gebruiken, verlagen we de tijd en kosten die gepaard gaan met het steeds opnieuw uitvoeren van dezelfde berekeningen op dezelfde datasets.

We kunnen caching ook gebruiken voor het opslaan van externe bronnen zoals webpagina's of databases. Laten we een voorbeeld bekijken en een DataCamp-artikel cachen. Eerst moeten we echter de externe module requests installeren door de volgende regel in de terminal uit te voeren:

$ python -m pip install requests

Zodra requests is geïnstalleerd, kunnen we de volgende code proberen, die twee keer hetzelfde DataCamp-artikel probeert op te halen met de decorator @lru_cache:

import requests
from functools import lru_cache

@lru_cache(maxsize=10)
def get_article(url):
    print(f"Fetching article from {url}")
    response = requests.get(url)
    return response.text

print(get_article("https://www.datacamp.com/tutorial/decorators-python"))
print(get_article("https://www.datacamp.com/tutorial/decorators-python"))

Terzijde: we hebben de output ingekort omdat die erg lang is. Let er echter op dat alleen de eerste aanroep van get_article() de zin Fetching article from {url} print.

Dit komt omdat de webpagina alleen bij de eerste aanroep wordt opgehaald. Het resultaat wordt opgeslagen in de cache van de functie. Wanneer we dezelfde webpagina de tweede keer opvragen, wordt de data uit de cache geretourneerd.

Caching zorgt ervoor dat er geen onnodige vertragingen zijn wanneer je dezelfde data herhaaldelijk ophaalt. Externe API's hebben vaak ook snelheidslimieten en kosten die samenhangen met het ophalen van data. Caching verlaagt API-kosten en verkleint de kans dat je snelheidslimieten raakt.

Een andere veelvoorkomende use-case is in machinelearning-toepassingen waar meerdere dure berekeningen herhaald moeten worden. Als we bijvoorbeeld tekst moeten tokenizen en vectoriseren voordat we die in een machinelearningmodel gebruiken, kunnen we de output in een cache opslaan. Zo hoeven we de rekenintensieve bewerkingen niet te herhalen.

Veelvoorkomende uitdagingen bij caching in Python

We hebben geleerd over de voordelen van caching in Python. Er zijn ook enkele uitdagingen en nadelen om rekening mee te houden bij het implementeren van een cache:

  • Cache-invalidatie en consistentie: Data kan in de tijd veranderen. Daarom moeten waardes in een cache soms worden bijgewerkt of verwijderd.
  • Geheugenbeheer: Het opslaan van grote hoeveelheden data in een cache vereist geheugen, en dit kan prestatieproblemen veroorzaken als een cache onbeperkt groeit.
  • Complexiteit: Caches toevoegen introduceert complexiteit in het systeem bij het maken en onderhouden van de cache. Vaak wegen de voordelen hiertegen op, maar deze extra complexiteit kan leiden tot bugs die lastig te vinden en te verhelpen zijn.

Conclusie

We kunnen caching gebruiken om de prestaties te optimaliseren wanneer rekenintensieve bewerkingen op dezelfde data worden herhaald.

Python heeft twee decorators om een cache te creëren bij het aanroepen van functies: @lru_cache en @cache in de module functools.

We moeten er wel voor zorgen dat we de cache up-to-date houden en het geheugen goed beheren.

Wil je meer leren over caching en Python? Bekijk dan deze skill track met zes cursussen: Python Programming.

Stephen Gruppetta

Auteur

Stephen Gruppetta

Ik studeerde natuurkunde en wiskunde op bachelorniveau aan de Universiteit van Malta. Daarna verhuisde ik naar Londen en behaalde ik mijn PhD in natuurkunde aan Imperial College. Ik werkte aan nieuwe optische technieken om het menselijk netvlies in beeld te brengen. Nu richt ik me op schrijven over Python, communiceren over Python en lesgeven in Python.

Onderwerpen

Gerelateerd