KI-Modelle im Browser zwischenspeichern

Die meisten KI-Modelle haben eines gemeinsam: Sie sind relativ groß für eine Ressource, die über das Internet übertragen wird. Das kleinste MediaPipe-Objekterkennungsmodell (SSD MobileNetV2 float16) hat eine Größe von 5,6 MB und das größte etwa 25 MB.

Das Open-Source-LLM gemma-2b-it-gpu-int4.bin hat eine Größe von 1,35 GB, was für ein LLM sehr klein ist. Generative KI-Modelle können enorm groß sein. Aus diesem Grund wird KI heute häufig in der Cloud eingesetzt. In immer mehr Apps werden hochoptimierte Modelle direkt auf dem Gerät ausgeführt. Es gibt zwar Demos von LLMs, die im Browser ausgeführt werden, aber hier sind einige Beispiele für andere Modelle in Produktionsqualität, die im Browser ausgeführt werden:

Adobe Photoshop im Web mit geöffnetem KI-basierten Objektauswahltool und drei ausgewählten Objekten: zwei Giraffen und ein Mond.

Damit zukünftige Starts Ihrer Apps schneller erfolgen, sollten Sie die Modelldaten explizit auf dem Gerät im Cache speichern, anstatt sich auf den impliziten HTTP-Browsercache zu verlassen.

In diesem Leitfaden wird der gemma-2b-it-gpu-int4.bin model zum Erstellen eines Chatbots verwendet. Der Ansatz kann jedoch auf andere Modelle und andere Anwendungsfälle auf dem Gerät angewendet werden. Am häufigsten wird eine App mit einem Modell verbunden, indem das Modell zusammen mit den restlichen App-Ressourcen bereitgestellt wird. Es ist wichtig, die Auslieferung zu optimieren.

Die richtigen Cache-Header konfigurieren

Wenn Sie KI-Modelle von Ihrem Server aus bereitstellen, ist es wichtig, den richtigen Cache-Control-Header zu konfigurieren. Das folgende Beispiel zeigt eine solide Standardeinstellung, auf der Sie aufbauen können, um die Anforderungen Ihrer App zu erfüllen.

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 mit Cache-Busting in der Anfrage-URL haben. Wenn Sie das Modell aktualisieren müssen, müssen Sie ihm eine neue URL geben.

Wenn der Nutzer die Seite aktualisiert, sendet der Client eine Anfrage zur erneuten Validierung, obwohl der Server weiß, dass die Inhalte stabil sind. In der Richtlinie immutable wird ausdrücklich darauf hingewiesen, dass eine erneute Validierung nicht erforderlich ist, da sich die Inhalte nicht ändern. Die immutable-Richtlinie wird von Browsern und Zwischencache- oder Proxyservern nicht weithin unterstützt. Wenn Sie sie jedoch mit der universell verstandenen max-age-Richtlinie kombinieren, können Sie für maximale Kompatibilität sorgen. Die Antwortanweisung public gibt an, dass die Antwort in einem freigegebenen Cache gespeichert werden kann.

In den Chrome DevTools werden die ProduktionsCache-Control-Header angezeigt, die von Hugging Face gesendet werden, wenn ein KI-Modell angefordert wird. (Quelle)

KI-Modelle clientseitig im Cache speichern

Wenn Sie ein KI-Modell bereitstellen, ist es wichtig, das Modell explizit im Browser im Cache zu speichern. So sind die Modelldaten sofort verfügbar, nachdem ein Nutzer die App neu geladen hat.

Es gibt verschiedene Möglichkeiten, dies zu erreichen. In den folgenden Codebeispielen wird davon ausgegangen, dass jede Modelldatei im Speicher in einem Blob-Objekt namens blob gespeichert ist.

Zur Leistungsanalyse sind die einzelnen Codebeispiele mit den Methoden performance.mark() und performance.measure() kommentiert. Diese Maßnahmen sind geräteabhängig und nicht generalisierbar.

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

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

Cache API

Die Cache API bietet einen dauerhaften Speicher für Request- und Response-Objektpaare, die im Long-Lived Memory im Cache gespeichert werden. Sie ist zwar in der Service Worker-Spezifikation definiert, kann aber auch über den Haupt- oder einen regulären Worker verwendet werden. 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 statt mit einem Request-Objekt gekoppelt ist.

In diesem Leitfaden wird davon ausgegangen, dass blob im Arbeitsspeicher gespeichert ist. Verwenden Sie eine gefälschte URL als Cache-Schlüssel und eine synthetische Response, die auf der blob basiert. Wenn Sie das Modell direkt herunterladen möchten, verwenden Sie die Response, die Sie über eine fetch()-Anfrage erhalten.

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

Origin Private File System API

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

Im Folgenden wird beispielsweise beschrieben, wie Sie eine Modelldatei im 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 zum dauerhaften Speichern beliebiger Daten im Browser. Sie ist berüchtigt für ihre etwas komplexe API. Mit einer Wrapper-Bibliothek wie idb-keyval können Sie IndexedDB jedoch 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 persistent markieren

