טופולוגיית זיכרון ושיתוף בין ליבות¶
נניח ששני תהליכונים מעדכנים שני מונים שונים, ובכל זאת כל הוספת תהליכון מאטה את העבודה. אין כאן בהכרח נעילה גלויה. אם המונים נמצאים באותו קו מטמון, כתיבה לאחד מהם יכולה לדרוש העברת בעלות על הקו כולו בין ליבות. זאת דוגמה ל־false sharing: הנתונים נפרדים מבחינת האלגוריתם אבל משותפים מבחינת יחידת העקביות של החומרה.
נשתמש בניסוי קטן עם מונים אטומיים כדי למנוע data race בזמן המדידה. בפרק השביעי נפרק את חוזי התהליכונים והאטומיות עצמם; כאן כל תהליכון מעדכן מונה נפרד, וההמתנה לסיום מתבצעת על ידי scope לפני שהפונקציה חוזרת.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
#[repr(C)]
struct Nearby { first: AtomicU64, second: AtomicU64 }
#[repr(align(128))]
struct Separated(AtomicU64);
fn measure(first: &AtomicU64, second: &AtomicU64) -> Duration {
let start = Instant::now();
std::thread::scope(|scope| {
scope.spawn(|| { for _ in 0..2_000_000 { first.fetch_add(1, Ordering::Relaxed); } });
scope.spawn(|| { for _ in 0..2_000_000 { second.fetch_add(1, Ordering::Relaxed); } });
});
start.elapsed()
}
fn main() {
let nearby = Nearby { first: AtomicU64::new(0), second: AtomicU64::new(0) };
let first = Separated(AtomicU64::new(0));
let second = Separated(AtomicU64::new(0));
println!("nearby={:?}", measure(&nearby.first, &nearby.second));
println!("separate={:?}", measure(&first.0, &second.0));
}
היישור של 128 בייט נבחר כדי להרחיק את האובייקטים בניסוי במערכות נפוצות, ואינו טענה שלכל מעבד יש קו מטמון בגודל הזה. בדקו את טופולוגיית המכונה ואת הכתובות בפועל. גם struct שבו השדות קרובים יכול ליפול על גבול בין קווים בהתאם ליישור, ולכן אין להפוך מדידה מקרית לחוק של השפה.
הריצו בבניית release וחזרו כמה פעמים עם סדר ניסויים מתחלף. אם שני התהליכונים רצים על אותה ליבה לוגית, לא נמדוד אותה תופעה כמו בשתי ליבות נפרדות. אם הם רצים על שני SMT siblings, המשאבים המשותפים שונים מהמצב של ליבות נפרדות. השתמשו ב־lscpu וב־taskset כדי לתעד את הבחירה, והימנעו מהנחה שמספרי CPU עוקבים תמיד מייצגים ליבות פיזיות שונות.
הפעולה האטומית fetch_add עדיין משלמת על תיאום של הקו גם עם Relaxed. סדר זיכרון חלש יותר אינו אומר שאין תקשורת חומרה. הוא מצמצם התחייבויות סדר מסוימות, אבל הכתיבה האטומית עצמה עדיין צריכה לעדכן ערך משותף באופן תקין. אם אפשר לאסוף מונים מקומיים ולסכום בסוף, שינוי האלגוריתם יכול לחסוך יותר מהחלפת ordering.
במערכת NUMA יש גם מרחק בין מעבד לזיכרון. הקצאה וירטואלית אינה מבטיחה שהעמודים הפיזיים נמצאים ליד הליבה שתשתמש בהם. מדיניות first touch יכולה לגרום לכך שהתהליכון שנוגע ראשון בעמוד משפיע על מיקומו, ולכן אתחול כל המאגר בתהליכון אחד עלול לרכז אותו בצומת אחד גם אם העיבוד מפוזר אחר כך.
בדקו את /proc/<pid>/numa_maps בזמן עומס, ואת הטופולוגיה באמצעות כלי המערכת. בסביבה שאין בה יותר מצומת NUMA אחד, אין טעם להציג ניסוי מקומי כמדידה של גישה מרוחקת אמיתית. אפשר עדיין ללמוד את כלי האבחון ולציין שהחומרה אינה מספקת את ההשוואה הזאת.
קיבוע affinity יכול לעזור לניסוי משוחזר, אבל אינו פתרון קסם לכל שירות. קיבוע שגוי יכול למנוע מה־scheduler לאזן עומס, להעמיס ליבה אחת או ליצור תחרות על interrupt handling. קודם נבין היכן הנתונים והעבודה נמצאים, ואז נבחן שינוי שמבודד את ההשערה.
הפרדת שדות למטמונים שונים מגדילה את גודל המבנים ואת צריכת הזיכרון. אם סורקים מיליוני מבנים, padding רב יכול לפגוע ב־cache locality וב־TLB. לכן נשתמש בו עבור נתונים שנכתבים במקביל ונמדוד את העלות הכוללת, במקום ליישר כל שדה גדול ככל האפשר.
הטופולוגיה והמדיניות מתוארות בתיעוד NUMA של הקרנל. המסקנה שאנחנו מחפשים אינה שמבנה מסוים תמיד מהיר יותר, אלא הוכחה שהמיקום של נתונים ושל עבודה מסביר את ההבדל שנמדד.