كيفية التحسين من أجل السرعة#

يقدم ما يلي بعض الإرشادات العملية لمساعدتك في كتابة تعليمات برمجية فعالة لمشروع scikit-learn.

ملاحظة

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

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

يقدم القسم خدعة خوارزمية بسيطة: عمليات إعادة التشغيل الدافئة مثالاً على إحدى هذه الحيل.

Python أو Cython أو C/C++؟#

بشكل عام، يؤكد مشروع scikit-learn على قابلية قراءة شفرة المصدر لتسهيل على مستخدمي المشروع الغوص في شفرة المصدر لفهم كيفية تصرف الخوارزمية على بياناتهم ولكن أيضًا لسهولة الصيانة (من قبل المطورين).

عند تنفيذ خوارزمية جديدة، يُوصى بالبدء بتنفيذها في Python باستخدام Numpy و Scipy عن طريق الحرص على تجنب كود التكرار باستخدام التعابير الاصطلاحية المتجهة لهذه المكتبات. من الناحية العملية، هذا يعني محاولة استبدال أي حلقات for متداخلة باستدعاءات لأساليب مصفوفة Numpy المكافئة. الهدف هو تجنب إضاعة وحدة المعالجة المركزية للوقت في مترجم Python بدلاً من معالجة الأرقام لتناسب نموذجك الإحصائي. من الجيد عمومًا مراعاة نصائح أداء NumPy و SciPy: https://scipy.github.io/old-wiki/pages/PerformanceTips

ومع ذلك، في بعض الأحيان لا يمكن التعبير عن الخوارزمية بكفاءة في كود Numpy متجه بسيط. في هذه الحالة، فإن الاستراتيجية المُوصى بها هي التالي:

  1. نمِّط تنفيذ Python للعثور على الاختناق الرئيسي و اعزله في دالة مستوى وحدة مخصصة. سيتم إعادة تنفيذ هذه الدالة كوحدة نمطية ملحق مُجمَّعة.

  2. إذا كان هناك تنفيذ C/C++ جيد الصيانة BSD أو MIT لنفس الخوارزمية التي ليست كبيرة جدًا، فيمكنك كتابة مغلِّف Cython لها وتضمين نسخة من شفرة المصدر للمكتبة في شجرة مصدر scikit-learn: يتم استخدام هذه الاستراتيجية للفئات svm.LinearSVC و svm.SVC و linear_model.LogisticRegression (مغلِّفات لـ liblinear و libsvm).

  3. خلاف ذلك، اكتب إصدارًا مُحسَّنًا من دالة Python باستخدام Cython مباشرةً. تُستخدم هذه الاستراتيجية للفئات linear_model.ElasticNet و linear_model.SGDClassifier على سبيل المثال.

  4. انقل إصدار Python للدالة في الاختبارات و استخدمه للتحقق من أن نتائج الملحق المُجمَّع تتوافق مع المعيار الذهبي، إصدار Python سهل التصحيح.

  5. بمجرد تحسين الشفرة (وليس مجرد نقطة اختناق يمكن اكتشافها بواسطة التنميط)، تحقق مما إذا كان من الممكن الحصول على توازي خشن الحبيبات مناسب لـ التعددية المعالجة باستخدام فئة joblib.Parallel.

تنميط كود Python#

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

لنفترض أننا نريد تنميط وحدة عامل المصفوفة غير السالب لـ scikit-learn. لنقم بإعداد جلسة IPython جديدة وتحميل مجموعة بيانات الأرقام كما في المثال التعرف على الأرقام المكتوبة بخط اليد:

In [1]: from sklearn.decomposition import NMF

In [2]: from sklearn.datasets import load_digits

In [3]: X, _ = load_digits(return_X_y=True)

قبل بدء جلسة التنميط والمشاركة في تكرارات التحسين المؤقتة، من المهم قياس إجمالي وقت التنفيذ للدالة التي نريد تحسينها دون أي نوع من النفقات العامة للمُنمِّط وحفظها في مكان ما للرجوع إليها لاحقًا:

In [4]: %timeit NMF(n_components=16, tol=1e-2).fit(X)
1 loops, best of 3: 1.7 s per loop

لإلقاء نظرة على ملف تعريف الأداء العام باستخدام أمر %prun السحري:

