تشغيل كود بايثون بسرعة تصل إلى 80 ضعفًا باستخدام مكتبة Cython

قد تكون لغة بايثون ممتازةً لإنشاء النماذج الأولية وتطوير البرمجيات بسرعة، لكن ما أسمعه كثيرًا عن استخدامها هو بطء تنفيذها. ويُعدّ هذا الأمر مشكلةً خاصةً لعلماء البيانات ومهندسي التعلّم الآلي، إذ غالبًا ما يُجرون عمليات حسابية مُكثّفة، مثل ضرب المصفوفات، وحسابات التدرّج الهبوطي، ومعالجة الصور.

بمرور الوقت، تطورت لغة بايثون داخليًا لمعالجة بعض هذه المشكلات من خلال إضافة ميزات جديدة، مثل تعدد الخيوط أو إعادة كتابة الوظائف الحالية لتحسين الأداء. مع ذلك، غالبًا ما أعاق استخدام بايثون لقفل المفسر العام (GIL) مثل هذه الجهود.

تم تطوير العديد من المكتبات الخارجية لسدّ فجوة الأداء الملحوظة بين لغة بايثون واللغات المُجمعة مثل جافا. ولعلّ أشهر هذه المكتبات وأكثرها استخدامًا هي مكتبة NumPy. صُمّمت NumPy، المكتوبة بلغة C، من الصفر لدعم معالجات متعددة النوى ومعالجة فائقة السرعة للأرقام والمصفوفات.

توجد بدائل لـ NumPy، وقد عرّفتُ في مقالٍ نُشر مؤخرًا على موقع TDS بمكتبة numexpr، التي قد تتفوق على NumPy في كثير من الحالات. إذا كنتم مهتمين بمعرفة المزيد، فسأُدرج رابطًا للمقال في نهاية هذا المقال.

مكتبة خارجية أخرى فعّالة للغاية هي Numba. تستخدم Numba مُترجمًا فوريًا (JIT) للغة Python، والذي يُترجم جزءًا من كود Python وNumPy إلى كود آلة سريع أثناء التشغيل. صُممت Numba لتسريع مهام الحوسبة العددية والعلمية من خلال الاستفادة من بنية مُترجم LLVM (الآلة الافتراضية منخفضة المستوى).

في هذه المقالة، أودّ مناقشة مكتبة خارجية أخرى لتحسين أداء وقت التشغيل، وهي Cython. تُعدّ Cython من بين أفضل مكتبات بايثون أداءً، ولكنها في الوقت نفسه من أقلها فهمًا واستخدامًا. أعتقد أن هذا يعود جزئيًا على الأقل إلى ضرورة إجراء بعض التعديلات على الكود الأصلي. ولكن باتباع الخطة البسيطة المكونة من أربع خطوات التي سأوضحها أدناه، ستُحقق فوائد في الأداء تجعلها تستحق العناء.

ما هو Cython؟

إذا لم تسمع من قبل عن Cython، فهي لغة برمجة مشتقة من Python مصممة لتوفير أداء مشابه للغة C مع كتابة الكود بشكل أساسي بلغة Python. تتيح Cython تحويل كود Python إلى كود C، والذي يمكن تجميعه لاحقًا في مكتبات مشتركة يمكن استيرادها إلى Python تمامًا مثل وحدات Python العادية. تُنتج هذه العملية مزايا أداء C مع الحفاظ على سهولة قراءة Python.

سأعرض الفوائد الدقيقة التي يمكنك تحقيقها من خلال تحويل التعليمات البرمجية الخاصة بك لاستخدام Cython، وسأقوم بفحص ثلاث حالات استخدام وتقديم الخطوات الأربع المطلوبة لتحويل التعليمات البرمجية الحالية الخاصة بك بلغة Python، بالإضافة إلى التوقيتات المقارنة لكل تشغيل.

إعداد بيئة التطوير

