Loading Now

Python Profile – Ứng Dụng Nhanh Chậm Do Đâu?

Là một lập trình viên Python, bạn sẽ có những hành động gì khi ứng dụng của bạn bị than phiền là chậm ?
Chắc là bạn sẽ phải lên danh sách các event nào mà khách hàng đang sử dụng và thấy nó chậm rồi. Sau đó thì sẽ phải kiểm tra xem thời gian phản hồi thực sự của các event đó đang là bao nhiêu. Quá trình thu thập thông tin này được gọi là “Profiling” và kết quả của quá trình thu được được gọi là “Profile” của ứng dụng.

Profile là gì ? Tại sao phải profiling ?

Profiling/Profile là một khái niệm mà bất kể lập trình viên Python chuyên nghiệp nào cũng phải biết (gần như một kỹ năng mà bắt buộc với những senior developer).

Profiling là một quá trình thực hiện kiểm tra tốc độ thực thi, dung lượng bộ nhớ RAM/ổ cứng, phần trăm CPU chiếm dụng của các đoạn source code cụ thể (function, một hoặc cả cụm câu lệnh).

Tổng hợp các kết quả và đưa ra profile của phần cần theo dõi theo các tiêu chí trên. Từ profile, các lập trình viên có kinh nghiệm sẽ cùng nhau phân tích, mổ xẻ và đưa ra các vấn đề xem ứng dụng đang bị “nghẽn” (bottlenecks) ở đâu, quyết định về việc thực hiện refactor code như thế nào để giải quyết được bài toán performance của ứng dụng.

Thông thường thì quá trình profiling sẽ được thực hiện:
TH1. Ngay trong thời gian thực hiện lập trình (nếu có đủ thời gian hoặc có yêu cầu hoặc tùy vào “tâm” của người làm lập trình).
TH2. Trong quá trình thực hiện system-testing hoặc performance testing, các tester hoặc developer sẽ đặt các đoạn code thực hiện đo đạc thông tin.
TH3. Khi có các cảnh báo không tốt trên hệ thống giám sát về thời gian phản hồi ứng dụng, chiếm dụng bộ nhớ, chiếm dụng CPU.
TH4. Khi nhận được phản hồi tiêu cực từ phía khách hàng/user.

Nếu rơi vào TH1 thì chi phí để sản xuất phần mềm sẽ phải bỏ thêm phần thời gian đặt các đoạn đo đặc thông tin. Với các senior developer hoặc các lập trình viên có kinh nghiệm, chi phí này có thể rất nhỏ vì mỗi dòng code có thể họ đã từng làm ở đâu đó và nó đã được “kiểm chứng” rồi. Đây cũng chính là một trong những sự khác biệt của fresher/junior và senior developer.
Chi phí sản xuất và những điều tệ hại sẽ tăng dần nên khi rơi vào TH2 –> TH3 –> TH4.

Phần lớn việc phát hiện sớm các vấn đề của ứng dụng là thời gian phản hồi (các tài nguyên RAM, CPU, HDD bị thiếu cũng dẫn đến thời gian phản hồi ứng dụng lâu) nên khi Profiling, việc đầu tiên cần quan tâm đó là thời gian thực thi. Bài viết này, tôi sẽ hướng dẫn các bạn các cách để thực hiện profiling theo dõi thời gian phản hồi của các đoạn mã/ứng dụng.

Cách cơ bản thực hiện profiling theo dõi thời gian

Đầu tiên chúng ta sẽ phải xác định đối tượng của profile: Xây dựng cho toàn bộ ứng dụng, cho module, cho function hay cho một block-code cụ thể.

