Chuyển đến nội dung chính

Bộ nhớ đệm trong Python: Hai phương pháp đơn giản

Học cách dùng decorator như @functools.lru_cache hoặc @functools.cache để lưu đệm hàm trong Python.
· 12 phút đọc

Khám phá cùng AI

Trong bài viết này, chúng ta sẽ tìm hiểu về caching trong Python. Ta sẽ hiểu caching là gì và cách sử dụng hiệu quả.

Caching là một kỹ thuật dùng để cải thiện hiệu năng ứng dụng bằng cách tạm thời lưu trữ các kết quả mà chương trình đã tính được để tái sử dụng khi cần sau này.

Trong hướng dẫn này, chúng ta sẽ học các kỹ thuật khác nhau để caching trong Python, bao gồm các decorator @lru_cache và @cache trong mô-đun functools.

Nếu bạn đang vội, hãy bắt đầu với một cách cài đặt caching thật ngắn gọn rồi tiếp tục với các chi tiết sau.

Câu trả lời ngắn: Cài đặt caching trong Python

Để tạo cache trong Python, ta có thể dùng decorator @cache từ mô-đun functools. Trong đoạn mã dưới đây, lưu ý rằng hàm print() chỉ được thực thi một lần:

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

Caching trong Python là gì?

Giả sử chúng ta cần giải một bài toán và mất một giờ để tìm được đáp án đúng. Nếu hôm sau phải giải lại đúng bài toán đó, sẽ hữu ích nếu ta có thể tái sử dụng phần việc đã làm trước đó thay vì bắt đầu lại từ đầu.

Caching trong Python tuân theo nguyên tắc tương tự—nó lưu các giá trị khi chúng được tính trong lời gọi hàm để tái sử dụng khi cần. Kiểu caching này còn được gọi là memoization.

Hãy xem một ví dụ ngắn tính tổng của một dải số lớn hai lần:

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

# 5000000050000000
# 5000000050000000

Chương trình phải tính tổng mỗi lần. Ta có thể xác nhận điều này bằng cách đo thời gian cho hai lần gọi:

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,
    )
)

Kết quả cho thấy cả hai lần gọi mất thời gian xấp xỉ như nhau (tùy thiết lập của chúng ta mà thời gian có thể nhanh hoặc chậm hơn).

Tuy nhiên, ta có thể dùng cache để tránh phải tính lại cùng một giá trị nhiều lần. Ta có thể gán lại tên sum bằng cách dùng hàm cache() trong mô-đun chuẩn 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,
    )
)

Lần gọi thứ hai giờ chỉ mất vài micro giây thay vì hơn một giây vì kết quả tính tổng các số từ 0 đến 100.000.000 đã được tính và lưu trong cache—lần gọi thứ hai sử dụng giá trị đã được tính và lưu trước đó.

Ở trên, ta dùng decorator functools.cache() để thêm cache cho hàm dựng sẵn sum(). Nhân tiện, decorator trong Python là một hàm dùng để thay đổi hành vi của hàm khác mà không sửa mã của hàm đó một cách vĩnh viễn. Bạn có thể tìm hiểu thêm về decorator trong Hướng dẫn Python Decorators này.

Decorator functools.cache() được thêm vào Python từ phiên bản 3.9, nhưng ta có thể dùng functools.lru_cache() cho các phiên bản cũ hơn. Ở phần tiếp theo, chúng ta sẽ khám phá cả hai cách tạo cache, bao gồm cách dùng cú pháp decorator thường gặp như @cache.

Caching trong Python: Các phương pháp khác nhau

Mô-đun functools của Python có hai decorator để áp dụng caching cho hàm. Hãy cùng tìm hiểu functools.lru_cache() và functools.cache() qua một ví dụ.

Hãy viết một hàm sum_digits() nhận vào một dãy số và trả về tổng các chữ số của những số đó. Ví dụ, nếu ta dùng bộ giá trị (23, 43, 8) làm đầu vào, thì:

  • Tổng các chữ số của 23 là năm.
  • Tổng các chữ số của 43 là bảy.
  • Tổng các chữ số của 8 là tám.
  • Vì vậy, tổng chung là 20.

Đây là một cách để viết hàm sum_digits() của chúng ta:

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

Hãy dùng hàm này để khám phá các cách khác nhau tạo cache.

Caching thủ công trong Python

Trước tiên, hãy tạo cache thủ công. Dù ta cũng có thể tự động hóa điều này dễ dàng, việc tạo cache thủ công giúp ta hiểu quy trình.

Hãy tạo một từ điển và thêm các cặp khóa-giá trị mỗi lần ta gọi hàm với một giá trị mới để lưu kết quả. Nếu ta gọi hàm với một giá trị đã được lưu trong từ điển này, hàm sẽ trả về giá trị đã lưu mà không cần tính lại:

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
    )
)

