• בלוג
  • שלוש דרכים לשפר ביצועים בקוד Python

שלוש דרכים לשפר ביצועים בקוד Python

07/05/2019

אז זיהיתם שיש לכם בעיית ביצועים בקוד - עכשיו מה? הנה שלוש טכניקות פשוטות שיעזרו לכם למצוא רעיונות לשיפור ביצועים של פונקציה או פעולה כבדה בקוד.

1. אבל קודם קצת קוד שעובד לאט

טוב לא בדיוק לאט אבל מספיק לאט בשביל שנוכל לדבר על זה. הקוד הבא מחשב כמה מספרים יש בין 0 למיליון שכל סיפרה בהם מופיעה פעם אחת בלבד. הוא מדפיס את מספר התוצאות שמצא וגם את המספר הראשון והאחרון:

import re

def main():
    items = [
            str(num) for num in range(1_000_000)
            if not re.search(r'(\d).*\1', str(num))
    ]

    print(len(items))
    print(items[0])
    print(items[-1])

if __name__ == '__main__':
    main()

הרצה של הקוד עם time אצלי על המק מחזירה את הפלט הבא:

$ time python3 s0.py 
168571
0
987654

real    0m1.673s
user    0m1.625s
sys 0m0.030s

שניה וחצי זה לא סוף העולם אבל בהחלט יש מקום לשיפור.

2. טכניקה 1: אפשר לשפר את הקוד

הדבר הכי טוב שאפשר לעשות זה למצוא דרך מהירה יותר להגיע לאותה תוצאה. בדוגמא שלנו ביטוי רגולארי הוא לא הדרך הכי טובה לבדוק אם סיפרה מופיעה יותר מפעם אחת במספר. דרך יותר מהירה היא להשתמש במבנה נתונים שמותאם במיוחד לחיפוש כפילויות למשל set.

הקוד הבא עושה בדיוק את אותה עבודה רק שהפעם אני משתמש ב set כדי לזהות מספרים שיש בהם סיפרה כפולה:

import re

def main():
    items = [
            str(num) for num in range(1_000_000)
            if len(set(str(num))) == len(str(num))
    ]

    print(len(items))
    print(items[0])
    print(items[-1])

if __name__ == '__main__':
    main()

השינוי הקטן הזה הצליח לגרד באזור החצי שניה מזמן הריצה של התוכנית, שזה לא רע בהתחשב בזה שהתחלנו עם שניה וחצי:

$ time python3 s1.py 
168571
0
987654

real    0m1.092s
user    0m1.060s
sys 0m0.024s

באופן כללי למצוא דרכים יעילות יותר לעשות את אותה עבודה זה כמעט תמיד אפשרי וכמעט תמיד הפיתרון הכי טוב לבעיות ביצועים שאתם עשויים להיתקל בהן.

3. טכניקה 2: אפשר לזרוק את הקוד למספר מעבדים

אחרי ששברנו את הראש והגענו לאלגוריתם הכי מקורי ומהיר שאנחנו יכולים לפעמים שווה לנסות לשפר ביצועים באמצעות פיצול העבודה למספר מעבדים. בפייתון קל מאוד לעשות את זה בעזרת המודול multiprocessing, שבאופן אוטומטי יפתח עבורכם עוד תוכניות פייתון שיפעילו את הפונקציה שלכם במקביל.

בגירסא הבאה של הקוד יצרתי פונקציה שבודקת אם יש סיפרה כפולה בערך מסוים, וביקשתי מהמודול multiprocessing של פייתון להפעיל אותה על כל המספרים בין 0 למיליון. סיננתי את התוצאות ושמרתי ברשימה:

import re
import multiprocessing

def no_repetitions(num):
    if len(set(str(num))) == len(str(num)):
        return num
    else:
        return None


def main():
    p = multiprocessing.Pool()    
    items = [
            n for n in p.map(no_repetitions, range(1_000_000))
            if n is not None
    ]

    print(len(items))
    print(items[0])
    print(items[-1])

if __name__ == '__main__':
    main()

השינוי הזה כן דרש מאמץ והביא שיפור פחות משמעותי מהקודם, אבל עדיין מורגש:

$ time python3 s2.py
168571
0
987654

real    0m0.804s
user    0m2.340s
sys 0m0.083s

הצלחנו לגרד 200 מאיות השניה באמצעות פיצול הקוד למספר מעבדים. ככל שהמשימה מסובכת יותר כך העבודה המקבילית עשויה להביא שיפור יותר משמעותי.

4. טכניקה 3: חישוב מקדים

והטכניקה השלישית שעשויה לעזור היא לשמור בצד מידע שיעזור לכם בריצה הבאה. כך אומנם לא נשפר את מהירות הריצה הראשונה אבל אנחנו עשויים להצליח לשפר את זמן הריצה בהפעלות הבאות. בתוכנית שלנו ממש פשוט לשמור את הרשימה לקובץ ולבדוק אם הקובץ הזה קיים בפעם הבאה שמפעילים את התוכנית:

import re
import multiprocessing
import shelve

def no_repetitions(num):
    if len(set(str(num))) == len(str(num)):
        return num
    else:
        return None


def main():
    with shelve.open('status') as db:
        if 'items' not in db:
            p = multiprocessing.Pool()    
            db['items'] = [
                    n for n in p.map(no_repetitions, range(1_000_000))
                    if n is not None
            ]
        items = db['items']

    print(len(items))
    print(items[0])
    print(items[-1])

if __name__ == '__main__':
    main()

ההפעלה הראשונה יצאה קצת יותר איטית מהגירסא הקודמת כי צריך לבנות את הקובץ ולשמור אליו את המידע:

$ time python3 s3.py 
168571
0
987654

real    0m0.922s
user    0m2.413s
sys 0m0.094s

אבל בפעם השניה שאנחנו מפעילים התוכנית כבר רצה כל כך מהר שקשה למדוד:

$ time python3 s3.py 
168571
0
987654

real    0m0.123s
user    0m0.096s
sys 0m0.020s

למעשה חישוב מקדים נתן את שיפור הביצועים המשמעותי ביותר בגלל שעכשיו זה בכלל לא משנה באיזה מנגנון בניתי את הרשימה - אני ממילא בונה אותה רק פעם אחת.