Rufe am Ende einer dieser Caching-Methoden navigator.storage.persist() auf, um die Berechtigung zur Verwendung des persistenten Speichers anzufordern. Diese Methode gibt ein Versprechen zurück, das zu true führt, wenn die Berechtigung gewährt wird, und zu false andernfalls. Je nach browserspezifischen Regeln wird die Anfrage vom Browser möglicherweise nicht berücksichtigt.

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

Besonderer Fall: Modell auf einer Festplatte verwenden

Sie können KI-Modelle als Alternative zum Browserspeicher direkt von der Festplatte eines Nutzers aus aufrufen. Mit dieser Technik können Apps, die auf Forschung ausgerichtet sind, die Machbarkeit des Ausführens bestimmter Modelle im Browser demonstrieren oder es Künstlern ermöglichen, selbst trainierte Modelle in Apps für kreative Experten zu verwenden.

File System Access API

Mit der File System Access API können Sie Dateien von der Festplatte öffnen und eine FileSystemFileHandle abrufen, die Sie in IndexedDB speichern können.

Mit diesem Muster muss der Nutzer nur einmal Zugriff auf die Modelldatei gewähren. Dank dauerhaften Berechtigungen kann der Nutzer dauerhaften Zugriff auf die Datei gewähren. Nach dem Neuladen der App und einer erforderlichen Nutzeraktion, z. B. einem Mausklick, kann die FileSystemFileHandle aus IndexedDB mit Zugriff auf die Datei auf der Festplatte wiederhergestellt werden.

Die Berechtigungen für den Dateizugriff werden abgefragt und bei Bedarf angefordert. Das sorgt für nahtlose zukünftige Aktualisierungen. Im folgenden Beispiel wird gezeigt, wie Sie einen Handle für eine Datei von der Festplatte abrufen und dann 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 sowohl explizit im Browser im Cache speichern als auch ein Modell von der Festplatte eines Nutzers verwenden.

Demo

Alle drei Speichermethoden für reguläre Fälle und die Festplattenmethode sind in der MediaPipe LLM-Demo implementiert.

Bonus: Große Dateien in Teilen herunterladen

Wenn Sie ein großes KI-Modell aus dem Internet herunterladen müssen, parallelisieren Sie den Download in separaten Teilen und setzen Sie ihn dann auf dem Client wieder zusammen.

Hier ist eine Hilfsfunktion, die Sie in Ihrem Code verwenden können. Sie müssen sie nur an url weitergeben. chunkSize (Standard: 5 MB), maxParallelRequests (Standard: 6), die progressCallback-Funktion (mit Berichten zu downloadedBytes und der Gesamtzahl von fileSize) und signal für ein AbortSignal-Signal sind optional.

Sie können die folgende Funktion in Ihr Projekt kopieren oder das fetch-in-chunks-Paket über npm 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 auswählen

In diesem Leitfaden wurden verschiedene Methoden zum effektiven Caching von KI-Modellen im Browser beschrieben. Diese Aufgabe ist entscheidend, um die Nutzerfreundlichkeit und Leistung Ihrer App zu verbessern. Das Chrome-Speicherteam empfiehlt die Cache API für eine optimale Leistung, um einen schnellen Zugriff auf KI-Modelle zu ermöglichen, die Ladezeiten zu verkürzen und die Reaktionsfähigkeit zu verbessern.

OPFS und IndexedDB sind weniger geeignete Optionen. Die OPFS- und IndexedDB-APIs müssen die Daten serialisieren, bevor sie gespeichert werden können. Außerdem müssen die Daten bei der Abfrage in IndexedDB deserialisiert werden, was sie zum schlechtesten Speicherort für große Modelle macht.

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

Wenn Sie Ihr KI-Modell schützen möchten, belassen Sie es auf dem Server. Sobald die Daten auf dem Client gespeichert sind, können sie mithilfe von DevTools oder der OFPS DevTools-Erweiterung sowohl aus dem Cache als auch aus IndexedDB extrahiert werden. Diese Speicher-APIs sind von Natur aus gleich sicher. Sie könnten versucht sein, eine verschlüsselte Version des Modells zu speichern. Sie müssen dann jedoch den Entschlüsselungsschlüssel an den Client senden, der abgefangen werden könnte. Das bedeutet, dass der Versuch eines Angreifers, Ihr Modell zu stehlen, etwas schwieriger, aber nicht unmöglich ist.

Wir empfehlen Ihnen, eine Caching-Strategie auszuwählen, die den Anforderungen Ihrer App, dem Verhalten der Zielgruppe und den Eigenschaften der verwendeten KI-Modelle entspricht. So sorgen Sie dafür, dass Ihre Anwendungen unter verschiedenen Netzwerkbedingungen und Systemeinschränkungen reaktionsschnell und robust sind.


Danksagungen

Dieser Artikel wurde von 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 geprüft.