Memorizza nella cache i modelli AI nel browser

La maggior parte dei modelli di IA ha una cosa in comune: sono abbastanza grandi per una risorsa trasferita su internet. Il modello di rilevamento di oggetti MediaPipe più piccolo (SSD MobileNetV2 float16) ha un peso di 5,6 MB, mentre il più grande è di circa 25 MB.

L'LLM open source gemma-2b-it-gpu-int4.bin ha un volume di 1,35 GB, un valore considerato molto ridotto per un LLM. I modelli di IA generativa possono essere enormi. Questo è il motivo per cui oggi gran parte dell'IA avviene nel cloud. Sempre più spesso le app eseguono modelli altamente ottimizzati direttamente sul dispositivo. Sebbene esistano demo di LLM in esecuzione nel browser, di seguito sono riportati alcuni esempi di produzione di altri modelli in esecuzione nel browser:

Adobe Photoshop sul web con lo strumento di selezione degli oggetti basato sull'IA aperto e tre oggetti selezionati: due giraffe e una luna.

Per velocizzare i lanci futuri delle applicazioni, devi memorizzare nella cache esplicitamente i dati del modello sul dispositivo, anziché affidarti alla cache implicita del browser HTTP.

Sebbene questa guida utilizzi gemma-2b-it-gpu-int4.bin model per creare un chatbot, l'approccio può essere generalizzato per adattarsi ad altri modelli e altri casi d'uso sul dispositivo. Il modo più comune per collegare un'app a un modello è pubblicare il modello insieme alle altre risorse dell'app. È fondamentale per ottimizzare la pubblicazione.

Configura le intestazioni della cache giuste

Se pubblichi modelli di IA dal tuo server, è importante configurare l'intestazione Cache-Control corretta. L'esempio seguente mostra un'impostazione predefinita solida, che puoi integrare per le esigenze della tua app.

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

Ogni versione rilasciata di un modello di IA è una risorsa statica. I contenuti che non cambiano mai devono avere un valore max-age lungo combinato con la svuotamento della cache nell'URL della richiesta. Se devi aggiornare il modello, devi assegnargli un nuovo URL.

Quando l'utente ricarica la pagina, il client invia una richiesta di convalida, anche se il server sa che i contenuti sono stabili. L'istruzione immutable indica esplicitamente che la riconvalida non è necessaria, perché i contenuti non cambieranno. L'istruzione immutable è non ampiamente supportata dai browser e dai server proxy o cache intermedi, ma se la combini con l'istruzione max-age universalmente compresa, puoi garantire la massima compatibilità. La direttiva di risposta public indica che la risposta può essere memorizzata in una cache condivisa.

Chrome DevTools mostra le intestazioni Cache-Control di produzione inviate da Hugging Face quando viene richiesto un modello di IA. (Fonte)

Memorizza nella cache i modelli di IA lato client

Quando pubblichi un modello di IA, è importante memorizzare nella cache esplicitamente il modello nel browser. In questo modo, i dati del modello sono subito disponibili dopo che un utente ha ricaricato l'app.

Esistono diverse tecniche che puoi utilizzare per raggiungere questo obiettivo. Per i seguenti esempi di codice, presupponiamo che ogni file del modello sia memorizzato in un oggetto Blob denominato blob in memoria.

Per comprendere il rendimento, ogni esempio di codice è annotato con i metodi performance.mark() e performance.measure(). Queste misure dipendono dal dispositivo e non sono generalizzabili.

In Chrome DevTools, Applicazione > Spazio di archiviazione, esamina il diagramma di utilizzo con i segmenti per IndexedDB, spazio di archiviazione della cache e file system. Si mostra che ogni segmento consuma 1354 megabyte di dati, per un totale di 4063 megabyte.

Puoi scegliere di utilizzare una delle seguenti API per memorizzare nella cache i modelli di IA nel browser: API Cache, API Origin Private File System e API IndexedDB. In generale, è consigliabile utilizzare l'API Cache, ma questa guida illustra i vantaggi e gli svantaggi di tutte le opzioni.

API Cache

L'API Cache fornisce archiviazione permanente per le coppie di oggetti Request e Response memorizzate nella cache in memoria a lungo termine. Sebbene sia definita nella specifica dei service worker, puoi utilizzare questa API dal thread principale o da un normale worker. Per utilizzarlo al di fuori di un contesto di worker di servizio, chiama il metodo Cache.put() con un oggetto Response sintetico accoppiato a un URL sintetico anziché a un oggetto Request.

Questa guida presuppone un valore blob in memoria. Utilizza un URL falso come chiave cache e un Response sintetico basato su blob. Se scarichi direttamente il modello, devi utilizzare il Response ottenuto inviando una richiesta fetch().

Ad esempio, ecco come archiviare e ripristinare un file di modello con l'API Cache.

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

Origin Private File System (OPFS) è un standard relativamente recente per un endpoint di archiviazione. È privato per l'origine della pagina e, pertanto, non è visibile all'utente, a differenza del normale file system. Fornisce l'accesso a un file speciale altamente ottimizzato per le prestazioni e offre l'accesso in scrittura ai relativi contenuti.