Lần gọi thứ hai tới sum_digits(numbers) nhanh hơn nhiều so với lần đầu vì nó dùng giá trị đã được lưu đệm.

Giờ hãy giải thích chi tiết hơn đoạn mã trên. Trước hết, lưu ý rằng ta tạo từ điển sum_digits.my_cache sau khi định nghĩa hàm dù ta dùng nó trong phần định nghĩa hàm.

Hàm sum_digits() kiểm tra xem đối số truyền vào hàm đã là một trong các khóa trong từ điển sum_digits.my_cache hay chưa. Tổng các chữ số chỉ được tính nếu đối số chưa có trong cache.

Vì đối số ta dùng khi gọi hàm đóng vai trò là khóa trong từ điển, nên nó phải là kiểu dữ liệu băm được (hashable). Danh sách không phải là hashable, nên ta không thể dùng nó làm khóa trong từ điển. Ví dụ, hãy thử thay numbers bằng một danh sách thay vì một bộ giá trị—điều này sẽ phát sinh TypeError:

# ...

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

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

Tạo cache thủ công rất phù hợp cho mục đích học tập, nhưng giờ hãy khám phá các cách nhanh hơn để làm việc này.

Caching với functools.lru_cache() trong Python

Python có decorator lru_cache() từ phiên bản 3.2. Chữ “lru” ở đầu tên hàm viết tắt cho "least recently used" (ít được dùng gần đây nhất). Ta có thể hình dung cache như một chiếc hộp lưu trữ các thứ hay dùng—khi đầy, chiến lược LRU sẽ bỏ đi mục mà ta đã lâu không dùng nhất để nhường chỗ cho thứ mới.

Hãy trang trí hàm sum_digits() bằng @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
    )
)

Nhờ có caching, lần gọi thứ hai chạy nhanh hơn đáng kể.

Mặc định, cache lưu 128 giá trị đầu tiên được tính. Khi cả 128 vị trí đều đầy, thuật toán xóa giá trị ít được dùng gần đây nhất (LRU) để tạo chỗ cho các giá trị mới.

Ta có thể đặt kích thước tối đa khác cho cache khi trang trí hàm bằng tham số 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)
    )

# ...

Trong trường hợp này, cache chỉ lưu năm giá trị. Ta cũng có thể đặt đối số maxsize thành None nếu không muốn giới hạn kích thước cache.

Caching với functools.cache() trong Python

Python 3.9 bổ sung một decorator caching đơn giản và nhanh hơn—functools.cache(). Decorator này có hai đặc điểm chính:

  • Nó không có kích thước tối đa—tương tự như gọi functools.lru_cache(maxsize=None).
  • Nó lưu tất cả lời gọi hàm và kết quả của chúng (không dùng chiến lược LRU). Điều này phù hợp với các hàm có đầu ra tương đối nhỏ hoặc khi ta không cần lo về giới hạn kích thước cache.

Hãy dùng decorator @functools.cache cho hàm 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
    )
)

Trang trí sum_digits() bằng @functools.cache tương đương với việc gán sum_digits cho 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)

Lưu ý rằng ta cũng có thể dùng kiểu import khác:

from functools import cache

Cách này cho phép ta trang trí hàm chỉ với @cache.

Các chiến lược caching khác

Các công cụ sẵn có của Python triển khai chiến lược caching LRU, trong đó các mục ít được dùng gần đây nhất sẽ bị xóa để tạo chỗ cho giá trị mới.

Hãy xem qua một vài chiến lược caching khác:

  • Vào trước, ra trước (FIFO): Khi cache đầy, mục được thêm đầu tiên sẽ bị loại bỏ để nhường chỗ cho giá trị mới. Khác biệt giữa LRU và FIFO là LRU giữ lại các mục được dùng gần đây, còn FIFO loại bỏ mục cũ nhất bất kể tần suất sử dụng.
  • Vào sau, ra trước (LIFO): Mục vừa được thêm gần nhất sẽ bị loại bỏ khi cache đầy. Hãy tưởng tượng một chồng đĩa ở căng-tin. Chiếc đĩa được đặt lên gần nhất (vào sau) sẽ được lấy ra đầu tiên (ra trước).
  • Được dùng gần đây nhất (MRU): Giá trị vừa được dùng gần đây nhất sẽ bị loại bỏ khi cần thêm chỗ trong cache.
  • Thay thế ngẫu nhiên (RR): Chiến lược này loại bỏ ngẫu nhiên một mục để tạo chỗ cho mục mới.

