Buforowanie modeli AI w przeglądarce

Większość modeli AI ma co najmniej 1 wspólną cechę: są dość duże jak na zasób przenoszony przez internet. Najmniejszy model wykrywania obiektów MediaPipe (SSD MobileNetV2 float16) waży 5,6 MB, a największy ma około 25 MB.

LLM gemma-2b-it-gpu-int4.bin na licencji open source mieści się w zakresie 1,35 GB.W przypadku LLM ten rozmiar jest uznawany za bardzo mały. Modele generatywnej AI mogą być ogromne. Dlatego obecnie większość zastosowań AI ma miejsce w chmurze. Coraz częściej aplikacje uruchamiają wysoce zoptymalizowane modele 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 w przyszłości przyspieszyć uruchamianie aplikacji, lepiej jawnie buforuj dane modelu na urządzeniu, zamiast korzystać z niejawnej pamięci podręcznej przeglądarki HTTP.

Chociaż w tym przewodniku użyto gemma-2b-it-gpu-int4.bin model do utworzenia czatbota, tę metodę można uogólnić, aby dopasować ją do 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ć wyświetlanie.

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 przedstawia 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 musisz zaktualizować model, musisz nadać mu nowy adres URL.

Gdy użytkownik odświeży stronę, klient wysyła żądanie ponownej weryfikacji, mimo że serwer wie, że treść jest stabilna. Dyrektywa immutable wyraźnie wskazuje, że ponowna weryfikacja jest niepotrzebna, bo treść się nie zmieni. Dyrektywa immutable nie jest powszechnie obsługiwana przez przeglądarki i pośrednie serwery pamięci podręcznej lub serwery proxy, ale połączenie jej z powszechnie rozumianą dyrektywą max-age zapewnia jej maksymalną zgodność. Dyrektywa odpowiedzi public wskazuje, że odpowiedź może być przechowywana we 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

Gdy udostępniasz model AI, ważne jest, by bezpośrednio umieścić go w pamięci podręcznej w przeglądarce. Dzięki temu dane modelu będą łatwo dostępne, gdy użytkownik ponownie załaduje aplikację.

Istnieje kilka metod, które można wykorzystać, aby to osiągnąć. W poniższych przykładach kodu zakładamy, że każdy plik modelu jest przechowywany w obiekcie Blob o nazwie blob w pamięci.

Aby ułatwić zrozumienie wydajności, do każdego przykładowego kodu zostały opatrzone adnotacje metodami performance.mark() i performance.measure(). Te wskaźniki zależą od urządzenia i nie można ich uogólnić.

W Narzędziach deweloperskich w Chrome kliknij Aplikacja > Pamięć i zobacz schemat wykorzystania z segmentami IndexedDB, Cache Storage i System plików. Widać, że każdy segment zużywa 1354 megabajty danych, co daje łącznie 4063 MB.

Do buforowania modeli AI w przeglądarce możesz użyć jednego z tych interfejsów API: Cache API, Origin Private File System API lub IndexedDB 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 dane dla par obiektów Request i Response, które są buforowane w pamięci długotrwałej. Chociaż zostało to zdefiniowane w specyfikacji Service Workers, możesz używać tego interfejsu API z wątku głównego lub zwykłego instancji roboczej. Aby używać go poza kontekstem skryptu service worker, 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 blob w pamięci. Użyj fałszywego adresu URL jako klucza pamięci podręcznej i syntetycznego Response opartego na blob. Gdyby bezpośrednio pobrać model, użyj opcji Response uzyskanej po wysł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

Origin Private File System (OPFS) to dość młody standard punktu końcowego pamięci masowej. Dane te są prywatne w przypadku pochodzenia strony, a więc są niewidoczne dla użytkownika w przeciwieństwie do zwykłego systemu plików. Zapewnia dostęp do specjalnego pliku, który jest wysoce zoptymalizowany pod kątem wydajności i oferuje uprawnienia do zapisu treści.

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 API IndexedDB

