브라우저에서 AI 모델 캐시

대부분의 AI 모델에는 한 가지 공통점이 있습니다. 인터넷을 통해 전송되는 리소스 중 상당히 큰 리소스라는 점입니다. 가장 작은 MediaPipe 객체 감지 모델(SSD MobileNetV2 float16)의 크기는 5.6MB이고 가장 큰 모델의 크기는 약 25MB입니다.

오픈소스 LLM인 gemma-2b-it-gpu-int4.bin의 크기는 1.35GB로, LLM에 비해 매우 작습니다. 생성형 AI 모델은 매우 클 수 있습니다. 이러한 이유로 오늘날 많은 AI가 클라우드에서 사용됩니다. 점점 더 많은 앱이 기기에서 직접 고도로 최적화된 모델을 실행하고 있습니다. 브라우저에서 실행되는 LLM의 데모는 있지만 다음은 브라우저에서 실행되는 다른 모델의 프로덕션 등급 예시입니다.

AI 기반 객체 선택 도구가 열려 있고 기린 2마리와 달 1개가 선택된 웹용 Adobe Photoshop

향후 애플리케이션을 더 빠르게 실행하려면 암시적 HTTP 브라우저 캐시를 사용하는 대신 기기에서 모델 데이터를 명시적으로 캐시해야 합니다.

이 가이드에서는 gemma-2b-it-gpu-int4.bin model를 사용하여 챗봇을 만들지만, 이 접근 방식은 기기의 다른 모델 및 다른 사용 사례에 맞게 일반화할 수 있습니다. 앱을 모델에 연결하는 가장 일반적인 방법은 나머지 앱 리소스와 함께 모델을 게재하는 것입니다. 전송을 최적화하는 것이 중요합니다.

올바른 캐시 헤더 구성

서버에서 AI 모델을 제공하는 경우 올바른 Cache-Control 헤더를 구성하는 것이 중요합니다. 다음 예는 앱의 요구사항에 따라 빌드할 수 있는 확실한 기본 설정을 보여줍니다.

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

AI 모델의 각 출시 버전은 정적 리소스입니다. 변경되지 않는 콘텐츠에는 요청 URL에 캐시 무효화와 결합된 긴 max-age를 제공해야 합니다. 모델을 업데이트해야 하는 경우 새 URL을 지정해야 합니다.

사용자가 페이지를 새로고침하면 서버는 콘텐츠가 안정적이라는 것을 알고 있지만 클라이언트는 재검증 요청을 전송합니다. immutable 디렉티브는 콘텐츠가 변경되지 않으므로 재검증이 불필요함을 명시적으로 나타냅니다. immutable 지시어는 브라우저와 중간 캐시 또는 프록시 서버에서 널리 지원되지는 않지만, 보편적으로 이해되는 max-age 지시어와 결합하면 최대한의 호환성을 보장할 수 있습니다. public 응답 지시문은 응답을 공유 캐시에 저장할 수 있음을 나타냅니다.

Chrome DevTools에 AI 모델을 요청할 때 Hugging Face에서 전송한 프로덕션 Cache-Control 헤더가 표시됩니다. (출처)

클라이언트 측에서 AI 모델 캐시

AI 모델을 제공할 때는 브라우저에 모델을 명시적으로 캐시하는 것이 중요합니다. 이렇게 하면 사용자가 앱을 새로고침한 후 모델 데이터를 쉽게 사용할 수 있습니다.

이를 위해 사용할 수 있는 기법은 여러 가지가 있습니다. 다음 코드 샘플에서는 각 모델 파일이 메모리에 blob라는 Blob 객체에 저장되어 있다고 가정합니다.

성능을 파악하기 위해 각 코드 샘플에는 performance.mark()performance.measure() 메서드가 주석으로 추가됩니다. 이러한 측정항목은 기기에 따라 다르며 일반화할 수 없습니다.

Chrome DevTools 애플리케이션 > 저장소에서 IndexedDB, 캐시 저장소, 파일 시스템의 세그먼트가 포함된 사용량 다이어그램을 검토합니다. 각 세그먼트는 총 4, 063MB의 데이터를 소비하는 것으로 표시됩니다.

Cache API, Origin Private File System API, IndexedDB API 중 하나를 사용하여 브라우저에 AI 모델을 캐시할 수 있습니다. 일반적으로 Cache API를 사용하는 것이 좋습니다. 하지만 이 가이드에서는 모든 옵션의 장단점을 설명합니다.

Cache API

Cache API는 장기 메모리에 캐시된 RequestResponse 객체 쌍의 영구 스토리지를 제공합니다. Service Workers 사양에 정의되어 있지만 이 API는 기본 스레드나 일반 작업자에서 사용할 수 있습니다. 서비스 워커 컨텍스트 외부에서 이를 사용하려면 Request 객체 대신 합성 URL과 페어링된 합성 Response 객체를 사용하여 Cache.put() 메서드를 호출합니다.

이 가이드에서는 메모리 내 blob를 가정합니다. 가짜 URL을 캐시 키로 사용하고 blob를 기반으로 합성 Response를 사용합니다. 모델을 직접 다운로드하는 경우 fetch() 요청을 통해 가져온 Response를 사용합니다.

예를 들어 다음은 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

Origin Private File System(OPFS)은 비교적 최근에 등장한 스토리지 엔드포인트용 표준입니다. 페이지 출처에 비공개이므로 일반 파일 시스템과 달리 사용자에게 표시되지 않습니다. 성능에 최적화되어 있고 콘텐츠에 대한 쓰기 액세스 권한을 제공하는 특수 파일에 대한 액세스를 제공합니다.