In [5]: %prun -l nmf.py NMF(n_components=16, tol=1e-2).fit(X)
         14496 استدعاء دالة في 1.682 ثانية لوحدة المعالجة المركزية

   مرتبة حسب: الوقت الداخلي
   تم تقليص القائمة من 90 إلى 9 بسبب التقييد <'nmf.py'>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       36    0.609    0.017    1.499    0.042 nmf.py:151(_nls_subproblem)
     1263    0.157    0.000    0.157    0.000 nmf.py:18(_pos)
        1    0.053    0.053    1.681    1.681 nmf.py:352(fit_transform)
      673    0.008    0.000    0.057    0.000 nmf.py:28(norm)
        1    0.006    0.006    0.047    0.047 nmf.py:42(_initialize_nmf)
       36    0.001    0.000    0.010    0.000 nmf.py:36(_sparseness)
       30    0.001    0.000    0.001    0.000 nmf.py:23(_neg)
        1    0.000    0.000    0.000    0.000 nmf.py:337(__init__)
        1    0.000    0.000    1.681    1.681 nmf.py:461(fit)

عمود tottime هو الأكثر إثارة للاهتمام: فهو يعطي إجمالي الوقت الذي تم قضاؤه في تنفيذ شفرة دالة معينة مع تجاهل الوقت الذي تم قضاؤه في تنفيذ الدوال الفرعية. يتم إعطاء إجمالي الوقت الحقيقي (الشفرة المحلية + استدعاءات الدوال الفرعية) بواسطة عمود cumtime.

لاحظ استخدام -l nmf.py الذي يقصر الإخراج على الأسطر التي تحتوي على سلسلة "nmf.py". هذا مفيد لإلقاء نظرة سريعة على النقطة الساخنة لوحدة nmf Python نفسها مع تجاهل أي شيء آخر.

فيما يلي بداية ناتج نفس الأمر بدون عامل تصفية -l nmf.py:

In [5] %prun NMF(n_components=16, tol=1e-2).fit(X)
         16159 استدعاء دالة في 1.840 ثانية لوحدة المعالجة المركزية

   مرتبة حسب: الوقت الداخلي

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     2833    0.653    0.000    0.653    0.000 {numpy.core._dotblas.dot}
       46    0.651    0.014    1.636    0.036 nmf.py:151(_nls_subproblem)
     1397    0.171    0.000    0.171    0.000 nmf.py:18(_pos)
     2780    0.167    0.000    0.167    0.000 {method 'sum' of 'numpy.ndarray' objects}
        1    0.064    0.064    1.840    1.840 nmf.py:352(fit_transform)
     1542    0.043    0.000    0.043    0.000 {method 'flatten' of 'numpy.ndarray' objects}
      337    0.019    0.000    0.019    0.000 {method 'all' of 'numpy.ndarray' objects}
     2734    0.011    0.000    0.181    0.000 fromnumeric.py:1185(sum)
        2    0.010    0.005    0.010    0.005 {numpy.linalg.lapack_lite.dgesdd}
      748    0.009    0.000    0.065    0.000 nmf.py:28(norm)
...

تُظهر النتائج أعلاه أن التنفيذ يهيمن عليه إلى حد كبير عمليات حاصل الضرب النقطي (المفوضة إلى blas). ومن ثم، ربما لا يوجد مكسب كبير متوقع من خلال إعادة كتابة هذه الشفرة في Cython أو C/C++: في هذه الحالة، من إجمالي وقت التنفيذ البالغ 1.7 ثانية، يتم قضاء ما يقرب من 0.7 ثانية في التعليمات البرمجية المُجمَّعة التي يمكننا اعتبارها مثالية. من خلال إعادة كتابة بقية كود Python بافتراض أنه يمكننا تحقيق زيادة بنسبة 1000٪ في هذا الجزء (وهو أمر غير مرجح للغاية نظرًا لضحالة حلقات Python)، لن نحصل على أكثر من 2.4x تسريع عالميًا.

ومن ثم، لا يمكن تحقيق تحسينات كبيرة إلا من خلال التحسينات الخوارزمية في هذا المثال المعين (على سبيل المثال، محاولة العثور على عملية مُكلفة وغير مجدية لتجنب حسابها بدلاً من محاولة تحسين تنفيذها).

مع ذلك، لا يزال من المثير للاهتمام التحقق مما يحدث داخل دالة _nls_subproblem التي تُعد النقطة الساخنة إذا أخذنا في الاعتبار كود Python فقط: فهي تستغرق حوالي 100٪ من الوقت المُتراكم للوحدة. من أجل فهم ملف تعريف هذه الدالة المحددة بشكل أفضل، دعنا نُثبِّت line_profiler ونوصله بـ IPython:

pip install line_profiler

ضمن IPython 0.13+، قم أولاً بإنشاء ملف تعريف تكوين:

ipython profile create

ثم سجِّل امتداد line_profiler في ~/.ipython/profile_default/ipython_config.py:

c.TerminalIPythonApp.extensions.append('line_profiler')
c.InteractiveShellApp.extensions.append('line_profiler')

سيؤدي هذا إلى تسجيل الأمر السحري %lprun في تطبيق محطة IPython والواجهات الأمامية الأخرى مثل qtconsole ودفتر الملاحظات.

الآن أعد تشغيل IPython ودعنا نستخدم هذه الأداة الجديدة:

In [1]: from sklearn.datasets import load_digits

In [2]: from sklearn.decomposition import NMF
  ... : from sklearn.decomposition._nmf import _nls_subproblem

In [3]: X, _ = load_digits(return_X_y=True)

In [4]: %lprun -f _nls_subproblem NMF(n_components=16, tol=1e-2).fit(X)
وحدة المؤقت: 1e-06 ثانية

الملف: sklearn/decomposition/nmf.py
الدالة: _nls_subproblem في السطر 137
إجمالي الوقت: 1.73153 ثانية

رقم السطر      الزيارات        الوقت  لكل زيارة   ٪ الوقت  محتويات السطر
===============================================================================
   137                                           def _nls_subproblem(V, W, H_init, tol, max_iter):
   138                                               """عامل المربعات الصغرى غير السالب
   ...
   170                                               """
   171        48         5863    122.1      0.3      if (H_init < 0).any():
   172                                                   raise ValueError("Negative values in H_init passed to NLS solver.")
   173
   174        48          139      2.9      0.0      H = H_init
   175        48       112141   2336.3      5.8      WtV = np.dot(W.T, V)
   176        48        16144    336.3      0.8      WtW = np.dot(W.T, W)
   177
   178                                               # القيم مُبررة في الورقة
   179        48          144      3.0      0.0      alpha = 1
   180        48          113      2.4      0.0      beta = 0.1
   181       638         1880      2.9      0.1      for n_iter in range(1, max_iter + 1):
   182       638       195133    305.9     10.2          grad = np.dot(WtW, H) - WtV
   183       638       495761    777.1     25.9          proj_gradient = norm(grad[np.logical_or(grad < 0, H > 0)])
   184       638         2449      3.8      0.1          if proj_gradient < tol:
   185        48          130      2.7      0.0              break
   186
   187      1474         4474      3.0      0.2          for inner_iter in range(1, 20):
   188      1474        83833     56.9      4.4              Hn = H - alpha * grad
   189                                                       # Hn = np.where(Hn > 0, Hn, 0)
   190      1474       194239    131.8     10.1              Hn = _pos(Hn)
   191      1474        48858     33.1      2.5              d = Hn - H
   192      1474       150407    102.0      7.8              gradd = np.sum(grad * d)
   193      1474       515390    349.7     26.9              dQd = np.sum(np.dot(WtW, d) * d)
   ...

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

تنميط استخدام الذاكرة#

يمكنك تحليل استخدام الذاكرة لأي كود Python بالتفصيل بمساعدة memory_profiler. أولاً، قم بتثبيت أحدث إصدار:

pip install -U memory_profiler

ثم قم بإعداد السحر بطريقة مشابهة لـ line_profiler.

ضمن IPython 0.11+، قم أولاً بإنشاء ملف تعريف تكوين:

ipython profile create

ثم سجِّل الامتداد في ~/.ipython/profile_default/ipython_config.py جنبًا إلى جنب مع مُنمِّط الخط:

c.TerminalIPythonApp.extensions.append('memory_profiler')
c.InteractiveShellApp.extensions.append('memory_profiler')

سيؤدي هذا إلى تسجيل الأوامر السحرية %memit و %mprun في تطبيق محطة IPython والواجهات الأمامية الأخرى مثل qtconsole و دفتر الملاحظات.

%mprun مفيد لفحص استخدام الذاكرة للدوال الرئيسية في برنامجك سطرًا بسطر. إنه مشابه جدًا لـ %lprun، الذي تمت مناقشته في القسم السابق. على سبيل المثال، من دليل memory_profiler examples:

In [1] from example import my_func

In [2] %mprun -f my_func my_func()
اسم الملف: example.py

