vp2vi / engine /constants.js
DanVP's picture
feat: publish vp2vi WebGPU browser app
c971a45 verified
Raw
History Blame Contribute Delete
6.2 kB
// Single source of numeric truth for the ACTIVE Marian model — shared by JS
// reference math, engine code, and templated into WGSL kernels.
//
// The geometry lives in `let` bindings booted to MoxhiMT-30 and swapped by
// applyModelConfig(manifest.model) when weights load (uploadParsed calls it),
// so ONE build serves the whole MT-30/60 family. Two rules keep that sound:
// 1. Consumers read these at CALL time (function bodies / default params) —
// never snapshot them into module-top-level derived consts.
// 2. Every dim baked into WGSL rides the pipeline-cache key (defines in
// normalizeFlags), so switching models builds fresh pipelines instead of
// reusing stale ones. runEncoder/createDecodeState assert the weights
// they're handed match the active config (assertModelActive) — using two
// models interleaved without re-applying is a caller error, caught loud.
export let D_MODEL, HEADS, HEAD_DIM, FFN, VOCAB;
export let ENC_LAYERS, DEC_LAYERS;
export let MAX_POS, SRC_CAP, DECODE_CAP;
// Attention kernel shared-memory scores capacity — max valid K/V length any
// dispatch may see (bucketed SRC_CAP and DECODE_CAP both fit), +32 headroom.
export let SCORES_CAP;
export let EOS, PAD, DECODER_START;
export let LN_EPS;
export let EMBED_SCALE; // √D_MODEL (scale_embedding), 1 when scaleEmbedding: false
export let ATTN_SCALE; // 1/√HEAD_DIM
export let BITMASK_WORDS; // ⌈VOCAB/32⌉ u32 words per repetition-bitmask row
// Decode policy (HF generation config), not model geometry — constant across
// the family so far; move into the manifest if a member ever changes it.
export const REP_PENALTY = 1.2;
// Validate a manifest `model` block and return it normalized. Throws on
// missing/ill-typed fields and on anything the kernels cannot serve — the
// engine's portability envelope is Marian with vec4-able dims, fused-QKV
// strides (heads·headDim == dModel), swish FFN, and tied embeddings (the
// lm_head reads shared.weight); those last two are the exporter's to assert.
const INT_FIELDS = [
'dModel', 'heads', 'headDim', 'ffn', 'encLayers', 'decLayers',
'vocab', 'maxPos', 'srcCap', 'decodeCap', 'eos', 'pad', 'decoderStart',
];
export function parseModelConfig(model) {
if (!model || typeof model !== 'object') throw new Error('model config: not an object');
const m = {};
for (const f of INT_FIELDS) {
const v = model[f];
if (!Number.isInteger(v) || v < 0) {
throw new Error(`model config: ${f} must be a non-negative integer, got ${v}`);
}
m[f] = v;
}
for (const f of INT_FIELDS.slice(0, 10)) { // all but eos/pad/decoderStart
if (m[f] === 0) throw new Error(`model config: ${f} must be positive`);
}
if (!(typeof model.lnEps === 'number' && model.lnEps > 0 && model.lnEps < 0.1)) {
throw new Error(`model config: lnEps must be in (0, 0.1), got ${model.lnEps}`);
}
m.lnEps = model.lnEps;
if (m.heads * m.headDim !== m.dModel) {
throw new Error(`model config: heads·headDim = ${m.heads * m.headDim} != dModel ${m.dModel}`);
}
for (const f of ['dModel', 'headDim', 'ffn']) {
if (m[f] % 4 !== 0) {
throw new Error(`model config: ${f} = ${m[f]} must be a multiple of 4 (vec4 kernels)`);
}
}
if (m.srcCap % 32 !== 0) {
throw new Error(`model config: srcCap ${m.srcCap} must be a multiple of 32 (bucketFor invariant)`);
}
if (m.srcCap > m.maxPos || m.decodeCap > m.maxPos) {
throw new Error(
`model config: srcCap ${m.srcCap} / decodeCap ${m.decodeCap} must not exceed maxPos ${m.maxPos}`);
}
for (const f of ['eos', 'pad', 'decoderStart']) {
if (m[f] >= m.vocab) throw new Error(`model config: ${f} = ${m[f]} out of vocab ${m.vocab}`);
}
// scale_embedding: false is representable (embed kernel takes EMBED_SCALE).
if (model.scaleEmbedding === false) m.scaleEmbedding = false;
m.embedScale = m.scaleEmbedding === false ? 1 : Math.sqrt(m.dModel);
return m;
}
let ACTIVE = null;
// The currently applied config, as a re-appliable copy — for policy caching
// keys and for tests that switch models and must restore what they found.
export function activeModelConfig() {
return { ...ACTIVE };
}
// Make `model` the active config. Idempotent; callers normally never invoke
// this directly — uploadParsed/loadWeights apply the manifest's block.
export function applyModelConfig(model) {
const m = parseModelConfig(model);
ACTIVE = m;
D_MODEL = m.dModel; HEADS = m.heads; HEAD_DIM = m.headDim; FFN = m.ffn; VOCAB = m.vocab;
ENC_LAYERS = m.encLayers; DEC_LAYERS = m.decLayers;
MAX_POS = m.maxPos; SRC_CAP = m.srcCap; DECODE_CAP = m.decodeCap;
SCORES_CAP = Math.ceil(Math.max(m.srcCap, m.decodeCap) / 32) * 32 + 32;
EOS = m.eos; PAD = m.pad; DECODER_START = m.decoderStart;
LN_EPS = m.lnEps;
EMBED_SCALE = m.embedScale;
ATTN_SCALE = 1 / Math.sqrt(m.headDim);
BITMASK_WORDS = Math.ceil(m.vocab / 32);
return m;
}
// Guard for engine entry points: the weights being dispatched must have been
// the last applied config (their dims are live in kernel defines right now).
export function assertModelActive(model, what = 'weights') {
const m = model ?? {};
const stale =
m.dModel !== D_MODEL || m.heads !== HEADS || m.headDim !== HEAD_DIM ||
m.ffn !== FFN || m.vocab !== VOCAB ||
m.encLayers !== ENC_LAYERS || m.decLayers !== DEC_LAYERS ||
m.maxPos !== MAX_POS || m.srcCap !== SRC_CAP || m.decodeCap !== DECODE_CAP ||
m.eos !== EOS || m.pad !== PAD || m.decoderStart !== DECODER_START;
if (stale) {
throw new Error(
`${what} belong to a different model config than the active one — ` +
'call applyModelConfig(weights.model) (loadWeights/uploadParsed do it) before dispatching');
}
}
// Boot config: MoxhiMT-30 zh→vi (d_model 448, 8 heads × 56, ffn 1792, enc 8 /
// dec 2, vocab 24k) — kept as the pre-load default so tools and unit tests
// see the shipped numbers (EMBED_SCALE √448, ATTN_SCALE 1/√56, SCORES_CAP 352).
applyModelConfig({
dModel: 448, heads: 8, headDim: 56, ffn: 1792,
encLayers: 8, decLayers: 2,
vocab: 24000, maxPos: 512, srcCap: 320, decodeCap: 224,
eos: 2, pad: 0, decoderStart: 0, lnEps: 1e-5,
});