KI-Modelle im Browser zwischenspeichern

Die meisten KI-Modelle haben mindestens eines gemeinsam: Sie sind für eine Ressource, die über das Internet übertragen wird, recht groß. Das kleinste MediaPipe-Objekterkennungsmodell (SSD MobileNetV2 float16) wiegt 5,6 MB, das größte ca.25 MB.

Das Open-Source-LLM gemma-2b-it-gpu-int4.bin hat eine Kapazität von 1,35 GB – und dies gilt für ein LLM als sehr klein. Modelle für generative KI können enorm sein. Deshalb findet ein Großteil der KI-Nutzung heute in der Cloud statt. Immer mehr Apps führen hochoptimierte Modelle direkt auf dem Gerät aus. Es gibt zwar Demos von LLMs, die im Browser ausgeführt werden, hier sind aber einige Produktionsbeispiele anderer Modelle, die im Browser ausgeführt werden:

Adobe Photoshop im Web, in dem das KI-gestützte Tool zur Objektauswahl geöffnet ist und drei Objekte ausgewählt sind: zwei Giraffen und ein Mond.

Um zukünftige Starts Ihrer Anwendungen zu beschleunigen, sollten Sie die Modelldaten explizit auf dem Gerät im Cache speichern, anstatt sich auf den impliziten HTTP-Browser-Cache zu verlassen.

In dieser Anleitung wird zwar gemma-2b-it-gpu-int4.bin model zum Erstellen eines Chatbots verwendet, der Ansatz kann jedoch verallgemeinert werden, damit er für andere Modelle und andere Anwendungsfälle auf dem Gerät geeignet ist. Die am häufigsten verwendete Methode zum Verbinden einer Anwendung mit einem Modell besteht darin, das Modell zusammen mit den restlichen Anwendungsressourcen bereitzustellen. Es ist sehr wichtig, die Auslieferung zu optimieren.

Die richtigen Cache-Header konfigurieren

Wenn Sie KI-Modelle über Ihren Server bereitstellen, ist es wichtig, den richtigen Cache-Control-Header zu konfigurieren. Das folgende Beispiel zeigt eine solide Standardeinstellung, auf die Sie für die Anforderungen Ihrer Anwendung aufbauen können.

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

Jede veröffentlichte Version eines KI-Modells ist eine statische Ressource. Inhalte, die sich nie ändern, sollten eine lange max-age in Kombination mit Cache-Busting in der Anfrage-URL erhalten. Wenn das Modell aktualisiert werden muss, müssen Sie ihm eine neue URL zuweisen.

Wenn der Nutzer die Seite neu lädt, sendet der Client eine Anfrage zur erneuten Validierung, auch wenn der Server weiß, dass der Inhalt stabil ist. Die Anweisung immutable gibt explizit an, dass eine erneute Validierung nicht erforderlich ist, da sich der Inhalt nicht ändert. Die Anweisung immutable wird von Browsern und zwischengeschalteten Cache- oder Proxyservern nicht allgemein unterstützt. Wenn Sie sie jedoch mit der allgemein anerkannten max-age-Anweisung kombinieren, können Sie für maximale Kompatibilität sorgen. Die Antwortanweisung public gibt an, dass die Antwort in einem gemeinsamen Cache gespeichert werden kann.

Die Chrome-Entwicklertools zeigen die Cache-Control-Produktionsheader an, die von Hugging Face beim Anfordern eines KI-Modells gesendet werden. (Quelle)

KI-Modelle clientseitig im Cache speichern

Wenn Sie ein KI-Modell bereitstellen, muss es explizit im Browser zwischengespeichert werden. Dadurch wird sichergestellt, dass die Modelldaten verfügbar sind, nachdem ein Nutzer die Anwendung neu geladen hat.

Es gibt eine Reihe von Techniken, die Sie anwenden können, um dies zu erreichen. Nehmen wir für die folgenden Codebeispiele an, dass jede Modelldatei im Arbeitsspeicher in einem Blob-Objekt namens blob gespeichert ist.