Ad esempio, ecco come archiviare e ripristinare un file modello nell'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 è uno standard consolidato per l'archiviazione permanente di dati arbitrari in modo permanente nel browser. È nota per la sua API un po' complessa, ma utilizzando una libreria wrapper come idb-keyval puoi trattare IndexedDB come un classico archivio chiave-valore.

Ad esempio:

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;
  }
};

Contrassegna lo spazio di archiviazione come persistente

Chiama navigator.storage.persist() alla fine di uno di questi metodi di memorizzazione nella cache per richiedere l'autorizzazione per utilizzare lo spazio di archiviazione permanente. Questo metodo restituisce una promessa che si risolve in true se la permissione è concessa e in false in caso contrario. Il browser potrebbe o meno soddisfare la richiesta, a seconda delle regole specifiche del browser.

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);
  }
}

Caso speciale: utilizzare un modello su un disco rigido

In alternativa all'archiviazione del browser, puoi fare riferimento ai modelli di IA direttamente dal disco rigido di un utente. Questa tecnica può aiutare le app incentrate sulla ricerca a mostrare la fattibilità di eseguire determinati modelli nel browser o consentire agli artisti di utilizzare modelli autoaddestrati in app di creatività esperte.

API File System Access

Con l'API File System Access, puoi aprire i file dal disco rigido e ottenere un FileSystemFileHandle che puoi rendere permanente su IndexedDB.

Con questo pattern, l'utente deve concedere l'accesso al file del modello solo una volta. Grazie alle autorizzazioni permanenti, l'utente può scegliere di concedere l'accesso permanente al file. Dopo aver ricaricato l'app e dopo aver eseguito un gesto dell'utente richiesto, ad esempio un clic del mouse, FileSystemFileHandle può essere ripristinato da IndexedDB con accesso al file sul disco rigido.

Le autorizzazioni di accesso ai file vengono sottoposte a query e richieste, se necessario, il che consente di eseguire ricariche future senza problemi. L'esempio seguente mostra come ottenere un handle per un file dall'hard disk, quindi archiviarlo e ripristinarlo.

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;
  }
};

Questi metodi non si escludono a vicenda. Potrebbe verificarsi un caso in cui memorizzi in cache esplicitamente un modello nel browser e utilizzi un modello dall'hard disk di un utente.

Demo

Puoi vedere tutti e tre i metodi di archiviazione delle normali richieste e il metodo del disco rigido implementato nella demo LLM di MediaPipe.

Bonus: scarica un file di grandi dimensioni in blocchi

Se devi scaricare un modello di IA di grandi dimensioni da internet, esegui il parallellismo del download in blocchi separati, quindi riuniscili di nuovo sul client.

Ecco una funzione di supporto che puoi utilizzare nel codice. Devi solo passare il url. chunkSize (valore predefinito: 5 MB), maxParallelRequests (valore predefinito: 6), funzione progressCallback (che genera report su downloadedBytes e fileSize in totale) e signal per un indicatore AbortSignal sono tutti facoltativi.

Puoi copiare la seguente funzione nel tuo progetto o installare il pacchetto fetch-in-chunks da 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;

Scegli il metodo più adatto a te

Questa guida ha esplorato vari metodi per memorizzare nella cache in modo efficace i modelli di IA nel browser, un'attività fondamentale per migliorare l'esperienza dell'utente e le prestazioni della tua app. Il team di archiviazione di Chrome consiglia l'API Cache per prestazioni ottimali, per garantire un accesso rapido ai modelli di IA, ridurre i tempi di caricamento e migliorare la reattività.

OPFS e IndexedDB sono opzioni meno utilizzabili. Le API OPFS e IndexedDB devono serializzare i dati prima che possano essere archiviati. IndexedDB deve anche deserializzare i dati quando vengono recuperati, il che lo rende il posto peggiore per archiviare modelli di grandi dimensioni.

Per le applicazioni di nicchia, l'API Accesso al file system offre l'accesso diretto ai file sul dispositivo di un utente, ideale per gli utenti che gestiscono i propri modelli di IA.

Se devi proteggere il tuo modello di IA, mantienilo sul server. Una volta archiviati sul client, è facile estrarre i dati sia dalla cache sia da IndexedDB con DevTools o con l'estensione DevTools OFPS. Queste API di archiviazione sono intrinsecamente uguali in termini di sicurezza. Potresti avere la tentazione di archiviare una versione criptata del modello, ma poi hai bisogno di inviare al client la chiave di decriptazione, che potrebbe essere intercettata. Ciò significa che il tentativo di un malintenzionato di rubare il tuo modello è leggermente più difficile, ma non impossibile.

Ti invitiamo a scegliere una strategia di memorizzazione nella cache in linea con i requisiti della tua app, il comportamento del pubblico di destinazione e le caratteristiche dei modelli di IA utilizzati. In questo modo, le tue applicazioni sono reattive e robuste in varie condizioni di rete e vincoli di sistema.


Ringraziamenti

La recensione è stata fatta da Joshua Bell, Reilly Grant, Evan Stade, Nathan Memmott, Austin Sullivan, Etienne Noël, André Bandarra, Alexandra Klepper, François Beaufort, Paul Kinlan e Rachel Andrew.