رقم السطر    استخدام الذاكرة  الزيادة   محتويات السطر
==========================================================
     3                           @profile
     4      5.97 MB    0.00 MB   def my_func():
     5     13.61 MB    7.64 MB       a = [1] * (10 ** 6)
     6    166.20 MB  152.59 MB       b = [2] * (2 * 10 ** 7)
     7     13.61 MB -152.59 MB       del b
     8     13.61 MB    0.00 MB       return a

أمر سحري مفيد آخر يُحدده memory_profiler هو %memit، وهو مُشابه لـ %timeit. يمكن استخدامه على النحو التالي:

In [1]: import numpy as np

In [2]: %memit np.zeros(1e7)
الحد الأقصى من 3: 76.402344 ميغابايت لكل حلقة

لمزيد من التفاصيل، انظر سلاسل الوثائق الخاصة بالسحر، باستخدام %memit؟ و %mprun؟.

استخدام Cython#

إذا كشف تنميط كود Python أن النفقات العامة لمترجم Python أكبر بمرتبة واحدة من حيث الحجم أو أكثر من تكلفة الحساب العددي الفعلي (على سبيل المثال، حلقات for على مكونات المتجه، التقييم المتداخل للتعبير الشرطي، الحساب القياسي...)، فمن المناسب استخراج جزء النقطة الساخنة من الشفرة كـ دالة مستقلة في ملف .pyx، وإضافة إعلانات النوع الثابت ثم استخدام Cython لإنشاء برنامج C مناسب ليتم تجميعه كـ وحدة نمطية ملحق Python.

تحتوي وثائق Cython على برنامج تعليمي ودليل مرجعي لتطوير مثل هذه الوحدة. لمزيد من المعلومات حول التطوير في Cython لـ scikit-learn، انظر أفضل ممارسات Cython والاتفاقيات والمعرفة.

تنميط الملحقات المُجمَّعة#

عند العمل مع الملحقات المُجمَّعة (مكتوبة بلغة C/C++ مع غلاف أو مباشرة كملحق Cython)، يكون مُنمِّط Python الافتراضي عديم الفائدة: نحن بحاجة إلى أداة مخصصة لاستقصاء ما يحدث داخل الملحق المُجمَّع نفسه.

استخدام yep و gperftools#

تنميط سهل بدون خيارات تجميع خاصة استخدم yep:

استخدام مُصحِّح أخطاء، gdb#

  • من المفيد استخدام gdb لتصحيح الأخطاء. للقيام بذلك، يجب على المرء استخدام مترجم Python تم تصميمه مع دعم التصحيح (رموز التصحيح والتحسين المناسب). لإنشاء بيئة conda جديدة (التي قد تحتاج إلى إلغاء تنشيطها وإعادة تنشيطها بعد البناء/التثبيت) باستخدام مترجم CPython مبني من المصدر:

    git clone https://github.com/python/cpython.git
    conda create -n debug-scikit-dev
    conda activate debug-scikit-dev
    cd cpython
    mkdir debug
    cd debug
    ../configure --prefix=$CONDA_PREFIX --with-pydebug
    make EXTRA_CFLAGS='-DPy_DEBUG' -j<num_cores>
    make install
    

استخدام gprof#

من أجل تنميط ملحقات Python المُجمَّعة، يمكن للمرء استخدام gprof بعد إعادة تجميع المشروع باستخدام gcc -pg واستخدام متغير python-dbg للمترجم على debian / ubuntu: ومع ذلك يتطلب هذا النهج أيضًا إعادة تجميع numpy و scipy باستخدام -pg وهو أمر معقد إلى حد ما لجعله يعمل.

لحسن الحظ، يوجد مُنمِّطان بديلان لا يتطلبان منك إعادة تجميع كل شيء.

استخدام valgrind / callgrind / kcachegrind#

kcachegrind#

يمكن استخدام yep لإنشاء تقرير تنميط. يوفر kcachegrind بيئة رسومية لتصور هذا التقرير:

# تشغيل yep لتنميط بعض البرامج النصية python
python -m yep -c my_file.py
# افتح my_file.py.callgrin باستخدام kcachegrind
kcachegrind my_file.py.prof

ملاحظة

يمكن تنفيذ yep مع الوسيطة --lines أو -l لتجميع تقرير تنميط "سطرًا بسطر".

التوازي متعدد النواة باستخدام joblib.Parallel#

انظر وثائق joblib

خدعة خوارزمية بسيطة: عمليات إعادة التشغيل الدافئة#

انظر إدخال المُصطلحات لـ warm_start