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ą wersje demonstracyjne modeli LLM działających w przeglądarce, oto kilka przykładów innych modeli klasy produkcyjnej działających w przeglądarce:

Aplikacja Adobe Photoshop w przeglądarce z otwartym narzędziem do wybierania obiektów opartym na AI i zaznaczonymi 3 obiektami: dwoma żyrafami i księżycem.

Aby przyspieszyć przyszłe uruchamianie aplikacji, musisz jawnie zapisać w pamięci podręcznej dane modelu 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 łą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 udostępniasz modele AI z serwera, musisz skonfigurować prawidłowy 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ść, która nigdy się nie zmienia, powinna mieć w adresie URL żądania długi parametr max-age w połączeniu z parametrem pomijanie pamięci podręcznej. 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.

W Narzędziach deweloperskich w Chrome wyświetlane są nagłówki produkcyjne Cache-Control wysłane przez Hugging Face podczas żądania 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 będą łatwo dostępne, gdy użytkownik ponownie załaduje aplikację.

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. Widać, że każdy segment zużywa 1354 megabajty danych, co daje łącznie 4063 MB.

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. Zaleca się używanie interfejsu Cache API, ale w tym przewodniku omawiamy zalety i wady wszystkich opcji.

Interfejs API pamięci podręcznej

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 połączonego 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().

Poniżej znajdziesz na przykład informacje o tym, jak zapisać i przywrócić 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;
  }
};

Interfejs 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 informacje o tym, jak zapisać i przywrócić plik modelu w pliku 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 przechowywania dowolnych danych w trwały sposób 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 zwraca wartość true, jeśli zezwolenie zostało przyznane, lub false w innym przypadku. 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 specjalny: użycie modelu na dysku twardym

Możesz odwoływać się do modeli AI bezpośrednio z dysku twardego użytkownika jako alternatywę dla miejsca na dane przeglądarki. Ta technika może pomóc w zaprezentowaniu możliwości uruchamiania określonych modeli w przeglądarce w aplikacjach skoncentrowanych na badaniach lub umożliwi wykonawcom używanie samodzielnie wytrenowanych modeli w profesjonalnych aplikacjach pobudzających kreatywność.

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 przypadku tego wzorca użytkownik musi tylko raz przyznać dostęp do pliku modelu. Dzięki trwałym uprawnieniom użytkownik może przyznać trwały dostęp do pliku. 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

W wersji demonstracyjnej LLM MediaPipe znajdziesz wszystkie 3 zwykłe metody przechowywania zgłoszeń oraz metodę dysku twardego.

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 kodzie. 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 metodę odpowiednią dla siebie

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 wydajności 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 wymaga też deserializacji danych podczas ich pobierania, co czyni z niego najgorsze miejsce 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ć swój model AI, zachowaj 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ć ochotę przechowywać zaszyfrowaną wersję modelu, ale musisz wtedy przekazać klucz odszyfrowywania klientowi, co może zostać przechwycone. Oznacza to, że próba kradzieży modelu przez nieuczciwego aktora jest nieco trudniejsza, ale nie jest 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.