שמירת מודלים של AI במטמון בדפדפן

לרוב המודלים של ה-AI יש מכנה אחד: הם גדולים למדי למשאב שמועבר באינטרנט. מודל זיהוי האובייקטים הקטן ביותר של MediaPipe (SSD MobileNetV2 float16) שוקל 5.6MB והגדול ביותר שוקל כ-25MB.

מודל הקוד הפתוח gemma-2b-it-gpu-int4.bin הוא 1.35GB, וגודלו נחשב לקטן מאוד ביחס ל-LLM. מודלים של AI גנרטיבי יכולים להיות עצומים. לכן, הרבה מהשימוש ב-AI מתבצע היום בענן. יותר ויותר אפליקציות מריצות מודלים שעברו אופטימיזציה גבוהה ישירות במכשיר. אומנם קיימות הדגמות של מודלים גדולים של שפה (LLM) שפועלים בדפדפן, אבל הנה כמה דוגמאות ברמת הייצור למודלים אחרים שפועלים בדפדפן:

Adobe Photoshop באינטרנט עם כלי הבחירה של אובייקטים מבוסס-ה-AI פתוח, עם שלושה אובייקטים שנבחרו: שני ג'ירפות וירח.

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

במדריך הזה נעשה שימוש ב-gemma-2b-it-gpu-int4.bin model כדי ליצור צ'אט בוט, אבל אפשר להכליל את הגישה הזו כך שתתאים למודלים אחרים ולתרחישים לדוגמה אחרים במכשיר. הדרך הנפוצה ביותר לקשר אפליקציה למודל היא להציג את המודל לצד שאר המשאבים של האפליקציה. חשוב לבצע אופטימיזציה של ההעברה.

הגדרת כותרות המטמון הנכונות

אם אתם מציגים מודלים של AI מהשרת שלכם, חשוב להגדיר את הכותרת הנכונה Cache-Control. בדוגמה הבאה מוצגת הגדרת ברירת מחדל טובה, שאפשר להתבסס עליה בהתאם לצרכים של האפליקציה.

Cache-Control: public, max-age=31536000, immutable

כל גרסה של מודל AI שהושקה היא משאב סטטי. תוכן שלא משתנה אף פעם צריך לכלול max-age ארוך בשילוב עם ביטול מטמון בכתובת ה-URL של הבקשה. אם אתם צריכים לעדכן את המודל, עליכם לתת לו כתובת URL חדשה.

כשהמשתמש טוען מחדש את הדף, הלקוח שולח בקשה לאימות מחדש, למרות שהשרת יודע שהתוכן יציב. ההנחיה immutable מציינת במפורש שאין צורך באימות מחדש כי התוכן לא ישתנה. ההוראה immutable לא נתמכת באופן נרחב בדפדפנים ובשרתי מטמון או שרתים מרוצים, אבל שילוב שלה עם ההוראה max-age, שכל הדפדפנים מבינים, מבטיח תאימות מקסימלית. הוראת התגובה public מציינת שאפשר לשמור את התגובה במטמון משותף.

ב-Chrome DevTools מוצגים כותרות Cache-Control הייצור שנשלחות על ידי Hugging Face כשמבקשים מודל AI. (מקור)

שמירת מודלים של AI במטמון בצד הלקוח

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

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

כדי להבין את הביצועים, כל דוגמת קוד מסומנת באמצעות הערות עם השיטה performance.mark() והשיטה performance.measure(). המדדים האלה תלויים במכשיר ולא ניתן להכליל אותם.

בכלי הפיתוח ל-Chrome, בקטע Application‏ > Storage, בודקים את תרשים השימוש עם פלחים של IndexedDB, אחסון במטמון ומערכת קבצים. כל מקטע מוצג לצרוך 1,354 מגה-בייט של נתונים, בסך הכל, עד 4,063 מגה-בייט.

אפשר להשתמש באחד מממשקי ה-API הבאים כדי לשמור בזיכרון מטמון דגמי AI בדפדפן: Cache API,‏ Origin Private File System API ו-IndexedDB API. ההמלצה הכללית היא להשתמש ב-Cache API, אבל מדריך זה מפרט את היתרונות והחסרונות של כל האפשרויות.

Cache API

