מפות סיביות וזיכרון

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

הגדרות של מפת סיביות ונתוני פיקסלים

כמות הזיכרון שמפת סיביות צורכת נקבעת בעיקר לפי המידות שלה (רוחב × גובה) והתצורה שלה (Bitmap.Config).

בהגדרה מוגדר כמה בייטים משמשים לייצוג כל פיקסל:

הגדרות אישיות בייטים לפיקסל תיאור
ALPHA_8 1 רק ערוץ אלפא (שקיפות). שימושי למסכות.
RGB_565 2 אדום (5 ביט), ירוק (6 ביט), כחול (5 ביט). אין אלפא. מתאים לתמונות אטומות שבהן נאמנות גבוהה לצבעים היא לא קריטית.
ARGB_8888 4 אלפא, אדום, ירוק, כחול (8 ביטים כל אחד). ברירת המחדל והאפשרות הכי נפוצה.
RGBA_F16 8 נקודה צפה בחצי דיוק. משמש לתוכן עם טווח צבעים רחב ולתוכן HDR.
HARDWARE לא רלוונטי מאוחסן בזיכרון ה-GPU (gralloc/DMABuf). מידע נוסף מופיע במאמר בנושא מפות סיביות של חומרה.

נוסחת הזיכרון: Memory (Bytes) = Width × Height × Bytes Per Pixel

לדוגמה, תמונה במסך מלא במכשיר 1080p ‏ (‎1,920x1,080) בפורמט ARGB_8888 תופסת: ‎1,920 × 1,080 × 4 bytes ≈ 8.3 MB.

מפות סיביות של ערימה לעומת מפות סיביות משותפות

מפות סיביות של ערימות (ערימה מקורית)

ב-Android מודרני (גרסה 8.0 ואילך), נתוני פיקסלים של מפת סיביות מאוחסנים ב-Native Heap, ואילו רק אובייקט wrapper קטן נמצא ב-Java heap.

כשאפליקציה צריכה להציג תמונה, היא בדרך כלל מפוענחת מקובץ תמונה דחוס ל-Bitmap ונשמרת ב-Heap.

מפות סיביות משותפות (ashmem/memfd)

כשמעבירים מפת סיביות בין תהליכים (למשל, דרך Binder אל SystemUI לצורך הצגת התראה), מערכת Android נמנעת מהעתקת נתוני הפיקסלים באמצעות זיכרון משותף (ashmem או memfd).

אפשר להעתיק מכונה של Bitmap לזיכרון משותף באופן מפורש על ידי קריאה ל-Bitmap.asShared(), או באופן מרומז אם מכניסים Bitmap לתוך Parcel (בדרך כלל על ידי הוספת Bitmap ל-Parcelable כמו Bundle) ושולחים אותו באמצעות Binder IPC.

כששולחים Bitmap משותף באמצעות Binder IPC, נתוני הפיקסלים עצמם לא מועתקים, אלא מתבצע שכפול של מתאר קובץ שמפנה לאזור זיכרון משותף לתהליך המקבל. יכול להיות שאזור הזיכרון הבסיסי משותף בין כמה תהליכים, והוא לא משוחרר עד שכל מתארי הקבצים שמפנים אליו נסגרים.

מפות סיביות שניתנות לשינוי לעומת מפות סיביות שלא ניתן לשנות

  • מפות סיביות שניתנות לשינוי: אפשר לשנות אותן אחרי שהן נוצרות (למשל, באמצעות Canvas). תמיד נדרשת הקצאת זיכרון פרטית משלהן. אם מעתיקים Bitmap שניתן לשינוי, צריך ליצור עותק עמוק (עותק שני של כל נתוני הפיקסלים).
  • מפות סיביות שלא ניתן לשנות: אי אפשר לשנות אותן. כך אפשר לבצע אופטימיזציות כמו שיתוף אותו מאגר זיכרון בסיסי בין מופעים שונים של Bitmap. מפות סיביות שנטענו ממקורות של חבילות APK ‏ (BitmapFactory) הן בדרך כלל בלתי ניתנות לשינוי.

טיפול יעיל במפת סיביות

איגום ושימוש חוזר של מפות סיביות

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

‫Google ממליצה על Glide כפתרון לאפליקציות מבוססות Java, ועל Coil לאפליקציות מבוססות Kotlin (במיוחד כשמשתמשים ב-Jetpack Compose).

