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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

בכלי הפיתוח ל-Chrome Application > Storage, בודקים את תרשים השימוש עם פלחים של IndexedDB, cache storage ו-File System. כל מקטע מוצג לצרוך 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 הזה מה-thread הראשי או מ-worker רגיל. כדי להשתמש בו מחוץ להקשר של קובץ שירות (service 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;
  }
};

ממשק ה-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 עם גישה לקובץ בדיסק הקשיח.

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

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 edialize של הנתונים כשמאחזרים, מה שהופך אותם למקום הגרוע ביותר לאחסון מודלים גדולים.

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

אם אתם צריכים לאבטח את מודל ה-AI, צריך להשאיר אותו בשרת. אחרי שמאוחסנים אצל הלקוח, אין בעיה לחלץ את הנתונים גם מהמטמון וגם מ-IndexedDB באמצעות כלי הפיתוח או התוסף של 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 ורייצ'ל אנדרו.