예를 들어 다음은 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;
  }
};

IndexedDB API

IndexedDB는 브라우저에 임의의 데이터를 영구적으로 저장하기 위한 잘 확립된 표준입니다. 다소 복잡한 API로 악명이 높지만 idb-keyval과 같은 래퍼 라이브러리를 사용하면 IndexedDB를 기존의 키-값 저장소처럼 취급할 수 있습니다.

예를 들면 다음과 같습니다.

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

저장소를 영구 저장소로 표시

이러한 캐싱 메서드 끝에 navigator.storage.persist()를 호출하여 영구 저장소 사용 권한을 요청합니다. 이 메서드는 권한이 부여된 경우 true로 확인되고, 부여되지 않은 경우 false로 확인되는 프라미스를 반환합니다. 브라우저별 규칙에 따라 브라우저가 요청을 수락하거나 거부할 수 있습니다.

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

특수 사례: 하드 디스크에서 모델 사용

브라우저 저장소 대신 사용자의 하드 디스크에서 AI 모델을 직접 참조할 수 있습니다. 이 기법을 사용하면 연구 중심 앱에서 브라우저에서 특정 모델을 실행하는 것이 가능한지 보여주거나 아티스트가 전문가 창의성 앱에서 자체 학습 모델을 사용할 수 있습니다.

File System Access API

File System Access API를 사용하면 하드 디스크에서 파일을 열고 IndexedDB에 유지할 수 있는 FileSystemFileHandle를 가져올 수 있습니다.

이 패턴을 사용하면 사용자는 모델 파일에 대한 액세스 권한을 한 번만 부여하면 됩니다. 지속되는 권한 덕분에 사용자는 파일에 대한 액세스 권한을 영구적으로 부여하도록 선택할 수 있습니다. 앱과 마우스 클릭과 같은 필수 사용자 동작을 새로고침한 후에는 하드 디스크의 파일에 액세스하여 IndexedDB에서 FileSystemFileHandle를 복원할 수 있습니다.

파일 액세스 권한은 필요한 경우 쿼리되고 요청되므로 향후 새로고침 시 원활하게 작동합니다. 다음 예는 하드 디스크에서 파일의 핸들을 가져온 후 핸들을 저장하고 복원하는 방법을 보여줍니다.

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

이러한 방법은 상호 배타적이지 않습니다. 브라우저에 모델을 명시적으로 캐시하고 사용자의 하드 디스크에서 모델을 사용하는 경우도 있습니다.

데모

MediaPipe LLM 데모에서 세 가지 일반 케이스 저장소 메서드와 하드 디스크 메서드가 모두 구현된 것을 확인할 수 있습니다.

보너스: 대용량 파일을 청크로 다운로드

인터넷에서 대용량 AI 모델을 다운로드해야 하는 경우 다운로드를 별도의 청크로 병렬화한 다음 클라이언트에서 다시 연결합니다.

다음은 코드에서 사용할 수 있는 도우미 함수입니다. url만 전달하면 됩니다. chunkSize (기본값: 5MB), maxParallelRequests(기본값: 6), progressCallback 함수 (downloadedBytes 및 총 fileSize에 관해 보고함), AbortSignal 신호의 signal는 모두 선택사항입니다.

프로젝트에서 다음 함수를 복사하거나 npm에서 fetch-in-chunks 패키지를 설치할 수 있습니다.

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;

적합한 방법 선택하기

이 가이드에서는 브라우저에서 AI 모델을 효과적으로 캐시하는 다양한 방법을 살펴봤습니다. 이는 앱의 사용자 환경과 성능을 개선하는 데 중요한 작업입니다. Chrome 스토리지팀은 최적의 성능을 위해 Cache API를 권장합니다. Cache API를 사용하면 AI 모델에 빠르게 액세스하여 로드 시간을 줄이고 응답성을 개선할 수 있습니다.

OPFS 및 IndexedDB는 사용성이 떨어지는 옵션입니다. OPFS 및 IndexedDB API는 데이터를 저장하기 전에 직렬화해야 합니다. 또한 IndexedDB는 데이터를 검색할 때 데이터를 역직렬화해야 하므로 대규모 모델을 저장하기에 최악의 장소입니다.

틈새 애플리케이션의 경우 File System Access API를 사용하면 사용자 기기의 파일에 직접 액세스할 수 있으므로 자체 AI 모델을 관리하는 사용자에게 적합합니다.

AI 모델을 보호해야 하는 경우 서버에 보관합니다. 클라이언트에 저장되면 DevTools 또는 OFPS DevTools 확장 프로그램을 사용하여 캐시와 IndexedDB에서 모두 데이터를 추출하는 것은 간단합니다. 이러한 스토리지 API는 본질적으로 보안 수준이 동일합니다. 암호화된 버전의 모델을 저장하고 싶을 수 있지만 그러면 클라이언트에 복호화 키를 가져와야 하며 이 키는 가로챌 수 있습니다. 즉, 악의적인 행위자가 모델을 훔치려는 시도가 다소 어려워지지만 불가능하지는 않습니다.

앱 요구사항, 타겟층 동작, 사용되는 AI 모델의 특성에 맞는 캐싱 전략을 선택하는 것이 좋습니다. 이렇게 하면 애플리케이션이 다양한 네트워크 조건과 시스템 제약 조건에서 반응이 빠르고 견고해집니다.


감사의 말씀

Joshua Bell, Reilly Grant, Evan Stade, Nathan Memmott, Austin Sullivan, Etienne Noël, André Bandarra, Alexandra Klepper, François Beaufort, Paul Kinlan, Rachel Andrew가 검토했습니다.