everything-webgpu / src /engine /prefetch.js
nyaaorick's picture
feat: publish everything-webgpu package, engine source and documentation
1944112 verified
Raw
History Blame Contribute Delete
8.05 kB
/**
* `prefetch(modelId)` — fill the cache without building an engine.
*
* The gap this closes: WebLLM only ever downloads a model as a side effect of
* `reload()`, which instantiates the wasm and needs a GPU before it will fetch
* a single weight shard. So "warm the cache during onboarding, decide about the
* GPU later" is not expressible — and it is the thing an app wants to do while
* the user is still reading the welcome screen.
*
* We already write these exact caches for injected models (`ingest.js`); this is
* the same write plan with the bytes arriving over the network instead of off
* disk.
*
* ## The one dangerous part, and how it is closed
*
* To fetch the artifacts ourselves we must know their URLs, which means
* applying HuggingFace's `/resolve/main/` rule — the same rule
* [ARCHIVE.md](../../ARCHIVE.md) records *removing* a copy of, and that §2a of
* the roadmap says not to derive at registration time.
*
* Those decisions still hold and this does not contradict them: they are about
* not deriving a URL that WebLLM will derive again at load, which double-applies
* it. Here WebLLM is not in the loop at all — we are the loader — so there is
* nothing to double-apply.
*
* What makes it *safe* is that we do not trust our own derivation. A key that is
* off by one character writes a cache WebLLM's loader will never look in, and
* the symptom is the worst kind: prefetch reports success and the user downloads
* the model twice. So every prefetch ends by asking **WebLLM's own
* `hasModelInCache`** — which derives the URL through the very function we are
* mirroring — whether the model is really there. If it says no, this throws
* instead of claiming success.
*
* `webllm-contract.test.mjs` additionally pins the rule against the bundle, so
* an upstream change to the URL scheme fails a test rather than a user's
* download.
*/
import { ERROR, EngineError } from "./errors.js";
import {
CHAT_CONFIG,
CONTENT_TYPES,
LEGACY_TENSOR_MANIFEST,
TENSOR_MANIFEST,
} from "./ingest.js";
import { CACHE_CONFIG, CACHE_MODEL, CACHE_WASM, isInjected } from "./model-store.js";
/**
* WebLLM's `cleanModelUrl`, mirrored deliberately.
*
* Kept character-for-character with the bundle's version (see the contract
* test) because the whole point is to produce the same cache keys its loader
* will look for.
*/
export function resolveModelUrl(modelUrl) {
let url = modelUrl + (modelUrl.endsWith("/") ? "" : "/");
if (!url.match(/.+\/resolve\/.+\//)) url += "resolve/main/";
return new URL(url).href;
}
/**
* @param {object} opts
* @param {string} opts.modelId
* @param {object} opts.record the merged app-config entry: `model`, `model_lib`
* @param {(p: {phase: string, done: number, total: number, label: string}) => void} [opts.onProgress]
* @param {AbortSignal} [opts.signal]
* @param {(url: string, init?: object) => Promise<Response>} [opts.fetchImpl]
* @returns {Promise<{modelId: string, files: number, bytes: number, alreadyCached: boolean}>}
*/
export async function prefetchModel({
modelId,
record,
onProgress = () => {},
signal,
fetchImpl = globalThis.fetch,
}) {
if (!record?.model_lib) {
throw new EngineError(
ERROR.BAD_REQUEST,
`"${modelId}" has no \`model_lib\`, so there is nothing to prefetch from. ` +
"A remote source needs one; see load(url, { modelLib }).",
{ modelId },
);
}
const base = resolveModelUrl(record.model);
const get = async (url, what) => {
if (signal?.aborted) throw aborted(modelId);
const res = await fetchImpl(url, signal ? { signal } : undefined).catch((err) => {
throw new EngineError(ERROR.GENERATION_FAILED, `Prefetch could not reach ${what}: ${err?.message ?? err}`, {
modelId,
url,
});
});
if (!res.ok) {
throw new EngineError(
ERROR.UNKNOWN_MODEL,
`Prefetch got ${res.status} for ${what} at ${url}. ` +
"Check the model's base URL — a 404 here usually means the id or the URL is wrong.",
{ modelId, url, status: res.status },
);
}
return res;
};
// The config first: it names the tokenizer files, so the plan cannot be built
// without it. Same order `reload()` uses.
onProgress({ phase: "manifest", done: 0, total: 1, label: CHAT_CONFIG });
const configRes = await get(base + CHAT_CONFIG, CHAT_CONFIG);
const configBytes = await configRes.arrayBuffer();
const chatConfig = parseJson(configBytes, CHAT_CONFIG, modelId);
// `tensor-cache.json`, falling back to the legacy name, exactly as ingest does.
let manifestName = TENSOR_MANIFEST;
let manifestRes = await fetchImpl(base + TENSOR_MANIFEST, signal ? { signal } : undefined).catch(() => null);
if (!manifestRes?.ok) {
manifestName = LEGACY_TENSOR_MANIFEST;
manifestRes = await get(base + LEGACY_TENSOR_MANIFEST, "the weight index");
}
const manifestBytes = await manifestRes.arrayBuffer();
const manifest = parseJson(manifestBytes, manifestName, modelId);
const shards = (manifest.records ?? []).map((r) => r.dataPath).filter(Boolean);
if (shards.length === 0) {
throw new EngineError(
ERROR.UNKNOWN_MODEL,
`${manifestName} at ${base} lists no weight shards, so this is not an MLC model directory.`,
{ modelId, url: base + manifestName },
);
}
const tokenizers = (Array.isArray(chatConfig.tokenizer_files) ? chatConfig.tokenizer_files : []).filter(
(n) => n === "tokenizer.json" || n === "tokenizer.model",
);
const plan = [
{ scope: CACHE_CONFIG, url: base + CHAT_CONFIG, body: configBytes, type: CONTENT_TYPES.json },
{ scope: CACHE_MODEL, url: base + manifestName, body: manifestBytes, type: CONTENT_TYPES.json },
...tokenizers.map((name) => ({
scope: CACHE_MODEL,
url: base + name,
type: name.endsWith(".json") ? CONTENT_TYPES.json : CONTENT_TYPES.bin,
})),
...shards.map((p) => ({ scope: CACHE_MODEL, url: new URL(p, base).href, type: CONTENT_TYPES.bin })),
// Verbatim, never derived — `model_lib` is a literal URL on the record and
// is not even on the same origin as the weights for any prebuilt model.
{ scope: CACHE_WASM, url: record.model_lib, type: CONTENT_TYPES.wasm },
];
const openCaches = new Map();
const cacheFor = async (scope) => {
if (!openCaches.has(scope)) openCaches.set(scope, await caches.open(scope));
return openCaches.get(scope);
};
let bytes = 0;
let done = 0;
for (const item of plan) {
if (signal?.aborted) throw aborted(modelId);
const cache = await cacheFor(item.scope);
onProgress({ phase: "downloading", done, total: plan.length, label: basename(item.url) });
// Skip what is already there: a resumed prefetch should cost only the
// remainder, the same way a resumed `load()` does.
if (item.body === undefined && (await cache.match(new Request(item.url)))) {
done += 1;
continue;
}
const body = item.body ?? (await (await get(item.url, basename(item.url))).arrayBuffer());
bytes += body.byteLength;
await cache.put(
new Request(item.url),
new Response(body, { status: 200, headers: { "Content-Type": item.type } }),
);
done += 1;
}
onProgress({ phase: "downloading", done, total: plan.length, label: "done" });
return { modelId, files: plan.length, bytes, alreadyCached: false };
}
const aborted = (modelId) =>
new EngineError(ERROR.ABORTED, `Prefetch of "${modelId}" was aborted.`, { modelId });
function parseJson(buffer, what, modelId) {
try {
return JSON.parse(new TextDecoder().decode(buffer));
} catch {
throw new EngineError(
ERROR.UNKNOWN_MODEL,
`${what} for "${modelId}" is not valid JSON — the URL is probably not an MLC model directory.`,
{ modelId, what },
);
}
}
const basename = (url) => url.split("/").pop() || url;
/** Only for injected models: they are in the cache before they are ever registered. */
export const isAlreadyLocal = (record) => isInjected(record);