Macro profile: Xây dựng profile cho toàn bộ ứng dụng hoặc những module lớn của ứng dụng. Toàn bộ những thông tin trong quá trình hoạt động sẽ được đưa vào thống kê. Thông thường phần này phải sử dụng công cụ thống kê theo dõi.
Micro profile: Xây dựng profile cho những phần nhỏ hoặc từng phần của ứng dụng. Cũng có thể hiểu là sẽ thực hiện theo dõi hoạt động của từng block-code, function. Phần này chủ yếu là sử dụng việc cài đặt trực tiếp trên mã nguồn.

Trong bài viết này, tôi muốn hướng đến phần micro-profile, những thứ mà lập trình viên chúng ta có thể tự làm được.
Tôi sẽ thực hiện “đo đạc” thời gian thực hiện của các function trong ví dụ mà tôi đã từng viết tại bài viết Tìm Số Nguyên Tố Trong Lập Trình Bằng Cách Nào?

import random
import math


def is_prime_basic(number):
    if number < 2:
        return False
    for value in range(2, number):
        if number % value == 0:
            return False
    return True


def is_prime_sqrt(number):
    if number < 2:
        return False
    max_range = int(math.sqrt(number)) + 1
    for value in range(2, max_range):
        if number % value == 0:
            return False
    return True


def is_prime_optimal(number):
    if number in [2, 3, 5]:
        return True
    if number % 2 == 0 or number % 3 == 0 or number % 5 == 0 or number < 2: 
        return False
    if number < 49:
        return True
    if (number %  7) == 0 or (number % 11) == 0 or (number % 13) == 0 or (number % 17) == 0 or \
       (number % 19) == 0 or (number % 23) == 0 or (number % 29) == 0 or (number % 31) == 0 or \
       (number % 37) == 0 or (number % 41) == 0 or (number % 43) == 0 or (number % 47) == 0:
        return False
    
    if number < 2809:
        return True
    
    max_range = int(math.sqrt(number)) + 1
    for value in range(53, max_range, 2):
        if number % value == 0:
            return False
    return True

Simple Profiling

Cách đơn giản nhất để thực hiện theo dõi thời gian thực hiện là đặt 2 biến dữ liệu thời gian ở đầu và cuối block-code muốn theo dõi.
Có nhiều cách để sinh ra các biến thời gian, tôi thường dùng module timeit để có thể sinh ra được khoảng cách thời gian đến mức 10^-n (với nhiều nhiều số 0 ở hàng thập phân).

Đầu tiên, tôi sẽ tạo file  basic_profile.py chứa code 3 function is_prime_basic, is_prime_sqrt, is_prime_optimal ở trên và bổ sung thêm các đoạn code thực hiện profiling bên dưới:

def run_basic_function(number):
    start = timeit.default_timer()
    result = is_prime_basic(number)
    stop = timeit.default_timer()
    print("Basic-function {0}: {1}: {2}(second)".format(number, result, (stop - start)))
    return stop - start


def run_sqrt_function(number):
    start = timeit.default_timer()
    result = is_prime_sqrt(number)
    stop = timeit.default_timer()
    print("Sqrt-function {0}: {1}: {2}(second)".format(number, result, (stop - start)))
    return stop - start    


def run_optimal_function(number):
    start = timeit.default_timer()
    result = is_prime_optimal(number)
    stop = timeit.default_timer()
    print("Optimal-function {0}: {1}: {2}(second)".format(number, result, (stop - start)))
    return stop - start

Chọn một số number ngẫu nhiên, và thực hiện các function run_basic_function, run_sqrt_function, run_optimal_function để lấy được profile về thời gian thực thi của 3 function trên.

if __name__ == "__main__":
    number = random.randint(1, 10**8)
    run_basic_function(number)
    run_sqrt_function(number)
    run_optimal_function(number)

Thực hiện chạy file basic_profile.py và thu được kết quả:

> python basic_profile.py
Basic-function 72096783: False: 6.899999999997186e-06(second)
Sqrt-function 72096783: False: 5.299999999999749e-06(second)
Optimal-function 72096783: False: 1.8999999999991246e-06(second)

