Buforowanie modeli AI w przeglądarce

Większość modeli AI ma jedną wspólną cechę: są dość duże jak na zasoby przesyłane przez internet. Najmniejszy model wykrywania obiektów MediaPipe (SSD MobileNetV2 float16) ma rozmiar 5,6 MB, a największy – około 25 MB.

LLM typu open source gemma-2b-it-gpu-int4.bin zajmuje 1,35 GB – co jest uważane za bardzo mało w przypadku LLM. Modele generatywnej AI mogą być ogromne. Dlatego obecnie wiele zastosowań AI działa w chmurze. Coraz częściej aplikacje korzystają z bardzo zoptymalizowanych modeli bezpośrednio na urządzeniu. Chociaż istnieją demo modeli LLM działających w przeglądarce, poniżej znajdziesz przykłady innych modeli w wersji produkcyjnej działających w przeglądarce:

Adobe Photoshop w przeglądarce z otwartym narzędziem do zaznaczania obiektów opartym na AI. Zaznaczone są 3 obiekty: 2 żyrafy i księżyc.

Aby przyspieszyć przyszłe uruchamianie aplikacji, musisz jawnie zapisać dane modelu w pamięci podręcznej na urządzeniu, zamiast polegać na domyślnej pamięci podręcznej przeglądarki HTTP.

Ten przewodnik opisuje tworzenie chatbota na przykładzie gemma-2b-it-gpu-int4.bin model, ale tę metodę można stosować na potrzeby innych modeli i innych zastosowań na urządzeniu. Najczęstszym sposobem połączenia aplikacji z modelem jest wyświetlanie modelu wraz z pozostałymi zasobami aplikacji. Ważne jest, aby zoptymalizować dostarczanie.

Skonfiguruj odpowiednie nagłówki pamięci podręcznej

Jeśli modele AI są dostarczane z serwera, musisz skonfigurować odpowiedni nagłówek Cache-Control. Poniższy przykład pokazuje solidne ustawienie domyślne, które możesz dostosować do potrzeb swojej aplikacji.

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

Każda opublikowana wersja modelu AI jest zasobem statycznym. Treści, które nigdy się nie zmieniają, powinny mieć długie max-agew połączeniu z czyszczeniem pamięci podręcznej w adresie URL żądania. Jeśli chcesz zaktualizować model, musisz podać jego nowy adres URL.

Gdy użytkownik ponownie wczyta stronę, klient wysyła żądanie ponownej weryfikacji, mimo że serwer wie, że zawartość jest stabilna. Dyrektywa immutable wyraźnie wskazuje, że ponowna weryfikacja nie jest konieczna, ponieważ zawartość nie ulegnie zmianie. Dyrektywa immutable nie jest szeroko obsługiwana przez przeglądarki i pośrednie serwery pamięci podręcznej lub serwery proxy, ale można ją połączyć z powszechnie rozumianą dyrektywą max-age, aby zapewnić maksymalną zgodność. Dyrektywa odpowiedzi public wskazuje, że odpowiedź może być przechowywana w współdzielonej pamięci podręcznej.

Narzędzia deweloperskie w Chrome wyświetlają nagłówki Cache-Control wysyłane przez Hugging Face, gdy żądanie dotyczy modelu AI. (źródło)

Buforowanie modeli AI po stronie klienta

Podczas obsługi modelu AI ważne jest, aby wyraźnie zapisać go w pamięci podręcznej przeglądarki. Dzięki temu dane modelu są łatwo dostępne po ponownym załadowaniu aplikacji przez użytkownika.

Możesz do tego wykorzystać kilka technik. W przypadku tych przykładów kodu przyjmij, że każdy plik modelu jest przechowywany w pamięci w obiekcie Blob o nazwie blob.

Aby ułatwić Ci zrozumienie działania kodu, każdy przykład został opatrzony adnotacjami z metodami performance.mark()performance.measure(). Te środki zależą od urządzenia i nie można ich uogólniać.

W Narzędziach deweloperskich w Chrome Aplikacja > Pamięć sprawdź diagram wykorzystania z segmentami dla IndexedDB, pamięci podręcznej i systemu plików. Każdy segment zużywa 1354 megabajty danych, co daje w sumie 4063 megabajty.

Aby przechowywać w pliku pamięci podręcznej modele AI w przeglądarce, możesz użyć jednego z tych interfejsów API: Cache API, interfejsu Origin Private File System APIIndexedDB API. Ogólnie zalecamy korzystanie z interfejsu Cache API, ale w tym przewodniku omawiamy zalety i wady wszystkich opcji.

Cache API

Interfejs Cache API zapewnia trwałe miejsce na pary obiektów Request i Response, które są przechowywane w pamięci długotrwałej. Mimo że jest on zdefiniowany w specyfikacji Service Workers, możesz go używać z głównego wątku lub zwykłego workera. Aby użyć go poza kontekstem service workera, wywołaj metodę Cache.put() za pomocą syntetycznego obiektu Response sparowanego z syntetycznym adresem URL zamiast obiektu Request.

W tym przewodniku przyjęto założenie, że blob jest przechowywany w pamięci. Użyj fałszywego adresu URL jako klucza pamięci podręcznej i syntetycznego Response na podstawie blob. Jeśli chcesz pobrać model bezpośrednio, użyjesz Response otrzymanego po przesłaniu żądania fetch().

Oto na przykład, jak przechowywać i przywracać plik modelu za pomocą interfejsu 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

