File size: 8,775 Bytes
7dbe69f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | // Browser side of the WebGPU check (loaded by harness.mjs into headless Chromium).
// 1. preprocessing parity: JS slicing/resize/normalize, prompt ids and canvas M-RoPE vs the Python processor;
// 2. model parity on WebGPU: verify margins (Yes - No) and decision_hidden vs PyTorch fp32, plus timings.
import * as ort from '/vendor/ort.webgpu.mjs';
import { AutoTokenizer, env } from '/vendor/transformers.web.js';
import { MINICPMV, MiniCPMVRuntime, canvasPositions, expandImagePlaceholders, preprocessImage, f16ToF32 } from '/runtime/minicpmv-runtime.mjs';
const log = (...a) => console.log('[check]', ...a);
async function loadRGB(url) {
const blob = await (await fetch(url)).blob();
const bmp = await createImageBitmap(blob, { colorSpaceConversion: 'none', premultiplyAlpha: 'none' });
const canvas = new OffscreenCanvas(bmp.width, bmp.height);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(bmp, 0, 0);
const rgba = ctx.getImageData(0, 0, bmp.width, bmp.height).data;
const rgb = new Uint8Array(bmp.width * bmp.height * 3);
for (let i = 0, j = 0; i < rgba.length; i += 4, j += 3) { rgb[j] = rgba[i]; rgb[j + 1] = rgba[i + 1]; rgb[j + 2] = rgba[i + 2]; }
return { rgb, width: bmp.width, height: bmp.height };
}
function cosine(a, b) {
let d = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { d += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
return d / Math.sqrt(na * nb);
}
function f32b64(arr) {
const bytes = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
let bin = '';
for (let i = 0; i < bytes.length; i += 8192) bin += String.fromCharCode(...bytes.subarray(i, i + 8192));
return btoa(bin);
}
function b64f32(text) {
const bin = atob(text);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new Float32Array(bytes.buffer);
}
export async function run({ bundle = '/bundle', fixtures = '/fixtures', requests = 6, model = true, adapterInfo = true,
embedGraph = 'embed_tokens_q8f16.onnx', decoderGraph = 'decoder_model_merged_q8f16.onnx',
visionGraph = 'vision_encoder_fp16.onnx', generateTest = true } = {}) {
const result = { checks: [], preprocessing: [], decisions: [], timings: {}, errors: [] };
if (adapterInfo && navigator.gpu) {
const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
result.adapter = adapter ? { ...adapter.info, vendor: adapter.info?.vendor, architecture: adapter.info?.architecture,
description: adapter.info?.description, features: [...adapter.features].filter(f => f.includes('f16')) } : null;
}
env.allowLocalModels = true;
env.allowRemoteModels = false;
env.localModelPath = '/';
ort.env.wasm.wasmPaths = '/vendor/';
ort.env.wasm.numThreads = 1;
const tokenizer = await AutoTokenizer.from_pretrained(bundle.replace(/^\//, ''));
const tok = { encode: text => tokenizer.encode(text, { add_special_tokens: false }) };
const fx = await (await fetch(`${fixtures}/fixtures.json`)).json();
const reqs = fx.requests.slice(0, requests);
// ---------------------------------------------------------------- preprocessing parity
const images = [];
for (const r of reqs) {
const t0 = performance.now();
const { rgb, width, height } = await loadRGB(`/d1${r.image}`);
const image = preprocessImage(rgb, width, height);
const prepMs = performance.now() - t0;
images.push(image);
const ref = r.crops_bin ? new Float32Array(await (await fetch(`${fixtures}/${r.crops_bin}`)).arrayBuffer()) : null;
let off = 0, maxDiff = ref ? 0 : null, nDiff = ref ? 0 : null;
const sizesOk = JSON.stringify(image.crops.map(c => [c.h, c.w])) === JSON.stringify(r.target_sizes);
for (const c of image.crops) {
if (!ref) break;
for (let i = 0; i < c.pixels.length; i++) {
const d = Math.abs(c.pixels[i] - ref[off + i]);
if (d > 0) nDiff++;
if (d > maxDiff) maxDiff = d;
}
off += c.pixels.length;
}
const text = expandImagePlaceholders(r.prefix, [image]);
const ids = tok.encode(text);
const idsEqual = ids.length === r.input_ids.length && ids.every((v, i) => v === r.input_ids[i]);
const pos = canvasPositions(ids, image.crops.map(c => [c.h, c.w]));
let posMismatch = 0;
for (let i = 0; i < ids.length && idsEqual; i++) {
if (pos.t[i] !== r.positions[0][i] || pos.h[i] !== r.positions[1][i] || pos.w[i] !== r.positions[2][i]) posMismatch++;
}
const row = { id: r.id, crops: image.crops.length, crop_sizes_equal: sizesOk, pixel_max_abs_diff: maxDiff,
pixel_values_differing: nDiff, pixel_values: ref ? ref.length : null, ids_equal: idsEqual, tokens: ids.length,
position_mismatches: posMismatch, preprocess_ms: Math.round(prepMs) };
result.preprocessing.push(row);
log(JSON.stringify(row));
}
if (!model) return result;
// ---------------------------------------------------------------- WebGPU model parity
const t0 = performance.now();
const rt = await MiniCPMVRuntime.create(ort, tok, {
embed: `${bundle}/onnx/${embedGraph}`,
vision: `${bundle}/onnx/${visionGraph}`,
decoder: `${bundle}/onnx/${decoderGraph}`,
});
result.graphs = { embedGraph, visionGraph, decoderGraph };
result.timings.load_ms = Math.round(performance.now() - t0);
const yes = fx.yes_id, no = fx.no_id;
const prefillMs = [], questionMs = [], visionMs = [];
const answers = {};
result.report = { engines: { webgpu: { answers } } };
for (let ri = 0; ri < reqs.length; ri++) {
const r = reqs[ri], image = images[ri];
const tv = performance.now();
const inputs = await rt.buildInputs(r.prefix, [image]);
visionMs.push(performance.now() - tv);
const tp = performance.now();
const pre = await rt.forward(inputs.embeds, inputs.pos, 0, inputs.ids.length, rt.emptyCache());
prefillMs.push(performance.now() - tp);
const P = inputs.ids.length;
for (const q of r.questions) {
const tq = performance.now();
const margins = [], cos = [], hiddens = [];
for (let oi = 0; oi < q.suffix_ids.length; oi++) {
const sids = q.suffix_ids[oi];
const full = inputs.ids.concat(sids);
const pos = canvasPositions(full, inputs.sizes);
const emb = await rt.embedIds(sids);
const shifted = { t: pos.t.subarray(P), h: pos.h.subarray(P), w: pos.w.subarray(P) };
const out = await rt.forward(emb, shifted, 0, sids.length, pre.cache, { keepCache: false });
margins.push(out.logits[yes] - out.logits[no]);
hiddens.push(f32b64(out.hidden));
if (q.hidden_b64) cos.push(cosine(out.hidden, b64f32(q.hidden_b64[oi])));
}
questionMs.push(performance.now() - tq);
const argmax = a => a.indexOf(Math.max(...a));
const row = { id: q.id, type: q.question.type, webgpu_margins: margins.map(v => +v.toFixed(3)),
pt_margins: q.margins?.map(v => +v.toFixed(3)), argmax_equal: q.margins ? argmax(margins) === argmax(q.margins) : null,
max_margin_diff: q.margins ? Math.max(...margins.map((v, i) => Math.abs(v - q.margins[i]))) : null,
hidden_cos_min: cos.length ? Math.min(...cos) : null };
result.decisions.push(row);
answers[q.id] = { margins, hidden_f32: hiddens };
log(JSON.stringify(row));
}
MiniCPMVRuntime.disposeCache(pre.cache);
}
// generation smoke test: one screenshot, one free-text question
if (generateTest) {
const r = reqs[0];
const prompt = `<|im_start|>user\n${MINICPMV.imagePad}\nDescribe this web page in one sentence.<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n`;
const tg = performance.now();
const gen = await rt.generate(prompt, [images[0]], { maxNewTokens: 48 });
const ms = performance.now() - tg;
result.generation = { request: r.id, prompt_tokens: gen.promptTokens, new_tokens: gen.ids.length,
text: tokenizer.decode(gen.ids, { skip_special_tokens: true }), ms: Math.round(ms),
decode_tokens_per_s: null };
log(JSON.stringify(result.generation));
}
const mean = a => a.reduce((s, v) => s + v, 0) / Math.max(1, a.length);
result.timings = { ...result.timings, vision_and_embed_ms_mean: Math.round(mean(visionMs)), prefill_ms_mean: Math.round(mean(prefillMs)),
question_ms_mean: Math.round(mean(questionMs)) };
const d = result.decisions.filter(x => x.argmax_equal !== null);
result.summary = { questions: d.length, argmax_agreement: d.filter(x => x.argmax_equal).length / Math.max(1, d.length),
worst_margin_diff: Math.max(...d.map(x => x.max_margin_diff)), mean_margin_diff: mean(d.map(x => x.max_margin_diff)),
min_hidden_cos: Math.min(...d.map(x => x.hidden_cos_min ?? 1)) };
return result;
}
|