pack-precision-map / precision_map.js
aleada's picture
pack precision map
e73d8ef verified
Raw
History Blame Contribute Delete
15.6 kB
/**
* What precision a pack is actually in — the browser half.
*
* The JavaScript port of `tools/quantize/precision_map.py`. Both are held
* together by `parity.mjs`, which runs real tensor names harvested from
* nine toolchains through each and diffs. The rules took a wrong answer
* to get right, and a port that drifted from them would bring it back
* silently.
*
* The wrong answer is worth stating, because it is the whole reason this
* file classifies the way it does. The first version keyed on tensor
* NAMES, learned from compressed-tensors packs where the payload is
* called `weight_packed`. Probing the ecosystem showed that convention is
* the minority: bitsandbytes, ModelOpt FP8, ModelOpt NVFP4 and
* compressed-tensors' own int-quantized and naive-quantized formats all
* store the quantized payload under the plain name `weight`. A
* name-driven reader reports every one of those packs as 100% full
* precision — confidently, and about most of what is published.
*
* So dtype is the primary evidence here. Names are used only to separate
* quantization metadata from model weights, a distinction that genuinely
* has no dtype signature.
*/
/** Bytes per element, keyed by the safetensors dtype string. */
export const DTYPE_BYTES = {
BF16: 2, F16: 2, F32: 4, F64: 8, F8_E4M3: 1, F8_E5M2: 1, F4: 1,
I8: 1, U8: 1, I16: 2, I32: 4, I64: 8, BOOL: 1,
};
/**
* A tensor in one of these dtypes is carrying quantized payload. Every
* packing scheme measured lands here: compressed-tensors packs into I32,
* GPTQ/AWQ/AutoRound into I32, bitsandbytes and MXFP4 and NVFP4 into U8,
* FP8 into F8_E4M3, int8 into I8.
*/
export const PAYLOAD_DTYPES = new Set(
['I32', 'I8', 'U8', 'F8_E4M3', 'F8_E5M2', 'F4', 'I16']);
/**
* Excluded on purpose: index buffers and masks (`position_ids`, causal
* masks, `weight_shape`), never packed weights.
*/
export const BUFFER_DTYPES = new Set(['I64', 'BOOL']);
/**
* Quantization metadata: scales, zero points, group permutations, the
* stored original shape. Real bytes, reported as their own category —
* counting them as full precision would overstate what stayed whole, and
* counting them as payload would hide the overhead.
*
* One union across all toolchains rather than a table per format: these
* names do not collide, so a reader that misidentifies the format still
* gets the metadata right.
*/
export const METADATA_SUFFIXES = [
// compressed-tensors
'weight_scale', 'weight_shape', 'weight_zero_point', 'weight_g_idx',
'input_scale', 'output_scale',
// gptq / awq / autoround
'qzeros', 'scales', 'g_idx',
// bitsandbytes — double quantization, so the scales are themselves quantized
'absmax', 'quant_map', 'nested_absmax', 'nested_quant_map',
'quant_state.bitsandbytes__nf4', 'quant_state.bitsandbytes__fp4',
// ModelOpt / native fp8 + fp4
'weight_scale_2', 'k_scale', 'v_scale', 'q_scale', 'prob_scale',
// mxfp4
'_scales',
];
/** Formats that give the payload its own name; the rest reuse `weight`. */
export const PAYLOAD_SUFFIXES = ['weight_packed', 'qweight', '_blocks'];
/**
* Every format here was verified by reading a real published pack of it.
*
* `producer` says where that pack records the version of the tool that
* wrote it, and `null` means the tool records nothing — which is worth
* reporting rather than hiding, since it tells the reader no version
* evidence exists for those bytes.
*
* AutoAWQ is deliberately `null` even though its config has a `version`
* field: that field holds `"gemm"`, the kernel variant. Printing it as a
* release number would be a small invented fact.
*/
export const FORMATS = {
'compressed-tensors': {
tool: 'llm-compressor / compressed-tensors',
producer: ['config', 'version'],
verifiedOn: 'aleada/Qwen3.8-27B-W4A16 (0.17.1), RedHatAI w8a8, RedHatAI FP8',
note: 'pack-quantized names the payload weight_packed; int-quantized and naive-quantized reuse `weight`',
},
gptq: {
tool: 'GPTQModel / AutoGPTQ',
producer: ['config', 'meta.quantizer'],
verifiedOn: 'ModelCloud vortex-v3 (gptqmodel:1.4.4), TheBloke/Llama-2-7B-Chat-GPTQ',
note: 'qweight + qzeros + scales + g_idx',
},
awq: {
tool: 'AutoAWQ',
producer: null,
verifiedOn: 'casperhansen/llama-3-8b-instruct-awq, Qwen/Qwen3-8B-AWQ',
note: 'qweight + qzeros + scales, no g_idx',
},
'auto-round': {
tool: 'Intel AutoRound',
producer: ['config', 'autoround_version'],
verifiedOn: 'OPEA/Qwen2.5-7B-Instruct-int4-sym-inc (0.4.0.dev)',
note: 'exports in gptq or compressed-tensors layout',
},
'intel/auto-round': {
tool: 'Intel AutoRound',
producer: ['config', 'autoround_version'],
verifiedOn: 'Intel/Qwen2-7B-int4-inc',
note: 'same layout, older method spelling',
},
bitsandbytes: {
tool: 'bitsandbytes',
producer: null,
verifiedOn: 'unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit',
note: 'payload is U8 under the plain name `weight`; absmax/quant_map are the scales',
},
mxfp4: {
tool: 'MXFP4 (native export)',
producer: null,
verifiedOn: 'openai/gpt-oss-20b',
note: '*_blocks payload + *_scales, per fused expert tensor',
},
quark: {
tool: 'AMD Quark',
producer: null,
verifiedOn: 'amd/Qwen3.8-27B-Quark-AWQ-INT4-W4A16',
note: 'payload is `weight` at I32, with weight_scale and an I32 weight_zero_point',
},
modelopt: {
tool: 'NVIDIA TensorRT ModelOpt',
producer: ['hf_quant_config', 'producer.version'],
verifiedOn: 'nvidia/Llama-3.3-70B-Instruct-FP4 (0.23.0), nvidia/Llama-3.1-8B-Instruct-FP8',
note: 'config.json declares nothing — the method lives in hf_quant_config.json',
},
};
export const RULES_MEASURED_ON = '2026-09-07';
/**
* Which module a full-precision tensor belongs to. Ordered: the first
* match wins, so specific patterns precede general ones.
*/
export const FAMILIES = [
[/(^|\.)mtp\.|(^|\.)draft/, 'auxiliary head (MTP / draft)'],
[/(^|\.)(lm_head|output_layer)\b/, 'output head (lm_head)'],
[/embed|embeddings?\b|wte\b/, 'embeddings'],
[/vision_tower|vision_model|visual|image_encoder|patch_embed/, 'vision tower'],
[/multi_modal_projector|mm_projector|merger/, 'multimodal projector'],
[/norm|layernorm|rmsnorm/, 'norms'],
[/A_log|dt_bias|conv1d|\.D$/, 'state-space parameters'],
[/(^|\.)(gate|router)\b|e_score_correction|sinks/, 'routers and sinks'],
[/rotary|inv_freq|position/, 'position buffers'],
[/\.bias$/, 'biases'],
[/experts?\./, 'experts (left unquantized)'],
];
const HEAD_RE = /lm_head|output_layer/;
/**
* quantized | metadata | full | buffer.
*
* Name first, but only to pull metadata out — a scale is a scale whatever
* its dtype. Then dtype, which is what actually decides whether a tensor
* holds full-precision weights.
*/
export function classify(name, dtype) {
for (const suf of METADATA_SUFFIXES) if (name.endsWith(suf)) return 'metadata';
for (const suf of PAYLOAD_SUFFIXES) if (name.endsWith(suf)) return 'quantized';
if (BUFFER_DTYPES.has(dtype)) return 'buffer';
if (PAYLOAD_DTYPES.has(dtype)) return 'quantized';
return 'full';
}
export function family(name) {
for (const [pattern, label] of FAMILIES) if (pattern.test(name)) return label;
return 'other linear weights';
}
/**
* `meta.quantizer` out of a nested object, tolerating a list value:
* GPTQModel writes that field as a bare string in some releases and a
* one-element array in others, and both are in the wild.
*/
export function dig(obj, path) {
let cur = obj;
for (const part of path.split('.')) {
if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) return null;
cur = cur[part];
if (cur === undefined) return null;
}
if (Array.isArray(cur)) return cur.length ? cur.map(String).join(', ') : null;
return cur === undefined ? null : cur;
}
/**
* The quantization method, from wherever the tool chose to record it.
*
* Never from the repo name: `cyankiwi/Qwen3-VL-8B-Instruct-AWQ-4bit`
* declares `compressed-tensors`, and a reader that trusted the name would
* apply the wrong convention to it.
*/
export function declaredMethod(cfg, hfQuant) {
let q = cfg?.quantization_config;
if (!q && cfg?.text_config && typeof cfg.text_config === 'object') {
q = cfg.text_config.quantization_config;
}
const m = q?.quant_method;
if (m) return String(m);
if (hfQuant) return 'modelopt';
return 'none';
}
/**
* Fold a list of `{name, dtype, shape}` into the report.
*
* Split out from any fetching so the parity gate can run it on fixtures
* and so the same arithmetic serves whichever transport reads the header.
*/
export function summarize(tensors, method) {
const spec = FORMATS[method] || null;
const buckets = { quantized: 0, metadata: 0, full: 0, buffer: 0 };
const families = {};
const dtypeBytes = {};
const unknownDtypes = new Set();
let headQuantized = false;
for (const t of tensors) {
if (!(t.dtype in DTYPE_BYTES)) unknownDtypes.add(t.dtype);
let n = 1;
for (const d of t.shape) n *= d;
const size = n * (DTYPE_BYTES[t.dtype] ?? 2);
const kind = classify(t.name, t.dtype);
buckets[kind] += size;
dtypeBytes[t.dtype] = (dtypeBytes[t.dtype] || 0) + size;
if (kind === 'full') {
const fam = family(t.name);
families[fam] = (families[fam] || 0) + size;
} else if (kind === 'quantized' && HEAD_RE.test(t.name)) {
headQuantized = true;
}
}
const total = Object.values(buckets).reduce((a, b) => a + b, 0) || 1;
// Largest first, ties broken by name. The tie is not hypothetical: an
// untied model's embeddings and output head are the same tensor
// transposed, so their byte counts are equal exactly. Shards are read
// concurrently, so without the second key those two rows would swap
// between runs of the same pack — and a report that changes when
// nothing changed is a report nobody can quote.
//
// Compared by code point, not localeCompare: Python's `sorted` orders
// by code point, and a locale-aware comparison disagrees with it on
// case and punctuation. The parity gate can only catch that when a
// fixture happens to contain a tie, so the two are made to agree by
// construction instead of by luck.
const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const sortDesc = (o) => Object.fromEntries(
Object.entries(o).sort((a, b) => b[1] - a[1] || byName(a[0], b[0])));
return {
method,
tool: spec ? spec.tool : null,
verifiedOn: spec ? spec.verifiedOn : null,
note: spec ? spec.note : null,
totalBytes: total,
buckets,
dtypeBytes: sortDesc(dtypeBytes),
fullByFamily: sortDesc(families),
fullPct: (buckets.full / total) * 100,
headQuantized,
unknownDtypes: [...unknownDtypes].sort(),
// A declared method with no payload found means this reader does not
// understand that tool's layout — not that the pack is unquantized.
// Saying so is the one guard that keeps a future convention change
// from producing a confident wrong answer.
recognised: buckets.quantized > 0 || method === 'none',
knownFormat: spec !== null || method === 'none',
};
}
// ------------------------------------------------------------- transport
const HF = 'https://huggingface.co';
async function json(url) {
const r = await fetch(url);
if (!r.ok) return null;
try { return await r.json(); } catch { return null; }
}
/**
* Read one safetensors file's header without downloading the file.
*
* The format opens with a little-endian u64 giving the header length,
* then that many bytes of JSON describing every tensor. Two ranged
* requests fetch it — typically a few hundred kilobytes against shards
* of many gigabytes, which is what makes this possible from a browser
* at all.
*/
export async function readHeader(repoId, file) {
const url = `${HF}/${repoId}/resolve/main/${file}`;
const head = await fetch(url, { headers: { Range: 'bytes=0-7' } });
if (!head.ok) throw new Error(`cannot read ${file} (HTTP ${head.status})`);
const len = Number(new DataView(await head.arrayBuffer()).getBigUint64(0, true));
if (!Number.isFinite(len) || len <= 0 || len > 200_000_000) {
throw new Error(`${file} does not look like a safetensors file`);
}
const body = await fetch(url, { headers: { Range: `bytes=8-${7 + len}` } });
if (!body.ok) throw new Error(`cannot read ${file} header (HTTP ${body.status})`);
const parsed = JSON.parse(new TextDecoder().decode(await body.arrayBuffer()));
const tensors = [];
for (const [name, info] of Object.entries(parsed)) {
if (name === '__metadata__') continue;
tensors.push({ name, dtype: info.dtype, shape: info.shape || [] });
}
return tensors;
}
/** Which safetensors files a repo publishes, sharded or not. */
export async function shardList(repoId) {
const index = await json(`${HF}/${repoId}/resolve/main/model.safetensors.index.json`);
if (index?.weight_map) return [...new Set(Object.values(index.weight_map))];
const head = await fetch(`${HF}/${repoId}/resolve/main/model.safetensors`,
{ headers: { Range: 'bytes=0-7' } });
if (head.ok) return ['model.safetensors'];
return [];
}
/** Everything the page needs about one repo. */
export async function inspect(repoId, onProgress = () => {}) {
const cfg = await json(`${HF}/${repoId}/resolve/main/config.json`);
if (!cfg) throw new Error('no config.json — gated, private, or not a model repo');
const hfQuant = await json(`${HF}/${repoId}/resolve/main/hf_quant_config.json`);
const method = declaredMethod(cfg, hfQuant);
let producerVersion = null;
const spec = FORMATS[method];
if (spec?.producer) {
const [where, path] = spec.producer;
const src = where === 'config' ? (cfg.quantization_config || {}) : (hfQuant || {});
const v = dig(src, path);
producerVersion = v === null || v === undefined ? null : String(v);
}
const files = await shardList(repoId);
if (!files.length) {
// A pack whose weights are a .pt or .bin cannot be read this way, and
// must not be reported as unquantized.
throw new Error('no safetensors weights published — torchao and HQQ '
+ 'packs often ship a .pt instead, and their contents cannot be read '
+ 'from metadata');
}
onProgress(0, files.length);
// Read the shards a few at a time rather than one after another. A
// 70B pack is nine shards and two ranged requests each; sequentially
// that is long enough that the page reads as broken. Four at a time
// is enough to hide most of the latency without opening a burst of
// connections against the Hub for a page that is only reading
// metadata.
const tensors = [];
let next = 0;
let finished = 0;
const worker = async () => {
while (next < files.length) {
const mine = files[next++];
const got = await readHeader(repoId, mine);
tensors.push(...got);
onProgress(++finished, files.length);
}
};
await Promise.all(
Array.from({ length: Math.min(4, files.length) }, worker));
return {
repo: repoId,
producerVersion,
shards: files.length,
tensorCount: tensors.length,
...summarize(tensors, method),
};
}