Python và performance (Phần 1)

Class, Instace và Member Methods (18/02/2024, 11:36)

Tổng hợp một số ý tưởng về optimize performance trong Python, được tìm hiểu và lấy từ nhiều nguồn khác nhau.

Python và High Performance thường có "myth" là không đi đôi với nhau, đương nhiên điều này còn phụ thuộc vào việc lựa chọn distribution, vì mỗi phiên bản sẽ có những approach khác nhau để giúp Python chạy nhanh và tốt hơn (Ví dụ: PyPy với JIT compiler). Do đó, trong phạm vi bài viết này, mình sẽ chỉ đề cập đến distribution chính thức đó là CPython với version mới nhất tính đến thời điểm hiện tại (18/02/2024) là 3.12.

Function reference

Vấn đề

Hãy xem đoạn code dưới đây:

a = []
print(id(a.append))
print(id(a.append))

Kết quả chúng ta nhận về sẽ là gì? Ở đây mình đã dùng hàm built-in id của Python để lấy thông tin "unique identifier" của append của class list. Nếu chúng ta chạy đoạn code trên, chúng ta sẽ thấy với mỗi lệnh print nó sẽ trả về id khác nhau của hàm append. Chúng ta có thể chạy bao nhiêu lần tuỳ thích, và mỗi lần dòng print sẽ in ra kết quả khác nhau.

Không chỉ trong Python, nếu bạn sử dụng Java hay C# thì điều tương tự cũng sẽ xảy ra.

Giải thích

Mình sẽ tập trung vào ngôn ngữ Python, các ngôn ngữ khác các bạn có thể tìm hiểu thêm trên các diễn đàn, mặc dù mỗi ngôn ngữ có thể có những cách tối ưu khác nhau. Tuy nhiên, về mặt ý tưởng sẽ có nhiều nét tương đồng.

Hãy bắt đầu bằng một ví dụ:

class Test:
    def rand_method(self):
        return "Hello world"

a = Test()
# This is what we usually do
print(a.rand_method()) # print "Hello world"
# Yes, this is not illegal 
print(Test.rand_method(a)) # Also print "Hello world"
# Can we just use it like this? 
print(Test.rand_method()) # Nope, this will be complained: TypeError: Test.rand_method() missing 1 required positional argument: 'self'

Tại sao chúng ta có thể gọi trực tiếp member function (non-static) của class Test trực tiếp thông qua class Test?

Theo document của Python 3:

When an instance method object is created by retrieving a user-defined function object from a class via one of its instances, its __self__ attribute is the instance, and the method object is said to be bound. The new method’s __func__ attribute is the original function object.

When an instance method object is called, the underlying function (__func__) is called, inserting the class instance (__self__) in front of the argument list. For instance, when C is a class which contains a definition for a function f(), and x is an instance of C, calling x.f(1) is equivalent to calling C.f(x, 1).

Và document cũng lý giải tại sao trong ví dụ ban đầu mỗi lần lấy id của method append nó lại trả về kết quả khác nhau.

Note that the transformation from function object to instance method object happens each time the attribute is retrieved from the instance. In some cases, a fruitful optimization is to assign the attribute to a local variable and call that local variable.

Về mặt design, các member functions của 1 class sẽ thuộc về chính class đó, và mỗi khi chúng ta gọi một member function thông qua interpreter sẽ:

  • Định danh class mà instance đó thuộc về
  • Tìm kiếm tên hàm đó ở trong class, resolve thành một variable mới trong call stack.
  • Gọi hàm đó và truyền (pass by reference) đối số self - instance, và những arguments khác.
a = Test()
a.rand_method()
# interpreter will translate to
A.rand_method(a)

Reference thêm một câu trả lời trên Stack Overflow liên quan đến C# cho bạn nào quan tâm: https://stackoverflow.com/a/1298133

For loop và List comprehensions:

List Comprehensions nhanh hơn For Loop?

Khi mình mới sử dụng Python để lập trình các ứng dụng, mình thấy khá nhiều anh chị nói mình nên dùng list comprehensions thay vì dùng for loop nếu có thể. Lúc đó mình chỉ đơn thuần nghĩ đó là một cách viết khác, ngắn gọn và dễ đọc hơn của for loop, và sẽ không có ảnh hưởng gì nếu chúng ta tiếp tục dùng For Loop bình thường. Tuy nhiên, nếu tìm hiểu kỹ, ta sẽ thấy có một số "cost" khi dùng For Loop, hãy xem ví dụ sau:

def test1():
    a = [i for i in range(10000)]
    return a


def test2():
    a = []
    for i in range(10000):
        a.append(i)

    return a


if __name__ == '__main__':
    # Tested on Macbook Pro M1, 16GB RAM
    import timeit
    print(timeit.timeit("test1()", setup="from __main__ import test1")) # 147.92s
    print(timeit.timeit("test2()", setup="from __main__ import test2")) # 185.81s

Với ví dụ trên, chúng ta chỉ đang đơn thuần để tạo list. Một số điểm chúng ta có thể để ý như sau:

  1. Do member method của instance được built với mỗi lần gọi, do đó trong test2 với mỗi vòng loop, chúng ta đang phải load lại hàm append 1 lần (Bạn có thể xem ByteCode với module dis để thấy điều này rõ hơn với LOAD_ATTR được gọi. Trong khi với test1, LIST_APPEND sẽ được gọi trực tiếp).
  2. Khi kết thúc vòng lặp cuối cùng, test2 vẫn sẽ truy cập được biến i trong khi test1 sẽ raise exception nếu chúng ta cố gắng truy cập i.

Tuy nhiên, theo những gì mình thử kiểm tra, nếu loại bỏ đi ý một bằng các tạo 1 local variable để chứa reference đến append = a.append thì không hẳn nó sẽ giúp for loop chạy nhanh hơn.

Một số kết luận

Trong Python chúng ta có khá nhiều công cụ được tạo sẵn để làm việc với các kiểu dữ liệu khác nhau. Nếu bạn đang làm việc cần phải lặp qua một iterator thì bạn có thể tham khảo một số cách ở đây để chọn lựa hàm phù hợp với mình: Looping Techniques - Python 3 Documentation.

Theo quan điểm của mình, list comprehensions là một cách rất hay để khởi tạo list (hay iterator nói chung), tuy nhiên nó không phải là kim chỉ nam cho tất cả:

  • Khi có nhiều logic phải thực hiện khi khởi tạo list, for loop nên được sử dụng để giúp cho code dễ đọc và maintain (mình nghĩ chỉ nên có tối đa 2 if else trong 1 lần sử dụng list comprehensions, nếu không nó sẽ dài và tương đối khó hiểu).
  • Nếu bạn không có ý định khởi tạo các iterators mà chỉ đơn thuần muốn loop qua để cập nhật dữ liệu, list comprehensions sẽ không phải là lựa chọn lý tưởng của bạn.
  • Function reference khi được assign vào biến local, sẽ giúp loại bỏ đi LOAD_ATTR mỗi lần for loop, nhưng theo kiểm nghiệm của mình thì việc này thậm chí còn đang làm chậm đi (một chút) khi không dùng local variable, tuy nhiên đây có thể chỉ đơn thuần do máy tính của mình, nếu có nhiều bằng chứng xác thực hơn mình sẽ cập nhật thêm.
  • Tạo list với list comprehensions nhanh hơn tương đối rõ ràng khi số lượng dữ liệu đầu vào lớn.

Mong rằng các bạn đã hiểu thêm về cách methods hoạt động với instance và tại sao self luôn là keyword đầu tiên của bất cứ member functions nào.

Go live on 11/07/2023 by Huy Vu