| from __future__ import annotations |
|
|
| import argparse |
| import hashlib |
| import json |
| import re |
| from collections import defaultdict |
| from pathlib import Path |
|
|
| import numpy as np |
| import onnx |
| import onnxruntime as ort |
| from onnx import TensorProto, helper, numpy_helper |
|
|
|
|
| ROOT = Path(__file__).resolve().parent |
| BASELINE = ROOT / ".work/models/shared/decoder_unified_gather_qdq_int8.onnx" |
| DEFAULT_SOURCE = ROOT / ".work/models/model-opt/decoder_gather_before_dq_int8.onnx" |
| DEFAULT_DESTINATION = ROOT / ".work/models/model-opt/decoder_static_fp16_matmul.onnx" |
| DEFAULT_REPORT = ROOT / ".work/reports/model-fp16-matmul-optimization.json" |
| VISION_TOKENS = 256 |
| KEEP_QDQ_MATMUL_NAMES = {"/lm_head/MatMul"} |
|
|
|
|
| def sha256(path: Path) -> str: |
| digest = hashlib.sha256() |
| with path.open("rb") as source: |
| for chunk in iter(lambda: source.read(1024 * 1024), b""): |
| digest.update(chunk) |
| return digest.hexdigest() |
|
|
|
|
| def dequantize_to_fp16( |
| quantized: onnx.TensorProto, |
| scale: onnx.TensorProto, |
| zero_point: onnx.TensorProto, |
| axis: int, |
| ) -> np.ndarray: |
| values = numpy_helper.to_array(quantized).astype(np.float32) |
| scales = numpy_helper.to_array(scale).astype(np.float32) |
| zeros = numpy_helper.to_array(zero_point).astype(np.float32) |
| broadcast_shape = [1] * values.ndim |
| broadcast_shape[axis] = scales.size |
| return ((values - zeros.reshape(broadcast_shape)) * scales.reshape(broadcast_shape)).astype(np.float16) |
|
|
|
|
| def rewrite_matmul_weights(model: onnx.ModelProto, selected_layers: set[int]) -> dict: |
| graph = model.graph |
| initializers = {value.name: value for value in graph.initializer} |
| consumers: dict[str, list[onnx.NodeProto]] = defaultdict(list) |
| for node in graph.node: |
| for name in node.input: |
| consumers[name].append(node) |
|
|
| dq_by_matmul_name: dict[str, onnx.NodeProto] = {} |
| fp16_initializers: list[onnx.TensorProto] = [] |
| removable_initializers: set[str] = set() |
| quantized_elements = 0 |
| for node in graph.node: |
| if node.op_type != "DequantizeLinear" or len(node.input) < 3: |
| continue |
| quantized = initializers.get(node.input[0]) |
| scale = initializers.get(node.input[1]) |
| zero_point = initializers.get(node.input[2]) |
| if quantized is None or scale is None or zero_point is None: |
| continue |
| matches = [consumer for consumer in consumers[node.output[0]] if consumer.op_type == "MatMul"] |
| if not matches: |
| continue |
| if len(matches) != 1: |
| raise RuntimeError(f"Expected one MatMul consumer for {node.name}, found {len(matches)}") |
| matmul = matches[0] |
| if matmul.name in KEEP_QDQ_MATMUL_NAMES: |
| continue |
| layer_match = re.search(r"/decoder/layers\.(\d+)/", matmul.name) |
| if layer_match is None: |
| raise RuntimeError(f"Unexpected non-layer MatMul {matmul.name}") |
| if int(layer_match.group(1)) not in selected_layers: |
| continue |
| if matmul.input[1] != node.output[0]: |
| raise RuntimeError(f"Quantized weight is not RHS input for {matmul.name}") |
| axis = next((attribute.i for attribute in node.attribute if attribute.name == "axis"), 1) |
| fp16_name = f"{quantized.name}_static_fp16" |
| fp16 = dequantize_to_fp16(quantized, scale, zero_point, axis) |
| fp16_initializers.append(numpy_helper.from_array(fp16, fp16_name)) |
| matmul.input[1] = fp16_name |
| dq_by_matmul_name[matmul.name] = node |
| removable_initializers.update(node.input[:3]) |
| quantized_elements += fp16.size |
|
|
| expected_matmuls = 7 * len(selected_layers) |
| if len(dq_by_matmul_name) != expected_matmuls: |
| raise RuntimeError(f"Expected {expected_matmuls} selected MatMuls, found {len(dq_by_matmul_name)}") |
|
|
| rewritten: list[onnx.NodeProto] = [] |
| removed_dq_names = {node.name for node in dq_by_matmul_name.values()} |
| for node in graph.node: |
| if node.name in removed_dq_names: |
| continue |
| if node.name not in dq_by_matmul_name: |
| rewritten.append(node) |
| continue |
| input_fp16 = f"{node.input[0]}__for_{node.name.replace('/', '_')}_fp16" |
| output_float = node.output[0] |
| output_fp16 = f"{output_float}__fp16" |
| rewritten.append( |
| helper.make_node( |
| "Cast", |
| [node.input[0]], |
| [input_fp16], |
| name=f"{node.name}/CastInputToFp16", |
| to=TensorProto.FLOAT16, |
| ) |
| ) |
| node.input[0] = input_fp16 |
| node.output[0] = output_fp16 |
| rewritten.append(node) |
| rewritten.append( |
| helper.make_node( |
| "Cast", |
| [output_fp16], |
| [output_float], |
| name=f"{node.name}/CastOutputToFp32", |
| to=TensorProto.FLOAT, |
| ) |
| ) |
|
|
| retained = [value for value in graph.initializer if value.name not in removable_initializers] |
| del graph.initializer[:] |
| graph.initializer.extend(retained) |
| graph.initializer.extend(fp16_initializers) |
| del graph.node[:] |
| graph.node.extend(rewritten) |
| return { |
| "matmuls_rewritten": len(dq_by_matmul_name), |
| "layers_rewritten": sorted(selected_layers), |
| "matmuls_kept_qdq": sorted(KEEP_QDQ_MATMUL_NAMES), |
| "weight_elements": quantized_elements, |
| "runtime_fp32_weight_bytes_avoided": quantized_elements * 4, |
| "static_fp16_weight_bytes": quantized_elements * 2, |
| "source_int8_weight_bytes_removed": quantized_elements, |
| "estimated_live_weight_bytes_avoided": quantized_elements * 3, |
| } |
|
|
|
|
| def feeds(session: ort.InferenceSession, past_length: int, *, prefill: bool) -> dict[str, np.ndarray]: |
| rng = np.random.default_rng(20260717 + past_length) |
| result: dict[str, np.ndarray] = {} |
| for value in session.get_inputs(): |
| if value.name == "vision_embeds": |
| length = VISION_TOKENS if prefill else 0 |
| result[value.name] = rng.normal(0, 0.2, [1, length, 512]).astype(np.float32) |
| elif value.name == "token_ids": |
| result[value.name] = np.array([[1 if prefill else 4]], dtype=np.int32) |
| elif value.name == "position_ids": |
| result[value.name] = ( |
| np.arange(VISION_TOKENS + 1, dtype=np.int32)[None, :] |
| if prefill |
| else np.array([[past_length]], dtype=np.int32) |
| ) |
| else: |
| result[value.name] = rng.normal(0, 0.02, [1, 2, past_length, 64]).astype(np.float32) |
| return result |
|
|
|
|
| def validate_cpu(baseline: Path, candidate: Path) -> dict: |
| baseline_session = ort.InferenceSession(str(baseline), providers=["CPUExecutionProvider"]) |
| candidate_session = ort.InferenceSession(str(candidate), providers=["CPUExecutionProvider"]) |
| checks = {} |
| for label, past_length, prefill in ( |
| ("prefill", 0, True), |
| ("step_257", 257, False), |
| ("step_383", 383, False), |
| ): |
| model_feeds = feeds(baseline_session, past_length, prefill=prefill) |
| expected = baseline_session.run(None, model_feeds) |
| actual = candidate_session.run(None, model_feeds) |
| max_abs = [float(np.max(np.abs(left - right))) for left, right in zip(expected, actual)] |
| checks[label] = { |
| "logits_max_abs": max_abs[0], |
| "all_outputs_max_abs": max(max_abs), |
| "top_token_baseline": int(expected[0][0, -1].argmax()), |
| "top_token_candidate": int(actual[0][0, -1].argmax()), |
| } |
| if checks[label]["top_token_baseline"] != checks[label]["top_token_candidate"]: |
| raise RuntimeError(f"CPU top-token parity failed for {label}: {checks[label]}") |
| return checks |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--baseline", type=Path, default=BASELINE) |
| parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE) |
| parser.add_argument("--destination", type=Path, default=DEFAULT_DESTINATION) |
| parser.add_argument("--report", type=Path, default=DEFAULT_REPORT) |
| parser.add_argument( |
| "--layers", |
| default="1,2,3", |
| help="Comma-separated decoder layers whose seven MatMuls execute in FP16", |
| ) |
| arguments = parser.parse_args() |
| arguments.destination.parent.mkdir(parents=True, exist_ok=True) |
| arguments.report.parent.mkdir(parents=True, exist_ok=True) |
|
|
| selected_layers = {int(value) for value in arguments.layers.split(",") if value != ""} |
| if not selected_layers.issubset(set(range(6))): |
| raise ValueError(f"Invalid decoder layers {sorted(selected_layers)}") |
| model = onnx.load(arguments.source) |
| optimization = rewrite_matmul_weights(model, selected_layers) |
| model.producer_name = "vibe-manga-baberu-webgpu-static-fp16-matmul" |
| model.producer_version = "1" |
| onnx.checker.check_model(model) |
| onnx.save(model, arguments.destination) |
| parity = validate_cpu(arguments.baseline, arguments.destination) |
| report = { |
| "source": { |
| "path": str(arguments.source.relative_to(ROOT)), |
| "bytes": arguments.source.stat().st_size, |
| "sha256": sha256(arguments.source), |
| }, |
| "optimized": { |
| "path": str(arguments.destination.relative_to(ROOT)), |
| "bytes": arguments.destination.stat().st_size, |
| "sha256": sha256(arguments.destination), |
| }, |
| "capability": { |
| "layers": 6, |
| "hidden_size": 512, |
| "kv_heads": 2, |
| "vocabulary": 14630, |
| "max_new_tokens": 128, |
| "architecture_changed": False, |
| }, |
| "static_fp16_matmul": optimization, |
| "cpu_validation": parity, |
| } |
| arguments.report.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") |
| print(json.dumps(report, indent=2)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|