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