قبل المتابعة، ينبغي إعداد بيئة تطوير منفصلة للبرمجة للحفاظ على استقلالية تبعيات المشروع. سأستخدم نظام التشغيل أوبونتو WSL2 لنظام ويندوز و Jupyter Notebook لتطوير الكود. أستخدم مدير حزم UV لإعداد بيئة التطوير، ولكن يمكنك استخدام أي أدوات أو طرق تناسبك.

$ uv init cython-test
$ cd cython-test
$ uv venv
$ source .venv/bin/activate
(cython-test) $ uv pip install cython jupyter numpy pillow matplotlib

الآن، اكتب الأمر “jupyter notebook” في موجه الأوامر. من المفترض أن يظهر لك notebook مفتوح في متصفحك. إذا لم يحدث ذلك تلقائيًا، فستظهر لك على الأرجح شاشة مليئة بالمعلومات بعد تشغيل أمر Jupyter Notebook. ستجد في أسفل الشاشة رابطًا (URL) عليك نسخه ولصقه في متصفحك لبدء تشغيل Jupyter Notebook.

سيكون عنوان URL الخاص بك مختلفًا عن عنوان URL الخاص بي، ولكن يجب أن يبدو كالتالي:

http://127.0.0.1:8888/tree?token=3b9f7bd07b6966b41b68e2350721b2d0b6f388d248cc69d

مثال 1 – تسريع حلقات التكرار

قبل أن نبدأ باستخدام Cython، دعونا نبدأ بدالة بايثون عادية ونقيس الوقت الذي تستغرقه لتشغيلها. سيكون هذا هو معيارنا الأساسي.

سنقوم ببرمجة دالة بسيطة ذات حلقة تكرار مزدوجة تستغرق بضع ثوانٍ لتشغيلها، ثم نستخدم Cython لتسريعها وقياس الاختلافات في وقت التشغيل بين الطريقتين.

إليكم كود بايثون القياسي الأساسي لدينا.

# sum_of_squares.py
import timeit

# Define the standard Python function
def slow_sum_of_squares(n):
    total = 0
    for i in range(n):
        for j in range(n):
            total += i * i + j * j
    return total

# Benchmark the Python function
print("Python function execution time:")
print("timeit:", timeit.timeit(
        lambda: slow_sum_of_squares(20000),
        number=1))

على جهازي، ينتج الكود أعلاه المخرجات التالية.

Python function execution time:
13.135973724005453

لنرى مدى التحسن الذي سيحققه برنامج Cython.

خطة من أربع خطوات للاستخدام الفعال ل Cython

يُعد استخدام Cython لتعزيز وقت تشغيل التعليمات البرمجية الخاصة بك في Jupyter Notebook عملية بسيطة تتكون من 4 خطوات.

لا تقلق إذا لم تكن مستخدمًا لبرنامج Notebook، فسأوضح لاحقًا كيفية تحويل ملفات .py ال1/ في الخلية الأولى من دفتر ملاحظاتك، قم بتحميل ملحق Cython عن طريق كتابة هذا الأمر.

1/ في الخلية الأولى من notebook، قم بتحميل ملحق Cython عن طريق كتابة هذا الأمر.

%load_ext Cython

2/ بالنسبة لأي خلايا لاحقة تحتوي على كود بايثون ترغب في تشغيله باستخدام cython، أضف الأمر السحري cython%% قبل الكود. على سبيل المثال،

%%cython
def myfunction():
    etc ...
        ...

3/ يجب أن تكون تعريفات الدوال التي تحتوي على معلمات مكتوبة بشكل صحيح.

4/ أخيرًا، يجب تحديد أنواع جميع المتغيرات بشكل صحيح باستخدام توجيه cdef. كذلك، استخدم الدوال من مكتبة C القياسية (المتوفرة في Cython باستخدام توجيه from libc.stdlib) حيثما كان ذلك مناسبًا.