> python basic_profile.py
Basic-function 10848917: True: 0.7584309(second)
Sqrt-function 10848917: True: 0.0002996999999999028(second)
Optimal-function 10848917: True: 0.00013299999999993872(second)

Một cách ngắn gọn hơn, thay vì phải viết 3 function để thực hiện chung một công việc, chúng ta có thể viết một fucntion dạng tổng quát và để gọi vào với các tham số tương ứng. Tạo file basic_benchmark_profile.py với 3 function is_prime_basic, is_prime_sqrt, is_prime_optimal  sau đó bổ sung thêm đoạn code bên dưới:

def benchmark_function(function, params):
    start = timeit.default_timer()
    result = function(params)
    end = timeit.default_timer()
    print("{0} - params: {1} #Result: {2} #Total time: {3}(second)".format(function.__name__, params, result, (end - start)))


if __name__ == "__main__":
    number = random.randint(1, 10**8)
    benchmark_function(is_prime_basic, number)
    benchmark_function(is_prime_sqrt, number)
    benchmark_function(is_prime_optimal, number)

Thực hiện chương trình: 

> python .\basic_benchmark_profile.py
is_prime_basic - params: 36127468 #Result: False #Total time: 8.29999999999928e-06(second)
is_prime_sqrt - params: 36127468 #Result: False #Total time: 6.4000000000001556e-06(second)
is_prime_optimal - params: 36127468 #Result: False #Total time: 1.7000000000003124e-06(second)

> python .\basic_benchmark_profile.py
is_prime_basic - params: 46251852 #Result: False #Total time: 7.200000000002343e-06(second)
is_prime_sqrt - params: 46251852 #Result: False #Total time: 6.099999999998468e-06(second)
is_prime_optimal - params: 46251852 #Result: False #Total time: 2.8000000000007186e-06(second)

 Use decorator function for profiling.

Cách này, chúng ta cũng sử dụng phương pháp đặt theo dõi thời gian thủ công nhưng thay vì yêu cầu “thủ công” việc đưa ra thời gian thực hiện vào từng block-code bên trong function thì chúng ta sẽ thực hiện mặc định cứ gọi đến function là sẽ hiển thị ra thời gian thực hiện.
Để thực hiện được, chúng ta sẽ sử dụng một chức năng khá thú vị trong Python là decorator.

Khái niệm về decorator, tôi sẽ trình bày trong một bài viết khác. Trong khuôn khổ bài viết này, bạn chỉ cần hiểu đầu vào của decorator function benchmark_me là function và đầu vào của function mà chúng ta muốn thực hiện profiling.

Tạo file decorator_benchmark_profile.py với function bên dưới và bổ sung thêm đoạn gán decorator function và 3 function is_prime_basic, is_prime_sqrt, is_prime_optimal   

def benchmark_me():
    def decorator(function):
        def wraps(*args, **kwargs):
            start = timeit.default_timer()
            result = function(*args, **kwargs)
            end = timeit.default_timer()
            print("{0} - params: {1} {2} #result: {3} total time: {4}(second)".format(function.__name__, str(*args), str(*kwargs) , result, (end - start)))
            return result
        return wraps
    return decorator

@benchmark_me()
def is_prime_sqrt(number):
    if number < 2:
        return False
    max_range = int(math.sqrt(number)) + 1
    for value in range(2, max_range):
        if number % value == 0:
            return False
    return True

Sau khi áp dụng technique này vào thì chúng ta chỉ cần gọi đến is_prime_sqrt(number) là đã thu được thời gian thực hiện thực hiện của function.

if __name__ == "__main__":
    number = random.randint(1, 10**8)
    is_prime_basic(number)
    is_prime_sqrt(number)
    is_prime_sqrt(number)

Gọi vào chương trình: 