Cache API מספק אחסון מתמיד לזוגות אובייקטים מסוג Request ו-Response שמאוחסנים במטמון בזיכרון לטווח ארוך. הוא מוגדר במפרט של Service Workers, אבל אפשר להשתמש ב-API הזה מהשרשור הראשי או מ-worker רגיל. כדי להשתמש בו מחוץ להקשר של שירות העבודה, צריך לבצע קריאה ל-method‏ Cache.put() עם אובייקט Response סינתטי, בשילוב עם כתובת URL סינתטית במקום אובייקט Request.

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

לדוגמה, כך מאחסנים ומשחזרים קובץ מודל באמצעות Cache API.

const storeFileInSWCache = async (blob) => {
  try {
    performance.mark('start-sw-cache-cache');
    const modelCache = await caches.open('models');
    await modelCache.put('model.bin', new Response(blob));
    performance.mark('end-sw-cache-cache');

    const mark = performance.measure(
      'sw-cache-cache',
      'start-sw-cache-cache',
      'end-sw-cache-cache'
    );
    console.log('Model file cached in sw-cache.', mark.name, mark.duration.toFixed(2));
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const restoreFileFromSWCache = async () => {
  try {
    performance.mark('start-sw-cache-restore');
    const modelCache = await caches.open('models');
    const response = await modelCache.match('model.bin');
    if (!response) {
      throw new Error(`File model.bin not found in sw-cache.`);
    }
    const file = await response.blob();
    performance.mark('end-sw-cache-restore');
    const mark = performance.measure(
      'sw-cache-restore',
      'start-sw-cache-restore',
      'end-sw-cache-restore'
    );
    console.log(mark.name, mark.duration.toFixed(2));
    console.log('Cached model file found in sw-cache.');
    return file;
  } catch (err) {    
    throw err;
  }
};

Origin Private File System API

Origin Private File System‏ (OPFS) הוא תקן חדש יחסית לנקודת קצה של אחסון. הוא פרטי למקור הדף, ולכן לא גלוי למשתמש, בניגוד למערכת הקבצים הרגילה. הוא מספק גישה לקובץ מיוחד שעובר אופטימיזציה רבה לשיפור הביצועים, ומציע גישה לכתיבה לתוכן שלו.

לדוגמה, כך שומרים קובץ מודל ב-OPFS ומשחזרים אותו.

const storeFileInOPFS = async (blob) => {
  try {
    performance.mark('start-opfs-cache');
    const root = await navigator.storage.getDirectory();
    const handle = await root.getFileHandle('model.bin', { create: true });
    const writable = await handle.createWritable();
    await blob.stream().pipeTo(writable);
    performance.mark('end-opfs-cache');
    const mark = performance.measure(
      'opfs-cache',
      'start-opfs-cache',
      'end-opfs-cache'
    );
    console.log('Model file cached in OPFS.', mark.name, mark.duration.toFixed(2));
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const restoreFileFromOPFS = async () => {
  try {
    performance.mark('start-opfs-restore');
    const root = await navigator.storage.getDirectory();
    const handle = await root.getFileHandle('model.bin');
    const file = await handle.getFile();
    performance.mark('end-opfs-restore');
    const mark = performance.measure(
      'opfs-restore',
      'start-opfs-restore',
      'end-opfs-restore'
    );
    console.log('Cached model file found in OPFS.', mark.name, mark.duration.toFixed(2));
    return file;
  } catch (err) {    
    throw err;
  }
};

ממשק API של IndexedDB

IndexedDB הוא תקן מוכר לאחסון נתונים שרירותיים באופן עקבי בדפדפן. היא ידועה לשמצה ב-API המורכב שלה במידה מסוימת, אבל באמצעות ספריית wrapper כמו idb-keyval אפשר להתייחס ל-IndexedDB כמו לאחסון קלאסי של ערכי מפתח.

לדוגמה:

import { get, set } from 'https://cdn.jsdelivr.net/npm/idb-keyval@latest/+esm';

const storeFileInIDB = async (blob) => {
  try {
    performance.mark('start-idb-cache');
    await set('model.bin', blob);
    performance.mark('end-idb-cache');
    const mark = performance.measure(
      'idb-cache',
      'start-idb-cache',
      'end-idb-cache'
    );
    console.log('Model file cached in IDB.', mark.name, mark.duration.toFixed(2));
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const restoreFileFromIDB = async () => {
  try {
    performance.mark('start-idb-restore');
    const file = await get('model.bin');
    if (!file) {
      throw new Error('File model.bin not found in IDB.');
    }
    performance.mark('end-idb-restore');
    const mark = performance.measure(
      'idb-restore',
      'start-idb-restore',
      'end-idb-restore'
    );
    console.log('Cached model file found in IDB.', mark.name, mark.duration.toFixed(2));
    return file;
  } catch (err) {    
    throw err;
  }
};

סימון האחסון כקבוע

קוראים לפונקציה navigator.storage.persist() בסוף כל אחת משיטות השמירה במטמון כדי לבקש הרשאה להשתמש באחסון מתמיד. השיטה הזו מחזירה הבטחה שמתקבלת כ-true אם ההרשאה ניתנת, וכ-false במקרה אחר. הדפדפן עשוי או לא עשוי למלא את הבקשה, בהתאם לכללים הספציפיים לדפדפן.

if ('storage' in navigator && 'persist' in navigator.storage) {
  try {
    const persistent = await navigator.storage.persist();
    if (persistent) {
      console.log("Storage will not be cleared except by explicit user action.");
      return;
    }
    console.log("Storage may be cleared under storage pressure.");  
  } catch (err) {
    console.error(err.name, err.message);
  }
}

מקרה מיוחד: שימוש במודל בדיסק קשיח

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

File System Access API

באמצעות File System Access API, תוכלו לפתוח קבצים מהדיסק הקשיח ולקבל FileSystemFileHandle שתוכלו להתמיד בו ב-IndexedDB.

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

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

import { fileOpen } from 'https://cdn.jsdelivr.net/npm/browser-fs-access@latest/dist/index.modern.js';
import { get, set } from 'https://cdn.jsdelivr.net/npm/idb-keyval@latest/+esm';

button.addEventListener('click', async () => {
  try {
    const file = await fileOpen({
      extensions: ['.bin'],
      mimeTypes: ['application/octet-stream'],
      description: 'AI model files',
    });
    if (file.handle) {
      // It's an asynchronous method, but no need to await it.
      storeFileHandleInIDB(file.handle);
    }
    return file;
  } catch (err) {
    if (err.name !== 'AbortError') {
      console.error(err.name, err.message);
    }
  }
});

const storeFileHandleInIDB = async (handle) => {
  try {
    performance.mark('start-file-handle-cache');
    await set('model.bin.handle', handle);
    performance.mark('end-file-handle-cache');
    const mark = performance.measure(
      'file-handle-cache',
      'start-file-handle-cache',
      'end-file-handle-cache'
    );
    console.log('Model file handle cached in IDB.', mark.name, mark.duration.toFixed(2));
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const restoreFileFromFileHandle = async () => {
  try {
    performance.mark('start-file-handle-restore');
    const handle = await get('model.bin.handle');
    if (!handle) {
      throw new Error('File handle model.bin.handle not found in IDB.');
    }
    if ((await handle.queryPermission()) !== 'granted') {
      const decision = await handle.requestPermission();
      if (decision === 'denied' || decision === 'prompt') {
        throw new Error(Access to file model.bin.handle not granted.');
      }
    }
    const file = await handle.getFile();
    performance.mark('end-file-handle-restore');
    const mark = performance.measure(
      'file-handle-restore',
      'start-file-handle-restore',
      'end-file-handle-restore'
    );
    console.log('Cached model file handle found in IDB.', mark.name, mark.duration.toFixed(2));
    return file;
  } catch (err) {    
    throw err;
  }
};

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

הדגמה (דמו)

אפשר לראות את כל שלוש שיטות האחסון הרגילות ואת השיטה של הדיסק הקשיח שמוטמעות בהדגמה של MediaPipe LLM.

בונוס: הורדה של קובץ גדול בחלקים

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

הנה פונקציית עזר שאפשר להשתמש בה בקוד שלכם. צריך רק להעביר אותו את url. הערכים של chunkSize (ברירת המחדל: 5MB), maxParallelRequests (ברירת המחדל: 6), הפונקציה progressCallback (שמדווחת על downloadedBytes ועל fileSize הכולל) והערך של signal עבור אות AbortSignal הם אופציונליים.

אפשר להעתיק את הפונקציה הבאה בפרויקט או להתקין את חבילת fetch-in-chunks מחבילת npm.

async function fetchInChunks(
  url,
  chunkSize = 5 * 1024 * 1024,
  maxParallelRequests = 6,
  progressCallback = null,
  signal = null
) {
  // Helper function to get the size of the remote file using a HEAD request
  async function getFileSize(url, signal) {
    const response = await fetch(url, { method: 'HEAD', signal });
    if (!response.ok) {
      throw new Error('Failed to fetch the file size');
    }
    const contentLength = response.headers.get('content-length');
    if (!contentLength) {
      throw new Error('Content-Length header is missing');
    }
    return parseInt(contentLength, 10);
  }

  // Helper function to fetch a chunk of the file
  async function fetchChunk(url, start, end, signal) {
    const response = await fetch(url, {
      headers: { Range: `bytes=${start}-${end}` },
      signal,
    });
    if (!response.ok && response.status !== 206) {
      throw new Error('Failed to fetch chunk');
    }
    return await response.arrayBuffer();
  }

  // Helper function to download chunks with parallelism
  async function downloadChunks(
    url,
    fileSize,
    chunkSize,
    maxParallelRequests,
    progressCallback,
    signal
  ) {
    let chunks = [];
    let queue = [];
    let start = 0;
    let downloadedBytes = 0;

    // Function to process the queue
    async function processQueue() {
      while (start < fileSize) {
        if (queue.length < maxParallelRequests) {
          let end = Math.min(start + chunkSize - 1, fileSize - 1);
          let promise = fetchChunk(url, start, end, signal)
            .then((chunk) => {
              chunks.push({ start, chunk });
              downloadedBytes += chunk.byteLength;

              // Update progress if callback is provided
              if (progressCallback) {
                progressCallback(downloadedBytes, fileSize);
              }

              // Remove this promise from the queue when it resolves
              queue = queue.filter((p) => p !== promise);
            })
            .catch((err) => {              
              throw err;              
            });
          queue.push(promise);
          start += chunkSize;
        }
        // Wait for at least one promise to resolve before continuing
        if (queue.length >= maxParallelRequests) {
          await Promise.race(queue);
        }
      }

      // Wait for all remaining promises to resolve
      await Promise.all(queue);
    }

    await processQueue();

    return chunks.sort((a, b) => a.start - b.start).map((chunk) => chunk.chunk);
  }

  // Get the file size
  const fileSize = await getFileSize(url, signal);

  // Download the file in chunks
  const chunks = await downloadChunks(
    url,
    fileSize,
    chunkSize,
    maxParallelRequests,
    progressCallback,
    signal
  );

  // Stitch the chunks together
  const blob = new Blob(chunks);

  return blob;
}

export default fetchInChunks;

בחירת השיטה המתאימה

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

האפשרויות OPFS ו-IndexedDB פחות שימושיות. כדי שאפשר יהיה לאחסן את הנתונים, צריך לבצע סריאליזציה שלהם באמצעות ממשקי ה-API של OPFS ו-IndexedDB. בנוסף, IndexedDB צריך לבצע פעולת des deserialize של הנתונים כשמאחזרים, מה שהופך אותם למקום הגרוע ביותר לאחסון מודלים גדולים.

לאפליקציות נישתיות, File System Access API מאפשר גישה ישירה לקבצים במכשיר של המשתמש, אידיאלי למשתמשים שמנהלים מודלים של AI משלהם.

אם אתם צריכים לאבטח את מודל ה-AI, כדאי להשאיר אותו בשרת. אחרי שמאחסנים את הנתונים בצד הלקוח, קל מאוד לחלץ אותם גם מהמטמון וגם מ-IndexedDB באמצעות DevTools או תוסף OFPS DevTools. ממשקי ה-API לאחסון הם בעלי רמת אבטחה זהה מטבעם. יכול להיות שתתפתתו לאחסן גרסה מוצפנת של המודל, אבל אז תצטרכו להעביר את מפתח הפענוח ללקוח, ויכול להיות שהוא ייאנטר. המשמעות היא שהניסיון של גורם זדוני לגנוב את המודל שלכם הוא קצת יותר קשה, אבל הוא לא בלתי אפשרי.

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


תודות

הבדיקה בוצעה על ידי Joshua Bell,‏ Reilly Grant,‏ Evan Stade,‏ Nathan Memmott,‏ Austin Sullivan,‏ Etienne Noël,‏ André Bandarra,‏ Alexandra Klepper,‏ François Beaufort,‏ Paul Kinlan ו-Rachel Andrew.