Jedes Codebeispiel wird mit den Methoden performance.mark() und performance.measure() annotiert, um die Leistung nachzuvollziehen. Diese Messungen sind geräteabhängig und nicht generalisierbar.

Sehen Sie sich in den Chrome-Entwicklertools unter Anwendung > Speicher das Nutzungsdiagramm mit Segmenten für „IndexedDB“, „Cache-Speicher“ und „Dateisystem“ an. Jedes Segment verbraucht 1.354 Megabyte Daten, was insgesamt 4.063 Megabyte ergibt.

Sie können eine der folgenden APIs verwenden, um KI-Modelle im Browser im Cache zu speichern: Cache API, Origin Private File System API und IndexedDB API. Die allgemeine Empfehlung ist die Verwendung der Cache API. In diesem Leitfaden werden jedoch die Vor- und Nachteile aller Optionen erläutert.

Cache-API

Die Cache API bietet nichtflüchtigen Speicher für Request- und Response-Objektpaare, die im langlebigen Arbeitsspeicher zwischengespeichert werden. Obwohl sie in der Service Workers-Spezifikation definiert ist, können Sie sie aus dem Hauptthread oder einem regulären Worker verwenden. Wenn Sie sie außerhalb eines Service Worker-Kontexts verwenden möchten, rufen Sie die Methode Cache.put() mit einem synthetischen Response-Objekt auf, das mit einer synthetischen URL anstelle eines Request-Objekts gekoppelt ist.

In dieser Anleitung wird von einem speicherinternen blob ausgegangen. Verwende eine fiktive URL als Cache-Schlüssel und eine synthetische Response auf Grundlage von blob. Wenn Sie das Modell direkt herunterladen, verwenden Sie die Response, die Sie in einer fetch()-Anfrage erhalten würden.

Hier erfahren Sie beispielsweise, wie Sie eine Modelldatei mit der Cache API speichern und wiederherstellen.

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

Ursprüngliche Private File System API

Das Origin Private File System (OPFS) ist ein vergleichsweise junger Standard für einen Speicherendpunkt. Sie ist für den Ursprung der Seite privat und somit im Gegensatz zum regulären Dateisystem für den Nutzer unsichtbar. Sie bietet Zugriff auf eine spezielle Datei, die für die Leistung stark optimiert ist und Schreibzugriff auf ihren Inhalt bietet.

So können Sie beispielsweise eine Modelldatei in der OPFS speichern und wiederherstellen:

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

IndexedDB API

IndexedDB ist ein etablierter Standard für die dauerhafte Speicherung beliebiger Daten im Browser. Sie ist berühmt für ihre komplexe API, aber mit einer Wrapper-Bibliothek wie idb-keyval können Sie IndexedDB wie einen klassischen Schlüssel/Wert-Speicher behandeln.

Beispiel:

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

Speicher als dauerhaft markieren

Rufen Sie am Ende einer dieser Caching-Methoden navigator.storage.persist() auf, um die Berechtigung zur Verwendung des nichtflüchtigen Speichers anzufordern. Diese Methode gibt ein Promise zurück, das in true aufgelöst wird, wenn die Berechtigung gewährt wird, andernfalls in false. Abhängig von den browserspezifischen Regeln kann der Browser die Anfrage berücksichtigen oder nicht.

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

Sonderfall: Modell auf einer Festplatte verwenden

Als Alternative zum Browserspeicher können Sie KI-Modelle direkt auf der Festplatte eines Nutzers referenzieren. Mit dieser Technik können forschungsorientierte Apps die Machbarkeit bestimmter Modelle im Browser ermitteln oder selbst trainierte Modelle in kreativen Apps verwenden.

File System Access API

Mit der File System Access API können Sie Dateien von der Festplatte öffnen und ein FileSystemFileHandle abrufen, das in IndexedDB beibehalten werden kann.

Bei diesem Muster muss der Nutzer nur einmal Zugriff auf die Modelldatei gewähren. Dank der dauerhaften Berechtigungen kann der Nutzer dauerhaft Zugriff auf die Datei gewähren. Nach dem erneuten Laden der App und einer erforderlichen Nutzergeste wie einem Mausklick kann FileSystemFileHandle aus IndexedDB mit Zugriff auf die Datei auf der Festplatte wiederhergestellt werden.