بأخذ كود بايثون الأصلي كمثال، هذا ما يجب أن يبدو عليه ليكون جاهزًا للتشغيل في notebook باستخدام cython بعد تطبيق جميع الخطوات الأربع المذكورة أعلاه.

%%cython
def fast_sum_of_squares(int n):
    cdef int total = 0
    cdef int i, j
    for i in range(n):
        for j in range(n):
            total += i * i + j * j
    return total

import timeit
print("Cython function execution time:")
print("timeit:", timeit.timeit(
        lambda: fast_sum_of_squares(20000),
        number=1))

كما آمل أن تلاحظ، فإن حقيقة تحويل التعليمات البرمجية الخاصة بك أسهل بكثير مما قد توحي به الخطوات الإجرائية الأربع المطلوبة.

كان وقت تشغيل الكود المذكور أعلاه مثيرًا للإعجاب. على جهازي، ينتج هذا الكود الجديد المكتوب بلغة سايثون المخرجات التالية.

Cython function execution time:
0.15829777799808653

هذا يعني زيادة في السرعة تتجاوز 80 ضعفًا.

مثال 2 – حساب قيمة pi باستخدام مونت كارلو

أما بالنسبة لمثالنا الثاني، فسوف ندرس حالة استخدام أكثر تعقيدًا، والتي تستند إلى العديد من التطبيقات الواقعية.

يُمكن لـ Cython أن يُظهر تحسناً ملحوظاً في الأداء في مجال المحاكاة العددية، لا سيما تلك التي تتطلب حسابات مكثفة، مثل محاكاة مونت كارلو (MC). تتضمن محاكاة مونت كارلو تشغيل العديد من التكرارات لعملية عشوائية لتقدير خصائص النظام. تُطبّق محاكاة مونت كارلو على نطاق واسع من مجالات الدراسة، بما في ذلك علوم المناخ والغلاف الجوي، ورسومات الحاسوب، والبحث بالذكاء الاصطناعي، والتمويل الكمي. وهي عملية تتطلب في الغالب موارد حاسوبية هائلة.

للتوضيح، سنستخدم طريقة مونت كارلو بطريقة مبسطة لحساب قيمة Pi. هذا مثال معروف حيث نأخذ مربعًا طول ضلعه وحدة واحدة ونرسم داخله ربع دائرة نصف قطرها وحدة واحدة، كما هو موضح هنا.

نسبة مساحة ربع الدائرة إلى مساحة المربع هي، كما هو واضح، (π/4).

لذا، إذا اعتبرنا العديد من النقاط العشوائية (x,y) التي تقع جميعها داخل أو على حدود المربع، فعندما يؤول العدد الإجمالي لهذه النقاط إلى اللانهاية، فإن نسبة النقاط التي تقع على أو داخل ربع الدائرة إلى العدد الإجمالي للنقاط تؤول إلى π/4. ثم نضرب هذه القيمة في 4 لنحصل على قيمة π نفسها.

إليك بعض نماذج أكواد بايثون النموذجية التي يمكنك استخدامها لنمذجة هذا.

import random
import time

def monte_carlo_pi(num_samples):
    inside_circle = 0
    for _ in range(num_samples):
        x = random.uniform(0, 1)
        y = random.uniform(0, 1)
        if (x**2) + (y**2) <= 1:  
            inside_circle += 1
    return (inside_circle / num_samples) * 4

# Benchmark the standard Python function
num_samples = 100000000

start_time = time.time()
pi_estimate = monte_carlo_pi(num_samples)
end_time = time.time()

print(f"Estimated Pi (Python): {pi_estimate}")
print(f"Execution Time (Python): {end_time - start_time} seconds")

أدى تشغيل هذا البرنامج إلى الحصول على نتيجة التوقيت التالية.

Estimated Pi (Python): 3.14197216
Execution Time (Python): 20.67279839515686 seconds

والآن، إليكم تطبيق Cython الذي نحصل عليه باتباع عملية الخطوات الأربع.

%%cython
import cython
import random
from libc.stdlib cimport rand, RAND_MAX