> python .\decorator_benchmark_profile.py
is_prime_basic - params: 58320833  #Result: True #Total time: 4.7889745999999995(second)
is_prime_sqrt - params: 58320833  #Result: True #Total time: 0.0009272999999998532(second)
is_prime_sqrt - params: 58320833  #Result: True #Total time: 0.0010278000000001342(second)

Sử dung cProfile để profiling

cProfile là một thư viện được cung cấp “chính chủ” bởi Python để hỗ trợ các lập trình thực hiện Profiling các source code của mình.
Kết quả profile trả về khá là chi tiết, đủ các thông tin để các lập trình viên quyết định các action tiếp theo.

Bây giờ tôi sẽ lưu đoạn source code kiểm tra số nguyên tố phía trên và thêm đoạn code phía dưới này của tôi thành file: find_prime.py

def run_function(function, numbers):
    for number in numbers:
        function(number)


if __name__ == "__main__":
    numbers = [80909548, 64284912, 32561271, 59635875, 43704113, 42511163, 7564624, 62719360, 29119365, 49141585]
    method = sys.argv[-1]
    if method == 'basic':
        run_function(is_prime_basic, numbers)
    elif method == 'sqrt':
        run_function(is_prime_sqrt, numbers)
    else:
        run_function(is_prime_optimal, numbers)

method = sys.argv[-1] là để lấy ra giá trị mà tôi sẽ truyền vào trên câu lệnh gọi đến cProfile.

Tại màn hình command-line (cmd/terminal/…), chúng ta gọi đến file find_prime.py để thực hiện lấy profile của các function:
python -m cProfile -s calls find_prime.py –> thực hiện lấy profile của function: is_prime_optimal
python -m cProfile -s calls find_prime.py basic –> thực hiện lấy profile của function: is_prime_basic
python -m cProfile -s calls find_prime.py sqrt –> thực hiện lấy profile của function: is_prime_sqrt

Trong 3 function trên ví dụ của tôi thì với gọi vào hàm is_prime_basic là mất khoảng 6s nên thời gian hiển thị dễ nhìn hơn so với is_prime_optimal, is_prime_sqrt. (Chi tiết log, các bạn có thể xem tại link github ở phía dưới)

> python -m cProfile -s calls find_prime.py basic
         68 function calls in 6.119 seconds

   Ordered by: call count

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       10    6.119    0.612    6.119    0.612 find_prime.py:4(is_prime_basic)
        4    0.000    0.000    0.000    0.000 {built-in method builtins.getattr}
        4    0.000    0.000    0.000    0.000 {built-in method builtins.hasattr}
        3    0.000    0.000    0.000    0.000 {built-in method _imp.acquire_lock}
        3    0.000    0.000    0.000    0.000 {built-in method _imp.release_lock}
        2    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:211(_call_with_frames_removed)
        2    0.000    0.000    0.000    0.000 {built-in method _thread.allocate_lock}
        2    0.000    0.000    0.000    0.000 {built-in method _thread.get_ident}
        2    0.000    0.000    0.000    0.000 {method 'get' of 'dict' objects}
        2    0.000    0.000    0.000    0.000 {method 'rpartition' of 'str' objects}
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:58(__init__)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:78(acquire)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:103(release)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:143(__init__)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:147(__enter__)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:151(__exit__)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:176(cb)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:157(_get_module_lock)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:222(_verbose_message)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:232(_requires_builtin_wrapper)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:342(__init__)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:389(parent)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:397(has_location)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:406(spec_from_loader)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:477(_init_module_attrs)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:549(module_from_spec)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:650(_load_unlocked)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:725(find_spec)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:746(create_module)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:754(exec_module)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:771(is_package)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:863(__enter__)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:867(__exit__)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:890(_find_spec)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:956(_find_and_load_unlocked)
        1    0.000    0.000    0.000    0.000 <frozen importlib._bootstrap>:986(_find_and_load)
        1    0.000    0.000    6.119    6.119 find_prime.py:45(run_function)
        1    0.000    0.000    6.119    6.119 find_prime.py:1(<module>)
        1    0.000    0.000    0.000    0.000 {method 'pop' of 'dict' objects}
        1    0.000    0.000    6.119    6.119 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {built-in method _imp.create_builtin}
        1    0.000    0.000    0.000    0.000 {built-in method _imp.is_builtin}
        1    0.000    0.000    0.000    0.000 {built-in method _imp.exec_builtin}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

