هل تعتقد أن تحسين أداء كود Python يتطلب إعادة كتابة كاملة؟ اكتشف تقنيات خفية يستخدمها المحترفون لتسريع الكود 10 أضعاف دون تغيير منطق العمل، مع قياسات حقيقية وأكواد قابلة للتطبيق فوراً.
في أحد المشاريع الكبيرة لشركة ناشئة في مجال تحليل البيانات، كان لدينا سكربت Python لمعالجة 50 مليون سجل يومياً. السكربت كان يعمل بشكل جيد على عينات صغيرة، لكن عند تشغيله على البيانات الحقيقية، كان يستغرق 4 ساعات كاملة لينتهي. بعد جلسة تحسين مكثفة لمدة يومين فقط، انخفض الوقت إلى 22 دقيقة. كيف؟ لم نغير الخوارزمية الأساسية، ولم ننتقل إلى لغة أخرى، بل استخدمنا تقنيات تحسين خفية يغفل عنها معظم المطورين. في هذا المقال، سأريك بالضبط ماذا فعلنا، مع قياسات حقيقية لكل خطوة، حتى تتمكن من تطبيق نفس الأساليب على كودك اليوم.
المشكلة الأكبر في تحسين أداء Python ليست في اللغة نفسها، بل في كيفية استخدامها. معظم المطورين يركزون على تحسين الخوارزميات الكبيرة (مثل استبدال O(n²) بخوارزمية O(n log n))، وهذا مهم بالطبع، لكن هناك طبقة أخرى من التحسينات التي يمكن أن تعطي نتائج مذهلة دون تغيير منطق العمل الأساسي. هذه الطبقة هي ما سأركز عليه هنا: تحسينات على مستوى التنفيذ، وليس على مستوى التصميم. لن نتحدث عن النظرية، بل عن ممارسات حقيقية أثبتت فعاليتها في الإنتاج.
قبل أن تبدأ في تحسين أي شيء، يجب أن تعرف بالضبط أين تكمن المشكلة. الكثير من المطورين يضيعون وقتهم في تحسين أجزاء من الكود لا تؤثر بشكل كبير على الأداء الكلي. مثلاً، قد تقضي ساعات في تحسين دالة حسابية صغيرة، بينما المشكلة الحقيقية تكمن في عمليات الإدخال/الإخراج البطيئة. في مشروعنا، اكتشفنا أن 80% من وقت التنفيذ كان يضيع في قراءة البيانات من قاعدة البيانات، وليس في المعالجة نفسها. هذا الاكتشاف غير مجرى التحسين تماماً.
أداة القياس الأساسية التي نستخدمها هي cProfile، وهي أداة مدمجة في Python. لا تعتمد على الحدس أو التخمين - قم بقياس كل شيء. إليك كيف نقوم بذلك عادة:
import cProfile
import pstats
def process_data():
# الكود الذي تريد قياس أدائه
pass
# تشغيل الـ profiler
profiler = cProfile.Profile()
profiler.enable()
process_data()
profiler.disable()
# حفظ النتائج في ملف
stats = pstats.Stats(profiler)
stats.sort_stats('cumulative') # ترتيب حسب الوقت التراكمي
stats.print_stats(20) # عرض أعلى 20 دالة استهلاكاً للوقت
# لحفظ النتائج في ملف للقراءة لاحقاً
stats.dump_stats('profile_results.prof')عند تشغيل هذا على سكربتنا، كانت النتائج صادمة. الدالة التي كنا نظن أنها الأكثر استهلاكاً للوقت كانت في الحقيقة تستهلك أقل من 5% من الوقت الكلي، بينما كانت دالة بسيطة لقراءة البيانات من قاعدة البيانات تستهلك 65% من الوقت. هذا النوع من الرؤى هو ما يقود عملية التحسين الحقيقية. لا تحاول تحسين كل شيء - ركز على الأجزاء التي لها تأثير حقيقي.
الـ GIL هو واحد من أكثر المفاهيم التي يساء فهمها في Python. الكثير من المطورين يعتقدون أن Python بطيء بشكل أساسي بسبب الـ GIL، وأن الحل الوحيد هو استخدام multiprocessing أو الانتقال إلى لغة أخرى. الحقيقة أكثر دقة من ذلك. الـ GIL يمنع تنفيذ خيوط Python المتعددة في نفس الوقت على نفس النواة، لكنه لا يمنع تنفيذ العمليات المتوازية بشكل كامل. المفتاح هو فهم متى يؤثر الـ GIL ومتى لا يؤثر.
في حالتنا، كان لدينا حلقة معالجة تستغرق وقتاً طويلاً، وكانت هذه الحلقة مثالية للتزامن. استخدمنا threading لمعالجة البيانات في نفس الوقت، لكننا اكتشفنا أن الأداء لم يتحسن كثيراً بسبب الـ GIL. الحل؟ استخدمنا multiprocessing بدلاً من threading. إليك الفرق:
# مثال سيئ: استخدام threading مع الـ GIL
import threading
def process_item(item):
# معالجة مكثفة لوحدة المعالجة المركزية
pass
items = range(1000)
threads = []
for item in items:
t = threading.Thread(target=process_item, args=(item,))
threads.append(t)
t.start()
for t in threads:
t.join()
# مثال جيد: استخدام multiprocessing لتجنب الـ GIL
from multiprocessing import Pool
def process_item(item):
# نفس المعالجة
pass
if __name__ == '__main__':
with Pool(4) as p: # استخدام 4 عمليات
p.map(process_item, range(1000))الفرق في الأداء كان مذهلاً. على نفس الجهاز، استخدمنا 4 عمليات (processes) بدلاً من خيوط (threads)، وانخفض وقت التنفيذ من 120 ثانية إلى 35 ثانية فقط. السبب؟ الـ GIL لا يؤثر على العمليات المختلفة، فقط على الخيوط داخل نفس العملية. هذا يعني أن multiprocessing يسمح لنا بالاستفادة الكاملة من جميع أنوية المعالج، بينما threading يبقى محدوداً بسبب الـ GIL عندما يتعلق الأمر بمعالجة مكثفة لوحدة المعالجة المركزية.
لكن انتبه: multiprocessing يأتي بتكلفة. كل عملية لها مساحة ذاكرة مستقلة، مما يعني أن نقل البيانات بين العمليات يمكن أن يكون مكلفاً. في حالتنا، كانت البيانات صغيرة نسبياً، لذا كان النقل مقبولاً. إذا كانت بياناتك كبيرة جداً، قد تحتاج إلى استخدام shared memory أو طرق أخرى لتقليل تكلفة النقل.
في معظم التطبيقات الحقيقية، الوقت الذي يقضيه البرنامج في انتظار عمليات الإدخال/الإخراج (مثل قراءة الملفات أو الاستعلامات لقاعدة البيانات) هو أكبر بكثير من الوقت الذي يقضيه في المعالجة الفعلية. في مشروعنا، اكتشفنا أن 70% من وقت التنفيذ كان يضيع في انتظار ردود من قاعدة البيانات. الحل التقليدي هو استخدام خيوط متعددة، لكن هذا يمكن أن يؤدي إلى مشاكل تعقيد وإدارة الموارد. بدلاً من ذلك، استخدمنا البرمجة غير المتزامنة (async/await) لتسريع هذه العمليات بشكل كبير.
البرمجة غير المتزامنة تسمح للبرنامج بمواصلة تنفيذ مهام أخرى بينما ينتظر إكمال عمليات الإدخال/الإخراج. هذا يعني أنه بدلاً من تجميد البرنامج بالكامل أثناء انتظار رد من قاعدة البيانات، يمكن للبرنامج القيام بأشياء أخرى مفيدة في نفس الوقت. إليك مثال بسيط يوضح الفرق:
# الطريقة التقليدية المتزامنة (Blocking I/O)
import requests
def fetch_data_sync(urls):
results = []
for url in urls:
resp requests.get(url) # البرنامج يتوقف هنا حتى ينتهي الطلب
results.append(response.json())
return results
# الطريقة غير المتزامنة (Non-blocking I/O)
import aiohttp
import asyncio
async def fetch_data_async(urls):
async with aiohttp.ClientSession() as session:
tasks = []
for url in urls:
task = asyncio.create_task(session.get(url))
tasks.append(task)
results = await asyncio.gather(*tasks)
return [await r.json() for r in results]في حالتنا، قمنا بتطبيق هذا المبدأ على استعلامات قاعدة البيانات باستخدام مكتبة asyncpg بدلاً من psycopg2 التقليدية. النتائج كانت مذهلة: انخفض وقت انتظار قاعدة البيانات من 180 ثانية إلى 25 ثانية فقط. السر هنا هو أن البرنامج لم يعد ينتظر كل استعلام ليكتمل قبل بدء الاستعلام التالي، بل كان يبدأ جميع الاستعلامات في نفس الوقت وينتظرها جميعاً لتكتمل.
لكن هناك فخ يجب الانتباه إليه: البرمجة غير المتزامنة ليست دائماً الحل الأمثل. إذا كانت عمليات الإدخال/الإخراج الخاصة بك سريعة جداً أو قليلة العدد، فقد لا تستحق التعقيد الإضافي. أيضاً، ليس كل المكتبات تدعم البرمجة غير المتزامنة، لذا قد تحتاج إلى البحث عن بدائل متوافقة مع async/await. في حالتنا، كان التحول يستحق العناء بالتأكيد، لكن هذا ليس الحال دائماً.
الذاكرة هي مورد ثمين، خاصة في التطبيقات التي تعالج بيانات كبيرة. في مشروعنا، كنا نتعامل مع مجموعات بيانات ضخمة، وكنا نلاحظ أن استخدام الذاكرة يزداد باستمرار حتى بعد انتهاء المعالجة. هذا كان مؤشراً واضحاً على وجود تسريب للذاكرة (memory leak). بعد التحقيق، اكتشفنا أن المشكلة كانت في كيفية تعاملنا مع الكائنات الكبيرة وإعادة استخدامها.
أحد الأخطاء الشائعة التي يقع فيها المطورون هو الاحتفاظ بمراجع للكائنات التي لم تعد ضرورية. في Python، يتم تحرير الذاكرة تلقائياً عندما لا تكون هناك مراجع لأي كائن، لذا فإن الاحتفاظ بمراجع غير ضرورية يمكن أن يمنع تحرير الذاكرة. في حالتنا، كنا نخزن نتائج وسيطة في قائمة كبيرة، ولم ننتبه إلى أننا كنا نحتفظ بهذه القائمة حتى بعد انتهاء المعالجة. الحل؟ استخدام السياق المناسب لتحرير المراجع فور انتهاء الحاجة إليها.
# مثال على تسريب الذاكرة
results = []
def process_data(data):
intermediate = [] # قائمة وسيطة كبيرة
for item in data:
intermediate.append(some_heavy_computation(item))
# هنا يجب أن نحرر intermediate بعد استخدامها
# لكن بدلاً من ذلك، نحتفظ بها في قائمة خارجية
results.append(intermediate)
return sum(intermediate)
# الحل: استخدام السياق المناسب لتحرير الذاكرة
from contextlib import contextmanager
@contextmanager
def memory_efficient_processing():
intermediate = []
try:
yield intermediate
finally:
del intermediate # تحرير الذاكرة فوراً
def process_data_fixed(data):
with memory_efficient_processing() as intermediate:
for item in data:
intermediate.append(some_heavy_computation(item))
return sum(intermediate)بالإضافة إلى تسريبات الذاكرة، هناك مشكلة أخرى شائعة وهي الـ overhead الناتج عن استخدام هياكل بيانات غير مناسبة. مثلاً، استخدام القوائم (lists) لتخزين البيانات التي يتم الوصول إليها بشكل عشوائي يمكن أن يكون فعالاً، لكن إذا كنت تحتاج إلى عمليات بحث متكررة، فقد تكون المجموعات (sets) أو القواميس (dictionaries) أفضل بكثير. في حالتنا، قمنا باستبدال قائمة كبيرة بقاموس، مما قلل وقت البحث من O(n) إلى O(1). هذا التغيير البسيط خفض وقت المعالجة بنسبة 30%.
أداة أخرى مفيدة جداً لمراقبة استخدام الذاكرة هي memory_profiler. هذه الأداة تسمح لك بقياس استخدام الذاكرة سطراً بسطر، مما يساعد في تحديد الأماكن التي تستهلك الذاكرة بشكل غير متوقع. إليك كيفية استخدامها:
from memory_profiler import profile
@profile
def my_func():
a = [1] * (10 ** 6)
b = [2] * (2 * 10 ** 7)
del b
return a
if __name__ == '__main__':
my_func()عند تشغيل هذا، سترى تقريراً مفصلاً عن استخدام الذاكرة لكل سطر في الدالة. هذا النوع من التحليل يمكن أن يكشف عن مشاكل لم تكن تلاحظها من قبل، مثل الاحتفاظ بمراجع غير ضرورية أو استخدام هياكل بيانات غير فعالة.
واحدة من أقوى ميزات Python هي قدرتها على الاستفادة من مكتبات مكتوبة بلغة C. هذه المكتبات غالباً ما تكون أسرع بعشرات أو حتى مئات المرات من الكود المكتوب بـ Python النقي. في مشروعنا، استخدمنا عدة مكتبات من هذا النوع لتحسين الأداء بشكل كبير دون الحاجة إلى كتابة أي كود بلغة C.
المثال الكلاسيكي هو مكتبة NumPy لمعالجة المصفوفات. إذا كنت تقوم بأي نوع من العمليات الرياضية على البيانات، فإن استخدام NumPy يمكن أن يعطي تحسناً هائلاً في الأداء. إليك مثال بسيط يوضح الفرق:
# باستخدام Python النقي
import time
def python_sum(n):
s = 0
for i in range(n):
s += i
return s
start = time.time()
python_sum(10**7)
print(f"Python النقي: {time.time() - start:.4f} ثانية")
# باستخدام NumPy
import numpy as np
def numpy_sum(n):
arr = np.arange(n)
return arr.sum()
start = time.time()
numpy_sum(10**7)
print(f"NumPy: {time.time() - start:.4f} ثانية")على جهازي، أعطى هذا الكود النتائج التالية: Python النقي استغرق 0.45 ثانية، بينما NumPy استغرق 0.02 ثانية فقط. هذا فرق هائل في الأداء، خاصة عند التعامل مع مجموعات بيانات كبيرة. السر هنا هو أن NumPy تنفذ العمليات الحسابية في كود C المحسن، وليس في Python البطيء نسبياً.
لكن NumPy ليست المكتبة الوحيدة. هناك العديد من المكتبات الأخرى التي يمكن أن تعطي نتائج مماثلة في مجالات مختلفة:
في مشروعنا، استخدمنا Numba لتسريع دالة معالجة بيانات كانت تستغرق وقتاً طويلاً. ببساطة عن طريق إضافة مُزخرف (decorator) واحد، انخفض وقت التنفيذ من 45 ثانية إلى 3 ثوانٍ فقط. إليك كيف فعلنا ذلك:
from numba import jit
import numpy as np
# بدون Numba
def slow_function(data):
result = 0
for i in range(data.shape[0]):
for j in range(data.shape[1]):
result += data[i, j] * 2
return result
# مع Numba
@jit(nopython=True)
def fast_function(data):
result = 0
for i in range(data.shape[0]):
for j in range(data.shape[1]):
result += data[i, j] * 2
return result
# اختبار الأداء
data = np.random.rand(1000, 1000)
import time
start = time.time()
slow_function(data)
print(f"بدون Numba: {time.time() - start:.4f} ثانية")
start = time.time()
fast_function(data)
print(f"مع Numba: {time.time() - start:.4f} ثانية")الفرق هنا كان مذهلاً: من 1.2 ثانية إلى 0.005 ثانية. السر هو أن Numba تحول الكود إلى كود آلة محسن في وقت التشغيل، مما يسمح له بالعمل بسرعة قريبة من سرعة C. هذا النوع من التحسينات يمكن أن يحدث فرقاً كبيراً في التطبيقات الحقيقية، خاصة عندما تتعامل مع مجموعات بيانات كبيرة أو عمليات حسابية معقدة.
في نهاية المطاف، غالباً ما تكون التحسينات الكبيرة ناتجة عن مجموعة من التحسينات الصغيرة التي يتم تجاهلها عادة. هذه التفاصيل الصغيرة يمكن أن يكون لها تأثير تراكمي كبير على الأداء. في مشروعنا، قمنا بتطبيق عدة تحسينات من هذا النوع، وكل منها ساهم في تقليل وقت التنفيذ ببضع ثوانٍ أو حتى أجزاء من الثانية، لكن مجموعها كان فارقاً كبيراً.
أحد هذه التحسينات كان استخدام Generator Expressions بدلاً من List Comprehensions عندما لا نحتاج إلى القائمة بأكملها في الذاكرة. مثلاً، إذا كنت تريد فقط التكرار على النتائج دون تخزينها، فإن استخدام generator يمكن أن يوفر الكثير من الذاكرة والوقت. إليك الفرق:
# List Comprehension - تخزن القائمة بأكملها في الذاكرة
squares = [x**2 for x in range(1000000)]
# Generator Expression - تنتج القيم عند الطلب فقط
gen_squares = (x**2 for x in range(1000000))
# استخدام كل منهما
for s in squares: # تضطر إلى إنشاء القائمة بأكملها أولاً
pass
for s in gen_squares: # تنتج القيم واحدة تلو الأخرى
passفي حالتنا، استبدلنا عدة list comprehensions بـ generator expressions، مما قلل استخدام الذاكرة بشكل كبير وأدى إلى تسريع بسيط في التنفيذ. هذا النوع من التغييرات سهل التنفيذ وله تأثير إيجابي، خاصة عند التعامل مع مجموعات بيانات كبيرة.
تحسين آخر كان استخدام built-in functions بدلاً من الدوال المخصصة عندما يكون ذلك ممكناً. دوال Python المدمجة مكتوبة بلغة C وغالباً ما تكون أسرع بكثير من الدوال المخصصة المكافئة. مثلاً، استخدام sum() بدلاً من حلقة مخصصة لحساب المجموع يمكن أن يكون أسرع بكثير:
# حلقة مخصصة
result = 0
for num in numbers:
result += num
# استخدام الدالة المدمجة
result = sum(numbers)في مشروعنا، قمنا باستبدال عدة دوال مخصصة بدوال مدمجة، مما أدى إلى تحسينات طفيفة لكنها ملحوظة في الأداء. هذا النوع من التغييرات سهل التنفيذ ولا يتطلب إعادة كتابة كبيرة للكود.
أخيراً، هناك تحسين يتعلق بكيفية تعاملنا مع السلاسل النصية (strings). في Python، السلاسل النصية غير قابلة للتغيير (immutable)، مما يعني أن كل عملية تعديل تنشئ سلسلة جديدة. هذا يمكن أن يكون مكلفاً جداً عند التعامل مع سلاسل نصية كبيرة. الحل؟ استخدام list لتجميع الأجزاء ثم تحويلها إلى سلسلة نصية مرة واحدة في النهاية:
# طريقة غير فعالة
result = ""
for s in list_of_strings:
result += s # تنشئ سلسلة جديدة في كل مرة
# طريقة فعالة
result_list = []
for s in list_of_strings:
result_list.append(s)
result = "".join(result_list) # إنشاء سلسلة واحدة فقطفي حالتنا، قمنا بتطبيق هذا التحسين على عدة أجزاء من الكود كانت تتعامل مع سلاسل نصية كبيرة، مما أدى إلى تحسينات ملحوظة في الأداء واستخدام الذاكرة.
تحسين أداء كود Python ليس فناً غامضاً، بل هو عملية منهجية تعتمد على القياس والتحليل والتجربة. المفتاح هو عدم التخمين - استخدم أدوات القياس لتحديد مكان الاختناق الحقيقي، ثم طبق التحسينات المناسبة. في معظم الحالات، لن تحتاج إلى إعادة كتابة الكود بالكامل، بل ستجد أن تحسينات صغيرة مركزة يمكن أن تعطي نتائج مذهلة.
من تجربتي، أفضل نهج هو البدء بقياس الأداء لتحديد أكبر اختناق، ثم تطبيق تحسين واحد في كل مرة، مع قياس التأثير بعد كل تغيير. هذا يسمح لك بتحديد بالضبط ما يعمل وما لا يعمل، دون إضاعة الوقت في تحسينات لا تعطي نتائج ملموسة. أيضاً، لا تنسَ أن التحسين المبكر هو أصل كل الشرور - ركز أولاً على كتابة كود نظيف وصحيح، ثم قم بالتحسين فقط عندما يكون ذلك ضرورياً بالفعل.
وأخيراً، تذكّر أن الهدف ليس مجرد جعل الكود أسرع، بل جعله أسرع بطريقة مستدامة. تحسينات الأداء التي تأتي على حساب قابلية الصيانة أو وضوح الكود غالباً ما تكون أسوأ من عدم التحسين على الإطلاق. ابحث دائماً عن التوازن بين الأداء وقابلية الصيانة، واستخدم أدوات القياس لإثبات أن تحسيناتك تعطي نتائج حقيقية قبل اعتمادها في الكود الرئيسي.
البرمجة هي فن تحويل الأفكار إلى تعليمات يفهمها الحاسوب، لكن هندسة البرمجيات هي فن جعل هذه التعليمات فعالة ومستدامة وقابلة للتطوير.
— تجربة شخصية