כשאין יותר צורך במפת סיביות, במקום לאפשר ל-GC לטפל בה, האפליקציה קוראת ל-bitmap.recycle() או מחזירה אותה למאגר. בפעם הבאה שיידרש bitmap עם אותם ממדים והגדרות, המאגר יספק את המאגר הקיים, וכך יימנע הקצאה חדשה.

מפות סיביות של חומרה

‫Bitmap.Config.HARDWARE מאפשרת לכם לאחסן נתוני פיקסלים ישירות בזיכרון הגרפי (DMABuf).

  • יתרונות:
    • חיסכון בזיכרון: לא נעשה שימוש ב-heap של האפליקציה או ב-heap של Native, אלא בזיכרון ה-GPU. לרוב, צריך להעתיק את מפות הביטים שמוצגות בממשק המשתמש של האפליקציה לזיכרון של ה-GPU, כך שהשימוש ב-BitmapPool חוסך את פעולת ההעתקה הזו ואת עלות הזיכרון הנוספת.
    • ביצועים: מהירות הציור גבוהה מאוד כי הנתונים כבר נמצאים במעבד ה-GPU.
  • חסרונות:
    • בלתי ניתן לשינוי: אי אפשר לשנות מפות סיביות של חומרה.
    • קריאה חוזרת איטית: הגישה לפיקסלים מהמעבד (לדוגמה, getPixel()) יקרה מאוד.
    • שיוך: קשה יותר לעקוב אחריו בכלים רגילים כמו AHAT (ראו בהמשך).

בעיות נפוצות בזיכרון של מפת הסיביות (bitmap)

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

פענוח של מפות סיביות (bitmap) שגדולות מדי

תמונה ברזולוציה מלאה של ‎4,000 × 3,000 פיקסלים תופסת נפח של 48MB ב-ARGB_8888. פענוח התמונה המלאה רק כדי לעבד אותה בתוך תמונה ממוזערת בגודל 200‎ × 150 פיקסלים מבזבז יותר מ-99% ממאגר הפיקסלים שהוקצה.

כשמפענחים תמונות ישירות באמצעות ImageDecoder או BitmapFactory, צריך להקטין את הרזולוציה במהלך שלב הפענוח כדי להתאים לממדי תצוגת היעד באמצעות ImageDecoder.setTargetSize() או BitmapFactory.Options.inSampleSize. ספריות לטעינת תמונות כמו Glide ו-Coil מבצעות את ההורדה של קצב הדגימה באופן אוטומטי כשמספקים גודל תצוגה מוגבל של יעד.

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

val source = ImageDecoder.createSource(resources, R.drawable.high_res_photo)
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
    if (info.size.width > targetWidth || info.size.height > targetHeight) {
        decoder.setTargetSize(targetWidth, targetHeight)
    }
}

שיעור גבוה של שמירה על נתונים בו-זמנית מפענוח מקביל

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

שיעור גבוה של שמירת נתונים במקביל מגדיל את טביעת הרגל של ה-heap המקומי בשיא העומס, ויכול לגרום לlmkd להפסיק את הפעולה לפני שהאצווה מסתיימת. הגבילו את פענוח הבו-זמניות באמצעות מאגר שרשורים מוגבל, סמפור או סידור עבודה של שגרות המשך כמו Dispatchers.IO.limitedParallelism(2), כדי שרק כמה מפות סיביות יפוענחו בו-זמנית.

מפות סיביות זמניות שלא נעשה בהן שימוש חוזר בלולאות של עיבוד מסגרות

ב-Android מגרסה 8.0 ואילך, אובייקט העטיפה Bitmap של Java תופס רק כ-56 בייט בזיכרון מ-Java, בעוד שמאגר הפיקסלים שלו נמצא בזיכרון המקורי ויכול לתפוס כמה מגה-בייט. אפשר לאמת את החלוקה הזו ב-Memory Profiler של Android Studio או ב-AHAT, שבהם כל מופע של Bitmap מציג גודל Java רדוד של ‎~56 bytes לצד הגודל המקורי שלו, שהוא כמה מגה-בייט, וב-dumpsys meminfo בקטע Native Allocations (Bitmap (malloced)).