Các tham số kết quả:
ncalls: tổng số lần gọi đến functions.

ncalls tottime percall cumtime percall filename:lineno(function)
10 6.119 0.612 6.119 0.612 find_prime.py:4(is_prime_basic)

Do numbers có 10 phần tử nên sẽ gọi vào function is_prime_basic 10 lần.

tottime: Tổng thời gian sử dụng để thực hiện một function.
percall: Bằng tottime / ncalls
cumtime: Tổng thời gian sử dụng để thực hiện xong phần code, từ khi được gọi cho đến khi kết thúc
filename:lineno(function): Module code và dòng code trong module được gọi đến.

Ngoài cách sử dụng terminal để gọi đến cProfile thì chúng ta cũng có thể import module cProfile vào code để lấy ra kết quả trực tiếp mỗi lần chạy chương trình.

Tạo file cprofile_inside.py, thêm đoạn code vào file:

import cProfile
import functools
import pstats
import tempfile

def profile_me(func):
    @functools.wraps(func)
    def wraps(*args, **kwargs):
        file = tempfile.mktemp()
        profiler = cProfile.Profile()
        profiler.runcall(func, *args, **kwargs)
        profiler.dump_stats(file)
        metrics = pstats.Stats(file)
        metrics.strip_dirs().sort_stats('time').print_stats(100)
    return wraps

@cprofile_me
def run_function(function, numbers):
    for number in numbers:
        function(number)

Bổ sung thêm function is_prime_basic, is_prime_sqrt, thêm đoạn khởi chạy cho chương trình:

if __name__ == "__main__":
    numbers = [80909548, 64284912, 32561271, 59635875, 43704113, 42511163, 7564624, 62719360, 29119365, 49141585]
    run_function(is_prime_basic, numbers)
    run_function(is_prime_sqrt, numbers)

Khi thực hiện gọi từ màn hình terminal, chúng ta sẽ thu được kết quả:

> python .\cprofile_inside.py
Sun Oct 25 10:12:16 2020    C:\Users\TIEUBA~1\AppData\Local\Temp\tmpk76o2ffn

         12 function calls in 6.341 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       10    6.341    0.634    6.341    0.634 cprofile_inside.py:24(is_prime_basic)
        1    0.000    0.000    6.341    6.341 cprofile_inside.py:19(run_function)
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}


Sun Oct 25 10:12:16 2020    C:\Users\TIEUBA~1\AppData\Local\Temp\tmpchye6dy7

         22 function calls in 0.001 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       10    0.001    0.000    0.001    0.000 cprofile_inside.py:32(is_prime_sqrt)
        1    0.000    0.000    0.001    0.001 cprofile_inside.py:19(run_function)
       10    0.000    0.000    0.000    0.000 {built-in method math.sqrt}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

Các bạn có thể tìm hiểu kỹ hơn về cProfile theo như tài liệu của Python: https://docs.python.org/3/library/profile.html

Kết

Như đã phân tích từ đầu, việc xây dựng profile cho ứng dụng được viết bằng Python là vô cùng quan trọng, chúng ta nên tập cho mình thói quen theo dõi phản hồi của ứng dụng ngay lúc bắt đầu lập trình giống như viết unit-test cho mỗi module vậy.
Toàn bộ source code của bài viết này được đặt tại github: https://github.com/quangvinh1986/python_profile. Các bạn có thể tham khảo tại đó.
Cảm ơn các bạn đã đọc bài viết.

Post Comment

Contact