Kể từ phiên bản Python 3, kiểu chuỗi str
trong Python sử dụng bảng mã Unicode. Các chuỗi Unicode có thể chiếm tới 4 byte cho mỗi ký tự tùy thuộc bộ mã hóa (encoding). Điều này dẫn tới vấn đề tốn kém bộ nhớ hơn rất nhiều. Trong bài viết này, Lập Trình Không Khó sẽ cùng các bạn đi làm rõ cách Python tối ưu bộ nhớ với kiểu chuỗi (string).
Để tối ưu việc sử dụng bộ nhớ và cải thiện hiệu suất, Python sử dụng 3 cách biểu diễn thành phần cho các chuỗi Unicode:
- 1 byte cho các ký tự (Latin-1 encoding)
- 2 byte cho các ký tự (UCS-2 encoding)
- 4 byte cho các ký tự (UCS-4 encoding)
Khi chúng ta làm việc với Python, tất cả các chuỗi vẫn có cách hoạt động giống nhau nên chúng ta không hề thấy được sự khác biệt nào. Tuy nhiên, sự khác biệt có thể được nhận biết rõ ràng hơn khi chúng ta làm việc với một lượng lớn dữ liệu văn bản.
[sc_quote]Tham khảo: Tôi đã xây dựng hệ thống chặn bình luận rác như nào?[/sc_quote]
Cách python tối ưu bộ nhớ
Để thấy được sự khác biệt trong các biểu diễn thành phần của str
, chúng ta có thể sử dụng hàm sys.getsizeof
, hàm này sẽ trả về kích thước của một object ở đơn vị byte:
>>> import sys >>> txt = "hello" >>> sys.getsizeof(txt) 54 >>> # 1-byte encoding >>> sys.getsizeof(txt + '!') - sys.getsizeof(txt) 1 >>> # 2-byte encoding >>> txt = "lập" >>> sys.getsizeof(txt + 't') - sys.getsizeof(txt) 2 >>> # 4-byte encoding >>> txt = "?" >>> sys.getsizeof(txt + '?') - sys.getsizeof(txt) 4 >>> sys.getsizeof(txt) 80 >>> sys.getsizeof("") 49
Như bạn có thể thấy, phụ thuộc vào nội dung của các biến str
, Python sẽ sử dụng các encoding khác nhau. Lưu ý rằng các chuỗi trong Python thường mất thêm 49 – 80 byte bộ nhớ để lưu các thông tin bổ sung. Đó có thể là độ dài, kích thước, loại encoding. Đó là lý do tại sao một chuỗi rỗng đã có kích thước 49 byte rồi.
Chúng ta cũng có thể kiểm tra encoding trực tiếp của một object sử dụng ctypes
:
import ctypes class PyUnicodeObject(ctypes.Structure): # internal fields of the string object _fields_ = [("ob_refcnt", ctypes.c_long), ("ob_type", ctypes.c_void_p), ("length", ctypes.c_ssize_t), ("hash", ctypes.c_ssize_t), ("interned", ctypes.c_uint, 2), ("kind", ctypes.c_uint, 3), ("compact", ctypes.c_uint, 1), ("ascii", ctypes.c_uint, 1), ("ready", ctypes.c_uint, 1), # ... # ... ] def get_string_kind(string): return PyUnicodeObject.from_address(id(string)).kind
Và thử với một vài chuỗi xem sao nhé:
>>> get_string_kind('hoa') 1 >>> get_string_kind('hòa') 1 >>> get_string_kind('lập') 2 >>> get_string_kind('toán') 1 >>> get_string_kind('bông') 1 >>> get_string_kind('bống') 2 >>> get_string_kind('?') 4
Giải thích: Nếu tất cả các ký tự trong chuỗi đều nằm trong phạm vi bảng mã ASCII, khi đó chuỗi được mã hóa sử dụng 1 byte cho mỗi ký tự (Latin-1 encoding). Về căn bản, Latin-1 biểu diễn 256 ký tự Unicding đầu tiên. Mã hóa này có thể biểu diễn được nhiều ngôn ngữ Latin như English, Swedish, Italian, Norwegian,… Tuy nhiên, mã hóa này không thể làm việc được với các ngôn ngữ có các ký tự non-Latin, như tiếng Trung, tiếng Nhật, tiếng Việt. Lý do là bởi các bảng chữ cái của các ngôn ngữ này có biểu diễn vượt ra khỏi phạm vi của 1 byte (0 – 255).
>>> ord('a') 97 >>> ord('á') 225 >>> ord('â') 226 >>> ord('ấ') 7845 >>> ord('你') 20320
Hầu hết các thứ tiếng (ngôn ngữ) có thể biểu diễn được trong phạm vi của 2 byte (UCS-2). Mã hóa 4-byte (UCS-4) được sử dụng để biểu diễn các chuỗi có chứa các ký hiệu đặc biệt, biểu tượng cảm xúc hoặc các ngôn ngữ hiếm. Có gần 300 block (range) trong tiêu chuẩn Unicode. Bạn có thể tìm thấy các block 4-byte sau block 0xFFFF.
Vấn đề: Hãy thử tưởng tượng bạn có một chuỗi 10GB dữ liệu văn bản tiếng anh (ASCII text) và chúng ta muốn đọc nó lên bộ nhớ RAM. Nếu bạn chèn thêm vào dữ liệu này chỉ duy nhất một biểu tượng cảm xúc thì kích thước của chuỗi sẽ tăng lên 4 lần. Đây là một sự khác biệt rất lớn mà bạn có thể gặp phải trong thực tế khi làm việc với các bài toán NLP.
[sc_quote]Tham khảo: Xử lý tiếng Việt trong Python[/sc_quote]
Tại sao không dùng mã hóa UTF-8?
Bảng mã Unicode nổi tiếng và phổ biến là UTF-8, nhưng Python không sử dụng nó để biểu diễn các ký tự trong chuỗi.
Khi một chuỗi được lưu trữ ở bảng mã UTF-8, mỗi ký tự sẽ được mã hóa tốn 1 – 4 byte tùy thuộc vào ký tự cần biểu diễn. Đó là một cách lưu trữ thật sự rất hiệu quả, nhưng nó có một nhược điểm khá nghiêm trọng.
Nếu sử dụng UTF-8, mỗi ký tự có thể có kích thước (byte) khác nhau, ta không thể truy cập đến một ký tự bất kỳ bằng chỉ số(index) mà không quét qua chuỗi. Khi đó, để thực hiện một thao tác truy cập chẳng hạn như string[5]
thì Python cần quét chuỗi cho tới khi tìm tới được ký tự được yêu cầu.
Cách mã hóa theo độ dài cố định sẽ không gặp phải vấn đề như vậy, để truy cập tới một phần tử ở chỉ số x
bất kỳ, ta chỉ cần nhân x
với kích thước (byte) của một ký tự (1, 2 hoặc 4 byte).
[sc_quote]Tham khảo: Quy trình tiền xử lý dữ liệu tiếng Việt (NLP)[/sc_quote]
String interning
Khi làm việc với chuỗi rỗng hoặc làm việc với 1 ký tự trong chuỗi biểu diễn trong phạm vi ASCII, Python sử dụng string interning. Cụ thể, nếu bạn có 2 chuỗi được interned, khi đó chỉ có một phiên bản của chúng trong bộ nhớ.
Chúng ta sử dụng hàm id
trong Python để lấy địa chỉ của object:
>>> id('') 139916320514800 >>> id('') 139916320514800 >>> a = 'áb' >>> b = 'bá' >>> id(a[0]) 139916309418432 >>> id(b[1]) 139916309418432 >>> c= 5 >>> d = 5 >>> id(c) == id(d) True
Như bạn có thể thấy, chúng đều có cùng địa chỉ trong bộ nhớ mặc dù nằm ở các biến khác nhau. Điều này có thể là bởi vì chuỗi trong Python là bất biến(immutable).
Trong Python, string interning không quan tâm tới các ký tự hay chuỗi rỗng. Các chuỗi được tạo trong quá trình biên dịch cũng có thể được interned nếu độ dài của chúng không vượt quá 20 ký tự.
Điều này bao gồm:
- Tên hàm(fucntion) và tên lớp(class)
- Tên biến(variable)
- Tên tham số(argument)
- Hằng (tất cả các chuỗi được định nghĩa trong code)
- key của cấu trúc dữ liệu từ điển
- tên các thuộc tính
Khi chúng ta nhấn Enter trong Python REPL, dòng code của bạn sẽ được biên dịch sang bytecode. Đó là lý do tại sao các chuỗi ngắn trong REPL được interned.
>>> b = 'teststring' >>> id(a), id(b), a is b (4569487216, 4569487216, True) >>> a = 'test'*5 >>> b = 'test'*5 >>> len(a), id(a), id(b), a is b (20, 4569499232, 4569499232, True) >>> a = 'test'*6 >>> b = 'test'*6 >>> len(a), id(a), id(b), a is b (24, 4569479328, 4569479168, False)
Nhưng ví dụ dưới đây sẽ không được interned, bởi vì các chuỗi này không phải hằng:
>>> open('test.txt','w').write('hello') 5 >>> open('test.txt','r').read() 'hello' >>> a = open('test.txt','r').read() >>> b = open('test.txt','r').read() >>> id(a), id(b), a is b (4384934576, 4384934688, False) >>> len(a), id(a), id(b), a is b (5, 4384934576, 4384934688, False)
Kỹ thuật string interning giúp tiết kiệm hàng chục nghìn phân bổ chuỗi trùng lặp. Xem xét bên trong, string interning được duy trì bởi một cấu trúc dữ liệu từ điển (dictionary) toàn cục có key là các chuỗi. Để kiểm tra một chuỗi có trong bộ nhớ chưa (có giống chuỗi nào đã có không) thì Python sử dụng hàm có sẵn của cấu trúc dữ liệu từ điển.
Unicode object có gần 16000 dòng code C, vì vậy có rất nhiều các tối ưu hóa nhỏ không được đề cập trong bài viết này. Nếu bạn muốn tìm hiểu sâu hơn về Unicode trong Python, tôi khuyên bạn nên đọc PEPs về chuỗi và ngắm các dòng code trong unicode object.
[sc_quote]Bài viết gốc: https://rushter.com/blog/python-strings-and-memory/[/sc_quote]
Để lại một bình luận