System plików Origin Private File System (OPFS) to stosunkowo nowy standard punktu końcowego pamięci masowej. Jest on prywatny dla źródła strony, a zatem niewidoczny dla użytkownika, w odróżnieniu od zwykłego systemu plików. Zapewnia dostęp do specjalnego pliku, który jest zoptymalizowany pod kątem wydajności i zawiera treści, do których można zapisywać dane.

Poniżej znajdziesz na przykład instrukcje przechowywania i przywracania pliku modelu w 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;
  }
};

Interfejs IndexedDB API

IndexedDB to dobrze znany standard umożliwiający trwałe przechowywanie dowolnych danych w przeglądarce. Jest on znany z trochę skomplikowanego interfejsu API, ale dzięki użyciu biblioteki owijającej, takiej jak idb-keyval, możesz traktować IndexedDB jak klasyczny magazyn klucz-wartość.

Na przykład:

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

Oznaczanie miejsca na dane jako trwałego

Na końcu każdej z tych metod buforowania wywołaj funkcję navigator.storage.persist(), aby poprosić o dostęp do trwałego miejsca na dane. Ta metoda zwraca obietnicę, która jest tłumaczona na true, jeśli udzielono uprawnień, a w przeciwnym razie na false. Przeglądarka może lub nie może spełnić prośbę w zależności od jej zasad.

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

Przypadek szczególny: korzystanie z modelu na dysku twardym

Jako alternatywę dla pamięci przeglądarki możesz odwoływać się do modeli AI bezpośrednio z twardego dysku użytkownika. Ta technika może pomóc aplikacjom skupionym na badaniach w prezentowaniu możliwości uruchomienia określonych modeli w przeglądarce lub umożliwić artystom korzystanie z modeli samouczenia w aplikacji do tworzenia treści.

File System Access API

Dzięki interfejsowi File System Access API możesz otwierać pliki z twardego dysku i pobierać FileSystemFileHandle, który możesz zapisać w IndexedDB.

W tym przypadku użytkownik musi tylko raz przyznać dostęp do pliku modelu. Dzięki trwałym uprawnieniom użytkownik może przyznać dostęp do pliku na stałe. Po ponownym załadowaniu aplikacji i wykonywaniu przez użytkownika odpowiedniego działania, np. kliknięcia myszką, FileSystemFileHandle może zostać przywrócony z IndexedDB z dostępem do pliku na dysku twardym.

W razie potrzeby wysyłane są zapytania o uprawnienia dostępu do plików, co pozwala na płynne ponowne wczytywanie. Ten przykład pokazuje, jak uzyskać uchwyt pliku z twardego dysku, a następnie zapisać i przywrócić ten uchwyt.

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

Te metody nie wykluczają się wzajemnie. Może się zdarzyć, że model jest wyraźnie przechowywany w pamięci podręcznej w przeglądarce i używany z twardego dysku użytkownika.

Prezentacja

Wszystkie 3 metody przechowywania w przypadku zwykłego przypadku oraz metoda z twardym dyskiem są zaimplementowane w prezentacji LLM MediaPipe.

Bonus: pobieranie dużego pliku w częściach

Jeśli musisz pobrać duży model AI z Internetu, podziel pobieranie na oddzielne fragmenty, a potem połącz je na kliencie.

Oto funkcja pomocnicza, której możesz użyć w kodze. Wystarczy, że przekażesz url. Opcjonalne są parametry chunkSize (domyślnie 5 MB), maxParallelRequests (domyślnie 6), funkcja progressCallback (która raportuje wartości downloadedBytes i łączną wartość fileSize) oraz parametr signal dla sygnału AbortSignal.

Możesz skopiować tę funkcję do swojego projektu lub zainstalować pakiet fetch-in-chunks z 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;

Wybierz odpowiednią metodę

W tym przewodniku omówiliśmy różne metody skutecznego przechowywania w przeglądarce w pamięci podręcznej modeli AI, co jest kluczowe dla poprawy wrażeń użytkownika i działania aplikacji. Zespół ds. pamięci w Chrome zaleca korzystanie z interfejsu Cache API, aby zapewnić szybki dostęp do modeli AI, skrócić czas wczytywania i zwiększyć responsywność.

Opcje OPFS i IndexedDB są mniej przydatne. Przed zapisaniem danych interfejsy OPFS i IndexedDB muszą je zserializować. IndexedDB musi też deserializować dane po ich pobraniu, co czyni go najgorszym miejscem do przechowywania dużych modeli.

W przypadku niszowych aplikacji interfejs File System Access API zapewnia bezpośredni dostęp do plików na urządzeniu użytkownika. Jest to idealne rozwiązanie dla użytkowników, którzy zarządzają własnymi modelami AI.

Jeśli chcesz zabezpieczyć model AI, przechowuj go na serwerze. Po zapisaniu na kliencie dane można łatwo wyodrębnić z pamięci podręcznej i IndexedDB za pomocą DevTools lub rozszerzenia OFPS DevTools. Te interfejsy API do przechowywania danych mają z zasady te same zabezpieczenia. Możesz mieć pokusę przechowywania zaszyfrowanej wersji modelu, ale musisz wtedy przekazać klucz odszyfrowywania klientowi, co może zostać przechwycone. Oznacza to, że próba kradzieży modelu przez osoby niepowołane jest nieco trudniejsza, ale nie niemożliwa.

Wybierz strategię buforowania dostosowaną do wymagań aplikacji, zachowania docelowych odbiorców i charakterystyki używanych modeli AI. Dzięki temu aplikacje będą responsywne i stabilne w różnych warunkach sieciowych oraz przy różnych ograniczeniach systemowych.


Podziękowania

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