כשאנחנו כותבים תוכנה, קל לחשוב שהמחשב פשוט "מריץ את הקוד". בפועל, מאחורי כל שורת קוד עומדת שרשרת שלמה של תהליכים, שבסופה המעבד מבצע הוראות מכונה פשוטות מאוד.
המעבד, או CPU, הוא הרכיב שאחראי על ביצוע ההוראות של התוכניות הפועלות במחשב. הוא מבצע חישובים, משווה ערכים, מעביר נתונים, מקבל החלטות ומתקדם בין הוראות התוכנית.
אבל המעבד אינו מבין ישירות קוד Python, Java או JavaScript. הוא מבין רק את סט ההוראות של הארכיטקטורה שלו, למשל ARM64 או x86-64.
כיצד המעבד מבצע הוראה?
באופן מפושט, ביצוע הוראה עובר דרך כמה שלבים:
Fetch -> Decode -> Execute -> Write BackFetch - הבאת ההוראה
המעבד צריך לדעת מהי ההוראה הבאה שעליו לבצע.
לשם כך קיים אוגר מיוחד בשם Program Counter, או בקיצור PC. הוא מחזיק את כתובת ההוראה הבאה.
בשלב ה-Fetch המעבד מביא את ההוראה שנמצאת בכתובת שאליה מצביע ה-PC. בדרך כלל ההוראה מגיעה מה-Instruction Cache, ואם היא אינה נמצאת שם, ייתכן שיהיה צורך להביא אותה מרמה נמוכה יותר בהיררכיית הזיכרון.
לאחר הבאת ההוראה, ה-PC מתקדם בדרך כלל לכתובת ההוראה הבאה. בקפיצה, קריאה לפונקציה או חזרה מפונקציה, ערכו עשוי להשתנות לכתובת אחרת.
Decode - פענוח ההוראה
בשלב ה-Decode המעבד מזהה:
- איזו פעולה צריך לבצע.
- אילו אוגרים מכילים את הנתונים.
- היכן צריך לשמור את התוצאה.
- האם נדרשת גישה לזיכרון.
- האם מדובר בחישוב, השוואה, קפיצה או פעולה אחרת.
לדוגמה:
ADD R3, R1, R2משמעות ההוראה היא לחבר את הערך שב-R1 לערך שב-R2 ולשמור את התוצאה ב-R3.
Execute - ביצוע
בשלב ה-Execute יחידת הביצוע מבצעת את הפעולה בפועל.
אם:
R1 = 10
R2 = 7אז יחידת החישוב מבצעת:
10 + 7 = 17המעבד עשוי גם לעדכן דגלי מצב, למשל:
- Zero Flag - האם התוצאה היא אפס.
- Sign Flag - האם התוצאה שלילית.
- Carry Flag - האם הייתה גלישה בחישוב לא-מסומן.
- Overflow Flag - האם הייתה גלישה בחישוב מסומן.
Write Back - כתיבת התוצאה
לאחר החישוב, התוצאה נכתבת בדרך כלל לאוגר היעד:
R3 = 17חשוב להבדיל בין כתיבה לאוגר לבין כתיבה לזיכרון. הוראת חיבור רגילה אינה חייבת לכתוב ל-RAM. כדי לכתוב תוצאה מהאוגר לזיכרון נדרשת בדרך כלל הוראת `STORE`.
מה תפקיד האוגרים?
האוגרים הם אזורי אחסון קטנים ומהירים מאוד שנמצאים בתוך המעבד.
הם משמשים לשמירת:
- נתונים שנדרשים לחישוב מיידי.
- תוצאות זמניות.
- כתובות בזיכרון.
- פרמטרים של פונקציות.
- כתובת ההוראה הבאה.
- מצבו הנוכחי של החישוב.
זרימת הנתונים הנפוצה היא:
RAM או Cache
↓
Registers
↓
יחידת הביצוע
↓
Registers
↓
לעיתים STORE חזרה לזיכרוןהמעבד מעדיף לעבוד עם אוגרים משום שהם מהירים בהרבה מה-RAM.
למה צריך Cache?
המעבד מהיר בהרבה מזיכרון ה-RAM. אילו היה צריך להמתין ל-RAM לפני כל הוראה, הוא היה מבזבז חלק גדול מזמנו בהמתנה.
לכן קיימות כמה רמות של זיכרון מטמון:
L1 -> L2 -> L3 -> RAMככל שהזיכרון קרוב יותר לליבה, הוא בדרך כלל מהיר יותר אך קטן יותר.
ה-Cache שומר נתונים והוראות שסביר שהמעבד יצטרך בקרוב. הוא מסתמך בין היתר על שני עקרונות:
- Temporal Locality - נתון שנעשה בו שימוש לאחרונה עשוי להידרש שוב.
- Spatial Locality - נתונים שנמצאים קרוב זה לזה בזיכרון עשויים להידרש יחד.
Cache יעיל יכול לגרום למעבד בעל תדר נמוך יותר לנצח מעבד בעל תדר גבוה יותר.
מהו Pipeline?
ה-Pipeline הוא מנגנון פנימי של המעבד שמאפשר לכמה הוראות להימצא בשלבי ביצוע שונים במקביל.
לדוגמה, בזמן שהוראה אחת נמצאת בשלב Execute, הוראה אחרת יכולה להיות בשלב Decode והוראה נוספת בשלב Fetch.
אפשר לדמות זאת למפעל:
הוראה א': Execute
הוראה ב': Decode
הוראה ג': Fetchאין פירוש הדבר שכל הוראה מסתיימת מיד או שכל ההוראות פועלות ללא תלות. תלות בין הוראות, החטאת Cache או חיזוי הסתעפות שגוי עלולים ליצור עיכובים.
מעבדים מודרניים משתמשים גם בטכניקות כמו:
- Out-of-Order Execution.
- Branch Prediction.
- Speculative Execution.
- ביצוע של כמה הוראות בכל מחזור.
כל אלה משפיעים על מספר ההוראות שהמעבד מצליח להשלים בפועל.
מדוע GHz אינו מדד מספיק?
GHz מציין את מספר מחזורי השעון שהמעבד מבצע בכל שנייה.
מעבד בתדר של 4GHz מבצע בקירוב ארבעה מיליארד מחזורים בשנייה, אך מחזור שעון אינו בהכרח הוראה אחת.
מדד נוסף הוא IPC:
IPC = Instructions Per Cycleכלומר, כמה הוראות המעבד משלים בממוצע בכל מחזור.
בקירוב:
ביצועי ליבה ≈ תדר × IPCלדוגמה:
מעבד א': 4GHz × IPC 2 = 8
מעבד ב': 3GHz × IPC 3 = 9למרות שהתדר של מעבד ב' נמוך יותר, הוא עשוי להיות מהיר יותר משום שהוא מבצע יותר עבודה בכל מחזור.
גם ה-IPC אינו מספר קבוע. הוא תלוי בתוכנית, ב-Cache, בתלויות בין ההוראות, בחיזוי הסתעפויות ובמיקרו-ארכיטקטורה של המעבד.
בנוסף, תדר המעבד משתנה בזמן אמת. בעומס גבוה המעבד עשוי להתחמם ולהפעיל Thermal Throttling, כלומר להוריד את התדר כדי להגן על עצמו.
מדוע יותר ליבות לא מאיצות כל תוכנית?
ליבה היא יחידת ביצוע שיכולה להריץ זרם הוראות.
כאשר למחשב יש כמה ליבות, הוא יכול לבצע כמה משימות במקביל. אבל כדי שתוכנית אחת תשתמש בכמה ליבות, העבודה שלה צריכה להיות ניתנת לחלוקה.
לולאת Python רגילה אינה מתחלקת אוטומטית בין כל הליבות:
total = 0
for number in range(20_000_000):
total += number * numberכדי לנצל כמה ליבות אפשר, לדוגמה, לחלק את הטווח בין כמה תהליכים:
תהליך 1 -> מחשב חלק מהטווח
תהליך 2 -> מחשב חלק אחר
התהליך הראשי -> מחבר את התוצאותגם אז לא נקבל בהכרח שיפור לינארי.
יש לכך כמה סיבות:
- עלות יצירת התהליכים.
- חלוקת העבודה.
- העברת התוצאות.
- תזמון מערכת ההפעלה.
- תחרות על Cache ורוחב פס לזיכרון.
- התחממות והורדת תדר.
- חלקים בתוכנית שאינם ניתנים לחלוקה.
- ליבות לוגיות שאינן בהכרח ליבות פיזיות מלאות.
לפי חוק אמדאל, החלק הסדרתי של התוכנית מגביל את ההאצה המרבית. אם חלק מהתוכנית אינו ניתן לחלוקה, שום מספר של ליבות לא יוכל להעלים את הזמן שלו.
כיצד תוכנית Python מגיעה למעבד?
המסלול ב-CPython נראה באופן מפושט כך:
Python Source
↓
Python Compiler
↓
Python Bytecode
↓
CPython Interpreter
↓
Machine Code של המפרש
↓
CPUקוד המקור של Python מיועד לבני אדם.
מהדר Python יוצר ממנו Bytecode. ה-Bytecode אינו קוד מכונה של ARM64 או x86-64, אלא סט הוראות עבור המכונה הווירטואלית והמפרש של Python.
המפרש CPython הוא תוכנה שכבר קומפלה לקוד המכונה של המחשב. המעבד מריץ את קוד המכונה של המפרש, והמפרש קורא ומטפל בהוראות ה-Bytecode.
לכן אותה תוכנית Python יכולה לפעול גם במחשב ARM64 וגם במחשב x86-64, כל עוד מותקן בכל מחשב מפרש Python המתאים לארכיטקטורה שלו.
הניסוי המעשי
הרצתי לולאת Python שמחשבת סכום ריבועים על מחשב macOS מבוסס ARM64 עם 18 מעבדים לוגיים.
התוצאות היו:
20 מיליון איטרציות:
0.42 שניות
CPU: 99.9%
Peak RSS: 16.69MB
40 מיליון איטרציות:
0.83 שניות
CPU: 99.9%
Peak RSS: 16.69MBהיחס בין הזמנים היה:
0.83 ÷ 0.42 ≈ 1.98כלומר, הכפלת מספר האיטרציות הכפילה כמעט בדיוק את זמן הריצה.
השימוש של כ-100% CPU בתהליך ב-macOS העיד על ניצול כמעט מלא של ליבה אחת, ולא של כל 18 המעבדים הלוגיים.
צריכת הזיכרון נשארה זהה משום שהתוכנית לא שמרה את כל התוצאות. היא החזיקה בעיקר את הסכום הנוכחי, את מונה הלולאה ואובייקטים זמניים. לכן צריכת הזיכרון הנוספת הייתה בקירוב קבועה, אף שכמות החישוב הוכפלה.
הניסוי הראה שלושה דברים:
- לולאת CPython יחידה אינה מתחלקת אוטומטית בין כל הליבות.
- במשימה כזו זמן הריצה גדל בקירוב בהתאם לכמות העבודה.
- מספר רב של ליבות אינו עוזר לתוכנית שלא נכתבה לניצול מקביליות.
סיכום
המעבד מבצע הוראות מכונה באמצעות תהליך שכולל הבאת הוראה, פענוח, קריאת נתונים, ביצוע וכתיבת התוצאה.
ה-PC מחזיק את כתובת ההוראה הבאה, האוגרים משמשים כאזור העבודה המהיר של המעבד, וה-Cache מקטין את זמני ההמתנה לזיכרון.
ביצועי המעבד אינם נקבעים רק על פי GHz. הם תלויים גם ב-IPC, ב-Cache, במבנה ה-Pipeline, בתלויות, בחיזוי ובטמפרטורה.
גם מספר הליבות לבדו אינו מבטיח שיפור. כדי להשתמש בכמה ליבות, התוכנית צריכה להיות ניתנת לחלוקה, ועלות החלוקה צריכה להיות קטנה מספיק ביחס לעבודה עצמה.
בסופו של דבר, השאלה החשובה אינה רק "כמה GHz וכמה ליבות יש למעבד?", אלא:
כמה עבודה המעבד מסוגל להשלים בפועל עבור התוכנית המסוימת שאנחנו מריצים?