@cython.boundscheck(False)
@cython.wraparound(False)
def monte_carlo_pi(int num_samples):
    cdef int inside_circle = 0
    cdef int i
    cdef double x, y
    
    for i in range(num_samples):
        x = rand() / <double>RAND_MAX
        y = rand() / <double>RAND_MAX
        if (x**2) + (y**2) <= 1:
            inside_circle += 1
            
    return (inside_circle / num_samples) * 4

import time

num_samples = 100000000

# Benchmark the Cython function
start_time = time.time()
pi_estimate = monte_carlo_pi(num_samples)
end_time = time.time()

print(f"Estimated Pi (Cython): {pi_estimate}")
print(f"Execution Time (Cython): {end_time - start_time} seconds")

وهذه هي النتيجة الجديدة.

Estimated Pi (Cython): 3.1415012
Execution Time (Cython): 1.9987852573394775 seconds

مرة أخرى، هذا تسريع مثير للإعجاب بمقدار 10 أضعاف لإصدار Cython.

أحد الأمور التي قمنا بها في مثال الكود هذا ولم نقم بها في المثال الآخر هو استيراد بعض المكتبات الخارجية من مكتبة C القياسية:

from libc.stdlib cimport rand, RAND_MAX

يُعدّ الأمر cimport كلمةً مفتاحيةً في لغة Cython تُستخدم لاستيراد الدوال والمتغيرات والثوابت والأنواع في لغة C. وقد استخدمناه لاستيراد نسخ مُحسّنة من دوال ()random.uniform المكافئة لها في لغة Python، والمُصنّفة بلغة C.

المثال 3 – معالجة الصور

في مثالنا الأخير، سنقوم ببعض عمليات معالجة الصور. تحديدًا، سنقوم بعملية الالتفاف، وهي عملية شائعة في معالجة الصور. للالتفاف استخدامات عديدة، وسنستخدمه لمحاولة تحسين وضوح الصورة الضبابية الموضحة أدناه.

أولاً، إليكم كود بايثون العادي.

from PIL import Image
import numpy as np
from scipy.signal import convolve2d
import time
import os
import matplotlib.pyplot as plt

def sharpen_image_color(image):

    # Start timing
    start_time = time.time()
    
    # Convert image to RGB in case it's not already
    image = image.convert('RGB')
    
    # Define a sharpening kernel
    kernel = np.array([[0, -1, 0],
                       [-1, 5, -1],
                       [0, -1, 0]])
    
    # Convert image to numpy array
    image_array = np.array(image)
    
    # Debugging: Check input values
    print("Input array values: Min =", image_array.min(), "Max =", image_array.max())
    
    # Prepare an empty array for the sharpened image
    sharpened_array = np.zeros_like(image_array)
    
    # Apply the convolution kernel to each channel (assuming RGB image)
    for i in range(3):
        channel = image_array[:, :, i]
        # Perform convolution
        convolved_channel = convolve2d(channel, kernel, mode='same', boundary='wrap')
        
        # Clip values to be in the range [0, 255]
        convolved_channel = np.clip(convolved_channel, 0, 255)
        
        # Store back in the sharpened array
        sharpened_array[:, :, i] = convolved_channel.astype(np.uint8)
    
    # Debugging: Check output values
    print("Sharpened array values: Min =", sharpened_array.min(), "Max =", sharpened_array.max())
    
    # Convert array back to image
    sharpened_image = Image.fromarray(sharpened_array)
    
    # End timing
    duration = time.time() - start_time
    print(f"Processing time: {duration:.4f} seconds")
    
    return sharpened_image

# Correct path for WSL2 accessing Windows filesystem
image_path = '/mnt/d/images/taj_mahal.png'

image = Image.open(image_path)

# Sharpen the image
sharpened_image = sharpen_image_color(image)