Die Dateizugriffsberechtigungen werden abgefragt und bei Bedarf angefordert, was bei zukünftigen Aktualisierungen nahtlos ist. Das folgende Beispiel zeigt, wie Sie einen Handle für eine Datei von der Festplatte abrufen und dann den Handle speichern und wiederherstellen.

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

Diese Methoden schließen sich nicht gegenseitig aus. Es kann vorkommen, dass Sie ein Modell explizit im Browser zwischenspeichern und ein Modell von der Festplatte eines Nutzers verwenden.

Demo

In der MediaPipe LLM-Demo sehen Sie alle drei regulären Methoden zur Fallspeicherung und die Festplattenmethode.

Bonus: Laden Sie eine große Datei in Teilen herunter

Wenn Sie ein großes KI-Modell aus dem Internet herunterladen müssen, parallelisieren Sie den Download in separate Blöcke und fügen Sie ihn dann noch einmal auf dem Client zusammen.

Hier ist eine Hilfsfunktion, die Sie in Ihrem Code verwenden können. Sie müssen dafür nur die url übergeben. chunkSize (Standardwert: 5 MB), maxParallelRequests (Standardwert: 6), die progressCallback-Funktion, die Berichte zu downloadedBytes und fileSize insgesamt liefert, sowie signal für ein AbortSignal-Signal sind optional.

Sie können die folgende Funktion in Ihr Projekt kopieren oder das Paket fetch-in-chunks aus dem npm-Paket installieren.

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;

Die richtige Methode für Sie

In diesem Leitfaden wurden verschiedene Methoden zum effektiven Caching von KI-Modellen im Browser erläutert. Diese Aufgabe ist wichtig, um die Nutzerfreundlichkeit und Leistung Ihrer Anwendung zu verbessern. Das Chrome Storage-Team empfiehlt die Cache API für optimale Leistung, um schnellen Zugriff auf KI-Modelle zu ermöglichen, Ladezeiten zu verkürzen und die Reaktionszeit zu verbessern.

OPFS und IndexedDB sind weniger brauchbare Optionen. Die OPFS und die IndexedDB APIs müssen die Daten serialisieren, bevor sie gespeichert werden können. IndexedDB muss die Daten beim Abrufen auch deserialisieren, was sie zum schlechtesten Ort zum Speichern großer Modelle macht.

Für Nischenanwendungen bietet die File System Access API direkten Zugriff auf Dateien auf dem Gerät eines Nutzers und ist ideal für Nutzer, die ihre eigenen KI-Modelle verwalten.

Wenn Sie Ihr KI-Modell schützen müssen, lassen Sie es auf dem Server. Nach dem Speichern im Client ist es einfach, die Daten sowohl aus dem Cache als auch aus IndexedDB mit DevTools oder der OFPS DevTools-Erweiterung zu extrahieren. Die Sicherheit dieser Speicher-APIs ist grundsätzlich gleich. Sie könnten versucht sein, eine verschlüsselte Version des Modells zu speichern, müssen dann aber den Entschlüsselungsschlüssel an den Client senden, der abgefangen werden könnte. Das bedeutet, dass der Versuch eines böswilligen Akteurs, Ihr Modell zu stehlen, etwas schwieriger, aber nicht unmöglich ist.

Wir empfehlen Ihnen, eine Caching-Strategie zu wählen, die den Anforderungen Ihrer App, dem Zielgruppenverhalten und den Eigenschaften der verwendeten KI-Modelle entspricht. Dadurch wird sichergestellt, dass Ihre Anwendungen unter verschiedenen Netzwerkbedingungen und Systemeinschränkungen reaktionsschnell und robust sind.


Danksagungen

Joshua Bell, Reilly Grant, Evan Stade, Nathan Memmott, Austin Sullivan, Etienne Noël, André Bandarra, Alexandra Klepper, François Beaufort, Paul Kinlan und Rachel Andrew.