Các chiến lược này cũng có thể kết hợp với các thước đo về thời hạn hiệu lực—điều này đề cập đến thời gian mà một dữ liệu trong cache được coi là hợp lệ hay còn phù hợp. Hãy hình dung một bài báo trong cache. Nó có thể được truy cập thường xuyên (LRU sẽ giữ lại), nhưng sau một tuần, tin tức có thể đã lỗi thời.

Caching trong Python: Các trường hợp sử dụng phổ biến

Từ đầu đến giờ, chúng ta dùng các ví dụ đơn giản cho mục đích học tập. Tuy vậy, caching có rất nhiều ứng dụng trong thực tế.

Trong khoa học dữ liệu, chúng ta thường thực hiện các phép toán lặp lại trên tập dữ liệu lớn. Việc dùng kết quả đã lưu đệm giúp giảm thời gian và chi phí khi phải lặp lại các phép tính giống nhau trên cùng tập dữ liệu.

Ta cũng có thể dùng caching để lưu các tài nguyên bên ngoài như trang web hoặc cơ sở dữ liệu. Hãy xét một ví dụ và lưu đệm một bài viết của DataCamp. Nhưng trước hết, ta cần cài đặt mô-đun bên thứ ba requests bằng cách chạy dòng lệnh sau trong terminal:

$ python -m pip install requests

Sau khi cài requests, ta có thể thử đoạn mã sau, đoạn này sẽ cố lấy cùng một bài viết DataCamp hai lần trong khi dùng 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"))

Lưu ý là chúng tôi đã lược bớt đầu ra vì nó rất dài. Tuy nhiên, hãy để ý rằng chỉ lần gọi đầu tiên tới get_article() mới in ra cụm từ Fetching article from {url}.

Đó là vì trang web chỉ được truy cập ở lần gọi đầu tiên. Kết quả được lưu trong cache của hàm. Khi ta yêu cầu cùng trang web lần thứ hai, dữ liệu lưu trong cache sẽ được trả về thay cho việc truy cập lại.

Caching giúp tránh các độ trễ không cần thiết khi lấy lại cùng một dữ liệu nhiều lần. Các API bên ngoài thường cũng có giới hạn tần suất gọi và chi phí khi truy xuất dữ liệu. Caching giúp giảm chi phí API và giảm khả năng chạm ngưỡng giới hạn tần suất.

Một trường hợp phổ biến khác là trong các ứng dụng machine learning nơi nhiều phép tính tốn kém cần được lặp lại. Ví dụ, nếu ta cần token hóa và vector hóa văn bản trước khi dùng trong mô hình học máy, ta có thể lưu đầu ra vào cache. Bằng cách này, ta sẽ không cần lặp lại các thao tác tốn tài nguyên tính toán.

Những thách thức thường gặp khi caching trong Python

Chúng ta đã tìm hiểu về các lợi ích của caching trong Python. Cũng có một số thách thức và hạn chế cần lưu ý khi triển khai cache:

  • Vô hiệu hóa cache và tính nhất quán: Dữ liệu có thể thay đổi theo thời gian. Do đó, các giá trị lưu trong cache cũng có thể cần được cập nhật hoặc loại bỏ.
  • Quản lý bộ nhớ: Lưu trữ lượng dữ liệu lớn trong cache cần bộ nhớ, và có thể gây ra vấn đề hiệu năng nếu cache tăng trưởng không kiểm soát.
  • Độ phức tạp: Việc thêm cache làm tăng độ phức tạp cho hệ thống trong quá trình tạo và bảo trì cache. Thường thì lợi ích vượt trội so với chi phí này, nhưng độ phức tạp tăng lên có thể dẫn đến các lỗi khó phát hiện và sửa chữa.

Kết luận

Chúng ta có thể dùng caching để tối ưu hiệu năng khi các phép toán tốn tài nguyên tính toán được lặp lại trên cùng dữ liệu.

Python có hai decorator để tạo cache khi gọi hàm: @lru_cache và @cache trong mô-đun functools.

Tuy nhiên, ta cần đảm bảo giữ cho cache được cập nhật và quản lý bộ nhớ đúng cách.

Nếu bạn muốn học thêm về caching và Python, hãy xem lộ trình kỹ năng gồm sáu khóa học Python Programming này.

Stephen Gruppetta

Tác giả

Stephen Gruppetta

Tôi học Vật lý và Toán ở bậc đại học tại Đại học Malta. Sau đó, tôi chuyển đến London và lấy bằng Tiến sĩ Vật lý tại Imperial College. Tôi nghiên cứu các kỹ thuật quang học mới để chụp ảnh võng mạc người. Hiện nay, tôi tập trung vào việc viết về Python, truyền đạt về Python và giảng dạy Python.

Chủ đề

Liên quan