IndexedDB to uznany standard przechowywania dowolnych danych w trwały sposób w przeglądarce. Jest znana ze swojego nieco złożonego interfejsu API, ale korzystając z biblioteki otoki, takiej jak idb-keyval, możesz traktować IndexedDB jak klasyczny magazyn par 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;
  }
};

Oznacz miejsce na dane jako przechowywane

Wywołaj navigator.storage.persist() na końcu dowolnej z tych metod buforowania, aby poprosić o pozwolenie na używanie pamięci trwałej. Ta metoda zwraca obietnicę, która zwraca wartość true (jeśli uprawnienie jest przyznane), a false w innym przypadku. Przeglądarka może zaakceptować żądanie lub nie, w zależności od właściwych dla niej reguł.

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

Za pomocą interfejsu File System Access API możesz otwierać pliki z dysku twardego i uzyskać element FileSystemFileHandle, który można zachować 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 trwale przyznać dostęp do pliku. Po ponownym załadowaniu aplikacji i wykonaniu wymaganego gestu użytkownika, takiego jak kliknięcie myszy, obiekt FileSystemFileHandle można przywrócić z bazy IndexedDB, uzyskując dostęp do pliku na dysku twardym.

Zapytania o uprawnienia dostępu do plików są wysyłane w razie potrzeby i w razie potrzeby pobierane, dzięki czemu bez problemu działa to w przypadku ponownego wczytywania plików w przyszłości. Z przykładu poniżej dowiesz się, jak uzyskać uchwyt pliku z dysku twardego, a potem zapisać go i przywrócić.

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 jednoznacznie buforujesz model w przeglądarce i używasz modelu z dysku twardego użytkownika.

Pokaz

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

Bonus: pobieraj duże pliki partiami

Jeśli musisz pobrać duży model AI z internetu, podziel go równolegle na osobne fragmenty, a potem ponownie połącz w kliencie.

Oto funkcja pomocnicza, której możesz użyć w kodzie. Wystarczy, że zaliczysz url. chunkSize (domyślnie: 5 MB), maxParallelRequests (domyślnie 6), funkcja progressCallback (która raportuje dane na temat downloadedBytes i łącznej sumy fileSize) oraz signal dla sygnału AbortSignal są opcjonalne.

Możesz skopiować poniższą funkcję w swoim projekcie lub zainstalować pakiet fetch-in-chunks z pakietu 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 przedstawiamy różne metody skutecznego buforowania modeli AI w przeglądarce. Zadanie to ma kluczowe znaczenie dla zwiększenia wygody użytkowników i wydajności aplikacji. Zespół ds. miejsca na dane w Chrome zaleca interfejs Cache API do zapewniania optymalnej wydajności, ponieważ zapewnia szybki dostęp do modeli AI, skracanie czasu wczytywania i poprawę responsywności.

Opcje OPFS i IndexedDB są mniej przydatne. Interfejsy OPFS i interfejsy IndexedDB API muszą zserializować dane, zanim będzie można je zapisać. 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, co jest idealnym rozwiązaniem dla użytkowników, którzy zarządzają własnymi modelami AI.

Jeśli chcesz zabezpieczyć swój model AI, zachowaj go na serwerze. Dane zapisane w kliencie można łatwo wyodrębnić z Cache i IndexedDB za pomocą Narzędzi deweloperskich lub rozszerzenia OFPS DevTools. Te interfejsy API do przechowywania danych z natury mają jednakowe podejście w zakresie bezpieczeństwa. Może Cię kusić, aby przechowywać zaszyfrowaną wersję modelu, ale później musisz uzyskać do klienta klucz odszyfrowywania, który może zostać przechwycony. Oznacza to, że próba kradzieży modelu przez nieuczciwego aktora jest nieco trudniejsza, ale nie jest niemożliwa.

Zachęcamy do wyboru strategii buforowania, która jest zgodna z wymaganiami Twojej aplikacji, zachowaniem docelowych odbiorców i charakterystyką używanych modeli AI. Dzięki temu aplikacje są elastyczne i niezawodne w różnych warunkach sieciowych i w różnych ograniczeniach systemu.


Podziękowania

Tę opinię ocenili: 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.