בצינורות (pipelines) של תהליכים בתדירות גבוהה, כמו ניתוח פריימים של מצלמה, OCR או לולאות של הסקת מסקנות של ML, מוקצה לעיתים קרובות bitmap חדש בכל פריים על ידי קריאה לפונקציות ImageProxy.toBitmap() ו-Bitmap.createBitmap() לסיבוב או לחיתוך. הסרת הפניות למסגרות שהוחלפו בלי לעשות בהן שימוש חוזר עלולה להגדיל את הזיכרון המקורי. העטיפות הקטנות של Java כמעט לא מגדילות את השימוש בזיכרון הערימה של Java, ולכן הן לא מפעילות מנגנון איסוף זבל מספיק מהר כדי למנוע הצטברות של מאות מגה-בייט של מאגרי פיקסלים מקוריים לפני ש-NativeAllocationRegistry משחרר אותם.

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

תרגיל מעשי: ניתוח של מפת סיביות

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

1. מדידה באמצעות dumpsys meminfo

מפעילים את BitmapLab ומקישים על ALLOCATE 10MB ARGB_8888. מריצים את הפקודה:

adb shell dumpsys meminfo -s com.android.bitmaplab

בגרסאות מודרניות של Android, מחפשים את הקטע Native Allocations. הם מספקים שיוך טוב יותר של מפות סיביות בהשוואה לסיכום האפליקציה הכללי:

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240  # <--- 10MB Bitmap data!
Bitmap (nonmalloced):        0                              0
  • ‫Bitmap (malloced): מפות סיביות שהוקצו בזיכרון המקומי של התהליך. כאן נמצאים רוב מפות הביטים הסטנדרטיות ב-Android מגרסה 8.0 ואילך.
  • ‫Bitmap (nonmalloced): מפות סיביות שמשתמשות בזיכרון מיוחד כמו Hardware Bitmaps או Shared Bitmaps (דרך ashmem או memfd).

אם מקצים Shared Bitmap ב-BitmapLab, הוא יופיע ב-Bitmap (nonmalloced):

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240
Bitmap (nonmalloced):        1                          10240  # <--- Shared Bitmap!

מעקב אחר מפות סיביות משותפות

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

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

adb shell setprop debug.hwui.bitmap_ashmem_long_name true

כשהאפשרות הזו מופעלת, לאזורי ה-ashmem ב-/proc/<pid>/smaps יש שמות יותר תיאוריים. ‫meminfo ינצל את היתרון הזה, והתוצאות ייראו כך:

 Shared Bitmaps
                         Count                       Size(KB)
                        ------                         ------
              Mapped:        1                          10240
              Unique:        1                          10240
  • ממופה: הגודל הכולל של כל מיפויי הזיכרון שקשורים למפת הסיביות.
  • ייחודי: הגודל של מפות הסיביות, רק אם לוקחים בחשבון את הערכים הייחודיים (כלומר, אם יש שני מיפויים או יותר של אותם נתוני פיקסלים משותפים של מפת סיביות, הם נספרים רק פעם אחת).

2. מפות סיביות ב-AHAT

‫AHAT מספקת הדמיה מצוינת של מפות סיביות.

  1. ב-BitmapLab, מקצים כמה מפות סיביות.
  2. יוצרים תמונת מצב של הזיכרון באמצעות הדגל -b (כדי לכלול נתוני bitmap מקוריים):

    adb shell am dumpheap -b png com.android.bitmaplab /data/local/tmp/bitmaps.hprof
    adb pull /data/local/tmp/bitmaps.hprof .
    ahat bitmaps.hprof
    
  3. פותחים את localhost:7100 ומחפשים את הקישור Bitmaps בסרגל הצד או מחפשים את הכיתה Bitmap.

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

‫AHAT מציג מפות סיביות שעברו רינדור

3. טראקים של מפת סיביות ב-Perfetto

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

  1. מתחילים מעקב. צריך לכלול את הקטגוריה gfx ולטרגט את חבילת האפליקציה הספציפית באמצעות הדגל -a:

    external/perfetto/tools/record_android_trace -o bitmaps.perfetto-trace \
        -t 15s -b 64mb view gfx dalvik am res memory -a com.android.bitmaplab
    
  2. ב-BitmapLab, מקישים שוב ושוב על הלחצנים הקצאה וניקוי.

  3. מקישים גם על Parcel/Unparcel Bitmap (חלוקה/ביטול חלוקה של מפת סיביות).

  4. מנתחים את נתוני המעקב בכתובת ui.perfetto.dev.

בקטע התהליך של com.android.bitmaplab, יוצגו הנתונים הבאים: * Bitmap Count: מונה שמציג את מספר מפות הסיביות הפעילות. ‫* Bitmap Memory: מונה שמראה את מספר הבייטים הכולל שמשמשים למפות סיביות.