if sharpened_image:
    # Show using PIL's built-in show method (for debugging)
    #sharpened_image.show(title="Sharpened Image (PIL Show)")

    # Display the original and sharpened images using Matplotlib
    fig, axs = plt.subplots(1, 2, figsize=(15, 7))

    # Original image
    axs[0].imshow(image)
    axs[0].set_title("Original Image")
    axs[0].axis('off')

    # Sharpened image
    axs[1].imshow(sharpened_image)
    axs[1].set_title("Sharpened Image")
    axs[1].axis('off')

    # Show both images side by side
    plt.show()
else:
    print("Failed to generate sharpened image.")

والنتيجة:

Input array values: Min = 0 Max = 255
Sharpened array values: Min = 0 Max = 255
Processing time: 0.1034 seconds

دعونا نرى ما إذا كان بإمكان Cython التغلب على وقت التشغيل البالغ 0.1034 ثانية.

%%cython
# cython: language_level=3
# distutils: define_macros=NPY_NO_DEPRECATED_API=NPY_1_7_API_VERSION

import numpy as np
cimport numpy as np
import cython

@cython.boundscheck(False)
@cython.wraparound(False)
def sharpen_image_cython(np.ndarray[np.uint8_t, ndim=3] image_array):
    # Define sharpening kernel
    cdef int kernel[3][3]
    kernel[0][0] = 0
    kernel[0][1] = -1
    kernel[0][2] = 0
    kernel[1][0] = -1
    kernel[1][1] = 5
    kernel[1][2] = -1
    kernel[2][0] = 0
    kernel[2][1] = -1
    kernel[2][2] = 0
    
    # Declare variables outside of loops
    cdef int height = image_array.shape[0]
    cdef int width = image_array.shape[1]
    cdef int channel, i, j, ki, kj
    cdef int value
    
    # Prepare an empty array for the sharpened image
    cdef np.ndarray[np.uint8_t, ndim=3] sharpened_array = np.zeros_like(image_array)

    # Convolve each channel separately
    for channel in range(3):  # Iterate over RGB channels
        for i in range(1, height - 1):
            for j in range(1, width - 1):
                value = 0  # Reset value at each pixel
                # Apply the kernel
                for ki in range(-1, 2):
                    for kj in range(-1, 2):
                        value += kernel[ki + 1][kj + 1] * image_array[i + ki, j + kj, channel]
                # Clip values to be between 0 and 255
                sharpened_array[i, j, channel] = min(max(value, 0), 255)

    return sharpened_array

# Python part of the code
from PIL import Image
import numpy as np
import time as py_time  # Renaming the Python time module to avoid conflict
import matplotlib.pyplot as plt

# Load the input image
image_path = '/mnt/d/images/taj_mahal.png'
image = Image.open(image_path).convert('RGB')

# Convert the image to a NumPy array
image_array = np.array(image)

# Time the sharpening with Cython
start_time = py_time.time()
sharpened_array = sharpen_image_cython(image_array)
cython_time = py_time.time() - start_time

# Convert back to an image for displaying
sharpened_image = Image.fromarray(sharpened_array)

# Display the original and sharpened image
plt.figure(figsize=(12, 6))
plt.subplot(1, 2, 1)
plt.imshow(image)
plt.title("Original Image")

plt.subplot(1, 2, 2)
plt.imshow(sharpened_image)
plt.title("Sharpened Image")

plt.show()

# Print the time taken for Cython processing
print(f"Processing time with Cython: {cython_time:.4f} seconds")

الناتج هو:

كلا البرنامجين قدما أداءً جيداً، لكن برنامج Cython كان أسرع بنحو 25 مرة.

ماذا عن تشغيل Cython خارج بيئة Notebook؟

حتى الآن، كل ما عرضته يفترض أنك تُشغّل الكود الخاص بك داخل دفتر Jupyter. السبب في ذلك هو أنه أسهل طريقة لتقديم Cython وتشغيل بعض الأكواد بسرعة. مع أن بيئة دفتر Jupyter شائعة جدًا بين مطوري Python، إلا أن جزءًا كبيرًا من أكواد Python لا يزال موجودًا في ملفات .py عادية ويُشغّل من سطر الأوامر باستخدام أمر Python.