פרוסות ברמה גבוהה (Perfetto SDK)

בנוסף, BitmapLab משתמש ב-Perfetto SDK כדי להפיק נתחי מידע ברמה גבוהה לגבי פעולות של מפות סיביות. מחפשים את BitmapLab_ בנתוני המעקב כדי למצוא: * BitmapLab_parcelUnparcel: פלחים שכוללים את הלוגיקה של חלוקה וביטול חלוקה. ‫* BitmapLab_postNotification: פלחים שכוללים את תהליך שליחת ההתראה.

מעקב אחרי זרימות של התראות

כשמקישים על שליחת התראה, האפליקציה יוצרת התראה שמכילה את מפת הסיביות הנוכחית ושולחת אותה למערכת. קוד המסגרת שאחראי לכך פולט פרוסות של Perfetto עם אירועי זרימה שמקשרים בין האריזה (כתיבת מפת הסיביות ל-Parcel כדי לשלוח אותה דרך Binder IPC) לבין פתיחת האריזה (קריאת מפת הסיביות מ-Parcel בצד המקבל).

בצילום המסך שלמטה אפשר לראות את האפליקציה מחלקת את מפת הסיביות הגדולה כדי להשתמש בה בעסקת Binder כדי לפרסם את ההתראה, ואת הפעולה ההפוכה של החלוקה בתהליך system_server.

‫Perfetto מציג זרימה מ-BitmapLab אל system_server דרך Notification

באמצעות Perfetto אפשר לעקוב אחרי אותה מפת סיביות של התראה כשהיא מועברת בין השרשורים והתהליכים, למשל משרשור של Binder ב-system_server (שמטמיע את שרת Binder של INotificationManager) לשרשורים של עובדים ב-system_server, שיכולים להעביר את אותה מפת סיביות ל-com.android.systemui כדי להציג אותה במרכז ההתראות.

אתגרים שקשורים לאפליקציות מערכת

אפליקציות מערכת כמו SystemUI (התראות) ו-Launcher מתמודדות עם אתגרים ייחודיים:

  1. תוכן ללא גבולות: יכול להיות שיהיו הרבה התראות ווידג'טים. אם כל אחד מהם מכיל מפת סיביות גדולה, יכול להיות שהמערכת תישאר אין זיכרון פנוי (OOM) במהירות.
  2. שכפול: יכול להיות שאותו סמל האפליקציה יישמר במטמון של מרכז האפליקציות, באזור ההתראות של SystemUI ובאפליקציית ההגדרות.
  3. שיתוף באמצעות מאגרי חומרה: כדי לצמצם את הבעיה הזו, רכיבי המערכת עוברים לשירות מרכזי של 'העברת תמונות' שמשתף מופעי HardwareBuffer בין תהליכים.
  4. ‫DMABuf Attribution: מפות סיביות של חומרה חוסכות מקום ב-heap, אבל משתמשות בזיכרון DMABuf, שקשה לשייך לתהליך ספציפי בכלי זיכרון רגילים.

    אפשר להשתמש ב-adb shell dmabuf_dump כדי לראות הקצאות של DMABuf בכל המערכת. הכלי הזה מספק פירוט של המאגרים לפי תהליך:

     droid.bitmaplab:19562
                      Name              Rss              Pss         nr_procs            Inode               Exporter
                 <unknown>          3840 kB          1280 kB                3             3397              virtio_gpu
                    system            12 kB             4 kB                3             3398                  system
                 <unknown>          3840 kB          1920 kB                2             3399              virtio_gpu
                    system            12 kB             6 kB                2             3400                  system
             PROCESS TOTAL         11556 kB          5136 kB
    
    • ‫RSS: הגודל הכולל של המאגר אם הוא ממופה בתהליך.
    • ‫Pss: הגודל היחסי (RSS חלקי מספר התהליכים שמשתפים את מאגר הנתונים הזמני). זהו המדד הטוב ביותר לראיית חשבון.
    • ‫nr_procs: מספר התהליכים שמחזיקים כרגע הפניה למאגר הזה.
    • היצואן: מנהל ההתקן שהקצה את המאגר (לדוגמה, virtio_gpu ב-Cuttlefish, או ערימת Ion/DMA-BUF ספציפית לספק בחומרה).

    אפשר גם להשתמש ב-adb shell dmabuf_dump -b כדי לקבל סיכום של כל המאגרים ושל השימוש הכולל ב-DMA-BUF בכל המערכת.


← Java | ↑ למעלה | Native →