إذا كانت هذه هي طريقتك الأساسية في كتابة التعليمات البرمجية وتشغيل نصوص بايثون، فلن تعمل أوامر IPython السحرية load_ext% و cython%% لأنها مفهومة فقط بواسطة Jupyter/IPython.

إذن، إليك كيفية تكييف عملية تحويل Cython المكونة من أربع خطوات إذا كنت تقوم بتشغيل التعليمات البرمجية الخاصة بك كبرنامج نصي Python عادي.

لنأخذ مثال sum_of_squares الأول لتوضيح ذلك.

1/ أنشئ ملفًا بامتداد .pyx بدلاً من استخدام cython%%

انقل الكود المُحسّن باستخدام Cython إلى ملف باسم، على سبيل المثال:

sum_of_squares.pyx

# sun_of_squares.pyx
def fast_sum_of_squares(int n):
    cdef int total = 0
    cdef int i, j
    for i in range(n):
        for j in range(n):
            total += i * i + j * j
    return total

كل ما فعلناه هو إزالة توجيه cython%% ورمز التوقيت (الذي سيكون الآن في دالة الاستدعاء).

2/ أنشئ ملف setup.py لتجميع ملف .pyx الخاص بك.

# setup.py
from setuptools import setup
from Cython.Build import cythonize

setup(
    name="cython-test",
    ext_modules=cythonize("sum_of_squares.pyx", language_level=3),
    py_modules=["sum_of_squares"],  # Explicitly state the module
    zip_safe=False,
)

3/ قم بتشغيل ملف setup.py باستخدام هذا الأمر،

$ python setup.py build_ext --inplace
running build_ext
copying build/lib.linux-x86_64-cpython-311/sum_of_squares.cpython-311-x86_64-linux-g

4/ قم بإنشاء وحدة بايثون عادية لاستدعاء كود Cython الخاص بنا، كما هو موضح أدناه، ثم قم بتشغيلها.

# main.py
import time, timeit
from sum_of_squares import fast_sum_of_squares

start = time.time()
result = fast_sum_of_squares(20000)

print("timeit:", timeit.timeit(
        lambda: fast_sum_of_squares(20000),
        number=1))
$ python main.py

timeit: 0.14675087109208107

آمل أن أكون قد أقنعتك بفعالية استخدام مكتبة Cython في برنامجك. مع أنها قد تبدو معقدة بعض الشيء للوهلة الأولى، إلا أنه ببذل جهد بسيط، يمكنك الحصول على تحسينات مذهلة في أداء وقت التشغيل مقارنةً باستخدام لغة بايثون العادية، حتى عند استخدام مكتبات حسابية سريعة مثل NumPy.

لقد قدمتُ عملية من أربع خطوات لتحويل كود بايثون العادي الخاص بك لاستخدام Cython لتشغيله ضمن بيئات Jupyter Notebook. بالإضافة إلى ذلك، شرحتُ الخطوات اللازمة لتشغيل كود Cython من سطر الأوامر خارج بيئة Notebook.

وأخيراً، قمت بتعزيز ما سبق من خلال عرض أمثلة لتحويل كود بايثون العادي لاستخدام Cython.

في الأمثلة الثلاثة التي عرضتها، حققنا مكاسب في السرعة بلغت 80 ضعفًا و10 أضعاف و25 ضعفًا، وهو أمر ليس سيئًا على الإطلاق.


اكتشاف المزيد من بايثون العربي

اشترك للحصول على أحدث التدوينات المرسلة إلى بريدك الإلكتروني.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *

Scroll to Top

اكتشاف المزيد من بايثون العربي

اشترك الآن للاستمرار في القراءة والحصول على حق الوصول إلى الأرشيف الكامل.

Continue reading