diff --git a/CMakeLists.txt b/CMakeLists.txt index 190a0f07..8cba44fb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -357,6 +357,16 @@ target_link_libraries(benchmarks libgemma hwy hwy_contrib nlohmann_json::nlohman add_executable(debug_prompt evals/debug_prompt.cc) target_link_libraries(debug_prompt libgemma hwy hwy_contrib nlohmann_json::nlohmann_json) +add_library(model_comparison evals/model_comparison.cc) +target_include_directories(model_comparison PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +add_executable(model_comparison_test evals/model_comparison_test.cc) +target_link_libraries(model_comparison_test model_comparison) + +add_executable(gemma_mmlu evals/run_mmlu.cc) +target_link_libraries(gemma_mmlu libgemma model_comparison hwy hwy_contrib + nlohmann_json::nlohmann_json) + ## Tests set(GEMMA_ENABLE_TESTS OFF CACHE BOOL "Enable Gemma tests") if (GEMMA_ENABLE_TESTS) @@ -445,6 +455,27 @@ endif() # GEMMA_ENABLE_TESTS ## Tools +# Standalone W8A8 vs BF16 MatMul benchmark (no gtest). The _biased variant +# forces the encoding x86 uses, to show what the bias correction costs. +add_executable(bench_matmul_i8 ops/bench_matmul_i8.cc) +target_link_libraries(bench_matmul_i8 libgemma hwy hwy_contrib) + +add_executable(bench_matmul_i8_biased ops/bench_matmul_i8.cc) +target_compile_definitions(bench_matmul_i8_biased PRIVATE + GEMMA_MM_I8_FORCE_BIASED_B=1) +target_link_libraries(bench_matmul_i8_biased libgemma hwy hwy_contrib) + +# W8A8 correctness, built once per A encoding so that the x86 biased-u8 path +# is covered on non-x86 hosts as well. +add_executable(matmul_i8_test ops/matmul_i8_test.cc) +target_compile_definitions(matmul_i8_test PRIVATE GEMMA_MM_I8_FORCE_BIASED_B=0) +target_link_libraries(matmul_i8_test libgemma hwy hwy_contrib) + +add_executable(matmul_i8_biased_test ops/matmul_i8_test.cc) +target_compile_definitions(matmul_i8_biased_test PRIVATE + GEMMA_MM_I8_FORCE_BIASED_B=1) +target_link_libraries(matmul_i8_biased_test libgemma hwy hwy_contrib) + add_executable(migrate_weights io/migrate_weights.cc) target_link_libraries(migrate_weights libgemma hwy hwy_contrib) diff --git a/evals/compare_mmlu.py b/evals/compare_mmlu.py new file mode 100755 index 00000000..aa7afc77 --- /dev/null +++ b/evals/compare_mmlu.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Compare baseline and compressed gemma_mmlu outputs. + +Each input is the stdout captured from gemma_mmlu and may contain unrelated +lines. Only lines beginning with ``MMLU_RESULT `` are parsed. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + + +RESULT_PREFIX = "MMLU_RESULT " + + +def load_results(path: Path) -> dict[int, dict[str, Any]]: + results: dict[int, dict[str, Any]] = {} + with path.open(encoding="utf-8") as source: + for line_number, line in enumerate(source, start=1): + if not line.startswith(RESULT_PREFIX): + continue + try: + result = json.loads(line[len(RESULT_PREFIX) :]) + question_id = int(result["id"]) + result["correct"] = bool(result["correct"]) + result["expected"] = str(result["expected"]) + result["predicted"] = str(result["predicted"]) + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + raise ValueError( + f"{path}:{line_number}: invalid MMLU_RESULT: {error}" + ) from error + if question_id in results: + raise ValueError(f"{path}:{line_number}: duplicate id {question_id}") + results[question_id] = result + + if not results: + raise ValueError(f"{path}: no {RESULT_PREFIX.strip()} lines found") + return results + + +def compare_results( + baseline: dict[int, dict[str, Any]], variant: dict[int, dict[str, Any]] +) -> dict[str, int | float]: + baseline_ids = set(baseline) + variant_ids = set(variant) + if baseline_ids != variant_ids: + missing = sorted(baseline_ids - variant_ids) + extra = sorted(variant_ids - baseline_ids) + raise ValueError( + "result IDs differ: " + f"missing from variant={missing[:10]}, extra in variant={extra[:10]}" + ) + + correct_to_incorrect = 0 + incorrect_to_correct = 0 + wrong_to_wrong_changes = 0 + answer_changes = 0 + baseline_correct = 0 + variant_correct = 0 + + for question_id in sorted(baseline_ids): + base = baseline[question_id] + changed = variant[question_id] + if base["expected"] != changed["expected"]: + raise ValueError( + f"id {question_id}: expected answers differ: " + f"{base['expected']!r} != {changed['expected']!r}" + ) + + base_correct = base["correct"] + changed_correct = changed["correct"] + baseline_correct += int(base_correct) + variant_correct += int(changed_correct) + answer_changed = base["predicted"] != changed["predicted"] + answer_changes += int(answer_changed) + + if base_correct and not changed_correct: + correct_to_incorrect += 1 + elif not base_correct and changed_correct: + incorrect_to_correct += 1 + elif not base_correct and not changed_correct and answer_changed: + wrong_to_wrong_changes += 1 + + samples = len(baseline_ids) + flips = correct_to_incorrect + incorrect_to_correct + return { + "samples": samples, + "baseline_correct": baseline_correct, + "variant_correct": variant_correct, + "baseline_accuracy": baseline_correct / samples, + "variant_accuracy": variant_correct / samples, + "accuracy_delta": (variant_correct - baseline_correct) / samples, + "correct_to_incorrect": correct_to_incorrect, + "incorrect_to_correct": incorrect_to_correct, + "flips": flips, + "flips_fraction": flips / samples, + "flips_percent": 100.0 * flips / samples, + "wrong_to_wrong_changes": wrong_to_wrong_changes, + "answer_changes": answer_changes, + "answer_changes_fraction": answer_changes / samples, + "answer_changes_percent": 100.0 * answer_changes / samples, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Compare baseline and variant gemma_mmlu output streams." + ) + parser.add_argument("baseline", type=Path, help="baseline gemma_mmlu stdout") + parser.add_argument("variant", type=Path, help="variant gemma_mmlu stdout") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + metrics = compare_results( + load_results(args.baseline), load_results(args.variant) + ) + except (OSError, ValueError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + print(f"MMLU_FLIPS {json.dumps(metrics, sort_keys=True)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/evals/compare_mmlu_test.py b/evals/compare_mmlu_test.py new file mode 100755 index 00000000..6d64c1e3 --- /dev/null +++ b/evals/compare_mmlu_test.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 + +import json +import tempfile +import unittest +from pathlib import Path + +from compare_mmlu import compare_results, load_results + + +def result(question_id: int, expected: str, predicted: str) -> dict[str, object]: + return { + "id": question_id, + "expected": expected, + "predicted": predicted, + "correct": expected == predicted, + } + + +class CompareMmluTest(unittest.TestCase): + def test_flip_counts(self) -> None: + baseline = { + 1: result(1, "A", "A"), + 2: result(2, "A", "B"), + 3: result(3, "A", "C"), + 4: result(4, "D", "D"), + } + variant = { + 1: result(1, "A", "B"), + 2: result(2, "A", "A"), + 3: result(3, "A", "D"), + 4: result(4, "D", "D"), + } + + metrics = compare_results(baseline, variant) + + self.assertEqual(metrics["correct_to_incorrect"], 1) + self.assertEqual(metrics["incorrect_to_correct"], 1) + self.assertEqual(metrics["flips"], 2) + self.assertEqual(metrics["flips_percent"], 50.0) + self.assertEqual(metrics["wrong_to_wrong_changes"], 1) + self.assertEqual(metrics["answer_changes"], 3) + self.assertEqual(metrics["answer_changes_percent"], 75.0) + self.assertEqual(metrics["accuracy_delta"], 0.0) + + def test_loads_prefixed_results_and_ignores_other_lines(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "run.log" + rows = [result(7, "B", "B"), result(8, "C", "A")] + path.write_text( + "startup noise\n" + + "\n".join(f"MMLU_RESULT {json.dumps(row)}" for row in rows) + + "\nMMLU_SUMMARY {}\n", + encoding="utf-8", + ) + + loaded = load_results(path) + + self.assertEqual(set(loaded), {7, 8}) + self.assertTrue(loaded[7]["correct"]) + self.assertFalse(loaded[8]["correct"]) + + def test_requires_matching_question_ids(self) -> None: + with self.assertRaisesRegex(ValueError, "result IDs differ"): + compare_results( + {1: result(1, "A", "A")}, {2: result(2, "A", "A")} + ) + + def test_requires_matching_expected_answers(self) -> None: + with self.assertRaisesRegex(ValueError, "expected answers differ"): + compare_results( + {1: result(1, "A", "A")}, {1: result(1, "B", "B")} + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/evals/compare_models.py b/evals/compare_models.py new file mode 100644 index 00000000..5917a508 --- /dev/null +++ b/evals/compare_models.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""Run generic root-vs-target model comparisons and render a report. + +The configuration contains arbitrary model weights, Gemma CLI arguments, and +environment variables. No optimization (W8A8 or otherwise) is special-cased. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from compare_mmlu import compare_results, load_results + + +@dataclass(frozen=True) +class ModelSpec: + name: str + weights: Path + args: tuple[str, ...] + env: dict[str, str] + + +@dataclass(frozen=True) +class RunMetrics: + wall_seconds: float + peak_rss_kib: int | None + + +def _resolve_path(value: str, base: Path) -> Path: + path = Path(value) + return path if path.is_absolute() else (base / path).resolve() + + +def parse_model_spec(data: dict[str, Any], base: Path) -> ModelSpec: + if not isinstance(data, dict): + raise ValueError("model must be an object") + try: + name = str(data["name"]) + weights = _resolve_path(str(data["weights"]), base) + except KeyError as error: + raise ValueError(f"model is missing {error.args[0]!r}") from error + if not name or re.search(r"[^A-Za-z0-9_.-]", name): + raise ValueError(f"invalid model name {name!r}") + raw_args = data.get("args", []) + if not isinstance(raw_args, list): + raise ValueError(f"{name}: args must be an array") + args = tuple(str(arg) for arg in raw_args) + raw_env = data.get("env", {}) + if not isinstance(raw_env, dict): + raise ValueError(f"{name}: env must be an object") + env = {str(key): str(value) for key, value in raw_env.items()} + return ModelSpec(name=name, weights=weights, args=args, env=env) + + +def _read_rss_kib(pid: int) -> int | None: + try: + status = Path(f"/proc/{pid}/status").read_text(encoding="utf-8") + except OSError: + return None + values: dict[str, int] = {} + for line in status.splitlines(): + if line.startswith(("VmHWM:", "VmRSS:")): + key, value, *_ = line.split() + values[key.rstrip(":")] = int(value) + return values.get("VmHWM", values.get("VmRSS")) + + +def run_command( + command: list[str], env_updates: dict[str, str], stdout_path: Path, + stderr_path: Path +) -> RunMetrics: + env = os.environ.copy() + env.update(env_updates) + start = time.perf_counter() + peak_rss_kib: int | None = None + with stdout_path.open("w", encoding="utf-8") as stdout, stderr_path.open( + "w", encoding="utf-8" + ) as stderr: + process = subprocess.Popen(command, stdout=stdout, stderr=stderr, env=env) + while process.poll() is None: + rss = _read_rss_kib(process.pid) + if rss is not None: + peak_rss_kib = max(peak_rss_kib or 0, rss) + time.sleep(0.02) + rss = _read_rss_kib(process.pid) + if rss is not None: + peak_rss_kib = max(peak_rss_kib or 0, rss) + return_code = process.returncode + wall_seconds = time.perf_counter() - start + if return_code != 0: + tail = "\n".join( + stderr_path.read_text(encoding="utf-8", errors="replace").splitlines()[ + -20: + ] + ) + raise RuntimeError( + f"command failed ({return_code}): {' '.join(command)}\n{tail}" + ) + return RunMetrics(wall_seconds=wall_seconds, peak_rss_kib=peak_rss_kib) + + +def parse_prefixed_json(path: Path, prefix: str) -> dict[str, Any]: + found: dict[str, Any] | None = None + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith(prefix): + found = json.loads(line[len(prefix) :]) + if found is None: + raise ValueError(f"{path}: no {prefix.strip()} line") + return found + + +def parse_entropy(path: Path) -> dict[str, float | int]: + text = path.read_text(encoding="utf-8") + token_matches = re.findall(r"Number of input tokens: (\d+)", text) + speed_matches = re.findall( + r"\[([0-9.eE+-]+) tokens / sec\]", text + ) + entropy_matches = re.findall( + r"Total cross entropy: [0-9.eE+-]+ \[cumulative: ([0-9.eE+-]+)\]", + text, + ) + if not token_matches or not speed_matches or not entropy_matches: + raise ValueError(f"{path}: incomplete cross-entropy output") + tokens = int(token_matches[-1]) + if tokens == 0: + raise ValueError(f"{path}: cross-entropy input has no tokens") + total_bits = float(entropy_matches[-1]) + return { + "tokens": tokens, + "total_bits": total_bits, + "bits_per_token": total_bits / tokens, + "tokens_per_second": float(speed_matches[-1]), + } + + + +def render_table(rows: list[dict[str, Any]]) -> str: + root = rows[0] + root_entropy = root.get("entropy") + lines = [ + "| Model | Entropy bits/token | Δ entropy | tok/s | Speedup | " + "MMLU accuracy | Flips | Mean KL | p95 KL | Peak RSS | Inference s | Inference speedup |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|", + ] + for row in rows: + entropy = row.get("entropy") + if entropy and root_entropy: + entropy_delta = 100.0 * ( + entropy["total_bits"] / root_entropy["total_bits"] - 1.0 + ) + speedup = 100.0 * ( + entropy["tokens_per_second"] + / root_entropy["tokens_per_second"] + - 1.0 + ) + entropy_text = f"{entropy['bits_per_token']:.4f}" + delta_text = f"{entropy_delta:+.3f}%" + speed_text = f"{entropy['tokens_per_second']:.2f}" + speedup_text = f"{speedup:+.1f}%" + else: + entropy_text = delta_text = speed_text = speedup_text = "—" + flips = row.get("flips") + kl = row.get("kl") + rss = row.get("peak_rss_kib") + inference = row.get("mmlu_inference_seconds") + root_inference = root.get("mmlu_inference_seconds") + inference_text = "—" if not inference else f"{inference:.3f}" + inference_speedup = ( + "—" if not inference or not root_inference + else f"{root_inference / inference:.3f}x" + ) + lines.append( + "| {name} | {entropy} | {delta} | {speed} | {speedup} | " + "{accuracy:.1f}% | {flips} | {mean_kl} | {p95_kl} | {rss} | " + "{inference} | {infer_speedup} |".format( + inference=inference_text, + infer_speedup=inference_speedup, + name=row["name"], + entropy=entropy_text, + delta=delta_text, + speed=speed_text, + speedup=speedup_text, + accuracy=100.0 * row["mmlu"]["accuracy"], + flips="—" if flips is None else f"{flips['flips_percent']:.2f}%", + mean_kl="—" if kl is None else f"{kl['mean']:.6g}", + p95_kl="—" if kl is None else f"{kl['p95']:.6g}", + rss="—" if rss is None else f"{rss / 1024.0:.1f} MiB", + ) + ) + return "\n".join(lines) + "\n" + + +def run_evaluation( + spec: ModelSpec, build_dir: Path, output_dir: Path, mmlu: Path, + max_questions: int, reference: Path, is_root: bool, + entropy_path: Path | None +) -> tuple[dict[str, Any], Path]: + stem = output_dir / spec.name + mmlu_out = stem.with_suffix(".mmlu.out") + mmlu_err = stem.with_suffix(".mmlu.err") + command = [ + str(build_dir / "gemma_mmlu"), + "--weights", + str(spec.weights), + "--input", + str(mmlu), + "--verbosity", + "0", + ] + if max_questions: + command.extend(["--max_questions", str(max_questions)]) + command.extend( + ["--reference_out" if is_root else "--reference_in", str(reference)] + ) + command.extend(spec.args) + mmlu_run = run_command(command, spec.env, mmlu_out, mmlu_err) + mmlu_summary = parse_prefixed_json(mmlu_out, "MMLU_SUMMARY ") + timing = parse_prefixed_json(mmlu_out, "MMLU_TIMING ") + kl_summary = ( + None + if is_root + else parse_prefixed_json(mmlu_out, "MMLU_KL_SUMMARY ") + ) + + entropy: dict[str, float | int] | None = None + entropy_run: RunMetrics | None = None + if entropy_path is not None: + entropy_out = stem.with_suffix(".entropy.out") + entropy_err = stem.with_suffix(".entropy.err") + entropy_command = [ + str(build_dir / "single_benchmark"), + "--weights", + str(spec.weights), + "--cross_entropy", + str(entropy_path), + "--verbosity", + "0", + *spec.args, + ] + entropy_run = run_command( + entropy_command, spec.env, entropy_out, entropy_err + ) + entropy = parse_entropy(entropy_out) + + peak_values = [mmlu_run.peak_rss_kib] + if entropy_run is not None: + peak_values.append(entropy_run.peak_rss_kib) + peak_rss = max((value for value in peak_values if value is not None), default=None) + return ( + { + "name": spec.name, + "weights": str(spec.weights), + "args": list(spec.args), + "env": spec.env, + "mmlu": mmlu_summary, + "kl": kl_summary, + "entropy": entropy, + "mmlu_timing": timing, + "mmlu_inference_seconds": timing["inference_seconds"], + "mmlu_wall_seconds": mmlu_run.wall_seconds, + "entropy_wall_seconds": None + if entropy_run is None + else entropy_run.wall_seconds, + "peak_rss_kib": peak_rss, + }, + mmlu_out, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Compare arbitrary target models against a root model." + ) + parser.add_argument("--config", required=True, type=Path) + parser.add_argument("--build_dir", type=Path, default=Path("build")) + parser.add_argument("--output_dir", type=Path) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + config_path = args.config.resolve() + config = json.loads(config_path.read_text(encoding="utf-8")) + base = config_path.parent + root = parse_model_spec(config["root"], base) + targets = [parse_model_spec(item, base) for item in config["targets"]] + if not targets: + raise ValueError("targets must contain at least one model") + names = [root.name, *(target.name for target in targets)] + if len(names) != len(set(names)): + raise ValueError("model names must be unique") + mmlu = _resolve_path(str(config["mmlu"]), base) + entropy_path = ( + None + if not config.get("cross_entropy") + else _resolve_path(str(config["cross_entropy"]), base) + ) + max_questions = int(config.get("max_questions", 0)) + if max_questions < 0: + raise ValueError("max_questions must be non-negative") + build_dir = args.build_dir.resolve() + required_paths = [mmlu, root.weights] + required_paths.extend(target.weights for target in targets) + if entropy_path is not None: + required_paths.append(entropy_path) + missing = [str(path) for path in required_paths if not path.is_file()] + if missing: + raise ValueError("file does not exist: " + ", ".join(missing)) + required_programs = [build_dir / "gemma_mmlu"] + if entropy_path is not None: + required_programs.append(build_dir / "single_benchmark") + missing_programs = [ + str(path) for path in required_programs if not path.is_file() + ] + if missing_programs: + raise ValueError( + "build executable does not exist: " + ", ".join(missing_programs) + ) + output_dir = ( + args.output_dir.resolve() + if args.output_dir + else (base / f"{config_path.stem}-results").resolve() + ) + output_dir.mkdir(parents=True, exist_ok=True) + reference = output_dir / f"{root.name}.root-kl.bin" + + root_row, root_output = run_evaluation( + root, build_dir, output_dir, mmlu, max_questions, + reference, True, entropy_path + ) + root_row["flips"] = None + root_results = load_results(root_output) + rows = [root_row] + for target in targets: + row, target_output = run_evaluation( + target, build_dir, output_dir, mmlu, + max_questions, reference, False, entropy_path + ) + row["flips"] = compare_results( + root_results, load_results(target_output) + ) + rows.append(row) + + report = { + "schema_version": 2, + "mmlu": str(mmlu), + "cross_entropy": None if entropy_path is None else str(entropy_path), + "reference": str(reference), + "models": rows, + } + (output_dir / "comparison.json").write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + table = render_table(rows) + (output_dir / "comparison.md").write_text(table, encoding="utf-8") + print(table, end="") + return 0 + except (KeyError, OSError, ValueError, RuntimeError, json.JSONDecodeError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/evals/compare_models_test.py b/evals/compare_models_test.py new file mode 100644 index 00000000..cfc83b22 --- /dev/null +++ b/evals/compare_models_test.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 + +import tempfile +import unittest +from pathlib import Path + +from compare_models import ( + parse_entropy, + parse_model_spec, + parse_prefixed_json, + render_table, +) + + +class CompareModelsTest(unittest.TestCase): + def test_parse_model_spec_resolves_paths_and_environment(self) -> None: + spec = parse_model_spec( + { + "name": "w8a8", + "weights": "models/model.sbs", + "args": ["--num_threads", 4], + "env": {"GEMMA_MM_I8": 1}, + }, + Path("/work"), + ) + + self.assertEqual(spec.name, "w8a8") + self.assertEqual(spec.weights, Path("/work/models/model.sbs")) + self.assertEqual(spec.args, ("--num_threads", "4")) + self.assertEqual(spec.env, {"GEMMA_MM_I8": "1"}) + + def test_parse_prefixed_json_uses_last_summary(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "output.log" + path.write_text( + 'MMLU_SUMMARY {"answers":1}\n' + 'noise\nMMLU_SUMMARY {"answers":2}\n', + encoding="utf-8", + ) + parsed = parse_prefixed_json(path, "MMLU_SUMMARY ") + + self.assertEqual(parsed, {"answers": 2}) + + def test_parse_entropy(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "entropy.log" + path.write_text( + "Number of input tokens: 8\n" + "Took 1.0 s [8.0 tokens / sec]\n" + "Total cross entropy: 12.0 [cumulative: 12.0]\n", + encoding="utf-8", + ) + parsed = parse_entropy(path) + + self.assertEqual(parsed["tokens"], 8) + self.assertEqual(parsed["total_bits"], 12.0) + self.assertEqual(parsed["bits_per_token"], 1.5) + self.assertEqual(parsed["tokens_per_second"], 8.0) + + def test_render_table(self) -> None: + root = { + "name": "root", + "mmlu_inference_seconds": 8.0, + "mmlu_wall_seconds": 12.0, + "mmlu": {"accuracy": 0.5}, + "entropy": { + "total_bits": 20.0, + "bits_per_token": 2.0, + "tokens_per_second": 10.0, + }, + "flips": None, + "kl": None, + "peak_rss_kib": 1024, + } + target = { + "name": "target", + "mmlu_inference_seconds": 4.0, + "mmlu_wall_seconds": 20.0, + "mmlu": {"accuracy": 0.75}, + "entropy": { + "total_bits": 22.0, + "bits_per_token": 2.2, + "tokens_per_second": 12.0, + }, + "flips": {"flips_percent": 25.0}, + "kl": {"mean": 0.01, "p95": 0.03}, + "peak_rss_kib": 2048, + } + + table = render_table([root, target]) + + self.assertIn("| 4.000 | 2.000x |", table) + self.assertIn("| root | 2.0000 | +0.000% | 10.00 | +0.0%", table) + self.assertIn( + "| target | 2.2000 | +10.000% | 12.00 | +20.0% | " + "75.0% | 25.00% | 0.01 | 0.03 | 2.0 MiB |", + table, + ) + + def test_rejects_unsafe_report_name(self) -> None: + with self.assertRaisesRegex(ValueError, "invalid model name"): + parse_model_spec( + {"name": "../target", "weights": "model.sbs"}, Path("/work") + ) + + def test_rejects_string_model_args(self) -> None: + with self.assertRaisesRegex(ValueError, "args must be an array"): + parse_model_spec( + {"name": "target", "weights": "model.sbs", "args": "--foo"}, + Path("/work"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/evals/model_comparison.cc b/evals/model_comparison.cc new file mode 100644 index 00000000..cb61c30d --- /dev/null +++ b/evals/model_comparison.cc @@ -0,0 +1,189 @@ +// Copyright 2026 Google LLC +// SPDX-License-Identifier: Apache-2.0 + +#include "evals/model_comparison.h" + +#include +#include +#include +#include +#include +#include + +namespace gcpp { +namespace { + +constexpr char kMagic[8] = {'G', 'C', 'P', 'P', 'K', 'L', '0', '1'}; +constexpr uint32_t kVersion = 1; +constexpr uint32_t kEndianMarker = 0x01020304u; + +template +void WriteValue(std::ofstream& stream, const T& value) { + static_assert(std::is_trivially_copyable::value, "binary scalar"); + stream.write(reinterpret_cast(&value), sizeof(value)); + if (!stream) throw std::runtime_error("failed to write KL reference file"); +} + +template +T ReadValue(std::ifstream& stream) { + static_assert(std::is_trivially_copyable::value, "binary scalar"); + T value; + stream.read(reinterpret_cast(&value), sizeof(value)); + if (!stream) throw std::runtime_error("truncated KL reference file"); + return value; +} + +void RequireEqual(const char* name, uint64_t actual, uint64_t expected) { + if (actual != expected) { + throw std::runtime_error(std::string("KL reference ") + name + + " mismatch: " + std::to_string(actual) + + " != " + std::to_string(expected)); + } +} + +} // namespace + +uint64_t ModelComparisonFingerprint(const std::string& bytes) { + uint64_t hash = 14695981039346656037ull; + for (const unsigned char byte : bytes) { + hash ^= byte; + hash *= 1099511628211ull; + } + return hash; +} + +double FullVocabLogSumExp(const float* logits, size_t size) { + if (size == 0) throw std::invalid_argument("empty logits"); + float max_logit = -std::numeric_limits::infinity(); + for (size_t i = 0; i < size; ++i) { + max_logit = std::max(max_logit, logits[i]); + } + if (!std::isfinite(max_logit)) { + throw std::invalid_argument("non-finite maximum logit"); + } + + double sum = 0.0; + for (size_t i = 0; i < size; ++i) { + sum += std::exp(static_cast(logits[i] - max_logit)); + } + return static_cast(max_logit) + std::log(sum); +} + +double FullVocabKLDivergence(const std::vector& root_logits, + double root_log_sum_exp, + const float* target_logits, size_t size) { + if (root_logits.size() != size) { + throw std::invalid_argument("root/target vocabulary size mismatch"); + } + const double target_log_sum_exp = FullVocabLogSumExp(target_logits, size); + double kl = 0.0; + for (size_t i = 0; i < size; ++i) { + const double root_log_prob = + static_cast(root_logits[i]) - root_log_sum_exp; + const double target_log_prob = + static_cast(target_logits[i]) - target_log_sum_exp; + kl += std::exp(root_log_prob) * (root_log_prob - target_log_prob); + } + return kl < 0.0 && kl > -1E-12 ? 0.0 : kl; +} + +ModelComparisonWriter::ModelComparisonWriter( + const std::string& path, const ModelComparisonMetadata& metadata) + : stream_(path, std::ios::binary), metadata_(metadata) { + static_assert(sizeof(float) == 4, "reference format requires float32"); + if (!stream_) throw std::runtime_error("cannot create KL reference: " + path); + stream_.write(kMagic, sizeof(kMagic)); + WriteValue(stream_, kVersion); + WriteValue(stream_, kEndianMarker); + WriteValue(stream_, metadata_.vocab_size); + WriteValue(stream_, metadata_.sample_count); + WriteValue(stream_, metadata_.dataset_fingerprint); + WriteValue(stream_, metadata_.tokenizer_fingerprint); +} + +ModelComparisonWriter::~ModelComparisonWriter() { + if (!finished_) stream_.close(); +} + +void ModelComparisonWriter::Write(int64_t sample_id, int32_t expected_label, + const float* logits, size_t size) { + if (finished_) throw std::runtime_error("KL reference already finished"); + if (size != metadata_.vocab_size) { + throw std::invalid_argument("logits do not match reference vocabulary"); + } + if (records_written_ >= metadata_.sample_count) { + throw std::runtime_error("too many KL reference records"); + } + WriteValue(stream_, sample_id); + WriteValue(stream_, expected_label); + const double log_sum_exp = FullVocabLogSumExp(logits, size); + WriteValue(stream_, log_sum_exp); + stream_.write(reinterpret_cast(logits), + static_cast(size * sizeof(float))); + if (!stream_) throw std::runtime_error("failed to write KL reference logits"); + ++records_written_; +} + +void ModelComparisonWriter::Finish() { + if (finished_) return; + if (records_written_ != metadata_.sample_count) { + throw std::runtime_error("KL reference record count mismatch"); + } + stream_.flush(); + if (!stream_) throw std::runtime_error("failed to finish KL reference file"); + finished_ = true; +} + +ModelComparisonReader::ModelComparisonReader(const std::string& path) + : stream_(path, std::ios::binary) { + if (!stream_) throw std::runtime_error("cannot open KL reference: " + path); + char magic[sizeof(kMagic)]; + stream_.read(magic, sizeof(magic)); + if (!stream_ || std::memcmp(magic, kMagic, sizeof(kMagic)) != 0) { + throw std::runtime_error("invalid KL reference magic"); + } + RequireEqual("version", ReadValue(stream_), kVersion); + RequireEqual("endianness", ReadValue(stream_), kEndianMarker); + metadata_.vocab_size = ReadValue(stream_); + metadata_.sample_count = ReadValue(stream_); + metadata_.dataset_fingerprint = ReadValue(stream_); + metadata_.tokenizer_fingerprint = ReadValue(stream_); +} + +void ModelComparisonReader::Validate( + const ModelComparisonMetadata& expected) const { + RequireEqual("vocabulary", metadata_.vocab_size, expected.vocab_size); + RequireEqual("sample count", metadata_.sample_count, expected.sample_count); + RequireEqual("dataset fingerprint", metadata_.dataset_fingerprint, + expected.dataset_fingerprint); + RequireEqual("tokenizer fingerprint", metadata_.tokenizer_fingerprint, + expected.tokenizer_fingerprint); +} + +ModelComparisonRecord ModelComparisonReader::Read() { + if (records_read_ >= metadata_.sample_count) { + throw std::runtime_error("too many KL reference reads"); + } + ModelComparisonRecord record; + record.sample_id = ReadValue(stream_); + record.expected_label = ReadValue(stream_); + record.log_sum_exp = ReadValue(stream_); + record.logits.resize(metadata_.vocab_size); + stream_.read( + reinterpret_cast(record.logits.data()), + static_cast(record.logits.size() * sizeof(float))); + if (!stream_) throw std::runtime_error("truncated KL reference logits"); + ++records_read_; + return record; +} + +void ModelComparisonReader::Finish() { + if (records_read_ != metadata_.sample_count) { + throw std::runtime_error("unread KL reference records"); + } + if (stream_.peek() != std::ifstream::traits_type::eof()) { + throw std::runtime_error("trailing bytes in KL reference file"); + } +} + +} // namespace gcpp diff --git a/evals/model_comparison.h b/evals/model_comparison.h new file mode 100644 index 00000000..cb8b276f --- /dev/null +++ b/evals/model_comparison.h @@ -0,0 +1,75 @@ +// Copyright 2026 Google LLC +// SPDX-License-Identifier: Apache-2.0 + +#ifndef THIRD_PARTY_GEMMA_CPP_EVALS_MODEL_COMPARISON_H_ +#define THIRD_PARTY_GEMMA_CPP_EVALS_MODEL_COMPARISON_H_ + +#include + +#include +#include +#include +#include + +namespace gcpp { + +uint64_t ModelComparisonFingerprint(const std::string& bytes); + +// Numerically stable full-vocabulary operations. KL is directional: +// D_KL(root || target). +double FullVocabLogSumExp(const float* logits, size_t size); +double FullVocabKLDivergence(const std::vector& root_logits, + double root_log_sum_exp, + const float* target_logits, size_t size); + +struct ModelComparisonMetadata { + uint32_t vocab_size = 0; + uint64_t sample_count = 0; + uint64_t dataset_fingerprint = 0; + uint64_t tokenizer_fingerprint = 0; +}; + +struct ModelComparisonRecord { + int64_t sample_id = 0; + int32_t expected_label = 0; + double log_sum_exp = 0.0; + std::vector logits; +}; + +// Versioned binary store for root-model logits. It is uncompressed so target +// runs can stream one question at a time without loading the whole dataset. +class ModelComparisonWriter { + public: + ModelComparisonWriter(const std::string& path, + const ModelComparisonMetadata& metadata); + ~ModelComparisonWriter(); + + void Write(int64_t sample_id, int32_t expected_label, const float* logits, + size_t size); + void Finish(); + + private: + std::ofstream stream_; + ModelComparisonMetadata metadata_; + uint64_t records_written_ = 0; + bool finished_ = false; +}; + +class ModelComparisonReader { + public: + explicit ModelComparisonReader(const std::string& path); + + const ModelComparisonMetadata& Metadata() const { return metadata_; } + void Validate(const ModelComparisonMetadata& expected) const; + ModelComparisonRecord Read(); + void Finish(); + + private: + std::ifstream stream_; + ModelComparisonMetadata metadata_; + uint64_t records_read_ = 0; +}; + +} // namespace gcpp + +#endif // THIRD_PARTY_GEMMA_CPP_EVALS_MODEL_COMPARISON_H_ diff --git a/evals/model_comparison_test.cc b/evals/model_comparison_test.cc new file mode 100644 index 00000000..57ed1962 --- /dev/null +++ b/evals/model_comparison_test.cc @@ -0,0 +1,86 @@ +// Copyright 2026 Google LLC +// SPDX-License-Identifier: Apache-2.0 + +#include "evals/model_comparison.h" + +#include + +#include +#include +#include + +namespace { + +int failures = 0; + +void CheckNear(const char* name, double actual, double expected, + double tolerance = 1E-12) { + if (std::abs(actual - expected) > tolerance) { + fprintf(stderr, "FAIL %s: %.17g != %.17g\n", name, actual, expected); + ++failures; + } +} + +void TestKL() { + const std::vector root = {std::log(0.25f), std::log(0.75f)}; + const std::vector same = root; + const std::vector shifted = {root[0] + 17.0f, root[1] + 17.0f}; + const std::vector uniform = {0.0f, 0.0f}; + const double root_lse = gcpp::FullVocabLogSumExp(root.data(), root.size()); + + CheckNear( + "identical", + gcpp::FullVocabKLDivergence(root, root_lse, same.data(), same.size()), + 0.0); + CheckNear("shift invariant", + gcpp::FullVocabKLDivergence(root, root_lse, shifted.data(), + shifted.size()), + 0.0, 1E-7); + const double expected = 0.25 * std::log(0.5) + 0.75 * std::log(1.5); + CheckNear("known KL", + gcpp::FullVocabKLDivergence(root, root_lse, uniform.data(), + uniform.size()), + expected, 1E-7); +} + +void TestReferenceRoundTrip() { + const char* path = "/tmp/gemma_model_comparison_test.bin"; + std::remove(path); + const gcpp::ModelComparisonMetadata metadata = { + /*vocab_size=*/3, + /*sample_count=*/1, + /*dataset_fingerprint=*/123, + /*tokenizer_fingerprint=*/456, + }; + const std::vector logits = {1.0f, -2.0f, 4.0f}; + { + gcpp::ModelComparisonWriter writer(path, metadata); + writer.Write(7, 2, logits.data(), logits.size()); + writer.Finish(); + } + { + gcpp::ModelComparisonReader reader(path); + reader.Validate(metadata); + const gcpp::ModelComparisonRecord record = reader.Read(); + if (record.sample_id != 7 || record.expected_label != 2 || + record.logits != logits) { + fprintf(stderr, "FAIL reference round trip\n"); + ++failures; + } + reader.Finish(); + } + std::remove(path); +} + +} // namespace + +int main() { + TestKL(); + TestReferenceRoundTrip(); + if (failures != 0) { + fprintf(stderr, "FAIL (%d failures)\n", failures); + return 1; + } + printf("PASS\n"); + return 0; +} diff --git a/evals/run_mmlu.cc b/evals/run_mmlu.cc index 66044397..fbeea436 100644 --- a/evals/run_mmlu.cc +++ b/evals/run_mmlu.cc @@ -16,17 +16,25 @@ #include #include +#include +#include +#include +#include +#include +#include +#include #include #include #include "evals/benchmark_helper.h" +#include "evals/model_comparison.h" #include "gemma/gemma.h" // Gemma -#include "io/io.h" // Path -#include "util/args.h" #include "hwy/base.h" #include "hwy/highway.h" #include "hwy/profiler.h" +#include "io/io.h" // Path #include "nlohmann/json.hpp" +#include "util/args.h" namespace gcpp { @@ -36,70 +44,134 @@ struct JsonArgs : public ArgsBase { } Path input; + Path reference_out; + Path reference_in; + size_t max_questions; + + bool matmul_autotune; - // Returns error string or nullptr if OK. const char* Validate() const { if (input.Empty()) return "Must specify --input"; if (!input.Exists()) return "--input file does not exist"; + if (!reference_out.Empty() && !reference_in.Empty()) { + return "Specify only one of --reference_out and --reference_in"; + } + if (!reference_in.Empty() && !reference_in.Exists()) { + return "--reference_in file does not exist"; + } return nullptr; } template void ForEach(const Visitor& visitor) { + visitor( + matmul_autotune, "matmul_autotune", false, + "Enable timing-dependent MatMul tuning (off for reproducible evals)."); visitor(input, "input", Path(), "Full pathname of mmlu.json."); - }; + visitor(reference_out, "reference_out", Path(), + "Write root-model full-vocabulary logits to this binary file."); + visitor(reference_in, "reference_in", Path(), + "Compare this target model against a root reference file."); + visitor(max_questions, "max_questions", size_t{0}, + "Maximum questions to run; zero runs the full dataset."); + } }; -// Linear search for a few tokens is faster than std::set. -// TODO: instead of accepting for each vocab entry, filter the logits once. -class TokenSet { +// Maps both "A" and " A" tokenizer variants to answer labels 0..3. +class AnswerTokens { public: - TokenSet(const GemmaTokenizer& tokenizer, - const std::vector& strings) { - all_tokens_.reserve(strings.size()); - for (const std::string& str : strings) { - std::vector tokens; - fprintf(stderr, "%s -> ", str.c_str()); - HWY_ASSERT(tokenizer.Encode(str, &tokens)); - for (int token : tokens) { - fprintf(stderr, "%d, ", token); - all_tokens_.push_back(token); + explicit AnswerTokens(const GemmaTokenizer& tokenizer) { + for (int label = 0; label < 4; ++label) { + for (const std::string& prefix : {std::string(), std::string(" ")}) { + const std::string str = prefix + static_cast('A' + label); + std::vector tokens; + HWY_ASSERT(tokenizer.Encode(str, &tokens)); + HWY_ASSERT(tokens.size() == 1); + fprintf(stderr, "%s -> %d\n", str.c_str(), tokens[0]); + tokens_.push_back({tokens[0], label}); } - fprintf(stderr, "\n"); } } - bool Contains(int token) const { - return std::find(all_tokens_.begin(), all_tokens_.end(), token) != - all_tokens_.end(); + int Label(int token) const { + const auto it = + std::find_if(tokens_.begin(), tokens_.end(), + [token](const auto& item) { return item.first == token; }); + return it == tokens_.end() ? -1 : it->second; } + const std::vector>& All() const { return tokens_; } + private: - std::vector all_tokens_; + std::vector> tokens_; }; -void Run(GemmaEnv& env, JsonArgs& json) { +double Percentile(const std::vector& sorted, double quantile) { + if (sorted.empty()) return 0.0; + const double position = quantile * static_cast(sorted.size() - 1); + const size_t lower = static_cast(std::floor(position)); + const size_t upper = static_cast(std::ceil(position)); + const double fraction = position - static_cast(lower); + return sorted[lower] + fraction * (sorted[upper] - sorted[lower]); +} + +void Run(GemmaEnv& env, JsonArgs& args) { + env.MutableEnv().autotune = args.matmul_autotune; + using Clock = std::chrono::steady_clock; + double generate_seconds = 0.0, sample_seconds = 0.0; + const double prepare_start = env.MutableEnv().weight_prepare_seconds; PROFILER_ZONE("Run.all"); - float answers = 0.0f; - float correct_answers = 0.0f; + size_t answers = 0; + size_t correct_answers = 0; + std::vector kl_values; + + const std::string json_text = ReadFileToString(args.input); + const auto json_data = nlohmann::json::parse(json_text); + const auto& samples = json_data["samples"]; + const size_t sample_count = + args.max_questions == 0 + ? samples.size() + : std::min(args.max_questions, samples.size()); + + const Gemma& gemma = *env.GetGemma(); + const GemmaTokenizer& tokenizer = gemma.Tokenizer(); + const ModelComparisonMetadata metadata = { + static_cast(gemma.Config().vocab_size), sample_count, + ModelComparisonFingerprint(json_text), + ModelComparisonFingerprint(tokenizer.Serialize())}; + + std::unique_ptr reference_writer; + std::unique_ptr reference_reader; + if (!args.reference_out.Empty()) { + reference_writer = std::make_unique( + args.reference_out.path, metadata); + } else if (!args.reference_in.Empty()) { + reference_reader = + std::make_unique(args.reference_in.path); + reference_reader->Validate(metadata); + kl_values.reserve(sample_count); + } + + const AnswerTokens answer_tokens(tokenizer); - auto json_data = nlohmann::json::parse(ReadFileToString(json.input)); + for (const auto& sample : samples) { + if (answers >= sample_count) break; + const int64_t id = sample["i"]; + fprintf(stderr, "Processing question %lld\n", static_cast(id)); + const int correct_label = sample["input_label"]; + const std::string correct_answer(1, static_cast('A' + correct_label)); - const std::vector accept_strings = { - "A", "B", "C", "D", // - " A", " B", " C", " D", // - "**", "**:", ":**", "The", "Answer", "is", ":", "."}; - const TokenSet accept_set(env.GetGemma()->Tokenizer(), accept_strings); + ModelComparisonRecord root_record; + if (reference_reader) { + root_record = reference_reader->Read(); + if (root_record.sample_id != id || + root_record.expected_label != correct_label) { + throw std::runtime_error("KL reference sample identity mismatch"); + } + } - for (auto sample : json_data["samples"]) { - const int id = sample["i"]; - fprintf(stderr, "Processing question %d\n", id); - const std::string& correct_answer = accept_strings[sample["input_label"]]; std::string prompt_string = sample["prompt"]; - // AcceptFunc restricts the output to one of these four tokens, so make an - // effort to steer the model towards that. See - // https://huggingface.co/blog/open-llm-leaderboard-mmlu prompt_string += "What is start of the line with the correct answer? " "Do not include any justifications or explanations. Reply only with a " @@ -107,45 +179,170 @@ void Run(GemmaEnv& env, JsonArgs& json) { const std::vector prompt = env.WrapAndTokenize(prompt_string); const size_t prompt_size = prompt.size(); - std::vector predicted_token_ids; - predicted_token_ids.reserve(4096); + int predicted_token = -1; + std::array answer_logits; + std::array answer_probs; + answer_logits.fill(-std::numeric_limits::infinity()); + answer_probs.fill(0.0f); + std::vector captured_logits; + double full_vocab_kl = 0.0; size_t generated = 0; - const StreamFunc stream_token = [&generated, prompt_size, - &predicted_token_ids](int token, - float proba) { + const StreamFunc stream_token = [&generated, prompt_size, &predicted_token]( + int token, float /*proba*/) { PROFILER_ZONE("Stream"); ++generated; if (generated > prompt_size) { - predicted_token_ids.push_back(token); + predicted_token = token; + return false; } return true; }; - // Although " A" is a token, it is difficult to associate that with the - // correct answer. Only accepting certain tokens is risky: (A) is easily - // confused with the word "A". gcpp::TimingInfo timing_info; gcpp::RuntimeConfig runtime_config = { - .max_generated_tokens = 30, + .max_generated_tokens = 1, .temperature = 0.0f, .verbosity = env.Verbosity(), .attention_impl = env.MutableConfig().attention_impl, .stream_token = stream_token, + .sample_func = [&answer_tokens, &answer_logits, &answer_probs, + &captured_logits, &full_vocab_kl, &reference_writer, + &reference_reader, &root_record, &sample_seconds]( + size_t /*query_idx*/, size_t /*pos*/, Logits logits, + size_t /*worker*/) -> TokenAndProb { + const auto sample_start = Clock::now(); + if (reference_writer) { + captured_logits.assign(logits.data(), + logits.data() + logits.size()); + } else if (reference_reader) { + full_vocab_kl = FullVocabKLDivergence(root_record.logits, + root_record.log_sum_exp, + logits.data(), logits.size()); + } + + int best_token = -1; + int best_label = -1; + float best_logit = -std::numeric_limits::infinity(); + for (const auto& [token, label] : answer_tokens.All()) { + if (logits[token] > answer_logits[label]) { + answer_logits[label] = logits[token]; + } + if (logits[token] > best_logit) { + best_logit = logits[token]; + best_token = token; + best_label = label; + } + } + + float sum = 0.0f; + for (int label = 0; label < 4; ++label) { + answer_probs[label] = std::exp(answer_logits[label] - best_logit); + sum += answer_probs[label]; + } + for (float& prob : answer_probs) prob /= sum; + sample_seconds += + std::chrono::duration(Clock::now() - sample_start) + .count(); + return TokenAndProb{.token = best_token, + .prob = answer_probs[best_label]}; + }, }; + const auto generate_start = Clock::now(); env.GetGemma()->Generate(runtime_config, prompt, /*pos=*/0, env.MutableKVCache(), env.MutableEnv(), timing_info); - std::string output_string = env.StringFromTokens(predicted_token_ids); + generate_seconds += + std::chrono::duration(Clock::now() - generate_start).count(); + + if (reference_writer) { + if (captured_logits.size() != metadata.vocab_size) { + throw std::runtime_error( + "failed to capture root full-vocabulary logits"); + } + reference_writer->Write(id, correct_label, captured_logits.data(), + captured_logits.size()); + } else if (reference_reader) { + if (!std::isfinite(full_vocab_kl) || full_vocab_kl < 0.0) { + throw std::runtime_error("invalid full-vocabulary KL divergence"); + } + kl_values.push_back(full_vocab_kl); + } + + const int predicted_label = answer_tokens.Label(predicted_token); + const std::string output_string = + predicted_label == -1 + ? std::string("?") + : std::string(1, static_cast('A' + predicted_label)); fprintf(stderr, "Correct %s, model '%s'\n", correct_answer.c_str(), output_string.c_str()); - answers += 1.0f; - if (output_string == correct_answer) { - correct_answers += 1.0f; + const bool is_correct = predicted_label == correct_label; + float second_logit = -std::numeric_limits::infinity(); + for (int label = 0; label < 4; ++label) { + if (label != predicted_label) { + second_logit = std::max(second_logit, answer_logits[label]); + } } - fprintf(stderr, "%.0f/%.0f = %.2f%%\n", correct_answers, answers, - correct_answers / answers); + ++answers; + correct_answers += static_cast(is_correct); + nlohmann::json result = { + {"id", id}, + {"expected", correct_answer}, + {"predicted", output_string}, + {"correct", is_correct}, + {"logits", answer_logits}, + {"probabilities", answer_probs}, + {"margin", predicted_label == -1 + ? 0.0f + : answer_logits[predicted_label] - second_logit}, + }; + if (reference_reader) result["full_vocab_kl"] = full_vocab_kl; + printf("MMLU_RESULT %s\n", result.dump().c_str()); + fflush(stdout); + fprintf(stderr, "%zu/%zu = %.2f%%\n", correct_answers, answers, + 100.0 * static_cast(correct_answers) / answers); + } + + if (reference_writer) reference_writer->Finish(); + if (reference_reader) reference_reader->Finish(); + + const double prepare_seconds = + env.MutableEnv().weight_prepare_seconds - prepare_start; + const nlohmann::json timing = { + {"generate_seconds", generate_seconds}, + {"weight_prepare_seconds", prepare_seconds}, + {"sample_seconds", sample_seconds}, + {"inference_seconds", + generate_seconds - prepare_seconds - sample_seconds}, + {"matmul_autotune", args.matmul_autotune}, + {"scope", + "Generate excluding lazy W8 preparation and evaluation sample callback"}, + }; + printf("MMLU_TIMING %s\n", timing.dump().c_str()); + const nlohmann::json summary = { + {"answers", answers}, + {"correct", correct_answers}, + {"accuracy", + answers == 0 ? 0.0 : static_cast(correct_answers) / answers}, + }; + printf("MMLU_SUMMARY %s\n", summary.dump().c_str()); + + if (!kl_values.empty()) { + std::sort(kl_values.begin(), kl_values.end()); + const double mean = + std::accumulate(kl_values.begin(), kl_values.end(), 0.0) / + kl_values.size(); + const nlohmann::json kl_summary = { + {"samples", kl_values.size()}, + {"mean", mean}, + {"median", Percentile(kl_values, 0.5)}, + {"p95", Percentile(kl_values, 0.95)}, + {"max", kl_values.back()}, + {"unit", "nats"}, + {"direction", "root||target"}, + }; + printf("MMLU_KL_SUMMARY %s\n", kl_summary.dump().c_str()); } } @@ -154,17 +351,22 @@ void Run(GemmaEnv& env, JsonArgs& json) { int main(int argc, char** argv) { gcpp::InternalInit(); - { - PROFILER_ZONE("Startup.all"); - gcpp::ConsumedArgs consumed(argc, argv); - gcpp::GemmaArgs args(argc, argv, consumed); - gcpp::JsonArgs json_args(argc, argv, consumed); - gcpp::AbortIfInvalidArgs(json_args); - consumed.AbortIfUnconsumed(); + try { + { + PROFILER_ZONE("Startup.all"); + gcpp::ConsumedArgs consumed(argc, argv); + gcpp::GemmaArgs args(argc, argv, consumed); + gcpp::JsonArgs json_args(argc, argv, consumed); + gcpp::AbortIfInvalidArgs(json_args); + consumed.AbortIfUnconsumed(); - gcpp::GemmaEnv env(args); - gcpp::Run(env, json_args); + gcpp::GemmaEnv env(args); + gcpp::Run(env, json_args); + } + PROFILER_PRINT_RESULTS(); + return 0; + } catch (const std::exception& error) { + fprintf(stderr, "model comparison failed: %s\n", error.what()); + return 1; } - PROFILER_PRINT_RESULTS(); // Must call outside the zone above. - return 0; } diff --git a/experimental/w8a8_calibration/README.md b/experimental/w8a8_calibration/README.md new file mode 100644 index 00000000..b8ad9e57 --- /dev/null +++ b/experimental/w8a8_calibration/README.md @@ -0,0 +1,118 @@ +# W8A8 calibration + +Offline calibration for the optional W8A8 model path. The recommended method is +**plain weight-only GPTQ**: keep `GEMMA_MM_I8_L2_SCALE=0` and omit +`--activation-correction`. Calibration changes packed weights and group scales; +it adds no work to the inference kernel. Activation correction and RMS +rescaling remain experimental options and are not part of this recommendation. + +The scripts require Python 3.11+, NumPy, and CUDA-enabled PyTorch with a compatible +GPU. `--rows` controls weight-row batch size; the full input covariance still +requires memory proportional to K squared. Run these commands from the repository +root, using a gemma binary built with the W8A8 hooks and no other `GEMMA_MM_I8_*` +settings inherited from earlier experiments. + +| Model | Quantization group | GPTQ damping | `MIN_K_SPLITS` | +|---|---:|---:|---:| +| Gemma 270M | 64 | 0.1 | 1 | +| Gemma 1B | 128 | 1.0 | 0 | + +## Capture, export, calibrate + +Choose paths and settings; use fresh output directories for each run: + +```sh +W8_GEMMA=./build/gemma +W8_WEIGHTS=/path/to/270m-sfp-it.sbs +W8_WORK=/tmp/gemma-w8a8-270m +W8_GROUP=64 +W8_DAMP=0.1 +W8_MIN_K_SPLITS=1 +mkdir -p "$W8_WORK" +``` + +Capture teacher inputs from diverse calibration prompts. In the interactive +session below, each prompt starts a new conversation; `%q` exits. Keep calibration +prompts separate from evaluation prompts. Capture works with W8A8 disabled and +records the BF16-rounded inputs used by the SFP reference. The row cap is per +tensor, with a global 1 GiB capture limit; check the JSON row counts before fitting. + +```sh +env GEMMA_MM_I8=0 GEMMA_MM_I8_L2_SCALE=0 \ + GEMMA_MM_I8_BLOCK_SIZE=128 GEMMA_MM_I8_HASH_BITS=32 \ + GEMMA_MM_I8_CALIBRATION_CAPTURE="$W8_WORK/capture" \ + GEMMA_MM_I8_CALIBRATION_SAMPLES=512 \ + GEMMA_MM_I8_CALIBRATION_ROWS_PER_CALL=32 \ + "$W8_GEMMA" --weights "$W8_WEIGHTS" --top_k 1 \ + --deterministic 1 --multiturn 0 --max_generated_tokens 16 +``` + +Export the matching rotated weights. One generated token exercises the dense +transformer and output head. Exported floats omit each tensor's `B.Scale()`; +the importer applies that factor once when packing. + +```sh +env GEMMA_MM_I8=1 GEMMA_MM_I8_L2_SCALE=0 GEMMA_MM_I8_MICROSCALE=1 \ + GEMMA_MM_I8_BLOCK_SIZE=128 GEMMA_MM_I8_HASH_BITS=32 \ + GEMMA_MM_I8_QUANT_BLOCK_SIZE="$W8_GROUP" \ + GEMMA_MM_I8_EXPORT_DIR="$W8_WORK/raw_weights" \ + "$W8_GEMMA" --weights "$W8_WEIGHTS" --top_k 1 \ + --max_generated_tokens 1 --prompt 'Explain how a bicycle works.' + +python3 experimental/w8a8_calibration/gptq_calibrate.py \ + --weights "$W8_WORK/raw_weights" --capture "$W8_WORK/capture" \ + --output "$W8_WORK/packed" --group "$W8_GROUP" --damp "$W8_DAMP" \ + --device cuda:0 +``` + +The output manifest records tensor dimensions, reconstruction errors and source +hashes. Review its tensor coverage: missing capture files are skipped, and missing +import files fall back to ordinary packing. Reconstruction error is a calibration +metric, not a guarantee of lower end-to-end KL. + +## Import + +Use the same checkpoint, rotation and quantization group: + +```sh +env GEMMA_MM_I8=1 GEMMA_MM_I8_L2_SCALE=0 GEMMA_MM_I8_MICROSCALE=1 \ + GEMMA_MM_I8_BLOCK_SIZE=128 GEMMA_MM_I8_HASH_BITS=32 \ + GEMMA_MM_I8_QUANT_BLOCK_SIZE="$W8_GROUP" \ + GEMMA_MM_I8_MIN_K_SPLITS="$W8_MIN_K_SPLITS" \ + GEMMA_MM_I8_IMPORT_DIR="$W8_WORK/packed" \ + "$W8_GEMMA" --weights "$W8_WEIGHTS" --top_k 1 +``` + +For the separately validated dual-activation inference experiment, add +`GEMMA_MM_I8_PACKED_HEAD=1`, `GEMMA_MM_I8_PACKED_HEAD_FULL_K=1`, +`GEMMA_MM_I8_DUAL_A_BODY=1`, `GEMMA_MM_I8_DUAL_A_HEAD=1`, and +`GEMMA_MM_I8_MATCH_BF16_A=1`. These runtime settings affect performance and quality; +measure them separately from calibration. The writer's covariance remains fitted +to the original single-stream activation quantizer. +`MIN_K_SPLITS` controls the fixed MatMul schedule when autotuning is disabled. +Automatic dual-A routing currently requires the supported x86-64 GCC build +using the AVX2 target on an AVX-VNNI-capable CPU. The flags alone do not promise +the same behavior or results on other hardware or compiler targets. + +## Compatibility and optional RMS experiments + +Files are little-endian. `W8RAW001` stores rotated unscaled F32 weight rows; +`MMI8WQ01` stores signed int8 rows followed by group-major F32 scales. The runtime +checks dimensions, group size, rotation and hash settings. These checks do **not** +identify a checkpoint: retain the manifest and use the exact checkpoint that was +exported. Deterministic reproduction also requires matching calibration prompts, +token sequences, capture selection and runtime settings. + +`prepare_rms_scaling.py CAPTURE OUTPUT` writes optional FFN input RMS files; +`--all-consumers` includes normalization consumers. These are used with +`GEMMA_MM_I8_L2_SCALE=1` and `GEMMA_MM_I8_SCALE_CALIBRATION_DIR=OUTPUT` when exporting +an experimental scaled basis. Export again after changing any scaling setting. +`W8RAW002` and `MMI8WQ02` include the exact F32 input-scale vector; import requires +a byte-identical vector. The writer preserves those bytes and transforms the +captured inputs into that basis. Scaled transforms currently support rotations +64/128 with hash32 and assume unit activation `A.Scale()`, as in these Gemma +buffers. Their inverse-rotation reconstruction is approximate and reported in +the manifest. Never pair unscaled packed weights with a scaled input basis. + +Only reusable logic belongs here. Keep checkpoints, captured tensors, packed +weights, logs, evaluation reports and binaries outside this source directory. diff --git a/experimental/w8a8_calibration/calibration_basis.py b/experimental/w8a8_calibration/calibration_basis.py new file mode 100644 index 00000000..6dd4accc --- /dev/null +++ b/experimental/w8a8_calibration/calibration_basis.py @@ -0,0 +1,97 @@ +"""Verified scaled-basis file metadata and bounded-precision capture transform.""" +import hashlib +from pathlib import Path +import struct +import numpy as np +import torch +from prepare_rms_scaling import hadamard, signs_for + + +def read_raw_header(path): + path = Path(path) + with path.open('rb') as stream: + header = stream.read(40) + if len(header) != 40: + raise ValueError(f'{path}: truncated raw weight header') + magic, n, k, rotation, hash_bits = struct.unpack('<8sQQQQ', header) + if magic not in (b'W8RAW001', b'W8RAW002') or not n or not k: + raise ValueError(f'{path}: invalid raw weight magic or dimensions') + scaled = magic == b'W8RAW002' + offset = 40 + (4 * k if scaled else 0) + if path.stat().st_size != offset + 4 * n * k: + raise ValueError(f'{path}: raw weight length mismatch') + basis_bytes = stream.read(4 * k) if scaled else b'' + basis = None + if scaled: + if rotation not in (64, 128) or k % rotation or hash_bits != 32: + raise ValueError(f'{path}: scaled basis supports rotation64/128 and hash32 only') + basis = np.frombuffer(basis_bytes, dtype=' 0).all(): + raise ValueError(f'{path}: input basis must contain K positive finite float32 values') + return {'magic': magic, 'N': n, 'K': k, 'rotation': rotation, + 'hash_bits': hash_bits, 'offset': offset, + 'basis_bytes': basis_bytes, 'basis': basis} + + +def round_bf16(values): + return torch.from_numpy(np.asarray(values, dtype=np.float32)).to(torch.bfloat16).float().numpy() + + +def forward_rotate(values, rotation): + values = np.asarray(values, dtype=np.float32) + if rotation not in (64, 128) or values.shape[1] % rotation: + raise ValueError('unsupported rotation shape') + normalization = np.float32(.125 if rotation == 64 else .08838834764831845) + signs = signs_for(values.shape[1]).astype(np.float32) + # Explicit sign, butterfly additions/subtractions, then F32 normalization. + return hadamard(values * signs, rotation) * normalization + + +def transform_capture(rotated, input_scale, rotation, hash_bits): + """Recover teacher BF16 channels, scale/BF16-round, then rotate in F32. + + Inverting rounded F32 rotation is approximate, including near-zero channels. + Returned diagnostics quantify that error; exact zero recovery is not assumed. + Capture applies A.Scale after rotation but does not serialize its value. + This transform assumes A.Scale == 1, as in current Gemma activation buffers; + the reconstruction tolerance is not a proof of that assumption. + """ + if rotation not in (64, 128) or hash_bits != 32: + raise ValueError('scaled capture transform supports rotation64/128 and hash32 only') + rotated = np.asarray(rotated, dtype=np.float32) + input_scale = np.asarray(input_scale, dtype=np.float32) + if (rotated.ndim != 2 or not len(rotated) or rotated.shape[1] % rotation or + input_scale.shape != (rotated.shape[1],) or + not np.isfinite(rotated).all() or not np.isfinite(input_scale).all() or + not (input_scale > 0).all()): + raise ValueError('invalid capture or input scale') + normalization = np.float32(.125 if rotation == 64 else .08838834764831845) + inverse = (hadamard(rotated.astype(np.float64), rotation) * signs_for(rotated.shape[1]) / + (rotation * float(normalization))) + original = round_bf16(inverse.astype(np.float32)) + reconstructed = forward_rotate(original, rotation) + max_error = float(np.max(np.abs(reconstructed.astype(np.float64) - rotated))) + max_relative = max_error / max(float(np.max(np.abs(rotated))), 1e-30) + if max_relative > 2e-6: + raise ValueError(f'capture does not reconstruct BF16 input within rotation tolerance: {max_relative}') + scaled_unrounded = original * input_scale + scaled = round_bf16(scaled_unrounded) + transformed = forward_rotate(scaled, rotation) + if not np.isfinite(transformed).all(): + raise ValueError('scaled capture produced nonfinite values') + diagnostics = { + 'source': 'exact F32 input_scale bytes exported by C++ in W8RAW002', + 'transform': 'inverse H/D in FP64, BF16 rounding, F32 channel scale, BF16 rounding, F32 D/H rotation', + 'recovery_is_bit_exact': False, + 'activation_scale_assumption': 'A.Scale == 1; capture metadata records application but not its numeric value', + 'activation_scale_assumption_source': 'Current Gemma FFN and normalization activation buffers use default unit scale; not generic nonunit support', + 'capture_reconstruction_max_absolute_error': max_error, + 'capture_reconstruction_max_relative_error': max_relative, + 'inverse_bf16_rounding_max_absolute_error': float(np.max(np.abs(inverse - original))), + 'scaled_bf16_rounding_max_absolute_error': float(np.max(np.abs(scaled_unrounded.astype(np.float64) - scaled))), + 'rotation_normalization_float32': float(normalization), + 'rotation_norm_squared_times_block_minus_one': rotation * float(normalization)**2 - 1., + 'input_scale_min_max': [float(input_scale.min()), float(input_scale.max())], + 'limitation': 'Isolated teacher-input rescaling; upstream gate/up errors and continuous-scale fold rounding require full-model validation', + } + return transformed, diagnostics diff --git a/experimental/w8a8_calibration/gptq_calibrate.py b/experimental/w8a8_calibration/gptq_calibrate.py new file mode 100644 index 00000000..f09f72ce --- /dev/null +++ b/experimental/w8a8_calibration/gptq_calibrate.py @@ -0,0 +1,182 @@ +"""Full-input reconstruction calibration, using only independent captured text. + +Produces the unchanged signed W8 + group-scale representation. GPTQ-style +error feedback couples columns across all groups. Optionally first compensate +activation quantization with a ridge-regularized least-squares weight update. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import struct +import time + +import numpy as np +import torch +from calibration_basis import read_raw_header, transform_capture + +p = argparse.ArgumentParser() +p.add_argument('--weights', required=True, type=Path) +p.add_argument('--capture', required=True, type=Path) +p.add_argument('--output', required=True, type=Path) +p.add_argument('--group', required=True, type=int, choices=[32, 64, 128]) +p.add_argument('--damp', type=float, default=0.1) +p.add_argument('--device', default='cuda:0') +p.add_argument('--rows', type=int, default=2048) +p.add_argument('--block', type=int, default=128) +p.add_argument('--activation-correction', action='store_true') +p.add_argument('--exclude-f32', action='store_true') +p.add_argument('--include', default='') +p.add_argument('--exclude', default='') +args = p.parse_args() +assert args.damp > 0 and args.rows > 0 and args.block > 0 and args.block % args.group == 0 +torch.set_num_threads(1) +torch.backends.cuda.matmul.allow_tf32 = False +torch.backends.cudnn.allow_tf32 = False +torch.set_float32_matmul_precision('highest') +args.output.mkdir(parents=True, exist_ok=True) +device = torch.device(args.device) +torch.cuda.set_device(device) +manifest = {'method': 'full-K GPTQ error feedback', 'args': vars(args).copy(), + 'writer_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'torch': torch.__version__, 'gpu': torch.cuda.get_device_name(device), + 'tensors': {}} +manifest['args'] = {k: str(v) if isinstance(v, Path) else v + for k, v in manifest['args'].items()} + +for raw in sorted(args.weights.glob('*.f32')): + name = raw.stem + if args.include and not any(t in name for t in args.include.split(',')): + continue + if args.exclude and any(t in name for t in args.exclude.split(',')): + continue + metadata_path = args.capture / (name + '.json') + if not metadata_path.exists(): + continue + metadata = json.loads(metadata_path.read_text()) + assert metadata['dtype'] == 'float32_le' and metadata['bf16_rounded_input'] is True + assert metadata['activation_scale_applied'] is True + if args.exclude_f32 and metadata['source_type'] == 'f32': + continue + start = time.monotonic() + raw_info = read_raw_header(raw) + n, k = raw_info['N'], raw_info['K'] + rotation, hash_bits = raw_info['rotation'], raw_info['hash_bits'] + assert k % args.group == 0 and metadata['K'] == k + assert metadata['rotation_block'] == rotation and metadata['hash_bits'] == hash_bits + wmap = np.memmap(raw, dtype=' 0 and np.isfinite(xcpu).all() + basis_transform = None + if raw_info['basis'] is not None: + xcpu, basis_transform = transform_capture( + xcpu, raw_info['basis'], rotation, hash_bits) + # Reconstruct exactly the F32 scale and nearest-even int8 activation rule. + shaped = xcpu.reshape(len(xcpu), -1, args.group) + amax = np.max(np.abs(shaped), axis=2, keepdims=True) + inv = np.divide(np.float32(127), amax, out=np.zeros_like(amax), where=amax != 0) + xhatcpu = (np.rint(shaped * inv).clip(-127, 127) * + (amax / np.float32(127))).reshape(-1, k) + x = torch.from_numpy(xcpu).to(device=device, dtype=torch.float64) + xhat = torch.from_numpy(xhatcpu).to(device=device, dtype=torch.float64) + h = xhat.T @ xhat + ridge = args.damp * torch.diag(h).mean().item() + if ridge == 0: + raise RuntimeError(f'{name}: all-zero calibration inputs') + h.diagonal().add_(ridge) + # FP64 factorization protects the low-rank calibration covariance. + chol = torch.linalg.cholesky(h) + hinv = torch.cholesky_inverse(chol) + u = torch.linalg.cholesky(hinv, upper=True).float() + del h, chol, hinv + basis = None + if args.activation_correction: + small = xhat @ xhat.T + small.diagonal().add_(ridge) + basis = torch.linalg.solve(small, xhat).float() + del small + x = x.float() + xhat = xhat.float() + residual_input = (x - xhat).T.contiguous() + output = args.output / (name + '.wq') + output_magic = b'MMI8WQ02' if raw_info['basis'] is not None else b'MMI8WQ01' + header = struct.pack('<8sQQQQQ', output_magic, n, k, args.group, rotation, hash_bits) + basis_bytes = raw_info['basis_bytes'] + q_offset = len(header) + len(basis_bytes) + with output.open('wb') as f: + f.write(header) + f.write(basis_bytes) # Preserve the exported F32 basis byte for byte. + f.truncate(q_offset + n * k + (k // args.group) * n * 4) + qmap = np.memmap(output, dtype=np.int8, mode='r+', offset=q_offset, shape=(n, k)) + smap = np.memmap(output, dtype='= np.uint64(0x80000000)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('capture', type=Path) + parser.add_argument('output', type=Path) + parser.add_argument('--all-consumers', action='store_true') + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + run_manifest = args.capture.parent / (args.capture.name + '.run.json') + manifest = {'capture': str(args.capture), 'method': 'uncentered RMS after inverse rotation', + 'consumer': 'all' if args.all_consumers else 'FFN down only', + 'writer_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'capture_run_sha256': hashlib.sha256(run_manifest.read_bytes()).hexdigest() + if run_manifest.exists() else None, + 'tensors': {}} + pattern = '*.json' if args.all_consumers else 'linear_w_*.json' + for metadata in sorted(args.capture.glob(pattern)): + meta = json.loads(metadata.read_text()) + if 'K' not in meta: + continue + k, block = meta['K'], meta['rotation_block'] + assert block in (64, 128) and k % block == 0 and meta['hash_bits'] == 32 + assert meta['bf16_rounded_input'] and meta['activation_scale_applied'] + rotated = np.fromfile(metadata.with_suffix('.f32'), dtype=' 0 + assert np.isfinite(rotated).all() + normalization = float(np.float32(0.125 if block == 64 else 0.08838834764831845)) + signs = signs_for(k) + original = hadamard(rotated, block) * signs / (block * normalization) + # A complete inverse/forward check catches sign placement and indexing + # mistakes; H and D do not commute. + reconstructed = hadamard(original[:4] * signs, block) * normalization + np.testing.assert_allclose(reconstructed, rotated[:4], rtol=1e-12, atol=1e-12) + rms = np.sqrt(np.mean(original * original, axis=0)).astype('= 0).all() + destination = args.output / (metadata.stem + '.rms') + with destination.open('wb') as stream: + stream.write(struct.pack('<8sQ', b'MMI8RM01', k)) + stream.write(rms.tobytes()) + manifest['tensors'][metadata.stem] = { + 'K': k, 'rows': len(rotated), + 'metadata_sha256': hashlib.sha256(metadata.read_bytes()).hexdigest(), + 'capture_sha256': hashlib.sha256(metadata.with_suffix('.f32').read_bytes()).hexdigest(), + 'rms_sha256': hashlib.sha256(destination.read_bytes()).hexdigest(), + 'rms_min_median_p95_max': np.percentile(rms, [0, 50, 95, 100]).tolist()} + if not manifest['tensors']: + raise ValueError('no matching activation captures found') + # A shared normalized input must have one common channel scale for all + # consumers. Reject inconsistent captures before a model can load them. + if args.all_consumers: + for name in manifest['tensors']: + peer = None + if name.startswith('gating1_w_'): + peer = name.replace('gating1_w_', 'gating2_w_', 1) + elif name.startswith('qkv1_w_'): + peer = name.replace('qkv1_w_', 'qkv2_w_', 1) + if peer is not None: + if peer not in manifest['tensors']: + raise ValueError(f'missing shared-input consumer {peer}') + if (args.output / (name + '.rms')).read_bytes() != (args.output / (peer + '.rms')).read_bytes(): + raise ValueError(f'shared consumers have different RMS: {name}, {peer}') + (args.output / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') + print(json.dumps({'output': str(args.output), 'tensors': len(manifest['tensors'])}), flush=True) + + +if __name__ == '__main__': + main() diff --git a/gemma/gemma-inl.h b/gemma/gemma-inl.h index 5c78ba84..0383c404 100644 --- a/gemma/gemma-inl.h +++ b/gemma/gemma-inl.h @@ -214,6 +214,9 @@ static inline void FFWNoVit(const LayerWeightsPtrs& layer, HWY_DASSERT(!layer_config.ff_biases); // Only used in Vit. + MMI8WeightCache::Get().PrepareFFN( + layer.gating_einsum_w1, layer.gating_einsum_w2, layer.linear_w, env); + activations.s_ffw_in.Notify(layer.layer_idx, activations.pre_ffw_rms_out, env.ctx); diff --git a/gemma/gemma.cc b/gemma/gemma.cc index 620dbb79..21f453ba 100644 --- a/gemma/gemma.cc +++ b/gemma/gemma.cc @@ -128,8 +128,23 @@ HWY_NOINLINE void TransformerLayer(const size_t num_tokens, return; } - RMSNormBatched(activations.x, layer.pre_attention_norm_scale, - activations.attention.pre_att_rms_out, env.ctx); + // Only Gemma's ordinary dense path uses this (gamma = 1 + stored weight). + const bool scale_i8 = layer_config.type == LayerAttentionType::kGemma && + !layer_config.IsMoE() && + MMI8WeightCache::Get().ScalingEnabled(); + auto& i8_cache = MMI8WeightCache::Get(); + std::vector qkv; + if (scale_i8 && i8_cache.Enabled()) { + if (layer.qkv_einsum_w.HasPtr()) + qkv = {&layer.qkv_einsum_w}; + else + qkv = {&layer.qkv_einsum_w1, &layer.qkv_einsum_w2}; + } + const MatPtr& att_norm = + scale_i8 ? i8_cache.NormWeights(layer.pre_attention_norm_scale, qkv, env) + : layer.pre_attention_norm_scale; + RMSNormBatched(activations.x, att_norm, activations.attention.pre_att_rms_out, + env.ctx); Attention(layer_config.type, num_tokens, layer_idx, layer, activations, qbatch, env); @@ -139,8 +154,12 @@ HWY_NOINLINE void TransformerLayer(const size_t num_tokens, ResidualConnection(activations.attention.att_sums, activations.x, layer, /*is_attention=*/true, env.ctx); - RMSNormBatched(activations.x, layer.pre_ffw_norm_scale, - activations.pre_ffw_rms_out, env.ctx); + const MatPtr& ff_norm = + scale_i8 ? i8_cache.NormWeights( + layer.pre_ffw_norm_scale, + {&layer.gating_einsum_w1, &layer.gating_einsum_w2}, env) + : layer.pre_ffw_norm_scale; + RMSNormBatched(activations.x, ff_norm, activations.pre_ffw_rms_out, env.ctx); if (layer_config.type == LayerAttentionType::kVit) { FFWVit(layer, activations, env); @@ -1421,8 +1440,15 @@ HWY_NOINLINE void FinalNormBatched(const ModelConfig& config, if (HWY_UNLIKELY(config.model_family_version == 4 && config.HasMLA())) { DeepSeekFinalNorm(weights, activations, env); } else { - RMSNormBatched(activations.x, weights.final_norm_scale, activations.x_bf, - env.ctx); + const MatPtr& head = weights.lm_head.HasPtr() + ? weights.lm_head + : weights.embedder_input_embedding; + // This branch uses Gemma's offset gamma regardless of family metadata. + // Older Gemma3 configs retain model_family_version=1. NormWeights checks + // whether the optional fold and its consumer are eligible. + const MatPtr& norm = MMI8WeightCache::Get().NormWeights( + weights.final_norm_scale, {&head}, env); + RMSNormBatched(activations.x, norm, activations.x_bf, env.ctx); } } diff --git a/ops/bench_matmul_i8.cc b/ops/bench_matmul_i8.cc new file mode 100644 index 00000000..3dfcb7f4 --- /dev/null +++ b/ops/bench_matmul_i8.cc @@ -0,0 +1,455 @@ +// Copyright 2025 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Compares the BF16 MatMul (`ops/matmul-inl.h`) against the W8A8 int8 kernel +// (`ops/matmul_i8-inl.h`) on Gemma-shaped problems, and reports the accuracy +// of both relative to an F64 reference. Standalone binary (no gtest) so that +// it can be run directly. + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "compression/types.h" // GEMMA_DISABLED_TARGETS +#ifndef HWY_DISABLED_TARGETS +#define HWY_DISABLED_TARGETS GEMMA_DISABLED_TARGETS +#endif // HWY_DISABLED_TARGETS + +#include "hwy/aligned_allocator.h" +#include "hwy/timer.h" +#include "ops/matmul.h" +#include "util/basics.h" +#include "util/mat.h" +#include "util/threading_context.h" + +// clang-format off +#undef HWY_TARGET_INCLUDE +#define HWY_TARGET_INCLUDE "ops/bench_matmul_i8.cc" // NOLINT +// clang-format on +#include "hwy/foreach_target.h" // IWYU pragma: keep +#include "hwy/highway.h" +// After highway.h +#include "compression/compress-inl.h" +#include "ops/matmul-inl.h" +#include "ops/matmul_i8-inl.h" + +HWY_BEFORE_NAMESPACE(); +namespace gcpp { +namespace HWY_NAMESPACE { +namespace hn = hwy::HWY_NAMESPACE; + +// Deterministic, reproducible pseudo-Gaussian. Real activations and weights +// are roughly bell-shaped; the ramp in `compression/test_util-inl.h` would +// flatter or penalize int8 quantization for the wrong reasons. +class Rng { + public: + explicit Rng(uint64_t seed) : state_(seed * 6364136223846793005ull + 1) {} + + float Normal() { + // Sum of 4 uniforms: close enough to Gaussian, and cheap. + float sum = 0.0f; + for (int i = 0; i < 4; ++i) sum += Uniform(); + return (sum - 2.0f) * 1.732f; // zero mean, unit-ish variance + } + + private: + float Uniform() { + state_ = state_ * 6364136223846793005ull + 1442695040888963407ull; + return static_cast((state_ >> 40) & 0xFFFFFF) / 16777216.0f; + } + uint64_t state_; +}; + +// Fills `mat` with N(0, stddev), and additionally gives a few columns of each +// row a 10x larger magnitude. Outlier channels are the known hard case for +// per-tensor int8; per-row/per-column scales are supposed to absorb them. +void FillNormal(MatStorageT& mat, uint64_t seed, float stddev, + bool outliers) { + Rng rng(seed); + for (size_t r = 0; r < mat.Rows(); ++r) { + float* HWY_RESTRICT row = mat.Row(r); + for (size_t c = 0; c < mat.Cols(); ++c) { + row[c] = rng.Normal() * stddev; + } + if (outliers) { + for (size_t c = (r * 7) % 64; c < mat.Cols(); c += 512) { + row[c] *= 10.0f; + } + } + for (size_t c = mat.Cols(); c < mat.Stride(); ++c) row[c] = 0.0f; + } +} + +// Converts F32 `in` to `MatT` (BF16 or a compressed stream), row by row. +template +void ConvertRows(const MatStorageT& in, MatStorageT& out, + ThreadingContext& ctx) { + CompressWorkingSet ws; + ws.tls.resize(ctx.pools.MaxWorkers()); + const size_t cols = in.Cols(); + ParallelFor(Parallelism::kFlat, in.Rows(), ctx, /*cluster_idx=*/0, + Callers::kTest, [&](size_t r, size_t thread) HWY_ATTR { + Compress(in.Row(r), cols, ws.tls[thread], + MakeSpan(out.Row(r), cols), /*packed_ofs=*/0); + }); +} + +//------------------------------------------------------------------------------ +// Reference and error metric + +// `B` is transposed: `ref[m, n] = sum_k A[m, k] * B[n, k]`. +void ReferenceMatMul(const MatStorageT& A, const MatStorageT& B, + MatStorageT& ref, ThreadingContext& ctx) { + const size_t K = A.Cols(); + ParallelFor(Parallelism::kFlat, A.Rows(), ctx, /*cluster_idx=*/0, + Callers::kTest, [&](size_t m, size_t /*thread*/) { + const float* HWY_RESTRICT a = A.Row(m); + double* HWY_RESTRICT out = ref.Row(m); + for (size_t n = 0; n < B.Rows(); ++n) { + const float* HWY_RESTRICT b = B.Row(n); + double sum = 0.0; + for (size_t k = 0; k < K; ++k) { + sum += + static_cast(a[k]) * static_cast(b[k]); + } + out[n] = sum; + } + }); +} + +// Relative Frobenius error ||C - ref|| / ||ref||. +template +double RelError(const MatStorageT& C, const MatStorageT& ref) { + double num = 0.0, den = 0.0; + for (size_t r = 0; r < ref.Rows(); ++r) { + const TC* HWY_RESTRICT c = C.Row(r); + const double* HWY_RESTRICT e = ref.Row(r); + for (size_t n = 0; n < ref.Cols(); ++n) { + const double d = hwy::ConvertScalarTo(c[n]) - e[n]; + num += d * d; + den += e[n] * e[n]; + } + } + return (den == 0.0) ? 0.0 : std::sqrt(num / den); +} + +//------------------------------------------------------------------------------ +// Timing + +struct Result { + double median_sec = 0.0; + double gflops = 0.0; + double rel_error = -1.0; // < 0 if not measured +}; + +// Repeats `fn` (which returns the autotune state) until autotuning has settled, +// then collects `num_samples` timings and returns the median. +template +Result TimeMatMul(size_t M, size_t K, size_t N, Fn&& fn) { + const size_t num_samples = M < 32 ? 40 : 12; + std::vector times; + times.reserve(num_samples); + + // Bound the loop: a config that never reports Best() would otherwise hang. + // Skip a few runs after autotuning settles, so the first timed sample is not + // the one that still has the autotuner's working set in cache. + size_t warmup = 3; + for (size_t iter = 0; times.size() < num_samples && iter < 8192; ++iter) { + const double t0 = hwy::platform::Now(); + MMPerKey* per_key = fn(); + const double t1 = hwy::platform::Now(); + if (!per_key->autotune.Best()) continue; + if (warmup != 0) { + --warmup; + continue; + } + times.push_back(t1 - t0); + } + HWY_ASSERT(!times.empty()); + + std::sort(times.begin(), times.end()); + Result r; + r.median_sec = times[times.size() / 2]; + r.gflops = 2.0 * M * K * N / r.median_sec * 1E-9; + return r; +} + +//------------------------------------------------------------------------------ +// One shape + +// Runs BF16xBF16, BF16xSFP and int8 W8A8 on the same `M x K x N` problem. +// `check_error` also computes the F64 reference, which is O(M*K*N) scalar work +// and thus only affordable for smaller shapes. +void BenchShape(size_t M, size_t K, size_t N, bool check_error, + ThreadingContext& ctx, MatMulEnv& env_bf, MatMulEnv& env_sfp, + MatMulEnv& env_i8, MMI8AStorage& a_i8, bool a_outliers = true) { + const Allocator& allocator = ctx.allocator; + const Extents2D A_extents(M, K); + const Extents2D B_extents(N, K); // already transposed + const Extents2D C_extents(M, N); + + // Sources, in F32. + MatStorageT A_f32("A_f32", A_extents, allocator, MatPadding::kOdd); + MatStorageT B_f32("B_f32", B_extents, allocator, MatPadding::kOdd); + FillNormal(A_f32, /*seed=*/1, /*stddev=*/1.0f, a_outliers); + FillNormal(B_f32, /*seed=*/2, /*stddev=*/0.02f, /*outliers=*/false); + + // Operands for the BF16 kernel. + MatStorageT A_bf("A_bf", A_extents, allocator, MatPadding::kOdd); + MatStorageT B_bf("B_bf", B_extents, allocator, MatPadding::kOdd); + MatStorageT B_sfp("B_sfp", B_extents, allocator, MatPadding::kOdd); + ConvertRows(A_f32, A_bf, ctx); + ConvertRows(B_f32, B_bf, ctx); + ConvertRows(B_f32, B_sfp, ctx); + + // Operands for the int8 kernel. `A` is quantized inside `MatMulI8`. + MatStorageT B_i8("B_i8", B_extents, allocator, MatPadding::kOdd); + const size_t requested_block = MMI8QuantBlockSize(); + const size_t block_size = requested_block && K % requested_block != 0 + ? MMI8RotateBlockSize() + : requested_block; + hwy::AlignedVector b_scale(N * (block_size ? K / block_size : 1)); + const double pack_t0 = hwy::platform::Now(); + const MMI8B B_packed = + PackB(B_f32, B_i8, b_scale.data(), ctx, nullptr, block_size); + const double pack_ms = (hwy::platform::Now() - pack_t0) * 1E3; + + MatStorageT C_bf("C_bf", C_extents, allocator, MatPadding::kOdd); + MatStorageT C_sfp("C_sfp", C_extents, allocator, MatPadding::kOdd); + MatStorageT C_i8("C_i8", C_extents, allocator, MatPadding::kOdd); + C_bf.AllocateAndAttachRowPtrs(env_bf.row_ptrs); + C_sfp.AllocateAndAttachRowPtrs(env_sfp.row_ptrs); + C_i8.AllocateAndAttachRowPtrs(env_i8.row_ptrs); + + Tristate use_spinning = Tristate::kDefault; + ctx.pools.MaybeStartSpinning(use_spinning); + + const Result r_bf = TimeMatMul(M, K, N, [&] { + return MatMul(A_bf, B_bf, /*add=*/nullptr, env_bf, C_bf); + }); + const Result r_sfp = TimeMatMul(M, K, N, [&] { + return MatMul(A_bf, B_sfp, /*add=*/nullptr, env_sfp, C_sfp); + }); + const Result r_i8 = TimeMatMul(M, K, N, [&] { + return MatMulI8(A_bf, B_packed, /*add=*/nullptr, env_i8, C_i8, a_i8); + }); + + ctx.pools.MaybeStopSpinning(use_spinning); + + double e_bf = -1.0, e_sfp = -1.0, e_i8 = -1.0; + if (check_error) { + MatStorageT ref("ref", C_extents, allocator, MatPadding::kOdd); + ReferenceMatMul(A_f32, B_f32, ref, ctx); + e_bf = RelError(C_bf, ref); + e_sfp = RelError(C_sfp, ref); + e_i8 = RelError(C_i8, ref); + } + + printf( + "%5zu %6zu %7zu | %8.1f %8.1f %8.1f | %6.3f %6.3f %6.3f | %7.3f | %5.2fx " + "%5.2fx", + M, K, N, r_bf.gflops, r_sfp.gflops, r_i8.gflops, r_bf.median_sec * 1E3, + r_sfp.median_sec * 1E3, r_i8.median_sec * 1E3, pack_ms, + r_i8.gflops / r_bf.gflops, r_i8.gflops / r_sfp.gflops); + if (check_error) { + printf(" | %.2e %.2e %.2e", e_bf, e_sfp, e_i8); + } + printf("\n"); + fflush(stdout); +} + +// Measures raw instruction throughput of the two dot products with 16 +// independent accumulator chains, no memory traffic. The matmul speedups below +// cannot exceed this ratio, and how close they get says how much of the win is +// compute rather than the halved footprint of `B`. +// Separates sign generation and the complete transform from MatMul timing. +void BenchRotation() { + constexpr size_t kSize = 6912; + constexpr size_t kSamples = 21; + constexpr size_t kReps = 64; + std::vector row(kSize); + Rng rng(991); + for (float& value : row) value = rng.Normal(); + + for (size_t hash_bits : {size_t{32}, size_t{16}}) { + size_t keep = 0; + const double t0 = hwy::platform::Now(); + for (size_t rep = 0; rep < 4000; ++rep) { + for (size_t i = 0; i < kSize; ++i) { + keep += MMI8NegativeSign(i + rep * kSize, hash_bits) ? 1 : 0; + } + } + const double elapsed = hwy::platform::Now() - t0; + hwy::PreventElision(keep); + printf("sign hash %2zu-bit: %.3f ns/value (negative %.3f)\n", hash_bits, + elapsed * 1E9 / (4000.0 * kSize), + static_cast(keep) / (4000.0 * kSize)); + } + + for (size_t block : {size_t{128}, size_t{64}}) { + for (size_t hash_bits : {size_t{32}, size_t{16}}) { + std::vector samples; + samples.reserve(kSamples); + for (size_t sample = 0; sample < kSamples; ++sample) { + const double t0 = hwy::platform::Now(); + for (size_t rep = 0; rep < kReps; ++rep) { + MMI8Rotate(row.data(), row.size(), block, hash_bits); + } + samples.push_back((hwy::platform::Now() - t0) / kReps); + } + std::sort(samples.begin(), samples.end()); + printf("rotation block=%3zu hash=%2zu: %.3f us/row (%.3f ns/value)\n", + block, hash_bits, samples[kSamples / 2] * 1E6, + samples[kSamples / 2] * 1E9 / kSize); + } + } +} +void BenchDotThroughput() { + const hn::ScalableTag dbf; + const hn::Repartition df; + const hn::ScalableTag di8; + const hn::Repartition di32; + constexpr size_t kChains = 16; + constexpr size_t kReps = 2000000; + + const size_t bf16_macs = hn::Lanes(dbf); // per instruction + const size_t i8_macs = hn::Lanes(di8); + + double keep = 0.0; + double bf_sec = 0.0, i8_sec = 0.0; + + { + const auto a = hn::Set(dbf, hwy::ConvertScalarTo(1.0f)); + hn::Vec c[kChains], unused = hn::Zero(df); + for (size_t i = 0; i < kChains; ++i) c[i] = hn::Zero(df); + const double t0 = hwy::platform::Now(); + for (size_t r = 0; r < kReps; ++r) { + for (size_t i = 0; i < kChains; ++i) { + c[i] = hn::ReorderWidenMulAccumulate(df, a, a, c[i], unused); + } + } + bf_sec = hwy::platform::Now() - t0; + for (size_t i = 0; i < kChains; ++i) keep += hn::GetLane(c[i]); + } + { + const auto a = hn::Set(di8, int8_t{1}); + hn::Vec c[kChains]; + for (size_t i = 0; i < kChains; ++i) c[i] = hn::Zero(di32); + const double t0 = hwy::platform::Now(); + for (size_t r = 0; r < kReps; ++r) { + for (size_t i = 0; i < kChains; ++i) { + c[i] = hn::SumOfMulQuadAccumulate(di32, a, a, c[i]); + } + } + i8_sec = hwy::platform::Now() - t0; + for (size_t i = 0; i < kChains; ++i) keep += hn::GetLane(c[i]); + } + hwy::PreventElision(keep); + + const double ops = static_cast(kChains) * kReps; + const double bf_gmac = ops * bf16_macs / bf_sec * 1E-9; + const double i8_gmac = ops * i8_macs / i8_sec * 1E-9; + printf( + "1-core dot product throughput: bf16 %.1f GMAC/s, int8 %.1f GMAC/s " + "(%.2fx)\n", + bf_gmac, i8_gmac, i8_gmac / bf_gmac); +} + +void BenchAll() { + ThreadingArgs threading_args; + ThreadingContext ctx(threading_args); + printf("target=%s %s %s\n", hwy::TargetName(HWY_TARGET), + ctx.topology.TopologyString(), ctx.pools.PinString()); + printf( + "B biased to u8: %d, block=%zu, hash=%zu, " + "HWY_NATIVE_DOT_BF16=%d, vector bytes=%zu\n", + GEMMA_MM_I8_BIASED_B, MMI8RotateBlockSize(), MMI8HashBits(), + HWY_NATIVE_DOT_BF16, hn::Lanes(hn::ScalableTag())); + BenchRotation(); + + if (MMI8Flag("GEMMA_MM_I8_BENCH_ROTATION_ONLY")) return; + + BenchDotThroughput(); + + MatMulEnv env_bf(ctx), env_sfp(ctx), env_i8(ctx); + // Sized for the largest shape below. + MMI8AStorage a_i8(/*max_M=*/512, /*max_K=*/8192, ctx.allocator); + + printf( + "\n M K N | GFLOPS: bf16 sfp i8 | ms: bf16 " + "sfp i8 | pack ms | i8 vs bf16/sfp | rel err: bf16 sfp i8\n"); + + // Gemma3-1B decode shapes, as in `ops/bench_matmul.cc`. + for (size_t M : {size_t{1}, size_t{4}}) { + BenchShape(M, 1152, 1536, /*check_error=*/false, ctx, env_bf, env_sfp, + env_i8, a_i8); // QKV + BenchShape(M, 1152, 13824, false, ctx, env_bf, env_sfp, env_i8, + a_i8); // FFN gate+up + BenchShape(M, 6912, 1152, false, ctx, env_bf, env_sfp, env_i8, + a_i8); // FFN down + BenchShape(M, 1152, 32768, false, ctx, env_bf, env_sfp, env_i8, + a_i8); // logits (N reduced to fit memory) + } + + // Prefill / batched shapes. + BenchShape(128, 3072, 3072, false, ctx, env_bf, env_sfp, env_i8, a_i8); + BenchShape(512, 3072, 3072, false, ctx, env_bf, env_sfp, env_i8, a_i8); + BenchShape(128, 1152, 13824, false, ctx, env_bf, env_sfp, env_i8, a_i8); + + // B larger than last-level cache in both formats, so neither kernel can + // hide the streaming cost of B. + printf("\n(B exceeds LLC in both formats)\n"); + BenchShape(1, 4096, 32768, false, ctx, env_bf, env_sfp, env_i8, a_i8); + BenchShape(8, 4096, 32768, false, ctx, env_bf, env_sfp, env_i8, a_i8); + BenchShape(128, 4096, 32768, false, ctx, env_bf, env_sfp, env_i8, a_i8); + + printf( + "\nAccuracy vs F64 reference. A has outlier channels (10x), which is the" + "\nknown hard case for per-token int8 activations:\n"); + BenchShape(32, 1152, 512, /*check_error=*/true, ctx, env_bf, env_sfp, env_i8, + a_i8, /*a_outliers=*/true); + BenchShape(32, 3072, 512, true, ctx, env_bf, env_sfp, env_i8, a_i8, true); + BenchShape(32, 6912, 512, true, ctx, env_bf, env_sfp, env_i8, a_i8, true); + + printf("\nSame, but A is plain Gaussian with no outlier channels:\n"); + BenchShape(32, 1152, 512, true, ctx, env_bf, env_sfp, env_i8, a_i8, + /*a_outliers=*/false); + BenchShape(32, 3072, 512, true, ctx, env_bf, env_sfp, env_i8, a_i8, false); + BenchShape(32, 6912, 512, true, ctx, env_bf, env_sfp, env_i8, a_i8, false); +} + +// NOLINTNEXTLINE(google-readability-namespace-comments) +} // namespace HWY_NAMESPACE +} // namespace gcpp +HWY_AFTER_NAMESPACE(); + +#if HWY_ONCE +namespace gcpp { +HWY_EXPORT(BenchAll); +void RunBenchmarks() { HWY_DYNAMIC_DISPATCH(BenchAll)(); } +} // namespace gcpp + +int main(int /*argc*/, char** /*argv*/) { + // Best available target only; this is a benchmark, not a test. + gcpp::RunBenchmarks(); + return 0; +} +#endif // HWY_ONCE diff --git a/ops/matmul-inl.h b/ops/matmul-inl.h index cabe5392..f4fb6658 100644 --- a/ops/matmul-inl.h +++ b/ops/matmul-inl.h @@ -17,6 +17,8 @@ #include #include +#include +#include #include #include "compression/types.h" @@ -53,6 +55,30 @@ namespace gcpp { namespace HWY_NAMESPACE { namespace hn = hwy::HWY_NAMESPACE; +// Shared integer dot-product primitive for direct quantized MatMul kernels. +// `kWeightsFirst` selects the operand order required by the encoding: +// unsigned weights must precede signed activations, whereas signed W8A8 keeps +// the activation first. This keeps target-specific dot-product details out of +// the W8A8 kernel. +template > +static HWY_INLINE void MMQuantizedDot4Accumulate( + DI32 di32, VA8 a, VB8 b0, VB8 b1, VB8 b2, VB8 b3, VI32& c0, VI32& c1, + VI32& c2, VI32& c3) { + static_assert(kNR == 4); + if constexpr (kWeightsFirst) { + c0 = hn::SumOfMulQuadAccumulate(di32, b0, a, c0); + c1 = hn::SumOfMulQuadAccumulate(di32, b1, a, c1); + c2 = hn::SumOfMulQuadAccumulate(di32, b2, a, c2); + c3 = hn::SumOfMulQuadAccumulate(di32, b3, a, c3); + } else { + c0 = hn::SumOfMulQuadAccumulate(di32, a, b0, c0); + c1 = hn::SumOfMulQuadAccumulate(di32, a, b1, c1); + c2 = hn::SumOfMulQuadAccumulate(di32, a, b2, c2); + c3 = hn::SumOfMulQuadAccumulate(di32, a, b3, c3); + } +} + // Like hn::PromoteOddTo, but uses assembly to avoid an extra vector register. template > static hn::VFromD FastPromoteOddTo(DF df, hn::VFromD vbf) { @@ -229,6 +255,20 @@ class MMStoreHorizontalSumsIntoC { // Stateless, wraps member functions. class MMDecompress { public: + // Quality-only experiment requested in #1. When enabled, every activation + // row is symmetrically quantized to int8 and immediately dequantized to + // BF16 before the existing MatMul. This intentionally adds overhead: its + // purpose is to isolate activation-quantization error from a new kernel and + // weight quantization. + static bool ActivationI8RoundtripEnabled() { + static const bool enabled = [] { + const char* value = std::getenv("GEMMA_MM_I8_ROUNDTRIP_A"); + return value != nullptr && value[0] != '\0' && + !(value[0] == '0' && value[1] == '\0'); + }(); + return enabled; + } + // Decompresses `kNR x kc` from `B[row_b, range_kc.begin()]` to row 0, // col 0 of `B_view`. Decompressing SFP is relatively cheap on `AVX3_DL` // thanks to its large table lookups, and less so on other targets. @@ -271,7 +311,9 @@ class MMDecompress { if constexpr (IsBF16()) { // We can use a view, regardless of columns/padding, because // `MMKernel::LoopKC` supports non-vector multiples. - return StridedViewBF(A, 0, 0, A.Cols()); + const StridedViewBF A_view(A, 0, 0, A.Cols()); + return ActivationI8RoundtripEnabled() ? RoundtripA(A, A_view, env) + : A_view; } else { // Always decompress. To reduce code size/compile time, we no longer // support a separate F32 kernel; most A are already BF16. We also only @@ -279,11 +321,52 @@ class MMDecompress { HWY_ASSERT(options.cluster_idx == 0); const StridedViewBF A_view = env.A_BF.A(A.Extents()); AutotuneDecompressA(A, A_view, autotune, env, options); - return A_view; + return ActivationI8RoundtripEnabled() ? RoundtripA(A, A_view, env) + : A_view; } } + // `TwoMatMul` only accepts BF16 A and does not call `MaybeDecompressA`. + static HWY_INLINE StridedViewBF MaybeRoundtripA(const MatPtrT& A, + const MatMulEnv& env) { + const StridedViewBF A_view(A, 0, 0, A.Cols()); + return ActivationI8RoundtripEnabled() ? RoundtripA(A, A_view, env) + : A_view; + } + private: + template + static HWY_NOINLINE StridedViewBF RoundtripA(const MatPtrT& A, + const StridedViewBF source, + const MatMulEnv& env) { + const StridedViewBF dest = env.A_BF.A(A.Extents()); + constexpr float kI8Max = 127.0f; + + for (size_t row = 0; row < A.Rows(); ++row) { + const BF16* HWY_RESTRICT from = source.Row(row); + BF16* HWY_RESTRICT to = dest.Row(row); + + float max_abs = 0.0f; + for (size_t col = 0; col < A.Cols(); ++col) { + max_abs = HWY_MAX( + max_abs, + std::fabs(hwy::ConvertScalarTo(from[col]))); + } + + const float scale = max_abs == 0.0f ? 1.0f : max_abs / kI8Max; + const float inv_scale = max_abs == 0.0f ? 0.0f : kI8Max / max_abs; + for (size_t col = 0; col < A.Cols(); ++col) { + const float value = hwy::ConvertScalarTo(from[col]); + const int32_t quantized = HWY_MIN( + int32_t{127}, + HWY_MAX(int32_t{-127}, static_cast(std::lroundf( + value * inv_scale)))); + to[col] = hwy::ConvertScalarTo(quantized * scale); + } + } + return dest; + } + // Decompresses all `M x K` from `A` into padded BF16 `A_view`. static HWY_NOINLINE void DecompressA(const MatPtrT& A, const StridedViewBF A_view, @@ -375,7 +458,7 @@ class MMDecompress { const MMParA other = (A.Rows() == 1) ? MMParA::kNone : MMParA::kM; std::vector candidates = {MMParA::kK1, MMParA::kK2, MMParA::kK4, other}; - autotune.SetCandidates(candidates); + autotune.SetCandidates(candidates, env.autotune); } const MMParA& par_a = autotune.NextConfig(); @@ -395,6 +478,9 @@ class MMDecompress { // Stateless, wraps member functions. Contains the innermost 2-4 loops. class MMKernel { public: + // Type of the `A` operand, see `MMLoops::Dispatch`. + using AView = StridedViewBF; + // Loop over NC/MC/KC, called from the outer loops. The MOMMS B3A2C0 reads // `mc x kc` of A, `nc x kc` of B, and updates the `mc x nc` `C_MC_NC`. // `CView` is either `RowPtrs` or `StridedView`. @@ -1150,10 +1236,11 @@ class MMImpl { } public: - static MMPerKey& FindOrAddPerKey(size_t M, size_t K, size_t N, size_t num_B, - size_t vector_bytes, - MatMulEnv::PerCluster& per_cluster) { - const MMKeys::Key key = MMKeys::KeyFromDims(M, K, N, num_B); + static MMPerKey& FindOrAddPerKey( + size_t M, size_t K, size_t N, size_t num_B, size_t vector_bytes, + MatMulEnv::PerCluster& per_cluster, + MMActivation activation = MMActivation::kBF16) { + const MMKeys::Key key = MMKeys::KeyFromDims(M, K, N, num_B, activation); intptr_t index = IndexOfKey(key, per_cluster.keys); // First time we see this shape/key. if (HWY_UNLIKELY(index < 0)) { @@ -1171,6 +1258,7 @@ class MMImpl { size_t num_B, double t0, MMAutoTune& tuner, const MMConfig& cfg) { + if (!env.autotune) return; const uint64_t t1 = env.have_timer_stop ? hwy::timer::Stop() : hwy::timer::Start(); const double min_elapsed = static_cast(tuner.NotifyTicks(t1 - t0)) / @@ -1217,9 +1305,12 @@ class MMLoops { public: // Called from `MatMul` from two places: either with the next autotune config, // or with the best config. `B2` is null unless called from `TwoMatMul`. - template - static HWY_NOINLINE void Dispatch(const StridedViewBF A, const MatPtrT& B, - const MatPtrT* B2, RowPtrs C, + // `Kernel` is `MMKernel` (BF16) or `MMI8Kernel` (int8); it defines the type + // of `A` and, with `BT`, how a tile is computed. The loops themselves only + // partition the work, hence they are shared between kernels. + template + static HWY_NOINLINE void Dispatch(const typename Kernel::AView A, const BT& B, + const BT* B2, RowPtrs C, const MMArgs& args) { GCPP_ZONE(args.env.ctx, args.env.ctx.Worker(args.options.cluster_idx), Zones::kMMDispatch); @@ -1227,7 +1318,7 @@ class MMLoops { DispatchParallelism( args.options.parallelism, [&](const auto& parallel) HWY_ATTR { DispatchOrder(args.order, [&](const auto& order) HWY_ATTR { - Loop(order, parallel, A, B, B2, C, args); + Loop(order, parallel, A, B, B2, C, args); }); }); } @@ -1240,10 +1331,10 @@ class MMLoops { } // Single M and K ranges, parallel N. - template + template static HWY_INLINE void Loop(MMOrderNT, Parallel parallel, - const StridedViewBF A, const MatPtrT& B, - const MatPtrT* B2, RowPtrs C, + const typename Kernel::AView A, const BT& B, + const BT* B2, RowPtrs C, const MMArgs& args) { const auto zone = args.env.ctx.profiler_zones.Get(Zones::kMMNT); HWY_DASSERT(args.ranges_mc.NumTasks() == 1); @@ -1258,14 +1349,14 @@ class MMLoops { MMZone mm_zone; mm_zone.MaybeEnter(worker, zone, args.env, &args.autotune); - MMKernel::B3A2C0(A, B, range_mc, range_kc, range_nc, args, MMSetC(), + Kernel::B3A2C0(A, B, range_mc, range_kc, range_nc, args, MMSetC(), C.View(0, range_nc.begin(), range_nc.Num())); const StridedViewBF C2 = args.env.C_tiles.C( Extents2D(range_mc.Num(), range_nc.Num()), worker); if (B2 != nullptr) { - MMKernel::B3A2C0(A, *B2, range_mc, range_kc, range_nc, args, + Kernel::B3A2C0(A, *B2, range_mc, range_kc, range_nc, args, MMSetC(), C2); } @@ -1276,10 +1367,10 @@ class MMLoops { } // Single M range, parallel N, sequential K. Sets C, then accumulates. - template + template static HWY_INLINE void Loop(MMOrderNT_K, Parallel parallel, - const StridedViewBF A, const MatPtrT& B, - const MatPtrT* B2, RowPtrs C, + const typename Kernel::AView A, const BT& B, + const BT* B2, RowPtrs C, const MMArgs& args) { const auto zone = args.env.ctx.profiler_zones.Get(Zones::kMMNT_K); HWY_DASSERT(args.ranges_mc.NumTasks() == 1); @@ -1291,7 +1382,7 @@ class MMLoops { [&](const IndexRange& range_nc, size_t worker) HWY_ATTR { MMZone mm_zone; mm_zone.MaybeEnter(worker, zone, args.env, &args.autotune); - MMKernel::ForeachKC( + Kernel::ForeachKC( A, B, range_mc, args.ranges_kc, range_nc, args, C.View(0, range_nc.begin(), range_nc.Num())); @@ -1299,7 +1390,7 @@ class MMLoops { Extents2D(range_mc.Num(), range_nc.Num()), worker); if (B2 != nullptr) { - MMKernel::ForeachKC(A, *B2, range_mc, args.ranges_kc, + Kernel::ForeachKC(A, *B2, range_mc, args.ranges_kc, range_nc, args, C2); } @@ -1312,10 +1403,10 @@ class MMLoops { // Parallel loops over mc/nc blocks of M/range_n, single K. // Fills `mc x nc` sections of C. - template + template static HWY_INLINE void Loop(MMOrderNT_MT, Parallel parallel, - const StridedViewBF A, const MatPtrT& B, - const MatPtrT* B2, RowPtrs C, + const typename Kernel::AView A, const BT& B, + const BT* B2, RowPtrs C, const MMArgs& args) { const auto zone = args.env.ctx.profiler_zones.Get(Zones::kMMNT_MT); HWY_DASSERT(args.ranges_kc.NumTasks() == 1); @@ -1327,7 +1418,7 @@ class MMLoops { size_t worker) HWY_ATTR { MMZone mm_zone; mm_zone.MaybeEnter(worker, zone, args.env, &args.autotune); - MMKernel::B3A2C0( + Kernel::B3A2C0( A, B, range_mc, range_kc, range_nc, args, MMSetC(), C.View(range_mc.begin(), range_nc.begin(), range_nc.Num())); @@ -1335,7 +1426,7 @@ class MMLoops { Extents2D(range_mc.Num(), range_nc.Num()), worker); if (B2 != nullptr) { - MMKernel::B3A2C0(A, *B2, range_mc, range_kc, range_nc, args, + Kernel::B3A2C0(A, *B2, range_mc, range_kc, range_nc, args, MMSetC(), C2); } if constexpr (IsBF16()) { @@ -1346,10 +1437,10 @@ class MMLoops { // Parallel loops over mc/nc blocks of M/range_n, sequential K. // Accumulates into `mc x nc` sections of `C`. - template + template static HWY_INLINE void Loop(MMOrderNT_MT_K, Parallel parallel, - const StridedViewBF A, const MatPtrT& B, - const MatPtrT* B2, RowPtrs C, + const typename Kernel::AView A, const BT& B, + const BT* B2, RowPtrs C, const MMArgs& args) { const auto zone = args.env.ctx.profiler_zones.Get(Zones::kMMNT_MT_K); @@ -1359,7 +1450,7 @@ class MMLoops { size_t worker) HWY_ATTR { MMZone mm_zone; mm_zone.MaybeEnter(worker, zone, args.env, &args.autotune); - MMKernel::ForeachKC( + Kernel::ForeachKC( A, B, range_mc, args.ranges_kc, range_nc, args, C.View(range_mc.begin(), range_nc.begin(), range_nc.Num())); @@ -1367,7 +1458,7 @@ class MMLoops { Extents2D(range_mc.Num(), range_nc.Num()), worker); if (B2 != nullptr) { - MMKernel::ForeachKC(A, *B2, range_mc, args.ranges_kc, range_nc, + Kernel::ForeachKC(A, *B2, range_mc, args.ranges_kc, range_nc, args, C2); } @@ -1378,10 +1469,10 @@ class MMLoops { } // Parallel loops over mc/nc blocks of M/range_n via SFC, single K. - template + template static HWY_INLINE void Loop(MMOrderSFC, Parallel parallel, - const StridedViewBF A, const MatPtrT& B, - const MatPtrT* B2, RowPtrs C, + const typename Kernel::AView A, const BT& B, + const BT* B2, RowPtrs C, const MMArgs& args) { const auto zone = args.env.ctx.profiler_zones.Get(Zones::kMMSFC); HWY_DASSERT(args.ranges_kc.NumTasks() == 1); @@ -1393,7 +1484,7 @@ class MMLoops { size_t worker) HWY_ATTR { MMZone mm_zone; mm_zone.MaybeEnter(worker, zone, args.env, &args.autotune); - MMKernel::B3A2C0( + Kernel::B3A2C0( A, B, range_mc, range_kc, range_nc, args, MMSetC(), C.View(range_mc.begin(), range_nc.begin(), range_nc.Num())); @@ -1401,7 +1492,7 @@ class MMLoops { Extents2D(range_mc.Num(), range_nc.Num()), worker); if (B2 != nullptr) { - MMKernel::B3A2C0(A, *B2, range_mc, range_kc, range_nc, args, + Kernel::B3A2C0(A, *B2, range_mc, range_kc, range_nc, args, MMSetC(), C2); } if constexpr (IsBF16()) { @@ -1411,10 +1502,10 @@ class MMLoops { } // Parallel loops over mc/nc blocks of M/range_n via SFC, sequential K. - template + template static HWY_INLINE void Loop(MMOrderSFC_K, Parallel parallel, - const StridedViewBF A, const MatPtrT& B, - const MatPtrT* B2, RowPtrs C, + const typename Kernel::AView A, const BT& B, + const BT* B2, RowPtrs C, const MMArgs& args) { const auto zone = args.env.ctx.profiler_zones.Get(Zones::kMMSFC_K); @@ -1424,7 +1515,7 @@ class MMLoops { size_t worker) HWY_ATTR { MMZone mm_zone; mm_zone.MaybeEnter(worker, zone, args.env, &args.autotune); - MMKernel::ForeachKC( + Kernel::ForeachKC( A, B, range_mc, args.ranges_kc, range_nc, args, C.View(range_mc.begin(), range_nc.begin(), range_nc.Num())); @@ -1432,7 +1523,7 @@ class MMLoops { Extents2D(range_mc.Num(), range_nc.Num()), worker); if (B2 != nullptr) { - MMKernel::ForeachKC(A, *B2, range_mc, args.ranges_kc, range_nc, + Kernel::ForeachKC(A, *B2, range_mc, args.ranges_kc, range_nc, args, C2); } @@ -1486,7 +1577,8 @@ HWY_NOINLINE MMPerKey* MatMul(const MatPtrT& A, const MatPtrT& B, // BRGeMM path for BF16×BF16 on Intel AMX/AVX-512. // Requires M,N,K >= 32 and K % 32 == 0 (AMX tile constraint). if constexpr (IsBF16() && IsBF16()) { - if (M >= 32 && N >= 32 && K >= 32 && (K % 32) == 0) { + if (!MMDecompress::ActivationI8RoundtripEnabled() && M >= 32 && N >= 32 && + K >= 32 && (K % 32) == 0) { const float scale = A.Scale() * B.Scale(); MMAutoTune& brg_tuner = per_key.brgemm_autotune; @@ -1499,7 +1591,7 @@ HWY_NOINLINE MMPerKey* MatMul(const MatPtrT& A, const MatPtrT& B, } if (HWY_UNLIKELY(!brg_tuner.HasCandidates())) { - brg_tuner.SetCandidates(BRGeMMCandidates(M, K, N)); + brg_tuner.SetCandidates(BRGeMMCandidates(M, K, N), env.autotune); } const BRGeMMConfig& cfg = brg_tuner.NextConfig(); @@ -1528,7 +1620,7 @@ HWY_NOINLINE MMPerKey* MatMul(const MatPtrT& A, const MatPtrT& B, // OneDNN matmul-primitive path for BF16xBF16 via the threadpool runtime. // M == 1 was showing worse performance with OneDNN. if constexpr (IsBF16() && IsBF16()) { - if (M > 1) { + if (!MMDecompress::ActivationI8RoundtripEnabled() && M > 1) { const float scale = A.Scale() * B.Scale(); if (DoMatMul_OneDnn(A, B, C_rows, M, K, N, scale, add, env, cluster_idx)) { @@ -1550,7 +1642,7 @@ HWY_NOINLINE MMPerKey* MatMul(const MatPtrT& A, const MatPtrT& B, if (HWY_LIKELY(tuner.Best())) { const MMArgs args(env, M, K, N, A.Scale(), add, options, tuner, *tuner.Best()); - MMLoops::Dispatch(A_view, B, B2, C_rows, args); + MMLoops::Dispatch(A_view, B, B2, C_rows, args); return &per_key; } @@ -1563,14 +1655,15 @@ HWY_NOINLINE MMPerKey* MatMul(const MatPtrT& A, const MatPtrT& B, HWY_ASSERT(N % kNR == 0); MMImpl::EnsureAligned(A, cache.VectorBytes()); tuner.SetCandidates( - MMCandidates(cache, M, K, N, num_B, sizeof(TC), env.print_config)); + MMCandidates(cache, M, K, N, num_B, sizeof(TC), env.print_config), + env.autotune); } const MMConfig& cfg = tuner.NextConfig(); const MMArgs args(env, M, K, N, A.Scale(), add, options, tuner, cfg); const uint64_t t0 = hwy::timer::Start(); - MMLoops::Dispatch(A_view, B, B2, C_rows, args); + MMLoops::Dispatch(A_view, B, B2, C_rows, args); MMImpl::NotifyAutotuneResult(env, M, K, N, num_B, t0, tuner, cfg); return &per_key; @@ -1603,14 +1696,14 @@ HWY_NOINLINE MMPerKey* TwoMatMul(const MatPtrT& A, const MatPtrT& B1, M, K, N, num_B, cache.VectorBytes(), env.per_cluster[cluster_idx]); // (Also auto-tunes, hence outside the timed section to prevent interference.) - const StridedViewBF A_view(A, 0, 0, A.Cols()); + const StridedViewBF A_view = MMDecompress::MaybeRoundtripA(A, env); MMAutoTune& tuner = per_key.autotune; if (HWY_LIKELY(tuner.Best())) { // Only A scale - B1/B2 may differ, and are passed separately. const MMArgs args(env, M, K, N, A.Scale(), /*add=*/nullptr, options, tuner, *tuner.Best()); - MMLoops::Dispatch(A_view, B1, &B2, C_rows, args); + MMLoops::Dispatch(A_view, B1, &B2, C_rows, args); return &per_key; } @@ -1624,8 +1717,9 @@ HWY_NOINLINE MMPerKey* TwoMatMul(const MatPtrT& A, const MatPtrT& B1, HWY_ASSERT(N % kNR == 0); MMImpl::EnsureAligned(A, cache.VectorBytes()); const size_t max_M = MMKeys::BucketM(M); - tuner.SetCandidates(MMCandidates(cache, max_M, K, N, num_B, sizeof(BF16), - env.print_config)); + tuner.SetCandidates( + MMCandidates(cache, max_M, K, N, num_B, sizeof(BF16), env.print_config), + env.autotune); } const MMConfig& cfg = tuner.NextConfig(); @@ -1634,7 +1728,7 @@ HWY_NOINLINE MMPerKey* TwoMatMul(const MatPtrT& A, const MatPtrT& B1, cfg); const uint64_t t0 = hwy::timer::Start(); - MMLoops::Dispatch(A_view, B1, &B2, C_rows, args); + MMLoops::Dispatch(A_view, B1, &B2, C_rows, args); MMImpl::NotifyAutotuneResult(env, M, K, N, num_B, t0, tuner, cfg); return &per_key; diff --git a/ops/matmul.h b/ops/matmul.h index 9c2ab8c7..c9dd630d 100644 --- a/ops/matmul.h +++ b/ops/matmul.h @@ -569,21 +569,24 @@ class MMAutoTune { HWY_DASSERT(!Best()); return !candidates_.empty(); } - void SetCandidates(std::vector candidates) { + void SetCandidates(std::vector candidates, bool tune = true) { HWY_DASSERT(!HasCandidates()); candidates_.swap(candidates); HWY_DASSERT(HasCandidates()); min_ticks_.resize(candidates_.size(), ~uint64_t{0}); + fixed_ = !tune; + if (fixed_) best_ = &candidates_.front(); } // Returns the current `TConfig` to measure. const TConfig& NextConfig() const { - HWY_DASSERT(!Best() && HasCandidates()); + HWY_DASSERT(fixed_ || (!Best() && HasCandidates())); return candidates_[config_idx_]; } // Returns the best ticks so far for this candidate. Negligible CPU time. uint64_t NotifyTicks(uint64_t ticks) { + if (fixed_) return ticks; HWY_DASSERT(HasCandidates()); HWY_DASSERT(!skipped_.Get(config_idx_)); @@ -641,6 +644,7 @@ class MMAutoTune { uint64_t FirstConfigTicks() const { return min_ticks_[0]; } private: + bool fixed_ = false; const TConfig* best_ = nullptr; std::vector candidates_; // Use Min because threads are pinned, so we only expect additive noise. @@ -654,6 +658,8 @@ class MMAutoTune { //------------------------------------------------------------------------------ +enum class MMActivation : uint8_t { kBF16, kI8, kI8Block }; + // Map of previously seen dimensions to index via linear search. class MMKeys { public: @@ -676,14 +682,16 @@ class MMKeys { } // Compresses the dimensions into a single Key for faster comparison. - static Key KeyFromDims(size_t M, size_t K, size_t N, size_t num_B) { + static Key KeyFromDims(size_t M, size_t K, size_t N, size_t num_B, + MMActivation activation = MMActivation::kBF16) { HWY_DASSERT(M < (Key{1} << 16)); // batch sizes are smaller HWY_DASSERT(K < (Key{1} << 20)); HWY_DASSERT(N < (Key{1} << 20)); HWY_DASSERT(num_B == 1 || num_B == 2); const Key key = static_cast(BucketM(M)) | (static_cast(K) << 16) | (static_cast(N) << 40) | - (static_cast(num_B) << 60); + (static_cast(num_B) << 60) | + (static_cast(activation) << 36); HWY_DASSERT(key != kPadding); return key; } @@ -747,6 +755,10 @@ struct MatMulEnv { ThreadingContext& ctx; bool have_timer_stop = false; + // Disable before the first MatMul for reproducible evaluation. + bool autotune = true; + // Lazy experimental weight preparation, excluded from inference timing. + double weight_prepare_seconds = 0.0; // Whether `MMCandidates()` should print the set of parameters. bool print_config = false; diff --git a/ops/matmul_i8-inl.h b/ops/matmul_i8-inl.h new file mode 100644 index 00000000..7c4832f1 --- /dev/null +++ b/ops/matmul_i8-inl.h @@ -0,0 +1,1751 @@ +// Copyright 2025 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// W8A8 MatMul: symmetric int8 weights times symmetric int8 activations, +// accumulating in int32 via the 4-way dot product (`vpdpbusd` on x86 VNNI, +// `sdot`/`usdot` on NEON, `svdot` on SVE). Unlike `MatMul`, which dequantizes +// `B` to BF16 for every tile (see `MMDecompress::DecompressB`), `B` is +// consumed as-is. +// +// Quantization scheme (see `#560` discussion): per-row (per-token) scales for +// `A`, computed on the fly, and per-row-of-transposed-B (per output channel) +// scales baked in at pack time. Both are symmetric, i.e. no zero point, so +// `C[r, c] = a_scale[r] * b_scale[c] * dot(qa[r], qb[c])` and the int32 +// accumulation can run over an entire `kc` range before a single scaling step. +// Optional microscaling instead stores one A/B scale per rotation block and +// accumulates dequantized block dot products in F32. +// +// On x86 the 4-way dot product requires one unsigned operand, so `B` is biased +// by 128 and the `128 * sum_k(qa)` term is subtracted per `kc` range, using +// prefix sums of the quantized `A`. Biasing `B` rather than `A` is what makes +// that per-range correction cheap: the correction then depends on `A`, which is +// small and quantized per call anyway, instead of on `B`. It also keeps the +// values written to `C` close to the true partial sums; correcting once over +// the whole `K` would inflate the intermediates that `MMAddC` accumulates +// through `C`, which loses a lot of precision when `C` is BF16 and the weight +// channels are not zero-mean. + +#include +#include +#include + +#include + +#include "hwy/base.h" +#include "ops/matmul.h" // IWYU pragma: export +#include "util/basics.h" +#include "util/mat.h" + +// Include guard for (potentially) SIMD code. +#if defined(THIRD_PARTY_GEMMA_CPP_MATMUL_I8_TOGGLE) == \ + defined(HWY_TARGET_TOGGLE) +#ifdef THIRD_PARTY_GEMMA_CPP_MATMUL_I8_TOGGLE +#undef THIRD_PARTY_GEMMA_CPP_MATMUL_I8_TOGGLE +#else +#define THIRD_PARTY_GEMMA_CPP_MATMUL_I8_TOGGLE +#endif + +#include "hwy/highway.h" +// After highway.h +#include "compression/compress-inl.h" +#include "ops/matmul-inl.h" + +// `SumOfMulQuadAccumulate` is native for i8*i8 on NEON with `FEAT_DotProd` +// and on SVE, but on x86 only for u8*i8 (`vpdpbusd`); there, i8*i8 costs two +// VNNI ops plus a shift and subtract, which would give up most of the win. +// Hence bias `B` by 128 into u8 on x86, and correct for it via `A`. +// Define `GEMMA_MM_I8_FORCE_BIASED_B` to 0 or 1 to exercise either encoding +// regardless of target; both are correct everywhere, only the speed differs. +// `ops/matmul_i8_test.cc` is built twice, once each way. +#undef GEMMA_MM_I8_BIASED_B +#ifdef GEMMA_MM_I8_FORCE_BIASED_B +#define GEMMA_MM_I8_BIASED_B GEMMA_MM_I8_FORCE_BIASED_B +#elif HWY_TARGET <= HWY_AVX2 +#define GEMMA_MM_I8_BIASED_B 1 +#else +#define GEMMA_MM_I8_BIASED_B 0 +#endif + +HWY_BEFORE_NAMESPACE(); +namespace gcpp { +namespace HWY_NAMESPACE { +namespace hn = hwy::HWY_NAMESPACE; + +// `A` is always symmetric int8; only `B`'s encoding varies by target, see +// `GEMMA_MM_I8_BIASED_B`. +using MMI8AT = int8_t; +#if GEMMA_MM_I8_BIASED_B +using MMI8BT = uint8_t; +#else +using MMI8BT = int8_t; +#endif + +// Largest quantized magnitude. 127 rather than 128 keeps the scheme symmetric, +// which is what lets us skip the zero-point correction terms. +HWY_INLINE_VAR constexpr float kMMI8Max = 127.0f; + +// Experimental QuaRot-style preprocessing. Applying the same orthonormal +// transform to A and each row of transposed B leaves their dot product +// unchanged, while spreading isolated activation outliers over a block. The +// fixed signs avoid always applying the same Hadamard basis to every block. +// The defaults preserve the issue #1002 reference configuration; the +// alternatives are selected per process for isolated ablations. +HWY_INLINE_VAR constexpr size_t kMMI8DefaultRotateBlock = 128; +HWY_INLINE_VAR constexpr size_t kMMI8DefaultHashBits = 32; + +static inline bool MMI8Flag(const char* name, bool fallback = false) { + const char* value = getenv(name); + return value == nullptr ? fallback : atoi(value) != 0; +} + +static inline bool MMI8FastRotate() { + static const bool enabled = MMI8Flag("GEMMA_MM_I8_FAST_ROTATE", true); + return enabled; +} + +static inline bool MMI8NativeVNNI() { +#if HWY_TARGET == HWY_AVX2 && GEMMA_MM_I8_BIASED_B && defined(__GNUC__) && \ + !defined(__clang__) + static const bool enabled = + MMI8Flag("GEMMA_MM_I8_VNNI", true) && __builtin_cpu_supports("avxvnni"); + return enabled; +#else + return false; +#endif +} + +template +static HWY_INLINE void MMI8Dot4(DI32 di32, VA a, VB b0, VB b1, VB b2, VB b3, + VI& c0, VI& c1, VI& c2, VI& c3) { +#if HWY_TARGET == HWY_AVX2 && GEMMA_MM_I8_BIASED_B && defined(__GNUC__) && \ + !defined(__clang__) + if constexpr (kNative) { + if constexpr (kCompact && HWY_ARCH_X86_64) { + // Nine distinct registers are required; x86-32 has only eight. + // Keep all four B vectors live across the outputs. Separate asm blocks + // let the allocator repeatedly recycle accumulators for B loads when + // the microscale F32 accumulators also occupy registers. + asm("%{vex%} vpdpbusd %[a], %[b0], %[c0]\n\t" + "%{vex%} vpdpbusd %[a], %[b1], %[c1]\n\t" + "%{vex%} vpdpbusd %[a], %[b2], %[c2]\n\t" + "%{vex%} vpdpbusd %[a], %[b3], %[c3]" + : [c0] "+&x"(c0.raw), [c1] "+&x"(c1.raw), [c2] "+&x"(c2.raw), + [c3] "+&x"(c3.raw) + : [a] "x"(a.raw), [b0] "x"(b0.raw), [b1] "x"(b1.raw), + [b2] "x"(b2.raw), [b3] "x"(b3.raw)); + return; + } + // VEX encoding: AVX-VNNI is available without AVX-512. Dispatch per tile. + asm("%{vex%} vpdpbusd %[a], %[b], %[c]" + : [c] "+x"(c0.raw) + : [a] "x"(a.raw), [b] "x"(b0.raw)); + asm("%{vex%} vpdpbusd %[a], %[b], %[c]" + : [c] "+x"(c1.raw) + : [a] "x"(a.raw), [b] "x"(b1.raw)); + asm("%{vex%} vpdpbusd %[a], %[b], %[c]" + : [c] "+x"(c2.raw) + : [a] "x"(a.raw), [b] "x"(b2.raw)); + asm("%{vex%} vpdpbusd %[a], %[b], %[c]" + : [c] "+x"(c3.raw) + : [a] "x"(a.raw), [b] "x"(b3.raw)); + return; + } +#endif + MMQuantizedDot4Accumulate(di32, a, b0, b1, b2, b3, c0, + c1, c2, c3); +} + +static inline size_t MMI8EnvChoice(const char* name, size_t fallback, + size_t alternative) { + const char* value = getenv(name); + if (value == nullptr || *value == '\0') return fallback; + const size_t parsed = static_cast(strtoull(value, nullptr, 10)); + return parsed == alternative ? alternative : fallback; +} + +static inline size_t MMI8RotateBlockSize() { + static const size_t block = + MMI8EnvChoice("GEMMA_MM_I8_BLOCK_SIZE", kMMI8DefaultRotateBlock, 64); + return block; +} + +static inline size_t MMI8HashBits() { + static const size_t bits = + MMI8EnvChoice("GEMMA_MM_I8_HASH_BITS", kMMI8DefaultHashBits, 16); + return bits; +} + +// Optional local quantization scales, independent of the rotation block. +// Smaller groups limit outlier influence; larger groups reduce kernel overhead. +static inline size_t MMI8QuantBlockSize() { + static const bool enabled = MMI8Flag("GEMMA_MM_I8_MICROSCALE"); + static const size_t block = []() { + const char* value = getenv("GEMMA_MM_I8_QUANT_BLOCK_SIZE"); + const size_t requested = value ? strtoull(value, nullptr, 10) : 0; + return requested == 32 || requested == 64 || requested == 128 + ? requested + : MMI8RotateBlockSize(); + }(); + return enabled ? block : 0; +} + +static inline bool MMI8FastMicro() { + static const bool enabled = MMI8Flag("GEMMA_MM_I8_FAST_MICRO", true); + return enabled; +} + +// A bijective mixer over uint16_t. Thus the full 65536-value sequence has no +// collisions and its high bit is exactly balanced. Only that high bit is used +// as the Rademacher sign. +static HWY_INLINE uint16_t MMI8Hash16(uint16_t value) { + value = static_cast(value + 0x9E37u); + value ^= static_cast(value >> 7); + value = static_cast(value * 0x85EBu); + value ^= static_cast(value >> 9); + value = static_cast(value * 0xC2B3u); + value ^= static_cast(value >> 8); + return value; +} + +static HWY_INLINE bool MMI8NegativeSign(size_t position, size_t hash_bits) { + if (hash_bits == 16) { + return (MMI8Hash16(static_cast(position)) >> 15) != 0; + } + const uint32_t hash = + static_cast(position) * 0x9E3779B9u + 0x7F4A7C15u; + return (hash >> 31) != 0; +} + +template +static HWY_NOINLINE void MMI8RotateFixed(float* HWY_RESTRICT row, size_t k, + size_t hash_bits) { + HWY_DASSERT(block_size == 64 || block_size == 128); + HWY_DASSERT(hash_bits == 16 || hash_bits == 32); + HWY_DASSERT((k % block_size) == 0); + const float normalize = block_size == 64 ? 0.125f : 0.08838834764831845f; + const hn::CappedTag df; + const hn::Rebind du; + const size_t lanes = hn::Lanes(df); + const bool fast = MMI8FastRotate(); + thread_local hwy::AlignedVector signs; + thread_local size_t cached_hash = 0; + if (fast && (signs.size() < k || cached_hash != hash_bits)) { + signs.resize(k); + for (size_t i = 0; i < k; ++i) { + signs[i] = MMI8NegativeSign(i, hash_bits) ? 0x80000000u : 0u; + } + cached_hash = hash_bits; + } + for (size_t block = 0; block < k; block += block_size) { + float* HWY_RESTRICT x = row + block; + for (size_t i = 0; !fast && i < block_size; ++i) { + // Deterministic Rademacher diagonal, shared by A and B. + if (MMI8NegativeSign(block + i, hash_bits)) x[i] = -x[i]; + } + // Complete stages smaller than a SIMD register with lane butterflies. + // Select left/right first so subtraction has exactly the scalar order. + if (fast) { + const auto lane = hn::Iota(du, 0); + for (size_t i = 0; i < block_size; i += lanes) { + // Apply the signs while loading the first butterfly stage. + auto v = hn::BitCast( + df, hn::Xor(hn::BitCast(du, hn::LoadU(df, x + i)), + hn::LoadU(du, signs.data() + block + i))); + for (size_t width = 1; width < lanes; width *= 2) { + const auto bit = hn::Set(du, static_cast(width)); + const auto perm = hn::IndicesFromVec(df, hn::Xor(lane, bit)); + const auto other = hn::TableLookupLanes(v, perm); + const auto upper = + hn::RebindMask(df, hn::Ne(hn::And(lane, bit), hn::Zero(du))); + const auto left = hn::IfThenElse(upper, other, v); + const auto right = hn::IfThenElse(upper, v, other); + v = hn::IfThenElse(upper, hn::Sub(left, right), hn::Add(left, right)); + } + hn::StoreU(v, df, x + i); + } + } + const size_t end_width = fast ? block_size / 2 : block_size; + for (size_t width = fast ? lanes : 1; width < end_width; width *= 2) { + for (size_t start = 0; start < block_size; start += 2 * width) { + size_t i = 0; + for (; fast && i + lanes <= width; i += lanes) { + const auto left = hn::LoadU(df, x + start + i); + const auto right = hn::LoadU(df, x + start + width + i); + hn::StoreU(hn::Add(left, right), df, x + start + i); + hn::StoreU(hn::Sub(left, right), df, x + start + width + i); + } + for (; i < width; ++i) { + const float left = x[start + i]; + const float right = x[start + width + i]; + x[start + i] = left + right; + x[start + width + i] = left - right; + } + } + } + if (fast) { + // Normalize the final butterfly outputs before storing them, preserving + // the original add/subtract-then-multiply order without another pass. + constexpr size_t half = block_size / 2; + const auto vnormalize = hn::Set(df, normalize); + for (size_t i = 0; i < half; i += lanes) { + const auto left = hn::LoadU(df, x + i); + const auto right = hn::LoadU(df, x + half + i); + hn::StoreU(hn::Mul(hn::Add(left, right), vnormalize), df, x + i); + hn::StoreU(hn::Mul(hn::Sub(left, right), vnormalize), df, + x + half + i); + } + } else { + for (size_t i = 0; i < block_size; ++i) x[i] *= normalize; + } + } +} + +static HWY_NOINLINE void MMI8Rotate(float* HWY_RESTRICT row, size_t k, + size_t block_size, size_t hash_bits) { + HWY_ASSERT(block_size == 64 || block_size == 128); + if (block_size == 64) + MMI8RotateFixed<64>(row, k, hash_bits); + else + MMI8RotateFixed<128>(row, k, hash_bits); +} + +static HWY_NOINLINE void MMI8Rotate(float* HWY_RESTRICT row, size_t k) { + MMI8Rotate(row, k, MMI8RotateBlockSize(), MMI8HashBits()); +} + +// Data-free L2 equalization for an FFN's multiplicative up projection and +// down projection. If hidden activations are multiplied by this value and the +// corresponding down-projection input column is divided by it, the +// full-precision computation is unchanged. Bounds prevent zero/tiny norms or +// unusually imbalanced channels from creating extreme values. +HWY_INLINE_VAR constexpr double kMMI8L2NormFloor = 1E-12; +HWY_INLINE_VAR constexpr float kMMI8L2ScaleMin = 1.0f / 16.0f; +HWY_INLINE_VAR constexpr float kMMI8L2ScaleMax = 16.0f; + +static HWY_INLINE float MMI8L2Scale(double up_l2, double down_l2, + bool* clamped = nullptr) { + const double safe_up = HWY_MAX(up_l2, kMMI8L2NormFloor); + const double safe_down = HWY_MAX(down_l2, kMMI8L2NormFloor); + const float raw = static_cast(std::sqrt(safe_down / safe_up)); + const float scale = HWY_MIN(kMMI8L2ScaleMax, HWY_MAX(kMMI8L2ScaleMin, raw)); + if (clamped != nullptr) *clamped = scale != raw; + return scale; +} + +//------------------------------------------------------------------------------ +// Quantized operands + +// View into quantized `A`, analogous to `StridedViewBF` but carrying the +// per-row scales (and, when `B` is biased, prefix sums along `K`) alongside, +// because `MMLoops` passes only this one object down to the kernel. +struct MMI8AView { + // Returns 2D subrange whose top-left is `r, c`, as `StridedView::View`. + // Only called on the whole-matrix view, hence the offsets do not compound. + MMI8AView View(size_t r, size_t c, size_t cols) const { + return ViewGroup(r, c, cols, block_size ? c / block_size : 0); + } + + // As View, with an already-known quantization group to avoid division in + // the microscaling kernel's group loop. + MMI8AView ViewGroup(size_t r, size_t c, size_t cols, size_t group) const { + return MMI8AView{ + data.View(r, c, cols), + scale + r + group * scale_stride, + prefix + r * prefix_stride + c, + prefix_stride, + scale_stride, + block_size}; + } + + // Sum of the quantized values of row `r` over the `cols` columns of this + // view. `prefix` has `K + 1` entries per row, so this is exact for any range. + int32_t RowSum(size_t r, size_t cols) const { + const int32_t* HWY_RESTRICT p = prefix + r * prefix_stride; + return p[cols] - p[0]; + } + + StridedView data{nullptr, 0, 0}; + const float* HWY_RESTRICT scale; // one per row of `data` + const int32_t* HWY_RESTRICT prefix; // null unless `GEMMA_MM_I8_BIASED_B` + size_t prefix_stride; + size_t scale_stride = 0; // group-major scales, one column per activation row + size_t block_size = 0; + // Optional second quantization of the first stream's reconstruction error. + // Set only on the whole-matrix view; its lifetime covers MMLoops::Dispatch. + const MMI8AView* residual = nullptr; +}; + +// Transposed, symmetric-int8 `B`: `N` rows of `K` values each, so that a +// row of `B` is contiguous along `K` and thus already in the layout the 4-way +// dot product wants. The stored bytes are `q + 128` if `GEMMA_MM_I8_BIASED_B`, +// else `q`; the buffer is typed `int8_t` either way and reinterpreted in the +// kernel. Production would pick one encoding for the on-disk format rather +// than deriving it from the target. +struct MMI8B { + size_t Rows() const { return data->Rows(); } + size_t Cols() const { return data->Cols(); } + + const MatPtrT* data; + const float* HWY_RESTRICT scale; // [N] dequantization scale + // Optional per-K multiplier applied to A before rotation. The packed B has + // already been divided by the same values, preserving the dot product. + const float* HWY_RESTRICT a_pre_scale = nullptr; + size_t block_size = 0; // scale[g * Rows() + row], or one scale per row + const float* HWY_RESTRICT bias = nullptr; // optional calibrated output bias + // Eight output channels interleaved in four-K chunks. Each packed tile + // starts at data->Row(tile_row), preserving the allocation and its padding. + // Individual data->Row(r) values are no longer ordinary weight rows. + bool packed_micro = false; + bool dual_a = false; // two activation streams with shared weights +}; + +static inline bool MMI8UseDualA(const MMI8B& B, size_t m) { + static const bool m1_only = MMI8Flag("GEMMA_MM_I8_DUAL_A_M1_ONLY"); + return B.dual_a && B.packed_micro && B.block_size != 0 && + (!m1_only || m == 1); +} + +// Existing model bias takes precedence. This experimental correction currently +// applies only to otherwise bias-free MatMuls, including each fused FFN branch. +static HWY_INLINE const float* MMI8Bias(const MMI8B& B, const float* add, + size_t row_b) { + const float* base = add != nullptr ? add : B.bias; + return base != nullptr ? base + row_b : nullptr; +} + +// Restrict automatic packing to the measured AVX2/VNNI path. A packed B is +// always interpreted by its layout flag, even if a kernel control is disabled. +static inline bool MMI8PackedHead() { + static const bool enabled = MMI8Flag("GEMMA_MM_I8_PACKED_HEAD"); + return enabled && MMI8FastMicro() && MMI8NativeVNNI(); +} + +// A continuous head scan can improve bandwidth. Keep this independent from +// transformer scheduling: only a single-token, packed head with F32 output is +// eligible, because a changed KC boundary changes floating-point sum order. +static inline bool MMI8PreferFullHeadK(const MMI8B& B, size_t m, + bool f32_output) { + static const bool enabled = MMI8Flag("GEMMA_MM_I8_PACKED_HEAD_FULL_K"); + return enabled && B.packed_micro && B.Rows() >= 65536 && m == 1 && f32_output; +} + +// In-place N8 x K4 transpose, using only one tile of temporary storage. The +// original eight-row padding follows the packed bytes at the end of the tile. +// Call only after every quantized weight and calibration update is final. +static HWY_NOINLINE void MMI8PackMicroB(MatPtrT& data) { + const size_t k = data.Cols(); + HWY_ASSERT(data.Rows() % 8 == 0 && k % 4 == 0); + hwy::AlignedVector tile(8 * k); + for (size_t r = 0; r < data.Rows(); r += 8) { + for (size_t c = 0; c < k; c += 4) { + for (size_t n = 0; n < 8; ++n) { + hwy::CopyBytes<4>(data.Row(r + n) + c, tile.data() + 8 * c + 4 * n); + } + } + hwy::CopyBytes(tile.data(), data.Row(r), tile.size()); + } +} + +//------------------------------------------------------------------------------ +// Reduction and store + +// Like `MMStoreHorizontalSumsIntoC`, but the tile accumulators are int32 and +// the scale is a per-row times per-column outer product rather than a scalar. +template +class MMI8StoreHorizontalSumsIntoC { + public: + static_assert(kNR == 4); // for `StoreInterleaved4` + + // Horizontal sums of the 16 (`kRowsAC x kNR`) int32 accumulators, using the + // same vector-length-agnostic transpose as the BF16 kernel. Valid because + // the 4-way dot product, like BF16's pairwise add, only permutes the terms + // of each dot product and thus preserves the horizontal sum. + template , + class D4 = hn::Full128, class V4 = hn::Vec> + HWY_INLINE void Reduce4x4(DI32 di32, // + VI32 C00, VI32 C01, VI32 C02, VI32 C03, // + VI32 C10, VI32 C11, VI32 C12, VI32 C13, // + VI32 C20, VI32 C21, VI32 C22, VI32 C23, // + VI32 C30, VI32 C31, VI32 C32, VI32 C33, // + V4& sum0, V4& sum1, V4& sum2, V4& sum3) { +#if HWY_TARGET == HWY_AVX2 + if constexpr (kFast) { + // Two pairwise additions produce the four column sums within each + // 128-bit half; one final add combines halves, without a stack transpose. + const D4 d4; + const auto reduce = [&](VI32 c0, VI32 c1, VI32 c2, VI32 c3) HWY_ATTR { + const auto pairs = + hn::PairwiseAdd128(di32, hn::PairwiseAdd128(di32, c0, c1), + hn::PairwiseAdd128(di32, c2, c3)); + return hn::Add(hn::LowerHalf(d4, pairs), hn::UpperHalf(d4, pairs)); + }; + sum0 = reduce(C00, C01, C02, C03); + if constexpr (kRowsAC > 1) sum1 = reduce(C10, C11, C12, C13); + if constexpr (kRowsAC > 2) sum2 = reduce(C20, C21, C22, C23); + if constexpr (kRowsAC > 3) sum3 = reduce(C30, C31, C32, C33); + return; + } +#endif + HWY_ALIGN int32_t buf[16 * hn::MaxLanes(di32)]; + HWY_LANES_CONSTEXPR const size_t N = hn::Lanes(di32); + + MaybeStoreInterleaved4<0>(di32, N, C00, C01, C02, C03, buf); + MaybeStoreInterleaved4<1>(di32, N, C10, C11, C12, C13, buf); + MaybeStoreInterleaved4<2>(di32, N, C20, C21, C22, C23, buf); + MaybeStoreInterleaved4<3>(di32, N, C30, C31, C32, C33, buf); + + const D4 d4; + sum0 = MaybeLoad<0>(d4, N, buf); + sum1 = MaybeLoad<1>(d4, N, buf); + sum2 = MaybeLoad<2>(d4, N, buf); + sum3 = MaybeLoad<3>(d4, N, buf); + + for (size_t lane = 1; lane < N; ++lane) { + sum0 = MaybeAdd<0>(d4, N, sum0, buf + kNR * lane); + sum1 = MaybeAdd<1>(d4, N, sum1, buf + kNR * lane); + sum2 = MaybeAdd<2>(d4, N, sum2, buf + kNR * lane); + sum3 = MaybeAdd<3>(d4, N, sum3, buf + kNR * lane); + } + } + + // Dequantizes the four 4-wide int32 dot products and stores them to `C`. + // `b_scale` points to the `kNR` current columns and `a_scale` to the current + // `range_mc` (hence indexed by `imc + kRow`), whereas `a_rowsum` holds just + // this tile's `kRowsAC` values and is indexed by `kRow` alone. It is the sum + // of the quantized `A` values over this `kc` range, which undoes `B`'s 128 + // bias, and is unused when `B` is not biased. + template , class Tag, class CView> + HWY_INLINE void Store(D4I d4i, V4I sum0, V4I sum1, V4I sum2, V4I sum3, + const float* HWY_RESTRICT a_scale, + const int32_t* HWY_RESTRICT a_rowsum, + const float* HWY_RESTRICT b_scale, + const float* HWY_RESTRICT add, const size_t imc, + Tag tag, CView C_MC_NR) const { + const hn::Full128 d4; + using V4F = hn::Vec; + + const V4F vb_scale = hn::LoadU(d4, b_scale); + HWY_ALIGN static constexpr float kZero[4] = {}; + const V4F vadd = hn::Load(d4, add ? add : kZero); + + // Each term is `(qb + 128) * qa` instead of `qb * qa`, hence subtract + // `128 * sum_k(qa)` over this `kc` range. Applied on every visit, so the + // values written to `C` stay close to the true partial sums. + MaybeScaleAndStore<0>(d4i, d4, sum0, a_rowsum, vb_scale, vadd, a_scale, tag, + imc, C_MC_NR); + MaybeScaleAndStore<1>(d4i, d4, sum1, a_rowsum, vb_scale, vadd, a_scale, tag, + imc, C_MC_NR); + MaybeScaleAndStore<2>(d4i, d4, sum2, a_rowsum, vb_scale, vadd, a_scale, tag, + imc, C_MC_NR); + MaybeScaleAndStore<3>(d4i, d4, sum3, a_rowsum, vb_scale, vadd, a_scale, tag, + imc, C_MC_NR); + } + + private: + template > + static HWY_INLINE void MaybeStoreInterleaved4(DI32 di32, size_t N, VI32 Cr0, + VI32 Cr1, VI32 Cr2, VI32 Cr3, + int32_t* HWY_RESTRICT buf) { + if constexpr (kRow < kRowsAC) { + hn::StoreInterleaved4(Cr0, Cr1, Cr2, Cr3, di32, buf + 4 * kRow * N); + } + } + + template > + static HWY_INLINE V4I MaybeLoad(D4I d4i, size_t N, + const int32_t* HWY_RESTRICT buf) { + if constexpr (kRow < kRowsAC) { + return hn::Load(d4i, buf + 4 * kRow * N); + } else { + return hn::Zero(d4i); + } + } + + template > + static HWY_INLINE V4I MaybeAdd(D4I d4i, size_t N, V4I sum, + const int32_t* HWY_RESTRICT buf) { + if constexpr (kRow < kRowsAC) { + return hn::Add(sum, hn::Load(d4i, buf + 4 * kRow * N)); + } else { + return sum; + } + } + + template , + class D4F, class V4F = hn::Vec, class Tag, class CView> + static HWY_INLINE void MaybeScaleAndStore( + D4I d4i, D4F d4, V4I sum, const int32_t* HWY_RESTRICT a_rowsum, + V4F vb_scale, V4F vadd, const float* HWY_RESTRICT a_scale, Tag, + const size_t imc, CView C_MC_NR) { + if constexpr (kRow < kRowsAC) { + using TC = hwy::RemoveCvRef; + TC* HWY_RESTRICT pos = C_MC_NR.Row(imc + kRow); + const hn::Rebind dc4; + + const V4F vscale = hn::Mul(vb_scale, hn::Set(d4, a_scale[imc + kRow])); + if constexpr (GEMMA_MM_I8_BIASED_B) { + sum = hn::Sub(sum, + hn::Set(d4i, static_cast(a_rowsum[kRow] * 128))); + } + const V4F dot = hn::ConvertTo(d4, sum); + + if constexpr (hwy::IsSame()) { + vadd = F32FromTC(dc4, hn::Load(dc4, pos)); // load prior value + } else { + static_assert(hwy::IsSame()); + // vadd remains the bias (added once, the first time we store to C) + } + const V4F out = hn::MulAdd(dot, vscale, vadd); + hn::Store(TCFromF32(dc4, out), dc4, pos); + } + } +}; // MMI8StoreHorizontalSumsIntoC + +//------------------------------------------------------------------------------ +// Kernel + +// Drop-in replacement for `MMKernel` (same `B3A2C0`/`ForeachKC` interface, so +// that `MMLoops` can drive either), but with int8 operands. +class MMI8Kernel { + public: + using AView = MMI8AView; + + template + static void B3A2C0(const AView A, const BT& B, const IndexRange& range_mc, + const IndexRange& range_kc, const IndexRange& range_nc, + const MMArgs& args, Tag out_tag, CView C_MC_NC) { + if (B.packed_micro) { + PackedMicroB3A2C0(A, B, range_mc, range_kc, range_nc, args, out_tag, + C_MC_NC); + return; + } + if (B.block_size != 0) { + if (MMI8FastMicro()) { + MicroB3A2C0(A, B, range_mc, range_kc, range_nc, args, out_tag, + C_MC_NC); + return; + } + // Accumulate group results in F32, rounding to BF16 only at the KC + // boundary. Adding BF16 partials per tiny group loses too much accuracy. + thread_local hwy::AlignedVector sums; + sums.resize(range_mc.Num() * kNR); + const StridedView tmp(sums.data(), kNR, kNR); + for (size_t inc = 0; inc < range_nc.Num(); inc += kNR) { + const size_t row_b = range_nc.begin() + inc; + const float* add = MMI8Bias(B, args.add, row_b); + bool first = true; + for (size_t c = range_kc.begin(); c < range_kc.end();) { + const size_t group = c / B.block_size; + const size_t count = HWY_MIN(static_cast(range_kc.end()), + (group + 1) * B.block_size) - + c; + const auto av = A.View(range_mc.begin(), c, count); + const StridedView bv(*B.data, row_b, c, count); + const float* scales = B.scale + group * B.Rows() + row_b; + if (first) + A2C0(av, bv, scales, args.mr, range_mc, count, nullptr, MMSetC(), + tmp); + else + A2C0(av, bv, scales, args.mr, range_mc, count, nullptr, MMAddC(), + tmp); + first = false; + c += count; + } + using TC = hwy::RemoveCvRef; + for (size_t r = 0; r < range_mc.Num(); ++r) { + for (size_t j = 0; j < kNR; ++j) { + float value = sums[r * kNR + j]; + if constexpr (hwy::IsSame()) { + value += hwy::ConvertScalarTo(C_MC_NC.Row(r)[inc + j]); + } else if (add != nullptr) { + value += add[j]; + } + C_MC_NC.Row(r)[inc + j] = hwy::ConvertScalarTo(value); + } + } + } + return; + } + const size_t kc = range_kc.Num(); + const AView A_view = A.View(range_mc.begin(), range_kc.begin(), kc); + + for (size_t inc = 0; inc < range_nc.Num(); inc += kNR) { + // For `add` and `B`, which are global, unlike `C_MC_NC`. + const size_t row_b = range_nc.begin() + inc; + // No decompression: `B` is already in the layout the kernel wants. + const StridedView B_view(*B.data, row_b, range_kc.begin(), kc); + const CView C_MC_NR = C_MC_NC.View(0, inc, kNR); + const float* HWY_RESTRICT add = MMI8Bias(B, args.add, row_b); + A2C0(A_view, B_view, B.scale + row_b, args.mr, range_mc, kc, add, out_tag, + C_MC_NR); + } + } + + template + static void ForeachKC(const AView A, const BT& B, const IndexRange& range_mc, + const IndexRangePartition& ranges_kc, + const IndexRange& range_nc, const MMArgs& args, + CView C_MC_NC) { + ranges_kc.VisitFirst([&](const IndexRange& range_kc) { + B3A2C0(A, B, range_mc, range_kc, range_nc, args, MMSetC(), C_MC_NC); + }); + ranges_kc.VisitRemaining([&](const IndexRange& range_kc) { + B3A2C0(A, B, range_mc, range_kc, range_nc, args, MMAddC(), C_MC_NC); + }); + } + + private: + // Innermost loop over `kc` columns in steps of one int8 vector, for + // `kRowsAC` rows of `A_view` and `kNR` rows of `B_view`. Mirrors + // `MMKernel::LoopKC`: elementwise along `K` with 16 accumulators whose + // horizontal sums are the `kRowsAC x kNR` results. + template + static HWY_INLINE void DotProducts(const AView& A_view, + const StridedView& B_view, + size_t imc, size_t kc, V4& sum0, V4& sum1, + V4& sum2, V4& sum3) { + const hn::ScalableTag da8; // A: always i8 + const hn::ScalableTag db8; // B: u8 or i8, same lane count + const hn::Repartition di32; + using VA8 = hn::Vec; + using VB8 = hn::Vec; + using VI32 = hn::Vec; + HWY_LANES_CONSTEXPR const size_t N8 = hn::Lanes(da8); + + HWY_DASSERT(kRowsAC <= kMaxMR); + static_assert(kNR == 4); + + const MMI8AT* HWY_RESTRICT ar0 = A_view.data.Row(imc + 0); + const MMI8AT* HWY_RESTRICT ar1 = + kRowsAC > 1 ? A_view.data.Row(imc + 1) : nullptr; + const MMI8AT* HWY_RESTRICT ar2 = + kRowsAC > 2 ? A_view.data.Row(imc + 2) : nullptr; + const MMI8AT* HWY_RESTRICT ar3 = + kRowsAC > 3 ? A_view.data.Row(imc + 3) : nullptr; + const MMI8BT* HWY_RESTRICT br0 = + HWY_RCAST_ALIGNED(const MMI8BT*, B_view.Row(0)); + const MMI8BT* HWY_RESTRICT br1 = + HWY_RCAST_ALIGNED(const MMI8BT*, B_view.Row(1)); + const MMI8BT* HWY_RESTRICT br2 = + HWY_RCAST_ALIGNED(const MMI8BT*, B_view.Row(2)); + const MMI8BT* HWY_RESTRICT br3 = + HWY_RCAST_ALIGNED(const MMI8BT*, B_view.Row(3)); + + VI32 C00 = hn::Zero(di32), C01 = hn::Zero(di32), C02 = hn::Zero(di32), + C03 = hn::Zero(di32), C10 = hn::Zero(di32), C11 = hn::Zero(di32), + C12 = hn::Zero(di32), C13 = hn::Zero(di32), C20 = hn::Zero(di32), + C21 = hn::Zero(di32), C22 = hn::Zero(di32), C23 = hn::Zero(di32), + C30 = hn::Zero(di32), C31 = hn::Zero(di32), C32 = hn::Zero(di32), + C33 = hn::Zero(di32); + + size_t ikc = 0; + if (kc >= N8) { + HWY_UNROLL(1) + for (; ikc <= kc - N8; ikc += N8) { + const VB8 b0 = hn::LoadU(db8, br0 + ikc); + const VB8 b1 = hn::LoadU(db8, br1 + ikc); + const VB8 b2 = hn::LoadU(db8, br2 + ikc); + const VB8 b3 = hn::LoadU(db8, br3 + ikc); + + { + const VA8 a0 = hn::LoadU(da8, ar0 + ikc); + MMI8Dot4(di32, a0, b0, b1, b2, b3, C00, C01, + C02, C03); + } + if constexpr (kRowsAC > 1) { + const VA8 a1 = hn::LoadU(da8, ar1 + ikc); + MMI8Dot4(di32, a1, b0, b1, b2, b3, C10, C11, + C12, C13); + } + if constexpr (kRowsAC > 2) { + const VA8 a2 = hn::LoadU(da8, ar2 + ikc); + MMI8Dot4(di32, a2, b0, b1, b2, b3, C20, C21, + C22, C23); + } + if constexpr (kRowsAC > 3) { + const VA8 a3 = hn::LoadU(da8, ar3 + ikc); + MMI8Dot4(di32, a3, b0, b1, b2, b3, C30, C31, + C32, C33); + } + } + } + + // Remainder. `LoadN` zeroes the upper lanes of both operands, so their + // products are zero. Zeroing `A` is what makes this safe: a zero `B` lane + // does not mean zero in the biased-u8 encoding. + const size_t remaining_kc = kc - ikc; + HWY_DASSERT(remaining_kc < N8); + if (HWY_UNLIKELY(remaining_kc != 0)) { + const VB8 b0 = hn::LoadN(db8, br0 + ikc, remaining_kc); + const VB8 b1 = hn::LoadN(db8, br1 + ikc, remaining_kc); + const VB8 b2 = hn::LoadN(db8, br2 + ikc, remaining_kc); + const VB8 b3 = hn::LoadN(db8, br3 + ikc, remaining_kc); + + { + const VA8 a0 = hn::LoadN(da8, ar0 + ikc, remaining_kc); + MMI8Dot4(di32, a0, b0, b1, b2, b3, C00, C01, C02, + C03); + } + if constexpr (kRowsAC > 1) { + const VA8 a1 = hn::LoadN(da8, ar1 + ikc, remaining_kc); + MMI8Dot4(di32, a1, b0, b1, b2, b3, C10, C11, C12, + C13); + } + if constexpr (kRowsAC > 2) { + const VA8 a2 = hn::LoadN(da8, ar2 + ikc, remaining_kc); + MMI8Dot4(di32, a2, b0, b1, b2, b3, C20, C21, C22, + C23); + } + if constexpr (kRowsAC > 3) { + const VA8 a3 = hn::LoadN(da8, ar3 + ikc, remaining_kc); + MMI8Dot4(di32, a3, b0, b1, b2, b3, C30, C31, C32, + C33); + } + } + + MMI8StoreHorizontalSumsIntoC horz; + horz.template Reduce4x4(di32, C00, C01, C02, C03, C10, C11, + C12, C13, C20, C21, C22, C23, C30, C31, + C32, C33, sum0, sum1, sum2, sum3); + } + + template + static HWY_INLINE void LoopKCImpl(const AView A_view, + const StridedView B_view, + const float* HWY_RESTRICT b_scale, + size_t imc, size_t kc, + const float* HWY_RESTRICT add, Tag tag, + CView C_MC_NR) { + const hn::Full128 d4i; + hn::Vec sum0, sum1, sum2, sum3; + DotProducts(A_view, B_view, imc, kc, sum0, sum1, + sum2, sum3); + + // Sums of the quantized `A` values over this `kc` range, for undoing `B`'s + // bias. `A_view` is already restricted to the range, so `kc` is its width. + int32_t a_rowsum[kNR] = {}; + if constexpr (GEMMA_MM_I8_BIASED_B) { + a_rowsum[0] = A_view.RowSum(imc + 0, kc); + if constexpr (kRowsAC > 1) a_rowsum[1] = A_view.RowSum(imc + 1, kc); + if constexpr (kRowsAC > 2) a_rowsum[2] = A_view.RowSum(imc + 2, kc); + if constexpr (kRowsAC > 3) a_rowsum[3] = A_view.RowSum(imc + 3, kc); + } + + MMI8StoreHorizontalSumsIntoC horz; + horz.Store(d4i, sum0, sum1, sum2, sum3, A_view.scale, a_rowsum, b_scale, + add, imc, tag, C_MC_NR); + } + + template + static HWY_INLINE void AccumulateMicro(const AView A, size_t kc, V4I sum, + V4F b_scale, V4F& accum) { + if constexpr (kRow < kRowsAC) { + const hn::Full128 di; + const hn::Full128 df; + if constexpr (GEMMA_MM_I8_BIASED_B) { + sum = hn::Sub(sum, hn::Set(di, A.RowSum(kRow, kc) * 128)); + } + const auto scale = hn::Mul(b_scale, hn::Set(df, A.scale[kRow])); + // Match the reference's group order and one FMA per group exactly. + accum = hn::MulAdd(hn::ConvertTo(df, sum), scale, accum); + } + } + + template + static HWY_INLINE void StoreMicro(V4F accum, size_t imc, const float* add, + Tag, CView C) { + if constexpr (kRow < kRowsAC) { + const hn::Full128 df; + using TC = hwy::RemoveCvRef; + const hn::Rebind dc; + TC* HWY_RESTRICT pos = C.Row(imc + kRow); + if constexpr (hwy::IsSame()) { + accum = hn::Add(accum, F32FromTC(dc, hn::LoadU(dc, pos))); + } else if (add != nullptr) { + accum = hn::Add(accum, hn::LoadU(df, add)); + } + hn::StoreU(TCFromF32(dc, accum), dc, pos); + } + } + + template + static HWY_NOINLINE void MicroTile(const AView& A, const BT& B, + const IndexRange& range_mc, + const IndexRange& range_kc, size_t row_b, + size_t imc, const float* add, Tag tag, + CView C) { + const hn::Full128 di; + const hn::Full128 df; + auto accum0 = hn::Zero(df), accum1 = hn::Zero(df); + auto accum2 = hn::Zero(df), accum3 = hn::Zero(df); + // Keep one F32 vector per output row across all quantization groups; + // only the completed KC tile is written to C (and rounded if BF16). + const size_t block = kBlock ? kBlock : B.block_size; + size_t group = range_kc.begin() / block; + for (size_t c = range_kc.begin(); c < range_kc.end(); ++group) { + const size_t count = kBlock ? kBlock + : HWY_MIN(static_cast(range_kc.end()), + (group + 1) * block) - + c; + const auto av = A.ViewGroup(range_mc.begin() + imc, c, count, group); + const StridedView bv(*B.data, row_b, c, count); + auto sum0 = hn::Zero(di), sum1 = hn::Zero(di); + auto sum2 = hn::Zero(di), sum3 = hn::Zero(di); + DotProducts(av, bv, 0, count, sum0, sum1, sum2, + sum3); + const auto scale = hn::LoadU(df, B.scale + group * B.Rows() + row_b); + AccumulateMicro<0, kRowsAC>(av, count, sum0, scale, accum0); + AccumulateMicro<1, kRowsAC>(av, count, sum1, scale, accum1); + AccumulateMicro<2, kRowsAC>(av, count, sum2, scale, accum2); + AccumulateMicro<3, kRowsAC>(av, count, sum3, scale, accum3); + c += count; + } + StoreMicro<0, kRowsAC>(accum0, imc, add, tag, C); + StoreMicro<1, kRowsAC>(accum1, imc, add, tag, C); + StoreMicro<2, kRowsAC>(accum2, imc, add, tag, C); + StoreMicro<3, kRowsAC>(accum3, imc, add, tag, C); + } + + template + static HWY_INLINE void MicroB3A2C0Impl(const AView A, const BT& B, + const IndexRange& range_mc, + const IndexRange& range_kc, + const IndexRange& range_nc, + const MMArgs& args, Tag tag, CView C) { + for (size_t inc = 0; inc < range_nc.Num(); inc += kNR) { + const size_t row_b = range_nc.begin() + inc; + const auto tile = C.View(0, inc, kNR); + const float* add = MMI8Bias(B, args.add, row_b); + const size_t mc = range_mc.Num(); + size_t r = 0; + if (args.mr == 4) { + for (; r + 4 <= mc; r += 4) { + MicroTile<4, kNative, kBlock>(A, B, range_mc, range_kc, row_b, r, add, + tag, tile); + } + } + if (args.mr >= 2) { + for (; r + 2 <= mc; r += 2) { + MicroTile<2, kNative, kBlock>(A, B, range_mc, range_kc, row_b, r, add, + tag, tile); + } + } + for (; r < mc; ++r) { + MicroTile<1, kNative, kBlock>(A, B, range_mc, range_kc, row_b, r, add, + tag, tile); + } + } + } + + // Covers arbitrary KC boundaries and targets without the optimized packed + // kernel. Keeping this path makes the layout independent of runtime flags. + template + static HWY_NOINLINE void PackedMicroReference(const AView& A, const BT& B, + const IndexRange& range_mc, + const IndexRange& range_kc, + const IndexRange& range_nc, + const MMArgs& args, Tag tag, + CView C) { + const hn::Full128 di; + const hn::Full128 df; + for (size_t inc = 0; inc < range_nc.Num(); inc += 4) { + const size_t row_b = range_nc.begin() + inc; + const size_t packed_row = row_b & ~size_t{7}; + const size_t lane = row_b % 8; + const auto* packed = + reinterpret_cast(B.data->Row(packed_row)); + const float* add = MMI8Bias(B, args.add, row_b); + const auto out = C.View(0, inc, 4); + for (size_t r = 0; r < range_mc.Num(); ++r) { + const auto* ar = A.data.Row(range_mc.begin() + r); + auto accum = hn::Zero(df); + for (size_t c = range_kc.begin(); c < range_kc.end();) { + const size_t group = c / B.block_size; + const size_t count = HWY_MIN(static_cast(range_kc.end()), + (group + 1) * B.block_size) - + c; + HWY_ALIGN int32_t sums[4] = {}; + HWY_ALIGN int32_t residual_sums[4] = {}; + for (size_t k = c; k < c + count; ++k) { + const auto* bp = packed + (k / 4) * 32 + lane * 4 + k % 4; + for (size_t n = 0; n < 4; ++n) { + sums[n] += static_cast(ar[k]) * bp[4 * n]; + if (A.residual != nullptr) { + residual_sums[n] += static_cast(A.residual->data.Row( + range_mc.begin() + r)[k]) * + bp[4 * n]; + } + } + } + const auto av = A.ViewGroup(range_mc.begin() + r, c, count, group); + const auto scale = hn::LoadU(df, B.scale + group * B.Rows() + row_b); + AccumulateMicro<0, 1>(av, count, hn::LoadU(di, sums), scale, accum); + if (A.residual != nullptr) { + const auto rv = + A.residual->ViewGroup(range_mc.begin() + r, c, count, group); + AccumulateMicro<0, 1>(rv, count, hn::LoadU(di, residual_sums), + scale, accum); + } + c += count; + } + StoreMicro<0, 1>(accum, r, add, tag, out); + } + } + } + +#if HWY_TARGET == HWY_AVX2 && GEMMA_MM_I8_BIASED_B && defined(__GNUC__) && \ + !defined(__clang__) + // Each dot lane directly produces one of eight output channels. Four + // independent chains hide VNNI latency; no horizontal reduction is needed. + template + static HWY_NOINLINE void PackedMicroNative(const AView& A, const BT& B, + const IndexRange& range_mc, + const IndexRange& range_kc, + const IndexRange& range_nc, + const MMArgs& args, Tag tag, + CView C) { + const hn::ScalableTag da; + const hn::ScalableTag db; + const hn::Repartition di; + const hn::Repartition du; + const hn::ScalableTag df; + const hn::Full128 d4f; + for (size_t nc = range_nc.begin(); nc < range_nc.end();) { + const size_t row_b = nc & ~size_t{7}; + const size_t lane = nc % 8; + const size_t count = HWY_MIN(size_t{8} - lane, range_nc.end() - nc); + const size_t inc = nc - range_nc.begin(); + const auto* packed = reinterpret_cast(B.data->Row(row_b)); + const float* add = MMI8Bias(B, args.add, nc); + for (size_t r = 0; r < range_mc.Num(); ++r) { + const auto* ar = A.data.Row(range_mc.begin() + r); + const auto* ar1 = + kDual ? A.residual->data.Row(range_mc.begin() + r) : nullptr; + auto accum = hn::Zero(df); + size_t group = range_kc.begin() / kBlock; + for (size_t c = range_kc.begin(); c < range_kc.end(); ++group) { + const size_t num_k = + kAlignedGroups ? kBlock + : HWY_MIN(kBlock - c % kBlock, range_kc.end() - c); + const auto* br = packed + c * 8; + auto d0 = hn::Zero(di), d1 = hn::Zero(di); + auto d2 = hn::Zero(di), d3 = hn::Zero(di); + auto e0 = hn::Zero(di), e1 = hn::Zero(di); + auto e2 = hn::Zero(di), e3 = hn::Zero(di); + const auto dot = [&](size_t offset, auto& sum, + auto& residual_sum) HWY_ATTR { + uint32_t bits; + hwy::CopyBytes<4>(ar + c + offset, &bits); + const auto a = hn::BitCast(da, hn::Set(du, bits)); + const auto b = hn::LoadU(db, br + offset * 8); + if constexpr (kDual) { + uint32_t residual_bits; + hwy::CopyBytes<4>(ar1 + c + offset, &residual_bits); + const auto a1 = hn::BitCast(da, hn::Set(du, residual_bits)); + // Keep B in a register for both dots. The dual specialization + // requires more than the eight SIMD registers of x86-32. + asm("%{vex%} vpdpbusd %[a], %[b], %[sum]\n\t" + "%{vex%} vpdpbusd %[a1], %[b], %[residual_sum]" + : [sum] "+&x"(sum.raw), [residual_sum] "+&x"(residual_sum.raw) + : [a] "x"(a.raw), [a1] "x"(a1.raw), [b] "x"(b.raw)); + } else { + asm("%{vex%} vpdpbusd %[a], %[b], %[sum]" + : [sum] "+x"(sum.raw) + : [a] "x"(a.raw), [b] "x"(b.raw)); + } + }; + size_t k = 0; + for (; k + 16 <= num_k; k += 16) { + dot(k, d0, e0); + dot(k + 4, d1, e1); + dot(k + 8, d2, e2); + dot(k + 12, d3, e3); + } + if constexpr (!kAlignedGroups) { + for (; k < num_k; k += 4) dot(k, d0, e0); + } + auto sum = hn::Add(hn::Add(d0, d1), hn::Add(d2, d3)); + const auto av = A.ViewGroup(range_mc.begin() + r, c, num_k, group); + sum = hn::Sub(sum, hn::Set(di, av.RowSum(0, num_k) * 128)); + const auto bs = hn::LoadU(df, B.scale + group * B.Rows() + row_b); + const auto scale = hn::Mul(bs, hn::Set(df, av.scale[0])); + accum = hn::MulAdd(hn::ConvertTo(df, sum), scale, accum); + if constexpr (kDual) { + auto residual_sum = hn::Add(hn::Add(e0, e1), hn::Add(e2, e3)); + const auto rv = + A.residual->ViewGroup(range_mc.begin() + r, c, num_k, group); + residual_sum = + hn::Sub(residual_sum, hn::Set(di, rv.RowSum(0, num_k) * 128)); + const auto residual_scale = hn::Mul(bs, hn::Set(df, rv.scale[0])); + accum = hn::MulAdd(hn::ConvertTo(df, residual_sum), residual_scale, + accum); + } + c += num_k; + } + if (count == 8) { + using TC = hwy::RemoveCvRef; + const hn::Rebind dc; + TC* pos = C.Row(r) + inc; + if constexpr (hwy::IsSame()) { + accum = hn::Add(accum, F32FromTC(dc, hn::LoadU(dc, pos))); + } else if (add != nullptr) { + accum = hn::Add(accum, hn::LoadU(df, add)); + } + hn::StoreU(TCFromF32(dc, accum), dc, pos); + } else { + // Generic scheduling may split N at four-channel boundaries. + HWY_DASSERT(count == 4); + const auto half = + lane ? hn::UpperHalf(d4f, accum) : hn::LowerHalf(d4f, accum); + StoreMicro<0, 1>(half, r, add, tag, C.View(0, inc, 4)); + } + } + nc += count; + } + } +#endif + + template + static HWY_INLINE void DispatchPackedMicro(const AView& A, const BT& B, + const IndexRange& range_mc, + const IndexRange& range_kc, + const IndexRange& range_nc, + const MMArgs& args, Tag tag, + CView C) { + HWY_DASSERT(B.block_size == 32 || B.block_size == 64 || + B.block_size == 128); + HWY_DASSERT(B.Rows() % 8 == 0 && range_nc.begin() % 4 == 0 && + range_nc.Num() % 4 == 0); +#if HWY_TARGET == HWY_AVX2 && GEMMA_MM_I8_BIASED_B && defined(__GNUC__) && \ + !defined(__clang__) + if constexpr (HWY_ARCH_X86_64 || !kDual) { + if (MMI8FastMicro() && MMI8NativeVNNI() && range_kc.begin() % 4 == 0 && + range_kc.end() % 4 == 0) { + const bool aligned = range_kc.begin() % B.block_size == 0 && + range_kc.end() % B.block_size == 0; + if (B.block_size == 32) { + if (aligned) + PackedMicroNative<32, true, kDual>(A, B, range_mc, range_kc, + range_nc, args, tag, C); + else + PackedMicroNative<32, false, kDual>(A, B, range_mc, range_kc, + range_nc, args, tag, C); + } else if (B.block_size == 64) { + if (aligned) + PackedMicroNative<64, true, kDual>(A, B, range_mc, range_kc, + range_nc, args, tag, C); + else + PackedMicroNative<64, false, kDual>(A, B, range_mc, range_kc, + range_nc, args, tag, C); + } else { + if (aligned) + PackedMicroNative<128, true, kDual>(A, B, range_mc, range_kc, + range_nc, args, tag, C); + else + PackedMicroNative<128, false, kDual>(A, B, range_mc, range_kc, + range_nc, args, tag, C); + } + return; + } + } +#endif + PackedMicroReference(A, B, range_mc, range_kc, range_nc, args, tag, C); + } + + template + static HWY_INLINE void PackedMicroB3A2C0(const AView& A, const BT& B, + const IndexRange& range_mc, + const IndexRange& range_kc, + const IndexRange& range_nc, + const MMArgs& args, Tag tag, + CView C) { + if (B.dual_a && A.residual != nullptr) { + DispatchPackedMicro(A, B, range_mc, range_kc, range_nc, args, tag, + C); + } else { + AView primary = A; + primary.residual = nullptr; + DispatchPackedMicro(primary, B, range_mc, range_kc, range_nc, args, + tag, C); + } + } + template + static HWY_INLINE void DispatchMicroBlock(const AView& A, const BT& B, + const IndexRange& range_mc, + const IndexRange& range_kc, + const IndexRange& range_nc, + const MMArgs& args, Tag tag, + CView C) { + // Full groups dominate model inference. Fixed widths let the compiler + // unroll their dot loops and remove all masked loads and view copies. + const size_t block = B.block_size; + if (range_kc.begin() % block != 0 || range_kc.end() % block != 0) { + MicroB3A2C0Impl(A, B, range_mc, range_kc, range_nc, args, tag, + C); + return; + } + switch (block) { + case 32: + MicroB3A2C0Impl(A, B, range_mc, range_kc, range_nc, args, + tag, C); + return; + case 64: + MicroB3A2C0Impl(A, B, range_mc, range_kc, range_nc, args, + tag, C); + return; + case 128: + MicroB3A2C0Impl(A, B, range_mc, range_kc, range_nc, args, + tag, C); + return; + default: + HWY_ABORT("Invalid microscaling group size %zu", block); + } + } + + template + static HWY_INLINE void MicroB3A2C0(const AView A, const BT& B, + const IndexRange& range_mc, + const IndexRange& range_kc, + const IndexRange& range_nc, + const MMArgs& args, Tag tag, CView C) { +#if HWY_TARGET == HWY_AVX2 && GEMMA_MM_I8_BIASED_B && defined(__GNUC__) && \ + !defined(__clang__) + if (MMI8NativeVNNI()) { + DispatchMicroBlock(A, B, range_mc, range_kc, range_nc, args, tag, + C); + return; + } +#endif + DispatchMicroBlock(A, B, range_mc, range_kc, range_nc, args, tag, C); + } + + template + static HWY_INLINE void LoopKC(const AView A, const StridedView B, + const float* scale, size_t imc, size_t kc, + const float* add, Tag tag, CView C) { +#if HWY_TARGET == HWY_AVX2 && GEMMA_MM_I8_BIASED_B && defined(__GNUC__) && \ + !defined(__clang__) + if (MMI8NativeVNNI()) { + LoopKCImpl(A, B, scale, imc, kc, add, tag, C); + return; + } +#endif + LoopKCImpl(A, B, scale, imc, kc, add, tag, C); + } + + // As `MMKernel::A2C0`. + template + static HWY_INLINE void A2C0(const AView A_view, + const StridedView B_view, + const float* HWY_RESTRICT b_scale, size_t mr, + const IndexRange& range_mc, size_t kc, + const float* HWY_RESTRICT add, Tag tag, + CView C_MC_NR) { + HWY_DASSERT(1 <= mr && mr <= kMaxMR); + const size_t mc = range_mc.Num(); + size_t imc = 0; + + if (HWY_UNLIKELY(mr == 1)) { + for (; imc < mc; ++imc) { + LoopKC<1>(A_view, B_view, b_scale, imc, kc, add, tag, C_MC_NR); + } + return; + } + + if (HWY_UNLIKELY(mr == 2)) { + if (HWY_LIKELY(mc >= 2)) { + for (; imc <= mc - 2; imc += 2) { + LoopKC<2>(A_view, B_view, b_scale, imc, kc, add, tag, C_MC_NR); + } + } + if (HWY_UNLIKELY(imc != mc)) { + LoopKC<1>(A_view, B_view, b_scale, imc, kc, add, tag, C_MC_NR); + } + return; + } + + HWY_DASSERT(mr == 4); + if (HWY_LIKELY(mc >= 4)) { + for (; imc <= mc - 4; imc += 4) { + LoopKC<4>(A_view, B_view, b_scale, imc, kc, add, tag, C_MC_NR); + } + } + const size_t remainder_mc = mc - imc; + HWY_DASSERT(remainder_mc < 4); + if (HWY_UNLIKELY(remainder_mc & 2)) { + LoopKC<2>(A_view, B_view, b_scale, imc, kc, add, tag, C_MC_NR); + imc += 2; + } + if (HWY_UNLIKELY(remainder_mc & 1)) { + LoopKC<1>(A_view, B_view, b_scale, imc, kc, add, tag, C_MC_NR); + imc += 1; + } + HWY_DASSERT(imc == mc); + } +}; // MMI8Kernel + +//------------------------------------------------------------------------------ +// Quantization + +// Loads one vector of F32 from F32 or BF16 `A`, so that quantization can read +// activations in whichever format the caller already has. +template > +static HWY_INLINE VF LoadF32(DF df, const TA* HWY_RESTRICT p) { + if constexpr (IsF32()) { + return hn::LoadU(df, p); + } else { + static_assert(IsBF16()); + return hn::PromoteTo(df, hn::LoadU(hn::Rebind(), p)); + } +} + +template > +static HWY_INLINE VF LoadNF32(DF df, const TA* HWY_RESTRICT p, size_t n) { + if constexpr (IsF32()) { + return hn::LoadN(df, p, n); + } else { + static_assert(IsBF16()); + return hn::PromoteTo(df, hn::LoadN(hn::Rebind(), p, n)); + } +} + +// Quantizes one row of `k` activations to symmetric int8, returning the +// dequantization scale. Also writes `k + 1` prefix sums of the quantized +// values (when `B` is biased), which the kernel uses to undo that bias for +// whichever `kc` range it is working on. `out` is zero-padded to `padded_k`. +template +static HWY_INLINE float QuantizeRowA(const TA* HWY_RESTRICT in, size_t k, + MMI8AT* HWY_RESTRICT out, + int32_t* HWY_RESTRICT prefix, + size_t padded_k, + int32_t prefix_base = 0) { + const hn::ScalableTag df; + const hn::Rebind di32; + const hn::Rebind d8; + using VF = hn::Vec; + const size_t NF = hn::Lanes(df); + + VF vmax = hn::Zero(df); + size_t i = 0; + if (k >= NF) { + for (; i <= k - NF; i += NF) { + vmax = hn::Max(vmax, hn::Abs(LoadF32(df, in + i))); + } + } + if (i != k) { + vmax = hn::Max(vmax, hn::Abs(LoadNF32(df, in + i, k - i))); + } + const float amax = hn::ReduceMax(df, vmax); + + const float scale = (amax == 0.0f) ? 1.0f : amax / kMMI8Max; + const float inv_scale = (amax == 0.0f) ? 0.0f : kMMI8Max / amax; + const VF vinv = hn::Set(df, inv_scale); + + i = 0; + if (k >= NF) { + for (; i <= k - NF; i += NF) { + const auto q = hn::NearestInt(hn::Mul(LoadF32(df, in + i), vinv)); + hn::StoreU(hn::DemoteTo(d8, q), d8, out + i); + } + } + for (; i < k; ++i) { + const float in_f = hwy::ConvertScalarTo(in[i]); + out[i] = static_cast(std::lroundf(in_f * inv_scale)); + } + for (; i < padded_k; ++i) { + out[i] = static_cast(0); + } + + if constexpr (GEMMA_MM_I8_BIASED_B) { + // Scalar, but only `M * K` additions per MatMul, i.e. the same order as + // the quantization itself and negligible next to `M * K * N` products. + int32_t sum = prefix_base; + prefix[0] = prefix_base; + for (size_t j = 0; j < k; ++j) { + sum += out[j]; + prefix[j + 1] = sum; + } + } + return scale; +} + +// Storage for quantized `A`, reused across `MatMulI8` calls. Analogous to +// `MMEntireA`, but sized by the caller because this is a prototype and +// `MatMulEnv` does not know about int8 yet. +class MMI8AStorage { + public: + // `prefix_` is `K + 1` per row, which is simple but the largest cost here. + // Production would instead compute one sum per (row, kc range) once the + // config is known, which is `NumTasks()` rather than `K` per row. + MMI8AStorage(size_t max_M, size_t max_K, const Allocator& allocator) + : data_("A_i8", Extents2D(max_M, max_K), allocator, MatPadding::kOdd), + prefix_stride_(hwy::RoundUpTo(max_K + 1, HWY_ALIGNMENT / 4)), + prefix_((GEMMA_MM_I8_BIASED_B ? max_M : 1) * prefix_stride_), + scale_(max_M) {} + + MMI8AView View(const Extents2D& extents, size_t block_size = 0) { + const size_t groups = block_size ? extents.cols / block_size : 1; + if (scale_.size() < groups * data_.Rows()) + scale_.resize(groups * data_.Rows()); + HWY_DASSERT(extents.rows <= data_.Rows()); + HWY_DASSERT(extents.cols <= data_.Cols()); + return MMI8AView{ + StridedView(HWY_RCAST_ALIGNED(MMI8AT*, data_.Row(0)), + extents.cols, data_.Stride()), + scale_.data(), + prefix_.data(), + prefix_stride_, + data_.Rows(), + block_size}; + } + + float* HWY_RESTRICT scale() { return scale_.data(); } + int32_t* HWY_RESTRICT prefix(size_t row) { + return prefix_.data() + (GEMMA_MM_I8_BIASED_B ? row : 0) * prefix_stride_; + } + size_t Stride() const { return data_.Stride(); } + + // Allocate only when requested; additional storage scales with the current + // activation batch. Both streams use the same quantized weights. + MMI8AView ResidualView(const Extents2D& extents, size_t block_size) { + HWY_ASSERT(block_size != 0); + const size_t groups = extents.cols / block_size; + residual_data_.resize(extents.rows * data_.Stride()); + residual_prefix_.resize((GEMMA_MM_I8_BIASED_B ? extents.rows : 1) * + prefix_stride_); + residual_scale_.resize(extents.rows * groups); + return MMI8AView{StridedView(residual_data_.data(), extents.cols, + data_.Stride()), + residual_scale_.data(), + residual_prefix_.data(), + prefix_stride_, + extents.rows, + block_size}; + } + + float* residual_scale() { return residual_scale_.data(); } + int32_t* residual_prefix(size_t row) { + return residual_prefix_.data() + + (GEMMA_MM_I8_BIASED_B ? row : 0) * prefix_stride_; + } + + private: + MatStorageT data_; + size_t prefix_stride_; + hwy::AlignedVector prefix_; + hwy::AlignedVector scale_; + hwy::AlignedVector residual_data_; + hwy::AlignedVector residual_prefix_; + hwy::AlignedVector residual_scale_; +}; + +// Copies an activation row before rotation. The optional F32 roundtrip uses +// exactly the same decompressor as MMDecompress::DecompressA, so comparisons +// with the SFP path begin with the same BF16-rounded activation values. +template +static HWY_INLINE void MMI8PrepareInputRow( + const TA* HWY_RESTRICT in, size_t k, const float* HWY_RESTRICT a_pre_scale, + bool match_bf16, float* HWY_RESTRICT out) { + if constexpr (IsF32()) { + if (match_bf16) { + const hn::ScalableTag dbf; + const size_t padded = hwy::RoundUpTo(k, hn::Lanes(dbf)); + thread_local hwy::AlignedVector rounded; + if (rounded.size() < padded) rounded.resize(padded); + DecompressAndZeroPad(dbf, MakeSpan(in, k), 0, rounded.data(), k); + for (size_t c = 0; c < k; ++c) { + const float value = hwy::ConvertScalarTo(rounded[c]); + out[c] = a_pre_scale == nullptr ? value : value * a_pre_scale[c]; + } + return; + } + } + for (size_t c = 0; c < k; ++c) { + const float value = hwy::ConvertScalarTo(in[c]); + out[c] = a_pre_scale == nullptr ? value : value * a_pre_scale[c]; + } +} + +// Quantizes all `M x K` of `A` into `storage`, in parallel over rows. +// This replaces `MMDecompress::DecompressA` and is the same order of cost: +// one pass over `A`, once per `MatMul` rather than per B tile. +template +static HWY_NOINLINE MMI8AView +QuantizeA(const MatPtrT& A, MMI8AStorage& storage, ThreadingContext& ctx, + size_t cluster_idx, const float* a_pre_scale = nullptr, + size_t block_size = 0, MMI8AView* residual = nullptr) { + MMI8AView view = storage.View(A.Extents(), block_size); + if (residual != nullptr) { + *residual = storage.ResidualView(A.Extents(), block_size); + view.residual = residual; + } + const size_t k = A.Cols(); + HWY_ASSERT(block_size == 0 || + ((block_size == 32 || block_size == 64 || block_size == 128) && + k % block_size == 0)); + const size_t padded_k = + hwy::RoundUpTo(k, hn::Lanes(hn::ScalableTag())); + float* HWY_RESTRICT scale = storage.scale(); + const float a_scale = A.Scale(); + static const bool match_bf16 = MMI8Flag("GEMMA_MM_I8_MATCH_BF16_A"); + HWY_DASSERT((k % MMI8RotateBlockSize()) == 0); + + ParallelFor( + Parallelism::kFlat, A.Rows(), ctx, cluster_idx, Callers::kMMQuantizeA, + [&](size_t r, size_t /*worker*/) HWY_ATTR { + thread_local hwy::AlignedVector rotated; + if (rotated.size() < padded_k) rotated.resize(padded_k); + MMI8PrepareInputRow(A.Row(r), k, a_pre_scale, match_bf16, + rotated.data()); + MMI8Rotate(rotated.data(), k); + const size_t group_size = block_size ? block_size : k; + int32_t* prefix = storage.prefix(r); + for (size_t c = 0; c < k; c += group_size) { + const int32_t base = GEMMA_MM_I8_BIASED_B && c ? prefix[c] : 0; + const float raw_scale = + QuantizeRowA(rotated.data() + c, group_size, view.data.Row(r) + c, + prefix + c, group_size, base); + scale[(c / group_size) * view.scale_stride + r] = a_scale * raw_scale; + if (residual != nullptr) { + const hn::CappedTag df; + const hn::Rebind di; + const hn::Rebind d8; + for (size_t j = 0; j < group_size; j += hn::Lanes(df)) { + const auto q = hn::ConvertTo( + df, + hn::PromoteTo(di, hn::LoadU(d8, view.data.Row(r) + c + j))); + const auto error = + hn::NegMulAdd(q, hn::Set(df, raw_scale), + hn::LoadU(df, rotated.data() + c + j)); + hn::StoreU(error, df, rotated.data() + c + j); + } + int32_t* rp = storage.residual_prefix(r); + const int32_t residual_base = GEMMA_MM_I8_BIASED_B && c ? rp[c] : 0; + storage.residual_scale()[(c / group_size) * residual->scale_stride + + r] = + a_scale * QuantizeRowA(rotated.data() + c, group_size, + residual->data.Row(r) + c, rp + c, + group_size, residual_base); + } + } + for (size_t c = k; c < padded_k; ++c) { + view.data.Row(r)[c] = 0; + if (residual != nullptr) residual->data.Row(r)[c] = 0; + } + }); + return view; +} + +// Symmetric int8 quantization of already-transposed `B`, i.e. `N` rows of `K`. +// Fills `data` (biased by 128 if `GEMMA_MM_I8_BIASED_B`, zero-padded to its +// stride) and `scale`. Called once per weight matrix, so not performance- +// critical. +static HWY_NOINLINE MMI8B PackB(const MatPtrT& B_f32, + MatPtrT& data, + float* HWY_RESTRICT scale, + ThreadingContext& ctx, + const float* a_pre_scale = nullptr, + size_t block_size = 0) { + const size_t k = B_f32.Cols(); + HWY_ASSERT(block_size == 0 || + ((block_size == 32 || block_size == 64 || block_size == 128) && + k % block_size == 0)); + HWY_DASSERT((k % MMI8RotateBlockSize()) == 0); + const float b_scale = B_f32.Scale(); + + ParallelFor( + Parallelism::kFlat, B_f32.Rows(), ctx, /*cluster_idx=*/0, Callers::kTest, + [&](size_t r, size_t /*worker*/) HWY_ATTR { + hwy::AlignedVector rotated(k); + hwy::CopyBytes(B_f32.Row(r), rotated.data(), k * sizeof(float)); + if (a_pre_scale != nullptr) { + for (size_t c = 0; c < k; ++c) rotated[c] /= a_pre_scale[c]; + } + MMI8Rotate(rotated.data(), k); + const float* HWY_RESTRICT in = rotated.data(); + const size_t group_size = block_size ? block_size : k; + MMI8BT* HWY_RESTRICT out = HWY_RCAST_ALIGNED(MMI8BT*, data.Row(r)); + for (size_t begin = 0; begin < k; begin += group_size) { + float amax = 0.0f; + for (size_t c = begin; c < begin + group_size; ++c) + amax = HWY_MAX(amax, hwy::ScalarAbs(in[c])); + const float qs = amax == 0.0f ? 1.0f : amax / kMMI8Max; + const float inv = amax == 0.0f ? 0.0f : kMMI8Max / amax; + for (size_t c = begin; c < begin + group_size; ++c) { + const int32_t q = static_cast(std::lroundf(in[c] * inv)); + out[c] = static_cast(q + (GEMMA_MM_I8_BIASED_B ? 128 : 0)); + } + scale[(begin / group_size) * B_f32.Rows() + r] = b_scale * qs; + } + for (size_t c = k; c < data.Stride(); ++c) out[c] = 0; + }); + + return MMI8B{&data, scale, a_pre_scale, block_size}; +} + +//------------------------------------------------------------------------------ +// Entry point + +static inline std::vector MMI8Candidates( + MatMulEnv& env, size_t M, size_t K, size_t N, size_t num_B, + size_t sizeof_TC, bool prefer_full_k = false) { + auto candidates = MMCandidates(env.ctx.cache_info, M, K, N, num_B, + sizeof_TC, env.print_config); + if (!env.autotune && + (prefer_full_k || MMI8Flag("GEMMA_MM_I8_MIN_K_SPLITS"))) { + // Generic candidates enumerate split-K loop orders first. Prefer fewer + // intermediate output rounds in fixed W8A8 evaluation while retaining the + // generator's legal cache/thread partitions and its order among ties. + const auto best = std::min_element( + candidates.begin(), candidates.end(), [&](const auto& a, const auto& b) { + return a.RangesOfKC(K).NumTasks() < b.RangesOfKC(K).NumTasks(); + }); + if (best != candidates.end()) std::iter_swap(candidates.begin(), best); + } + return candidates; +} + +// As `MatMul`, but `A` is quantized on the fly and `B` was packed by `PackB`. +// Reuses the same blocking, parallelization and autotuning as `MatMul`; only +// the kernel and operand types differ. Tuning keys distinguish A8 from BF16. +template +HWY_NOINLINE MMPerKey* MatMulI8(const MatPtrT& A, const MMI8B& B, + const float* HWY_RESTRICT add, MatMulEnv& env, + MatPtrT& C, MMI8AStorage& a_storage, + MMOptions options = MMOptions()) { + const size_t cluster_idx = options.cluster_idx; + HWY_DASSERT(cluster_idx < env.row_ptrs.size()); + GCPP_ZONE(env.ctx, env.ctx.Worker(cluster_idx), Zones::kMMMatMul); + + RowPtrs C_rows = GetOrSetTempRowPtrs(C, env.row_ptrs[cluster_idx]); + + const size_t M = A.Rows(); + const size_t K = A.Cols(); + const size_t N = B.Rows(); + const size_t num_B = 1; + + const CacheInfo& cache = env.ctx.cache_info; + MMPerKey& per_key = MMImpl::FindOrAddPerKey( + M, K, N, num_B, cache.VectorBytes(), env.per_cluster[cluster_idx], + B.block_size ? MMActivation::kI8Block : MMActivation::kI8); + + // Outside the timed section, as `MMDecompress::MaybeDecompressA`. + MMI8AView residual; + const MMI8AView A_view = + QuantizeA(A, a_storage, env.ctx, cluster_idx, B.a_pre_scale, B.block_size, + MMI8UseDualA(B, M) ? &residual : nullptr); + + const MMI8B* B2 = nullptr; // required for type matching + + // Scales are per row/column, hence folded into `A_view.scale` and + // `B.scale`; the scalar `MMArgs::scale_A` is unused. + MMAutoTune& tuner = per_key.autotune; + if (HWY_LIKELY(tuner.Best())) { + const MMArgs args(env, M, K, N, /*scale_A=*/1.0f, add, options, tuner, + *tuner.Best()); + MMLoops::Dispatch(A_view, B, B2, C_rows, args); + return &per_key; + } + + if (HWY_UNLIKELY(!tuner.HasCandidates())) { + HWY_ASSERT(K == B.Cols()); + HWY_ASSERT(M <= kMaxBatchSize); + HWY_ASSERT(N % kNR == 0); + tuner.SetCandidates( + MMI8Candidates(env, M, K, N, num_B, sizeof(TC), + MMI8PreferFullHeadK(B, M, hwy::IsSame())), + env.autotune); + } + + const MMConfig& cfg = tuner.NextConfig(); + const MMArgs args(env, M, K, N, /*scale_A=*/1.0f, add, options, tuner, cfg); + + const uint64_t t0 = hwy::timer::Start(); + MMLoops::Dispatch(A_view, B, B2, C_rows, args); + MMImpl::NotifyAutotuneResult(env, M, K, N, num_B, t0, tuner, cfg); + + return &per_key; +} + +// As `TwoMatMul`: computes `A * B1` into `C` and `A * B2` into a per-worker +// tile, passing both to `options.func`. Used by gated FFNs. +static HWY_NOINLINE MMPerKey* TwoMatMulI8(const MatPtrT& A, + const MMI8B& B1, const MMI8B& B2, + MatMulEnv& env, MatPtrT& C, + MMI8AStorage& a_storage, + MMOptions options) { + const size_t cluster_idx = options.cluster_idx; + HWY_DASSERT(cluster_idx < env.row_ptrs.size()); + GCPP_ZONE(env.ctx, env.ctx.Worker(cluster_idx), Zones::kMMTwoMatMul); + HWY_DASSERT(options.func != nullptr); // no other way to get access to C2. + + RowPtrs C_rows = GetOrSetTempRowPtrs(C, env.row_ptrs[cluster_idx]); + + const size_t M = A.Rows(); + const size_t K = A.Cols(); + const size_t N = B1.Rows(); + const size_t num_B = 2; + + const CacheInfo& cache = env.ctx.cache_info; + MMPerKey& per_key = MMImpl::FindOrAddPerKey( + M, K, N, num_B, cache.VectorBytes(), env.per_cluster[cluster_idx], + B1.block_size ? MMActivation::kI8Block : MMActivation::kI8); + + HWY_DASSERT(B1.a_pre_scale == nullptr && B2.a_pre_scale == nullptr); + HWY_ASSERT(B1.block_size == B2.block_size); + MMI8AView residual; + const bool dual = MMI8UseDualA(B1, M) || MMI8UseDualA(B2, M); + const MMI8AView A_view = + QuantizeA(A, a_storage, env.ctx, cluster_idx, nullptr, B1.block_size, + dual ? &residual : nullptr); + + MMAutoTune& tuner = per_key.autotune; + if (HWY_LIKELY(tuner.Best())) { + const MMArgs args(env, M, K, N, /*scale_A=*/1.0f, /*add=*/nullptr, options, + tuner, *tuner.Best()); + MMLoops::Dispatch(A_view, B1, &B2, C_rows, args); + return &per_key; + } + + if (HWY_UNLIKELY(!tuner.HasCandidates())) { + HWY_ASSERT(K == B1.Cols()); + HWY_ASSERT(K == B2.Cols()); + HWY_ASSERT(M <= kMaxBatchSize); + HWY_ASSERT(N % kNR == 0); + const size_t max_M = MMKeys::BucketM(M); + tuner.SetCandidates( + MMI8Candidates(env, max_M, K, N, num_B, sizeof(BF16)), + env.autotune); + } + + const MMConfig& cfg = tuner.NextConfig(); + const MMArgs args(env, M, K, N, /*scale_A=*/1.0f, /*add=*/nullptr, options, + tuner, cfg); + const uint64_t t0 = hwy::timer::Start(); + MMLoops::Dispatch(A_view, B1, &B2, C_rows, args); + MMImpl::NotifyAutotuneResult(env, M, K, N, num_B, t0, tuner, cfg); + + return &per_key; +} + +// NOLINTNEXTLINE(google-readability-namespace-comments) +} // namespace HWY_NAMESPACE +} // namespace gcpp +HWY_AFTER_NAMESPACE(); + +#endif // NOLINT diff --git a/ops/matmul_i8_calibration-inl.h b/ops/matmul_i8_calibration-inl.h new file mode 100644 index 00000000..ed74033b --- /dev/null +++ b/ops/matmul_i8_calibration-inl.h @@ -0,0 +1,561 @@ +// Copyright 2026 Google LLC +// SPDX-License-Identifier: Apache-2.0 + +// Experimental offline calibration for W8A8. Capture and refinement are both +// opt-in; normal inference does not retain calibration activations or matrices. +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(THIRD_PARTY_GEMMA_CPP_MATMUL_I8_CALIBRATION_TOGGLE) == \ + defined(HWY_TARGET_TOGGLE) +#ifdef THIRD_PARTY_GEMMA_CPP_MATMUL_I8_CALIBRATION_TOGGLE +#undef THIRD_PARTY_GEMMA_CPP_MATMUL_I8_CALIBRATION_TOGGLE +#else +#define THIRD_PARTY_GEMMA_CPP_MATMUL_I8_CALIBRATION_TOGGLE +#endif + +HWY_BEFORE_NAMESPACE(); +namespace gcpp { +namespace HWY_NAMESPACE { + +// Percent-escape every byte except ASCII letters, digits, '_' and '-'. +// Model tensor names already use these safe characters and are unique. +static inline std::string MMI8CalibrationName(const char* name) { + static constexpr char hex[] = "0123456789ABCDEF"; + std::string result; + for (const unsigned char* p = reinterpret_cast(name); + *p != 0; ++p) { + if ((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '_' || *p == '-') { + result += static_cast(*p); + } else { + result += '%'; + result += hex[*p >> 4]; + result += hex[*p & 15]; + } + } + return result; +} + +static inline size_t MMI8CalibrationSize(const char* name, size_t fallback, + size_t maximum) { + const char* value = getenv(name); + if (value == nullptr || *value == '\0' || *value == '-') return fallback; + char* end = nullptr; + const unsigned long long parsed = strtoull(value, &end, 10); + if (end == value || *end != '\0') return fallback; + return static_cast(HWY_MIN(parsed, + static_cast(maximum))); +} + +static inline bool MMI8CalibrationCaptureEnabled() { + static const bool enabled = []() { + const char* path = getenv("GEMMA_MM_I8_CALIBRATION_CAPTURE"); + return path != nullptr && *path != '\0'; + }(); + return enabled; +} + +// Raw files are little-endian F32 [rows,K], with dimensions and transform +// settings in a JSON sidecar. Each call samples evenly spaced finite rows. +class MMI8CalibrationCapture { + public: + static MMI8CalibrationCapture& Get() { + static MMI8CalibrationCapture capture; + return capture; + } + + template + void Capture(const MatPtrT& A, const MatPtr& B) { + if (directory_.empty() || max_rows_ == 0 || !B.HasPtr() || + A.Cols() != B.Cols() || B.Rows() % kNR != 0 || A.Cols() == 0 || + A.Cols() % MMI8RotateBlockSize() != 0) + return; + std::lock_guard lock(mutex_); + const std::string name = MMI8CalibrationName(B.Name()); + auto& entry = entries_[name]; + if (entry.k != 0 && entry.k != A.Cols()) + HWY_ABORT("Calibration tensor %s changed shape", B.Name()); + entry.k = A.Cols(); + if (entry.rows >= max_rows_ || total_bytes_ >= kMaxBytes) return; + const size_t k = A.Cols(); + const size_t row_bytes = k * sizeof(float); + if (row_bytes > kMaxBytes - total_bytes_) return; + const size_t count = HWY_MIN(HWY_MIN(A.Rows(), rows_per_call_), + max_rows_ - entry.rows); + hwy::AlignedVector row(k); + std::vector captured; + captured.reserve(HWY_MIN(count, (kMaxBytes - total_bytes_) / row_bytes) * k); + for (size_t r = 0; r < count; ++r) { + if ((captured.size() + k) * sizeof(float) > kMaxBytes - total_bytes_) + break; + const size_t source_row = count == 1 ? A.Rows() - 1 + : r * (A.Rows() - 1) / (count - 1); + MMI8PrepareInputRow(A.Row(source_row), k, nullptr, true, row.data()); + MMI8Rotate(row.data(), k); + bool finite = true; + for (size_t c = 0; c < k; ++c) { + row[c] *= A.Scale(); + finite &= std::isfinite(row[c]); + } + if (!finite) { + ++entry.skipped; + continue; + } + captured.insert(captured.end(), row.begin(), row.end()); + } + if (captured.empty()) return; + const std::string path = directory_ + "/" + name + ".f32"; + if (entry.rows == 0 && std::filesystem::exists(path)) + HWY_ABORT("Calibration output already exists: %s; use a fresh directory", + path.c_str()); + FILE* file = fopen(path.c_str(), entry.rows == 0 ? "wb" : "ab"); + if (file == nullptr) HWY_ABORT("Cannot open calibration output %s", path.c_str()); + const size_t written = fwrite(captured.data(), sizeof(float), captured.size(), file); + const int close_result = fclose(file); + if (written != captured.size() || close_result != 0) + HWY_ABORT("Cannot write calibration output %s", path.c_str()); + entry.rows += captured.size() / k; + total_bytes_ += captured.size() * sizeof(float); + const std::string metadata = directory_ + "/" + name + ".json"; + file = fopen(metadata.c_str(), "w"); + if (file == nullptr) HWY_ABORT("Cannot open calibration metadata %s", metadata.c_str()); + const int result = fprintf( + file, + "{\"name\":\"%s\",\"K\":%zu,\"rows\":%zu,\"dtype\":\"float32_le\"," + "\"rotation_block\":%zu,\"hash_bits\":%zu,\"source_type\":\"%s\"," + "\"bf16_rounded_input\":true,\"activation_scale_applied\":true," + "\"skipped_nonfinite\":%zu}\n", + name.c_str(), k, entry.rows, MMI8RotateBlockSize(), MMI8HashBits(), + TypeName(), entry.skipped); + const int metadata_close = fclose(file); + if (result < 0 || metadata_close != 0) + HWY_ABORT("Cannot write calibration metadata %s", metadata.c_str()); + } + + private: + MMI8CalibrationCapture() { + const char* path = getenv("GEMMA_MM_I8_CALIBRATION_CAPTURE"); + if (path == nullptr || *path == '\0') return; + directory_ = path; + max_rows_ = MMI8CalibrationSize("GEMMA_MM_I8_CALIBRATION_SAMPLES", 2048, 8192); + rows_per_call_ = MMI8CalibrationSize("GEMMA_MM_I8_CALIBRATION_ROWS_PER_CALL", 32, 8192); + const uint16_t endian = 1; + if (*reinterpret_cast(&endian) != 1) + HWY_ABORT("Calibration capture requires a little-endian host"); + std::error_code error; + std::filesystem::create_directories(directory_, error); + if (error) HWY_ABORT("Cannot create calibration directory %s", directory_.c_str()); + } + + struct Entry { + size_t k = 0; + size_t rows = 0; + size_t skipped = 0; + }; + static constexpr size_t kMaxBytes = size_t{1} << 30; + std::string directory_; + size_t max_rows_ = 0; + size_t rows_per_call_ = 32; + size_t total_bytes_ = 0; + std::mutex mutex_; + std::unordered_map entries_; +}; + +// Offline calibration interchange. All header integers and float payloads are +// little-endian. Export omits B.Scale(); import scales are multiplied by it in +// the model packer. Version 02 adds K input-scale floats after the fixed header +// so a transformed weight basis cannot be imported into an incompatible model. +// Files must come from the matching original checkpoint. +class MMI8WeightIO { + public: + MMI8WeightIO(const MatPtr& B, size_t block_size, bool selected, + const float* expected_input_scale = nullptr) + : n_(B.Rows()), k_(B.Cols()), block_(block_size) { + if (!selected) return; + const char* input = getenv("GEMMA_MM_I8_IMPORT_DIR"); + const char* output = getenv("GEMMA_MM_I8_EXPORT_DIR"); + if ((input == nullptr || *input == '\0') && + (output == nullptr || *output == '\0')) return; + const uint16_t endian = 1; + if (*reinterpret_cast(&endian) != 1) + HWY_ABORT("Weight calibration interchange requires little endian"); + // Bound products before computing payload sizes or seeking. In particular, + // a malformed dimension must not wrap into a plausible small file size. + const uint64_t max_offset = static_cast(LONG_MAX); + if (n_ == 0 || k_ == 0 || uint64_t{k_} > max_offset / sizeof(float) || + uint64_t{n_} > max_offset / k_) + HWY_ABORT("Invalid weight interchange dimensions for %s", B.Name()); + const uint64_t q_bytes = uint64_t{n_} * k_; + const uint64_t basis_bytes = + expected_input_scale == nullptr ? 0 : uint64_t{k_} * sizeof(float); + if (expected_input_scale != nullptr) { + for (size_t c = 0; c < k_; ++c) + if (!(expected_input_scale[c] > 0.0f) || + !std::isfinite(expected_input_scale[c])) + HWY_ABORT("Invalid input scale for weight interchange: %s", B.Name()); + } + const std::string name = MMI8CalibrationName(B.Name()); + if (input != nullptr && *input != '\0') { + const std::string path = std::string(input) + "/" + name + ".wq"; + import_ = fopen(path.c_str(), "rb"); + if (import_ == nullptr && errno != ENOENT) + HWY_ABORT("Cannot open calibrated weights %s", path.c_str()); + if (import_ != nullptr) { + unsigned char header[48]; + if (fread(header, 1, sizeof(header), import_) != sizeof(header)) + HWY_ABORT("Invalid calibrated weight header %s", path.c_str()); + const bool has_basis = memcmp(header, "MMI8WQ02", 8) == 0; + if (!has_basis && memcmp(header, "MMI8WQ01", 8) != 0) + HWY_ABORT("Invalid calibrated weight header %s", path.c_str()); + if (has_basis != (expected_input_scale != nullptr)) + HWY_ABORT("Calibrated weight input basis mismatch for %s", B.Name()); + const auto read64 = [&](size_t offset) { + uint64_t value = 0; + for (size_t i = 0; i < 8; ++i) + value |= uint64_t{header[offset + i]} << (8 * i); + return value; + }; + if (read64(8) != n_ || read64(16) != k_ || + read64(24) != block_ || read64(32) != MMI8RotateBlockSize() || + read64(40) != MMI8HashBits() || + (block_ != 32 && block_ != 64 && block_ != 128) || k_ % block_ != 0) + HWY_ABORT("Calibrated weight settings mismatch for %s", B.Name()); + const uint64_t scale_count = q_bytes / block_; + if (basis_bytes > max_offset - sizeof(header)) + HWY_ABORT("Invalid calibrated weight basis size %s", path.c_str()); + const uint64_t data_offset = sizeof(header) + basis_bytes; + if (q_bytes > max_offset - data_offset || + scale_count > (uint64_t{1} << 28) || + scale_count > + (max_offset - data_offset - q_bytes) / sizeof(float)) + HWY_ABORT("Invalid calibrated weight payload size %s", path.c_str()); + const uint64_t scale_offset = data_offset + q_bytes; + std::error_code error; + const uint64_t file_size = std::filesystem::file_size(path, error); + if (error || file_size != scale_offset + scale_count * sizeof(float)) + HWY_ABORT("Invalid calibrated weight payload size %s", path.c_str()); + if (has_basis) { + std::vector input_scale(k_); + if (fread(input_scale.data(), sizeof(float), k_, import_) != k_) + HWY_ABORT("Cannot read calibrated weight basis %s", path.c_str()); + for (float scale : input_scale) + if (!(scale > 0.0f) || !std::isfinite(scale)) + HWY_ABORT("Invalid calibrated weight input scale %s", path.c_str()); + if (memcmp(input_scale.data(), expected_input_scale, + static_cast(basis_bytes)) != 0) + HWY_ABORT("Calibrated weight input scale mismatch for %s", B.Name()); + } + scales_.resize(static_cast(scale_count)); + if (fseek(import_, static_cast(scale_offset), SEEK_SET) != 0 || + fread(scales_.data(), sizeof(float), scales_.size(), import_) != scales_.size() || + fseek(import_, static_cast(data_offset), SEEK_SET) != 0) + HWY_ABORT("Cannot read calibrated weight scales %s", path.c_str()); + for (float scale : scales_) + if (!(scale > 0.0f) || !std::isfinite(scale)) + HWY_ABORT("Invalid calibrated weight scale %s", path.c_str()); + } + } + if (output != nullptr && *output != '\0') { + constexpr uint64_t header_bytes = 40; + if (basis_bytes > max_offset - header_bytes || + q_bytes > (max_offset - header_bytes - basis_bytes) / sizeof(float)) + HWY_ABORT("Invalid weight export payload size for %s", B.Name()); + std::error_code error; + std::filesystem::create_directories(output, error); + if (error) HWY_ABORT("Cannot create weight export directory %s", output); + const std::string path = std::string(output) + "/" + name + ".f32"; + if (std::filesystem::exists(path)) + HWY_ABORT("Weight export already exists: %s; use a fresh directory", path.c_str()); + export_ = fopen(path.c_str(), "wb"); + if (export_ == nullptr) HWY_ABORT("Cannot open weight export %s", path.c_str()); + const uint64_t header[4] = {n_, k_, MMI8RotateBlockSize(), MMI8HashBits()}; + const char* magic = expected_input_scale == nullptr ? "W8RAW001" : "W8RAW002"; + if (fwrite(magic, 1, 8, export_) != 8 || + fwrite(header, 1, sizeof(header), export_) != sizeof(header) || + (expected_input_scale != nullptr && + fwrite(expected_input_scale, sizeof(float), k_, export_) != k_)) + HWY_ABORT("Cannot write weight export header %s", path.c_str()); + } + } + + MMI8WeightIO(const MMI8WeightIO&) = delete; + MMI8WeightIO& operator=(const MMI8WeightIO&) = delete; + ~MMI8WeightIO() { + if (import_ != nullptr && (imported_rows_ != n_ || fclose(import_) != 0)) + HWY_ABORT("Incomplete calibrated weight import"); + if (export_ != nullptr && (exported_rows_ != n_ || fclose(export_) != 0)) + HWY_ABORT("Incomplete rotated weight export"); + } + + bool Importing() const { return import_ != nullptr; } + bool Exporting() const { return export_ != nullptr; } + + bool ImportRow(size_t row, MMI8BT* out) { + if (!Importing()) return false; + if (row != imported_rows_ || fread(out, 1, k_, import_) != k_) + HWY_ABORT("Cannot read calibrated weight row %zu", row); + for (size_t c = 0; c < k_; ++c) { + const int q = reinterpret_cast(out)[c]; + if (q == -128) HWY_ABORT("Calibrated weight q must be in [-127,127]"); + out[c] = static_cast(q + (GEMMA_MM_I8_BIASED_B ? 128 : 0)); + } + ++imported_rows_; + return true; + } + + float Scale(size_t row, size_t group) const { + HWY_DASSERT(Importing() && row < n_ && group < k_ / block_); + return scales_[group * n_ + row]; + } + + void ExportRow(size_t row, const float* weights) { + if (!Exporting()) return; + if (row != exported_rows_) HWY_ABORT("Out-of-order weight export"); + for (size_t c = 0; c < k_; ++c) + if (!std::isfinite(weights[c])) HWY_ABORT("Nonfinite rotated weight export"); + if (fwrite(weights, sizeof(float), k_, export_) != k_) + HWY_ABORT("Cannot write rotated weight row %zu", row); + ++exported_rows_; + } + + private: + const size_t n_; + const size_t k_; + const size_t block_; + FILE* import_ = nullptr; + FILE* export_ = nullptr; + size_t imported_rows_ = 0; + size_t exported_rows_ = 0; + std::vector scales_; +}; + +// Mean-only correction is O(K) per packed row. The sidecar is magic MMI8MU01, +// little-endian uint64 K/group, then float32 muX[K], followed by muXhat[K]. +class MMI8MeanCalibration { + public: + MMI8MeanCalibration(const MatPtr& B, size_t block_size, bool selected) { + const char* directory = getenv("GEMMA_MM_I8_CALIBRATION_DIR"); + if (!selected || !MMI8Flag("GEMMA_MM_I8_BIAS_CORRECTION") || + block_size == 0 || directory == nullptr || *directory == '\0') + return; + const std::string path = std::string(directory) + "/" + + MMI8CalibrationName(B.Name()) + ".mean"; + FILE* file = fopen(path.c_str(), "rb"); + if (file == nullptr) { + if (errno == ENOENT) return; + HWY_ABORT("Cannot open mean calibration %s", path.c_str()); + } + unsigned char header[24]; + if (fread(header, 1, sizeof(header), file) != sizeof(header) || + memcmp(header, "MMI8MU01", 8) != 0) + HWY_ABORT("Invalid mean calibration header %s", path.c_str()); + const auto read64 = [&](size_t offset) { + uint64_t value = 0; + for (size_t i = 0; i < 8; ++i) + value |= uint64_t{header[offset + i]} << (8 * i); + return value; + }; + const uint64_t k = read64(8), group = read64(16); + if (k != B.Cols() || group != block_size || + (group != 32 && group != 64 && group != 128) || k % group != 0) + HWY_ABORT("Mean calibration dimensions do not match %s", B.Name()); + const uint16_t endian = 1; + if (*reinterpret_cast(&endian) != 1) + HWY_ABORT("Mean calibration requires a little-endian host"); + std::error_code error; + const auto file_size = std::filesystem::file_size(path, error); + if (error || k > (uint64_t{1} << 27) || + file_size != sizeof(header) + 2 * k * sizeof(float)) + HWY_ABORT("Invalid mean calibration payload size %s", path.c_str()); + means_.resize(static_cast(2 * k)); + const size_t loaded = + fread(means_.data(), sizeof(float), means_.size(), file); + const int close_result = fclose(file); + if (loaded != means_.size() || close_result != 0) + HWY_ABORT("Cannot read mean calibration payload %s", path.c_str()); + for (float value : means_) + if (!std::isfinite(value)) + HWY_ABORT("Nonfinite mean calibration %s", path.c_str()); + k_ = static_cast(k); + block_ = block_size; + } + + bool Enabled() const { return !means_.empty(); } + + double Correction(const float* weights, size_t begin, const MMI8BT* bytes, + float scale) const { + if (!Enabled()) return 0.0; + double correction = 0.0; + for (size_t i = 0; i < block_; ++i) { + const int q = static_cast(bytes[i]) - + (GEMMA_MM_I8_BIASED_B ? 128 : 0); + correction += double(means_[begin + i]) * weights[i] - + double(means_[k_ + begin + i]) * q * scale; + } + return correction; + } + + private: + size_t k_ = 0; + size_t block_ = 0; + std::vector means_; +}; + +// A calibration file contains magic MMI8HG01, little-endian uint64 K/group/ +// samples, followed by row-major F32 H and G for each group. H = Xhat^T Xhat, +// G = Xhat^T X. Offline generation may add the same ridge prior to H and G. +// The object lives only while one tensor is packed, bounding retained memory. +class MMI8WeightCalibration { + public: + MMI8WeightCalibration(const MatPtr& B, size_t block_size, bool selected) { + const char* directory = getenv("GEMMA_MM_I8_CALIBRATION_DIR"); + if (!selected || block_size == 0 || directory == nullptr || *directory == '\0') + return; + const std::string path = std::string(directory) + "/" + + MMI8CalibrationName(B.Name()) + ".hg"; + FILE* file = fopen(path.c_str(), "rb"); + if (file == nullptr) { + if (errno == ENOENT) return; + HWY_ABORT("Cannot open weight calibration %s", path.c_str()); + } + unsigned char header[32]; + if (fread(header, 1, sizeof(header), file) != sizeof(header) || + memcmp(header, "MMI8HG01", 8) != 0) + HWY_ABORT("Invalid calibration header %s", path.c_str()); + const auto read64 = [&](size_t offset) { + uint64_t value = 0; + for (size_t i = 0; i < 8; ++i) value |= uint64_t{header[offset + i]} << (8 * i); + return value; + }; + const uint64_t k = read64(8), group = read64(16), samples = read64(24); + if (k != B.Cols() || group != block_size || samples == 0 || + (group != 32 && group != 64 && group != 128) || k % group != 0) + HWY_ABORT("Calibration dimensions do not match %s", B.Name()); + const uint16_t endian = 1; + if (*reinterpret_cast(&endian) != 1) + HWY_ABORT("Calibration refinement requires a little-endian host"); + const uint64_t count = 2 * k * group; + std::error_code error; + const auto file_size = std::filesystem::file_size(path, error); + if (error || count > (uint64_t{1} << 28) || file_size != sizeof(header) + count * sizeof(float)) + HWY_ABORT("Invalid calibration payload size %s", path.c_str()); + matrices_.resize(static_cast(count)); + const size_t loaded = fread(matrices_.data(), sizeof(float), matrices_.size(), file); + const int close_result = fclose(file); + if (loaded != matrices_.size() || close_result != 0) + HWY_ABORT("Cannot read calibration payload %s", path.c_str()); + for (float value : matrices_) + if (!std::isfinite(value)) HWY_ABORT("Nonfinite calibration matrix %s", path.c_str()); + block_ = block_size; + // Make the Hessian explicitly symmetric for the quadratic coordinate updates. + for (size_t c = 0; c < B.Cols(); c += block_) { + float* h = matrices_.data() + 2 * c * block_; + for (size_t i = 0; i < block_; ++i) { + if (h[i * block_ + i] < 0.0f) + HWY_ABORT("Negative calibration diagonal %s", path.c_str()); + for (size_t j = 0; j < i; ++j) { + const float average = static_cast( + (double(h[i * block_ + j]) + h[j * block_ + i]) * 0.5); + h[i * block_ + j] = h[j * block_ + i] = average; + } + } + } + sweeps_ = MMI8CalibrationSize("GEMMA_MM_I8_CALIBRATION_SWEEPS", 1, 8); + } + + float Refine(const float* weights, size_t begin, MMI8BT* bytes, float initial_scale) const { + if (matrices_.empty()) return initial_scale; + const float* h = matrices_.data() + 2 * begin * block_; + const float* g = h + block_ * block_; + std::array q{}, best_q{}; + std::array target{}, gradient{}, hq{}; + for (size_t i = 0; i < block_; ++i) { + q[i] = static_cast(bytes[i]) - (GEMMA_MM_I8_BIASED_B ? 128 : 0); + best_q[i] = q[i]; + for (size_t j = 0; j < block_; ++j) + target[i] += double(g[i * block_ + j]) * weights[j]; + } + // H*q changes only when the integer vector changes, not when its scale + // changes. Keep the original reduction order while avoiding repeated GEMV. + const auto refresh_hq = [&]() { + for (size_t i = 0; i < block_; ++i) { + hq[i] = 0.0; + for (size_t j = 0; j < block_; ++j) + hq[i] += double(h[i * block_ + j]) * q[j]; + } + }; + const auto objective = [&](float scale) { + double value = 0.0; + for (size_t i = 0; i < block_; ++i) + value += double(scale) * q[i] * (double(scale) * hq[i] - 2.0 * target[i]); + return value; + }; + float scale = initial_scale, best_scale = initial_scale; + refresh_hq(); + double best_objective = objective(scale); + if (!std::isfinite(best_objective)) return initial_scale; + for (size_t pass = 0; pass <= sweeps_; ++pass) { + double numerator = 0.0, denominator = 0.0; + for (size_t i = 0; i < block_; ++i) { + numerator += q[i] * target[i]; + denominator += q[i] * hq[i]; + } + if (denominator > 0.0) { + const float candidate = static_cast(numerator / denominator); + if (candidate > 0.0f && std::isfinite(candidate)) scale = candidate; + } + const double loss = objective(scale); + if (std::isfinite(loss) && loss < best_objective) { + best_objective = loss; + best_scale = scale; + best_q = q; + } + if (pass == sweeps_ || !(scale > 0.0f) || !std::isfinite(scale)) break; + for (size_t i = 0; i < block_; ++i) + gradient[i] = double(scale) * hq[i] - target[i]; + for (size_t j = 0; j < block_; ++j) { + const double diagonal = h[j * block_ + j]; + if (!(diagonal > 0.0)) continue; + const double desired = q[j] - gradient[j] / (double(scale) * diagonal); + if (!std::isfinite(desired)) continue; + const int rounded = static_cast(std::lround(HWY_MIN(127.0, HWY_MAX(-127.0, desired)))); + const int change = rounded - q[j]; + const double step = double(scale) * change; + const double delta = 2.0 * step * gradient[j] + step * step * diagonal; + if (change == 0 || !(delta < 0.0)) continue; + q[j] = rounded; + for (size_t i = 0; i < block_; ++i) + gradient[i] += step * h[i * block_ + j]; + } + refresh_hq(); + } + for (size_t i = 0; i < block_; ++i) + bytes[i] = static_cast(best_q[i] + (GEMMA_MM_I8_BIASED_B ? 128 : 0)); + return best_scale; + } + + private: + size_t block_ = 0; + size_t sweeps_ = 0; + std::vector matrices_; +}; + +} // namespace HWY_NAMESPACE +} // namespace gcpp +HWY_AFTER_NAMESPACE(); + +#endif // THIRD_PARTY_GEMMA_CPP_MATMUL_I8_CALIBRATION_TOGGLE diff --git a/ops/matmul_i8_model-inl.h b/ops/matmul_i8_model-inl.h new file mode 100644 index 00000000..a8425f29 --- /dev/null +++ b/ops/matmul_i8_model-inl.h @@ -0,0 +1,750 @@ +// Copyright 2025 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Experiment harness that routes the model's MatMuls through the W8A8 kernel +// in `ops/matmul_i8-inl.h`, to measure end-to-end quality. Weights are +// quantized lazily on first use and cached, keyed by their data pointer, so +// this needs no changes to the loading path. +// +// NOT a production integration: +// - quantizing from whatever the file holds (e.g. SFP) stacks a second +// quantization on top of the first; a real path would quantize the +// original checkpoint; +// - the cache is a process-wide singleton and never freed. +// +// Enabled by environment variables, so no CLI plumbing is needed: +// GEMMA_MM_I8=1 route eligible MatMuls through the int8 kernel +// GEMMA_MM_I8_MIN_K= leave tensors with K < n in their original format +// GEMMA_MM_I8_SKIP_ROWS= leave tensors with N >= n alone (e.g. the vocab- +// sized logits projection, the usual first thing to +// exclude from W8A8) +// GEMMA_MM_I8_INCLUDE= only quantize tensor names containing one of the +// comma-separated substrings +// GEMMA_MM_I8_EXCLUDE= leave matching tensor names in their old format +// GEMMA_MM_I8_VERBOSE=1 log each tensor as it is quantized +// GEMMA_MM_I8_BLOCK_SIZE=64 select 64-wide instead of 128-wide rotation +// GEMMA_MM_I8_HASH_BITS=16 select the cheaper 16-bit sign hash +// GEMMA_MM_I8_L2_SCALE=1 equalize FFN hidden and RMSNorm input channels +// GEMMA_MM_I8_MICROSCALE=1 use local A/B quantization scales +// GEMMA_MM_I8_FAST_MICRO=0 use the reference microscaling kernel +// GEMMA_MM_I8_MIN_K_SPLITS=1 prefer fewer KC ranges when autotuning is off +// GEMMA_MM_I8_QUANT_BLOCK_SIZE= quantize groups of 32, 64 or 128 values; +// fall back to rotation width if K is not divisible +// GEMMA_MM_I8_SCALE_FFN=0 disable hidden-channel scaling for ablation +// GEMMA_MM_I8_SCALE_NORM=0 disable RMSNorm folding for ablation +// GEMMA_MM_I8_SCALE_NORM_INCLUDE= fold only matching norm tensor names +// GEMMA_MM_I8_SCALE_CALIBRATION_DIR= measured input RMS for folding +// GEMMA_MM_I8_SCALE_CALIBRATION_ALPHA= balancing exponent (default 0.5) +// GEMMA_MM_I8_SCALE_CALIBRATION_POW2=0 disable power-of-two scale rounding +// GEMMA_MM_I8_CALIBRATION_CAPTURE= capture rotated SFP activation rows +// GEMMA_MM_I8_CALIBRATION_SAMPLES= captured rows per tensor (default 2048) +// GEMMA_MM_I8_CALIBRATION_ROWS_PER_CALL= sample each call (default 32) +// GEMMA_MM_I8_CALIBRATION_DIR= refine packed weights using H/G files +// GEMMA_MM_I8_CALIBRATION_SWEEPS= 0 = scales only, default 1, maximum 8 +// GEMMA_MM_I8_CALIBRATION_INCLUDE/EXCLUDE= select calibration tensors +// GEMMA_MM_I8_BIAS_CORRECTION=1 use .mean files for output bias correction +// GEMMA_MM_I8_PACKED_HEAD=1 pack large microscale heads for the N8 kernel +// GEMMA_MM_I8_PACKED_HEAD_FULL_K=1 prefer one KC for packed F32 M1 heads +// GEMMA_MM_I8_DUAL_A_DOWN=1 pack FFN-down weights and quantize A residuals +// GEMMA_MM_I8_DUAL_A_HEAD=1 pack c_embedding logits and quantize A residuals +// GEMMA_MM_I8_DUAL_A_BODY=1 use A residuals for eligible non-embedding weights +// GEMMA_MM_I8_DUAL_A_M1_ONLY=1 limit A residual correction to decode +// GEMMA_MM_I8_EXPORT_DIR= export rotated unscaled F32 weight rows +// GEMMA_MM_I8_IMPORT_DIR= import externally calibrated q/scales + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include // NOLINT +#include +#include +#include + +#include "hwy/base.h" +#include "ops/matmul.h" +#include "util/mat.h" +#include "util/threading_context.h" + +// Include guard for (potentially) SIMD code. +#if defined(THIRD_PARTY_GEMMA_CPP_MATMUL_I8_MODEL_TOGGLE) == \ + defined(HWY_TARGET_TOGGLE) +#ifdef THIRD_PARTY_GEMMA_CPP_MATMUL_I8_MODEL_TOGGLE +#undef THIRD_PARTY_GEMMA_CPP_MATMUL_I8_MODEL_TOGGLE +#else +#define THIRD_PARTY_GEMMA_CPP_MATMUL_I8_MODEL_TOGGLE +#endif + +#include "hwy/highway.h" +// After highway.h +#include "compression/compress-inl.h" +#include "ops/matmul_i8-inl.h" +#include "ops/matmul_i8_calibration-inl.h" + +HWY_BEFORE_NAMESPACE(); +namespace gcpp { +namespace HWY_NAMESPACE { +namespace hn = hwy::HWY_NAMESPACE; + +// Reads an integer environment variable, or returns `fallback`. +static inline size_t MMI8EnvSize(const char* name, size_t fallback) { + const char* s = getenv(name); + if (s == nullptr || *s == '\0') return fallback; + const long long v = atoll(s); // NOLINT + return v < 0 ? fallback : static_cast(v); +} + +// True if `name` contains any non-empty comma-separated token in `list`. +static inline bool MMI8NameMatches(const char* name, const char* list) { + if (list == nullptr || *list == '\0') return false; + for (const char* begin = list; *begin != '\0';) { + const char* end = strchr(begin, ','); + if (end == nullptr) end = begin + strlen(begin); + const size_t len = static_cast(end - begin); + if (len != 0) { + for (const char* at = name; *at != '\0'; ++at) { + if (strncmp(at, begin, len) == 0) return true; + } + } + begin = *end == '\0' ? end : end + 1; + } + return false; +} + +// Quantizes one row of `k` floats to symmetric int8, biased by 128 if +// `GEMMA_MM_I8_BIASED_B`. Returns the dequantization scale. +static HWY_INLINE float PackBRow(const float* HWY_RESTRICT in, size_t k, + MMI8BT* HWY_RESTRICT out, size_t padded_k) { + const hn::ScalableTag df; + const hn::Rebind di32; + const hn::Rebind di8; + using VF = hn::Vec; + const size_t NF = hn::Lanes(df); + + VF vmax = hn::Zero(df); + size_t i = 0; + if (k >= NF) { + for (; i <= k - NF; i += NF) { + vmax = hn::Max(vmax, hn::Abs(hn::LoadU(df, in + i))); + } + } + if (i != k) vmax = hn::Max(vmax, hn::Abs(hn::LoadN(df, in + i, k - i))); + const float amax = hn::ReduceMax(df, vmax); + + const float scale = (amax == 0.0f) ? 1.0f : amax / kMMI8Max; + const float inv = (amax == 0.0f) ? 0.0f : kMMI8Max / amax; + const VF vinv = hn::Set(df, inv); + // Store as int8 and add the bias afterwards: `DemoteTo` to u8 would saturate + // negative values to zero. + const auto vbias = hn::Set(di32, GEMMA_MM_I8_BIASED_B ? 128 : 0); + + i = 0; + if (k >= NF) { + for (; i <= k - NF; i += NF) { + const auto q = hn::NearestInt(hn::Mul(hn::LoadU(df, in + i), vinv)); + // Bias in the int32 domain, then narrow; `DemoteTo` saturates, and + // `q + 128` is within [1, 255] so nothing is clamped. + if constexpr (GEMMA_MM_I8_BIASED_B) { + const hn::Rebind du8; + hn::StoreU(hn::DemoteTo(du8, hn::Add(q, vbias)), du8, + HWY_RCAST_ALIGNED(uint8_t*, out) + i); + } else { + hn::StoreU(hn::DemoteTo(di8, q), di8, + HWY_RCAST_ALIGNED(int8_t*, out) + i); + } + } + } + for (; i < k; ++i) { + const int32_t q = static_cast(std::lroundf(in[i] * inv)); + out[i] = static_cast(q + (GEMMA_MM_I8_BIASED_B ? 128 : 0)); + } + for (; i < padded_k; ++i) out[i] = static_cast(0); + return scale; +} + +// Process-wide cache of int8 weights, keyed by the tensor's data pointer. +class MMI8WeightCache { + public: + static MMI8WeightCache& Get() { + static MMI8WeightCache cache; + return cache; + } + + bool Enabled() const { return enabled_; } + + bool ScalingEnabled() const { return enabled_ && l2_scaling_; } + + bool Eligible(const MatPtr& B) const { + if (!enabled_ || !B.HasPtr() || B.Cols() < min_k_ || + B.Rows() >= skip_rows_ || B.Rows() % kNR != 0 || + B.Cols() % MMI8RotateBlockSize() != 0) + return false; + if (include_ && *include_ && !MMI8NameMatches(B.Name(), include_)) + return false; + return !MMI8NameMatches(B.Name(), exclude_); + } + + // Keep every eligible tensor in W8A8 even if its K cannot use the requested + // quantization group. Fused FFN inputs have the same K and choose alike. + size_t QuantBlockSize(const MatPtr& B) const { + const size_t requested = MMI8QuantBlockSize(); + if (requested == 0) return 0; + return B.Cols() % requested == 0 ? requested : MMI8RotateBlockSize(); + } + + // Explicit pairing avoids stale "previous tensor" state, including when + // routing filters skip a projection or an unfused FFN is used. + void PrepareFFN(const MatPtr& gate, const MatPtr& up, const MatPtr& down, + MatMulEnv& env) { + if (!l2_scaling_ || !MMI8Flag("GEMMA_MM_I8_SCALE_FFN", true) || + !Eligible(gate) || !Eligible(up) || !Eligible(down)) + return; + std::lock_guard lock(mutex_); + const void* key = down.RowBytes(0); + if (map_.count(key)) return; + // Never change the representation of an already packed up projection. + if (map_.count(up.RowBytes(0))) return; + if (gate.Rows() != up.Rows() || up.Rows() != down.Cols()) return; + PrepareTimer timer(env); + std::vector up_norms; + const bool calibrated = LoadInputRMS(down, up_norms); + if (!calibrated) { + std::vector gate_norms(gate.Rows()); + up_norms.resize(up.Rows()); + CallUpcasted(&gate, [&](const auto* typed) { + ComputeRowNorms(*typed, env.ctx, gate_norms); + }); + CallUpcasted(&up, [&](const auto* typed) { + ComputeRowNorms(*typed, env.ctx, up_norms); + }); + // Data-free proxy for the magnitude of act(W1*x) * (W2*x). + // Calibration instead supplies the actual hidden activation RMS. + for (size_t i = 0; i < up_norms.size(); ++i) up_norms[i] *= gate_norms[i]; + } + auto entry = std::make_unique(down, env.ctx.allocator, + QuantBlockSize(down)); + CallUpcasted(&down, [&](const auto* typed) { + ConfigureL2Scale(*typed, up_norms, up.RowBytes(0), *entry, env.ctx, + calibrated); + Quantize(*typed, *entry); + }); + // Scale the linear branch's output in its existing dequantization multiply. + // GELU(W1*x) * (s*W2*x), followed by Wdown/s, preserves the real-valued + // FFN. + output_scales_[up.RowBytes(0)] = entry->input_scale; + entry->b.a_pre_scale = nullptr; + map_[key] = std::move(entry); + } + + // Fuse input equalization into RMSNorm gamma and compensate every consumer. + // Check all consumers before changing anything: fallback must stay exact. + const MatPtr& NormWeights(const MatPtr& norm, + const std::vector& consumers, + MatMulEnv& env) { + if (!l2_scaling_ || !MMI8Flag("GEMMA_MM_I8_SCALE_NORM", true) || + !norm.HasPtr() || norm.Scale() != 1.0f || consumers.empty()) + return norm; + const char* include = getenv("GEMMA_MM_I8_SCALE_NORM_INCLUDE"); + if (include != nullptr && *include != '\0' && + !MMI8NameMatches(norm.Name(), include)) + return norm; + for (const MatPtr* b : consumers) + if (!Eligible(*b) || b->Cols() != norm.Cols()) return norm; + std::lock_guard lock(mutex_); + const void* key = norm.RowBytes(0); + auto found = norms_.find(key); + if (found != norms_.end()) return *found->second; + for (const MatPtr* b : consumers) + if (map_.count(b->RowBytes(0))) return norm; + PrepareTimer timer(env); + const size_t k = norm.Cols(); + const hn::ScalableTag df; + const size_t padded = hwy::RoundUpTo(k, hn::Lanes(df)); + hwy::AlignedVector gamma(padded), row(padded); + CallUpcasted(&norm, [&](const auto* typed) { + DecompressAndZeroPad(df, typed->PaddedSpan(), 0, gamma.data(), k); + }); + std::vector column_sq(k, 0.0); + for (const MatPtr* b : consumers) + CallUpcasted(b, [&](const auto* typed) { + for (size_t r = 0; r < b->Rows(); ++r) { + DecompressAndZeroPad(df, typed->PaddedSpan(), r * b->Stride(), + row.data(), k); + for (size_t c = 0; c < k; ++c) { + const double v = row[c] * static_cast(b->Scale()); + column_sq[c] += v * v; + } + } + }); + auto folded = std::make_unique>( + "i8_norm", norm.Extents(), env.ctx.allocator, MatPadding::kOdd); + hwy::AlignedVector scales(k); + std::vector input_rms; + const bool calibrated = LoadInputRMS(*consumers.front(), input_rms); + for (size_t i = 1; i < consumers.size(); ++i) { + std::vector other_rms; + const bool other_calibrated = LoadInputRMS(*consumers[i], other_rms); + if (other_calibrated != calibrated || + (calibrated && other_rms != input_rms)) + HWY_ABORT("RMSNorm consumers must share identical activation RMS: " + "%s and %s", consumers.front()->Name(), consumers[i]->Name()); + } + if (calibrated) MeasuredInputScales(input_rms, column_sq, scales); + for (size_t c = 0; c < k; ++c) { + const float g = + 1.0f + gamma[c]; // Gemma's stored gamma is offset by one. + if (!calibrated) + scales[c] = MMI8L2Scale(std::abs(g), std::sqrt(column_sq[c])); + folded->Row(0)[c] = g * scales[c] - 1.0f; + } + + for (const MatPtr* b : consumers) input_scales_[b->RowBytes(0)] = scales; + if (verbose_) + fprintf(stderr, "MM.I8: RMSNorm fold %s, %zu consumers, %s RMS\n", + norm.Name(), consumers.size(), calibrated ? "measured" : "proxy"); + const MatPtr& result = *folded; + norms_[key] = std::move(folded); + return result; + } + + template + const MMI8B* Lookup(const MatPtrT& B, MatMulEnv& env) { + if (!Eligible(B)) return nullptr; + + const void* key = B.RowBytes(0); + std::lock_guard lock(mutex_); + auto it = map_.find(key); + if (it != map_.end()) return &it->second->b; + PrepareTimer timer(env); + auto entry = std::make_unique(B, env.ctx.allocator, + QuantBlockSize(B)); + const auto input = input_scales_.find(key); + if (input != input_scales_.end()) entry->input_scale = input->second; + Quantize(B, *entry); + const auto output = output_scales_.find(key); + if (output != output_scales_.end()) { + const size_t groups = + entry->b.block_size ? B.Cols() / entry->b.block_size : 1; + for (size_t g = 0; g < groups; ++g) + for (size_t r = 0; r < B.Rows(); ++r) + entry->scale[g * B.Rows() + r] *= output->second[r]; + for (size_t r = 0; r < entry->bias.size(); ++r) + entry->bias[r] *= output->second[r]; + } + if (verbose_) + fprintf(stderr, "MM.I8: quantized %-16s %6zu x %6zu block=%zu\n", + B.Name(), B.Rows(), B.Cols(), entry->b.block_size); + const MMI8B* result = &entry->b; + map_[key] = std::move(entry); + return result; + } + + // Storage for the quantized `A`, grown on demand. `MatMul` for a given + // `MatMulEnv` is not called concurrently, and this experiment runs a single + // cluster, so one instance suffices. + MMI8AStorage& AStorage(size_t M, size_t K, const Allocator& allocator) { + if (a_ == nullptr || M > a_max_M_ || K > a_max_K_) { + a_max_M_ = HWY_MAX(M, a_max_M_); + a_max_K_ = HWY_MAX(K, a_max_K_); + a_ = std::make_unique(a_max_M_, a_max_K_, allocator); + } + return *a_; + } + + private: + class PrepareTimer { + public: + explicit PrepareTimer(MatMulEnv& env) + : env_(env), start_(std::chrono::steady_clock::now()) {} + ~PrepareTimer() { + env_.weight_prepare_seconds += + std::chrono::duration(std::chrono::steady_clock::now() - + start_) + .count(); + } + + private: + MatMulEnv& env_; + std::chrono::steady_clock::time_point start_; + }; + + static bool PackedHeadEligible(const MatPtr& B, size_t block_size) { + return MMI8PackedHead() && B.Rows() >= 65536 && B.Rows() % 8 == 0 && + (block_size == 32 || block_size == 64 || block_size == 128); + } + + static bool DualAEligible(const MatPtr& B, size_t block_size) { + static const bool down = MMI8Flag("GEMMA_MM_I8_DUAL_A_DOWN"); + static const bool head = MMI8Flag("GEMMA_MM_I8_DUAL_A_HEAD"); + static const bool body = MMI8Flag("GEMMA_MM_I8_DUAL_A_BODY"); + if ((!down && !head && !body) || !HWY_ARCH_X86_64 || !MMI8FastMicro() || + !MMI8NativeVNNI() || + B.Rows() % 8 != 0 || + (block_size != 32 && block_size != 64 && block_size != 128) || + B.Cols() % block_size != 0) + return false; + const bool is_embedding = strcmp(B.Name(), "c_embedding") == 0; + if (is_embedding) return head && B.Rows() >= 65536; + if (body) return true; + if (!down) return false; + // Limit the down selection to ordinary FFN-down tensors. A head requires + // its separate flag; other tensors containing "linear" remain unchanged. + constexpr char prefix[] = "linear_w_"; + if (strncmp(B.Name(), prefix, sizeof(prefix) - 1) != 0) return false; + const char* suffix = B.Name() + sizeof(prefix) - 1; + if (*suffix == '\0') return false; + for (; *suffix != '\0'; ++suffix) + if (*suffix < '0' || *suffix > '9') return false; + return true; + } + + static bool PackedEligible(const MatPtr& B, size_t block_size) { + return PackedHeadEligible(B, block_size) || + DualAEligible(B, block_size); + } + + struct Entry { + Entry(const MatPtr& B, const Allocator& allocator, size_t block_size) + : data("B_i8", Extents2D(B.Rows(), B.Cols()), allocator, + PackedEligible(B, block_size) ? MatPadding::kPacked + : MatPadding::kOdd), + scale(B.Rows() * (block_size ? B.Cols() / block_size : 1)) { + b = MMI8B{&data, scale.data(), nullptr, block_size}; + b.dual_a = DualAEligible(B, block_size); + } + MatStorageT data; + hwy::AlignedVector scale; + hwy::AlignedVector bias; + hwy::AlignedVector input_scale; + const void* scale_source = nullptr; + MMI8B b; + }; + + MMI8WeightCache() + : enabled_(MMI8EnvSize("GEMMA_MM_I8", 0) != 0), + verbose_(MMI8EnvSize("GEMMA_MM_I8_VERBOSE", 0) != 0), + l2_scaling_(MMI8EnvSize("GEMMA_MM_I8_L2_SCALE", 0) != 0), + min_k_(MMI8EnvSize("GEMMA_MM_I8_MIN_K", 0)), + skip_rows_(MMI8EnvSize("GEMMA_MM_I8_SKIP_ROWS", ~size_t{0})), + include_(getenv("GEMMA_MM_I8_INCLUDE")), + exclude_(getenv("GEMMA_MM_I8_EXCLUDE")) {} + + template + void ComputeRowNorms(const MatPtrT& B, ThreadingContext& ctx, + std::vector& norms) { + const hn::ScalableTag df; + const size_t K = B.Cols(); + const size_t padded_k = hwy::RoundUpTo(K, hn::Lanes(df)); + hwy::AlignedVector row(padded_k + hn::Lanes(df)); + const PackedSpan span = B.PaddedSpan(); + const double tensor_scale = hwy::ScalarAbs(B.Scale()); + for (size_t r = 0; r < B.Rows(); ++r) { + DecompressAndZeroPad(df, span, r * B.Stride(), row.data(), K); + double sum_sq = 0.0; + for (size_t c = 0; c < K; ++c) { + sum_sq += static_cast(row[c]) * row[c]; + } + norms[r] = tensor_scale * std::sqrt(sum_sq); + } + (void)ctx; + } + + bool LoadInputRMS(const MatPtr& down, std::vector& rms) { + const char* directory = getenv("GEMMA_MM_I8_SCALE_CALIBRATION_DIR"); + if (directory == nullptr || *directory == '\0') return false; + const std::string path = std::string(directory) + "/" + + MMI8CalibrationName(down.Name()) + ".rms"; + FILE* file = fopen(path.c_str(), "rb"); + if (file == nullptr) { + if (errno == ENOENT) return false; + HWY_ABORT("Cannot open activation RMS calibration %s", path.c_str()); + } + unsigned char header[16]; + if (fread(header, 1, sizeof(header), file) != sizeof(header) || + memcmp(header, "MMI8RM01", 8) != 0) + HWY_ABORT("Invalid activation RMS calibration header %s", path.c_str()); + uint64_t k = 0; + for (size_t i = 0; i < 8; ++i) k |= uint64_t{header[8 + i]} << (8 * i); + const uint16_t endian = 1; + if (k != down.Cols() || + *reinterpret_cast(&endian) != 1) + HWY_ABORT("Activation RMS dimensions/endian mismatch %s", path.c_str()); + std::vector values(static_cast(k)); + if (fread(values.data(), sizeof(float), values.size(), file) != values.size() || + fgetc(file) != EOF || ferror(file)) + HWY_ABORT("Invalid activation RMS calibration payload %s", path.c_str()); + if (fclose(file) != 0) + HWY_ABORT("Cannot close activation RMS calibration %s", path.c_str()); + rms.resize(values.size()); + for (size_t i = 0; i < values.size(); ++i) { + if (!std::isfinite(values[i]) || values[i] < 0.0f) + HWY_ABORT("Invalid activation RMS value %s", path.c_str()); + rms[i] = values[i]; + } + // H/G and mean files describe a specific input basis. They must be + // regenerated after input equalization before the two can be combined. + const char* weight_calibration = getenv("GEMMA_MM_I8_CALIBRATION_DIR"); + if (weight_calibration != nullptr && *weight_calibration != '\0') + HWY_ABORT("Activation scaling requires its own transformed H/G basis; " + "unset GEMMA_MM_I8_CALIBRATION_DIR"); + return true; + } + + // For activation RMS a_i and joint consumer-column L2 b_i, the isotropic + // quantization-error proxy is (sum a_i^2 s_i^2)(sum b_i^2 / s_i^2). + // Its minimizer has s_i proportional to sqrt(b_i / a_i). A common factor + // cancels in the real-valued product; normalize before rounding/clamping. + size_t MeasuredInputScales(const std::vector& input_rms, + const std::vector& column_sq, + hwy::AlignedVector& scales) { + HWY_DASSERT(input_rms.size() == column_sq.size() && !input_rms.empty()); + const size_t k = input_rms.size(); + scales.resize(k); + double alpha = 0.5; + const char* value = getenv("GEMMA_MM_I8_SCALE_CALIBRATION_ALPHA"); + if (value != nullptr && *value != '\0') { + char* end = nullptr; + alpha = strtod(value, &end); + if (end == value || *end != '\0' || !std::isfinite(alpha) || + alpha < 0.0 || alpha > 1.0) + HWY_ABORT("GEMMA_MM_I8_SCALE_CALIBRATION_ALPHA must be in [0,1]"); + } + const bool powers_of_two = + MMI8Flag("GEMMA_MM_I8_SCALE_CALIBRATION_POW2", true); + const auto log_scale = [&](size_t c) { + const double activation = HWY_MAX(input_rms[c], kMMI8L2NormFloor); + const double weight = HWY_MAX(std::sqrt(column_sq[c]), kMMI8L2NormFloor); + return alpha * (std::log(weight) - std::log(activation)); + }; + double mean_log_scale = 0.0; + for (size_t c = 0; c < k; ++c) mean_log_scale += log_scale(c); + mean_log_scale /= static_cast(k); + size_t clamped = 0; + for (size_t c = 0; c < k; ++c) { + const double centered = log_scale(c) - mean_log_scale; + const double raw = powers_of_two + ? std::exp2(std::round(centered / std::log(2.0))) + : std::exp(centered); + const double bounded = HWY_MIN(double(kMMI8L2ScaleMax), + HWY_MAX(double(kMMI8L2ScaleMin), raw)); + scales[c] = static_cast(bounded); + clamped += raw != bounded ? 1 : 0; + } + return clamped; + } + + template + void ConfigureL2Scale(const MatPtrT& down, + const std::vector& up_norms, const void* source, + Entry& entry, ThreadingContext& ctx, + bool calibrated = false) { + const hn::ScalableTag df; + const size_t K = down.Cols(); + const size_t padded_k = hwy::RoundUpTo(K, hn::Lanes(df)); + hwy::AlignedVector row(padded_k + hn::Lanes(df)); + const PackedSpan span = down.PaddedSpan(); + std::vector sum_sq(K, 0.0); + double before_max = 0.0; + const double tensor_scale = hwy::ScalarAbs(down.Scale()); + for (size_t r = 0; r < down.Rows(); ++r) { + DecompressAndZeroPad(df, span, r * down.Stride(), row.data(), K); + for (size_t c = 0; c < K; ++c) { + const double value = tensor_scale * row[c]; + sum_sq[c] += value * value; + before_max = HWY_MAX(before_max, hwy::ScalarAbs(value)); + } + } + + entry.input_scale.resize(K); + size_t clamped = 0; + if (calibrated) { + clamped = MeasuredInputScales(up_norms, sum_sq, entry.input_scale); + } else { + for (size_t c = 0; c < K; ++c) { + bool was_clamped = false; + entry.input_scale[c] = + MMI8L2Scale(up_norms[c], std::sqrt(sum_sq[c]), &was_clamped); + clamped += was_clamped ? 1 : 0; + } + } + entry.scale_source = source; + entry.b.a_pre_scale = entry.input_scale.data(); + + if (verbose_) { + std::vector sorted(entry.input_scale.begin(), + entry.input_scale.end()); + std::sort(sorted.begin(), sorted.end()); + double after_max = 0.0; + for (size_t r = 0; r < down.Rows(); ++r) { + DecompressAndZeroPad(df, span, r * down.Stride(), row.data(), K); + for (size_t c = 0; c < K; ++c) { + after_max = HWY_MAX(after_max, hwy::ScalarAbs(tensor_scale * row[c] / + entry.input_scale[c])); + } + } + const size_t p95 = (95 * (K - 1)) / 100; + fprintf(stderr, + "MM.I8: %s %-16s scale[min/med/p95/max]=" + "%.5g/%.5g/%.5g/%.5g clamped=%zu weight|max|=%.5g->%.5g\n", + calibrated ? "RMS" : "L2", down.Name(), sorted.front(), + sorted[K / 2], sorted[p95], + sorted.back(), clamped, before_max, after_max); + } + (void)ctx; + } + // Serial (the caller may already be inside a parallel region), but + // vectorized, so a 2B-parameter model takes a few seconds in total. + template + void Quantize(const MatPtrT& B, Entry& entry) { + const hn::ScalableTag df; + const size_t K = B.Cols(); + const size_t padded_k = hwy::RoundUpTo(K, hn::Lanes(df)); + hwy::AlignedVector row(padded_k + hn::Lanes(df)); + const PackedSpan span = B.PaddedSpan(); + const float b_scale = B.Scale(); + const char* include = getenv("GEMMA_MM_I8_CALIBRATION_INCLUDE"); + const bool selected = + (include == nullptr || *include == '\0' || + MMI8NameMatches(B.Name(), include)) && + !MMI8NameMatches(B.Name(), getenv("GEMMA_MM_I8_CALIBRATION_EXCLUDE")); + MMI8WeightIO interchange( + B, entry.b.block_size, selected, + entry.input_scale.empty() ? nullptr : entry.input_scale.data()); + const MMI8WeightCalibration calibration( + B, entry.b.block_size, selected && !interchange.Importing()); + const MMI8MeanCalibration means(B, entry.b.block_size, selected); + if (means.Enabled()) { + entry.bias.resize(B.Rows()); + entry.b.bias = entry.bias.data(); + } + + const bool needs_original = !interchange.Importing() || + interchange.Exporting() || means.Enabled(); + for (size_t r = 0; r < B.Rows(); ++r) { + if (needs_original) { + DecompressAndZeroPad(df, span, r * B.Stride(), row.data(), K); + if (!entry.input_scale.empty()) { + for (size_t c = 0; c < K; ++c) row[c] /= entry.input_scale[c]; + } + MMI8Rotate(row.data(), K); + interchange.ExportRow(r, row.data()); + } + MMI8BT* HWY_RESTRICT out = HWY_RCAST_ALIGNED(MMI8BT*, entry.data.Row(r)); + const bool imported = interchange.ImportRow(r, out); + const size_t group_size = entry.b.block_size ? entry.b.block_size : K; + double bias = 0.0; + for (size_t c = 0; c < K; c += group_size) { + float refined_scale; + if (imported) { + refined_scale = interchange.Scale(r, c / group_size); + } else { + const float initial_scale = + PackBRow(row.data() + c, group_size, out + c, group_size); + refined_scale = + calibration.Refine(row.data() + c, c, out + c, initial_scale); + } + entry.scale[(c / group_size) * B.Rows() + r] = b_scale * refined_scale; + bias += means.Correction(row.data() + c, c, out + c, refined_scale); + } + if (means.Enabled()) { + entry.bias[r] = static_cast(double(b_scale) * bias); + if (!std::isfinite(entry.bias[r])) + HWY_ABORT("Nonfinite calibrated output bias for %s", B.Name()); + } + for (size_t c = K; c < entry.data.Stride(); ++c) out[c] = 0; + } + if (PackedEligible(B, entry.b.block_size)) { + MMI8PackMicroB(entry.data); + entry.b.packed_micro = true; + } + } + + bool enabled_; + bool verbose_; + bool l2_scaling_; + size_t min_k_; + size_t skip_rows_; + const char* include_; + const char* exclude_; + + std::mutex mutex_; + std::unordered_map> map_; + + std::unordered_map> input_scales_; + std::unordered_map> output_scales_; + std::unordered_map>> norms_; + std::unique_ptr a_; + size_t a_max_M_ = 0; + size_t a_max_K_ = 0; +}; + +// As `MaybeMatMulI8`, for the fused gated-FFN pair. Both operands must be +// eligible, else we fall back so that the pair stays consistent. +static inline MMPerKey* MaybeTwoMatMulI8(const MatPtrT& A, + const MatPtr& B1, const MatPtr& B2, + MatMulEnv& env, MatPtrT& C, + const MMOptions& options) { + if (MMI8CalibrationCaptureEnabled()) { + MMI8CalibrationCapture::Get().Capture(A, B1); + MMI8CalibrationCapture::Get().Capture(A, B2); + } + MMI8WeightCache& cache = MMI8WeightCache::Get(); + if (!cache.Enabled()) return nullptr; + return CallUpcastedSame( + &B1, &B2, [&](const auto* B1_t, const auto* B2_t) -> MMPerKey* { + const MMI8B* i8_1 = cache.Lookup(*B1_t, env); + if (i8_1 == nullptr) return nullptr; + const MMI8B* i8_2 = cache.Lookup(*B2_t, env); + if (i8_2 == nullptr) return nullptr; + MMI8AStorage& a_storage = + cache.AStorage(A.Rows(), A.Cols(), env.ctx.allocator); + return TwoMatMulI8(A, *i8_1, *i8_2, env, C, a_storage, options); + }); +} + +// If the int8 path is enabled and `B` is eligible, computes `C = A * B + add` +// with the W8A8 kernel and returns its autotune state; else returns nullptr so +// the caller falls back to `MatMulStatic`. +template +MMPerKey* MaybeMatMulI8(const MatPtrT& A, const MatPtrT& B, + const float* HWY_RESTRICT add, MatMulEnv& env, + MatPtrT& C, const MMOptions& options) { + if (MMI8CalibrationCaptureEnabled()) + MMI8CalibrationCapture::Get().Capture(A, B); + MMI8WeightCache& cache = MMI8WeightCache::Get(); + if (!cache.Enabled()) return nullptr; + // `TwoMatMul`'s fused second output is not wired up here. + if (options.func != nullptr) return nullptr; + const MMI8B* B_i8 = cache.Lookup(B, env); + if (B_i8 == nullptr) return nullptr; + + MMI8AStorage& a_storage = + cache.AStorage(A.Rows(), A.Cols(), env.ctx.allocator); + return MatMulI8(A, *B_i8, add, env, C, a_storage, options); +} + +// NOLINTNEXTLINE(google-readability-namespace-comments) +} // namespace HWY_NAMESPACE +} // namespace gcpp +HWY_AFTER_NAMESPACE(); + +#endif // NOLINT diff --git a/ops/matmul_i8_test.cc b/ops/matmul_i8_test.cc new file mode 100644 index 00000000..8412eada --- /dev/null +++ b/ops/matmul_i8_test.cc @@ -0,0 +1,1058 @@ +// Copyright 2025 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Correctness of the W8A8 kernel in `ops/matmul_i8-inl.h`. The reference is +// computed in F64 from the *quantized* operands, so this checks the kernel's +// arithmetic (accumulation, remainder handling, the u8 bias correction, and +// the MMSetC/MMAddC split across kc ranges) rather than quantization error. +// +// Built twice, with `GEMMA_MM_I8_FORCE_BIASED_B` 0 and 1, so that the x86 +// biased-u8 path is covered on non-x86 hosts too. + +#include +#include +#include + +#include +#include + +#include "compression/types.h" // GEMMA_DISABLED_TARGETS +#ifndef HWY_DISABLED_TARGETS +#define HWY_DISABLED_TARGETS GEMMA_DISABLED_TARGETS +#endif // HWY_DISABLED_TARGETS + +#include "hwy/aligned_allocator.h" +#include "ops/matmul.h" +#include "util/basics.h" +#include "util/mat.h" +#include "util/threading_context.h" + +// clang-format off +#undef HWY_TARGET_INCLUDE +#define HWY_TARGET_INCLUDE "ops/matmul_i8_test.cc" // NOLINT +// clang-format on +#include "hwy/foreach_target.h" // IWYU pragma: keep +#include "hwy/highway.h" +// After highway.h +#include "compression/compress-inl.h" +#include "ops/matmul-inl.h" +#include "ops/matmul_i8-inl.h" + +#include "ops/matmul_i8_model-inl.h" + +HWY_BEFORE_NAMESPACE(); +namespace gcpp { + +// Not in HWY_NAMESPACE: the `HWY_ONCE` section below must read the same +// instance that the dispatched target wrote to. +extern size_t g_failures; + +namespace HWY_NAMESPACE { +namespace hn = hwy::HWY_NAMESPACE; + +class Rng { + public: + explicit Rng(uint64_t seed) : state_(seed * 6364136223846793005ull + 1) {} + float Normal() { + float sum = 0.0f; + for (int i = 0; i < 4; ++i) sum += Uniform(); + return (sum - 2.0f) * 1.732f; + } + + private: + float Uniform() { + state_ = state_ * 6364136223846793005ull + 1442695040888963407ull; + return static_cast((state_ >> 40) & 0xFFFFFF) / 16777216.0f; + } + uint64_t state_; +}; + +void TestRotationPreservesDotProducts(size_t block_size, size_t hash_bits) { + const size_t size = 2 * block_size; + std::vector a(size); + std::vector b(size); + Rng rng(123); + double expected = 0.0; + for (size_t i = 0; i < size; ++i) { + a[i] = rng.Normal(); + b[i] = rng.Normal(); + expected += static_cast(a[i]) * b[i]; + } + + MMI8Rotate(a.data(), a.size(), block_size, hash_bits); + MMI8Rotate(b.data(), b.size(), block_size, hash_bits); + double actual = 0.0; + for (size_t i = 0; i < size; ++i) { + actual += static_cast(a[i]) * b[i]; + } + + const double relative = hwy::ScalarAbs(actual - expected) / + HWY_MAX(1.0, hwy::ScalarAbs(expected)); + if (relative > 1E-6) { + ++g_failures; + printf("FAIL rotation block=%zu hash=%zu dot relative error %.3e\n", + block_size, hash_bits, relative); + } else { + printf(" ok rotation block=%zu hash=%zu preserves dot (%.3e)\n", + block_size, hash_bits, relative); + } +} + +void TestRotationMatchesScalar(size_t block_size, size_t hash_bits) { + std::vector expected(3 * block_size); + Rng rng(891); + for (float& value : expected) value = rng.Normal(); + auto actual = expected; + MMI8Rotate(actual.data(), actual.size(), block_size, hash_bits); + for (size_t start = 0; start < expected.size(); start += block_size) { + for (size_t i = 0; i < block_size; ++i) { + if (MMI8NegativeSign(start + i, hash_bits)) + expected[start + i] = -expected[start + i]; + } + for (size_t width = 1; width < block_size; width *= 2) { + for (size_t group = 0; group < block_size; group += 2 * width) { + for (size_t i = 0; i < width; ++i) { + const size_t at = start + group + i; + const float left = expected[at], right = expected[at + width]; + expected[at] = left + right; + expected[at + width] = left - right; + } + } + } + const float norm = block_size == 64 ? 0.125f : 0.08838834764831845f; + for (size_t i = 0; i < block_size; ++i) expected[start + i] *= norm; + } + if (memcmp(actual.data(), expected.data(), actual.size() * sizeof(float)) != + 0) { + ++g_failures; + printf("FAIL SIMD/scalar transform mismatch block=%zu hash=%zu\n", + block_size, hash_bits); + } +} + +void TestHash16() { + std::vector seen(65536, false); + size_t negatives = 0; + bool deterministic = true; + for (size_t i = 0; i < 65536; ++i) { + const uint16_t hash = MMI8Hash16(static_cast(i)); + deterministic &= hash == MMI8Hash16(static_cast(i)); + if (seen[hash]) { + ++g_failures; + printf("FAIL 16-bit hash collision at %zu\n", i); + return; + } + seen[hash] = true; + negatives += MMI8NegativeSign(i, 16) ? 1 : 0; + } + + bool short_period = false; + for (size_t period = 1; period <= 256; ++period) { + bool matches = true; + for (size_t i = 0; i < 4096; ++i) { + if (MMI8NegativeSign(i, 16) != MMI8NegativeSign(i + period, 16)) { + matches = false; + break; + } + } + short_period |= matches; + } + + const bool ok = deterministic && negatives == 32768 && !short_period; + if (!ok) ++g_failures; + printf("%s 16-bit hash deterministic=%d negatives=%zu short_period=%d\n", + ok ? " ok" : "FAIL", deterministic, negatives, short_period); +} + +void TestL2Scaling() { + bool clamped = false; + const float balanced = MMI8L2Scale(4.0, 1.0, &clamped); + const bool balanced_ok = hwy::ScalarAbs(balanced - 0.5f) < 1E-7f && !clamped; + const float both_zero = MMI8L2Scale(0.0, 0.0, &clamped); + const bool zero_ok = both_zero == 1.0f && !clamped; + const float low = MMI8L2Scale(1E20, 1E-20, &clamped); + const bool low_ok = low == kMMI8L2ScaleMin && clamped; + const float high = MMI8L2Scale(1E-20, 1E20, &clamped); + const bool high_ok = high == kMMI8L2ScaleMax && clamped; + + constexpr size_t kSize = 256; + std::vector a(kSize), b(kSize), scale(kSize); + Rng rng(456); + double expected = 0.0; + for (size_t i = 0; i < kSize; ++i) { + a[i] = rng.Normal(); + b[i] = rng.Normal(); + scale[i] = MMI8L2Scale(0.25 + (i % 13), 0.5 + (i % 17)); + expected += static_cast(a[i]) * b[i]; + a[i] *= scale[i]; + b[i] /= scale[i]; + } + + double scaled_dot = 0.0; + for (size_t i = 0; i < kSize; ++i) { + scaled_dot += static_cast(a[i]) * b[i]; + } + MMI8Rotate(a.data(), a.size(), 64, 16); + MMI8Rotate(b.data(), b.size(), 64, 16); + double transformed_dot = 0.0; + for (size_t i = 0; i < kSize; ++i) { + transformed_dot += static_cast(a[i]) * b[i]; + } + const double scaled_relative = hwy::ScalarAbs(scaled_dot - expected) / + HWY_MAX(1.0, hwy::ScalarAbs(expected)); + const double transformed_relative = + hwy::ScalarAbs(transformed_dot - expected) / + HWY_MAX(1.0, hwy::ScalarAbs(expected)); + const bool invariant = + scaled_relative <= 1E-6 && transformed_relative <= 1E-6; + const bool ok = balanced_ok && zero_ok && low_ok && high_ok && invariant; + if (!ok) ++g_failures; + printf( + "%s L2 scaling calculation/clamping and dot invariance " + "(scaled %.3e, transformed %.3e)\n", + ok ? " ok" : "FAIL", scaled_relative, transformed_relative); +} + +// Fills A and B. Row magnitudes deliberately vary by up to 7x, so that a +// mixed-up per-row scale index would show up. +void FillOperands(size_t M, size_t K, size_t N, MatStorageT& A_f32, + MatStorageT& B_f32, float b_mean = 0.0f) { + Rng rng(M * 131 + K * 17 + N); + for (size_t r = 0; r < M; ++r) { + float* row = A_f32.Row(r); + const float row_scale = 0.01f * static_cast(1 + (r % 7)); + for (size_t c = 0; c < K; ++c) row[c] = rng.Normal() * row_scale; + for (size_t c = K; c < A_f32.Stride(); ++c) row[c] = 0.0f; + } + for (size_t r = 0; r < N; ++r) { + float* row = B_f32.Row(r); + const float row_scale = 0.5f * static_cast(1 + (r % 5)); + // A nonzero mean makes the per-channel sums of the quantized weights + // large. Correcting `B`'s bias once over the whole `K` (rather than per + // `kc` range) would then write intermediates to `C` that are far larger + // than the result, which is unrecoverable when `C` is BF16. + for (size_t c = 0; c < K; ++c) { + row[c] = (rng.Normal() + b_mean) * row_scale; + } + for (size_t c = K; c < B_f32.Stride(); ++c) row[c] = 0.0f; + } +} + +// One `M x K x N` case. `TC` is the output type; `add` exercises the bias. +template +void TestCase(size_t M, size_t K, size_t N, bool add, ThreadingContext& ctx, + MatMulEnv& env, MMI8AStorage& a_i8, float b_mean = 0.0f, + const float* a_pre_scale = nullptr, size_t block_size = 0) { + const Allocator& allocator = ctx.allocator; + MatStorageT A_f32("A", Extents2D(M, K), allocator, MatPadding::kOdd); + MatStorageT B_f32("B", Extents2D(N, K), allocator, MatPadding::kOdd); + FillOperands(M, K, N, A_f32, B_f32, b_mean); + // Non-unit tensor scales, which must be folded in by QuantizeA/PackB. + A_f32.SetScale(0.75f); + B_f32.SetScale(1.25f); + + MatStorageT B_i8("B_i8", Extents2D(N, K), allocator, + MatPadding::kOdd); + hwy::AlignedVector b_scale(N * (block_size ? K / block_size : 1)), + add_row(N); + const MMI8B B_packed = + PackB(B_f32, B_i8, b_scale.data(), ctx, a_pre_scale, block_size); + for (size_t n = 0; n < N; ++n) + add_row[n] = 0.125f * static_cast(n % 9); + + MatStorageT C("C", Extents2D(M, N), allocator, MatPadding::kOdd); + C.AllocateAndAttachRowPtrs(env.row_ptrs); + // Run until autotuning settles, then check the result produced by the best + // config. Otherwise every call would use a different blocking, and with a + // BF16 `C` the number of kc ranges changes how much precision is lost. + MMPerKey* per_key = nullptr; + for (size_t iter = 0; iter < 4096; ++iter) { + per_key = + MatMulI8(A_f32, B_packed, add ? add_row.data() : nullptr, env, C, a_i8); + if (per_key->autotune.Best()) break; + } + HWY_ASSERT(per_key->autotune.Best()); + const size_t kc = per_key->autotune.Best()->KC(); + const size_t k_ranges = per_key->autotune.Best()->RangesOfKC(K).NumTasks(); + if (!env.autotune && MMI8Flag("GEMMA_MM_I8_MIN_K_SPLITS")) + HWY_ASSERT(K > kMaxKC || k_ranges == 1); + MatMulI8(A_f32, B_packed, add ? add_row.data() : nullptr, env, C, a_i8); + + // Reference from the quantized operands. `QuantizeA` has already written + // them, so read them back rather than re-deriving. + const MMI8AView A_q = a_i8.View(Extents2D(M, K), block_size); + double max_abs_err = 0.0; + double sum_sq = 0.0; + for (size_t m = 0; m < M; ++m) { + const MMI8AT* qa = A_q.data.Row(m); + for (size_t n = 0; n < N; ++n) { + const MMI8BT* qb = HWY_RCAST_ALIGNED(const MMI8BT*, B_i8.Row(n)); + double expected = add ? add_row[n] : 0.0; + const size_t group_size = block_size ? block_size : K; + for (size_t begin = 0; begin < K; begin += group_size) { + int64_t dot = 0; + for (size_t k = begin; k < begin + group_size; ++k) { + const int32_t b = + static_cast(qb[k]) - (GEMMA_MM_I8_BIASED_B ? 128 : 0); + dot += static_cast(qa[k]) * b; + } + const size_t group = begin / group_size; + expected += + static_cast(A_q.scale[group * A_q.scale_stride + m]) * + b_scale[group * N + n] * static_cast(dot); + } + const double actual = hwy::ConvertScalarTo(C.Row(m)[n]); + max_abs_err = HWY_MAX(max_abs_err, hwy::ScalarAbs(actual - expected)); + sum_sq += expected * expected; + } + } + // Individual outputs are sums of `K` signed products and can cancel to near + // zero, where an elementwise relative error is meaningless. Normalize the + // worst absolute error by the RMS of the expected outputs instead. + const double rms = std::sqrt(sum_sq / static_cast(M * N)); + const double err = (rms == 0.0) ? 0.0 : max_abs_err / rms; + + // BF16 output has 8 mantissa bits, and `MMAddC` rounds once per kc range. + const double tolerance = IsBF16() ? 6E-2 : 1E-5; + const bool ok = err <= tolerance; + if (!ok) ++g_failures; + printf( + "%s M=%4zu K=%5zu N=%5zu add=%d TC=%-5s biasedB=%d kc=%5zu(x%zu) " + "err/rms=%.2e\n", + ok ? " ok " : "FAILED", M, K, N, add, TypeName(), + GEMMA_MM_I8_BIASED_B, kc, k_ranges, err); + if (MMI8Flag("GEMMA_MM_I8_TEST_FIXED")) { + // Stable digest for comparing fast/reference kernels in separate runs. + uint64_t digest = 14695981039346656037ull; + for (size_t r = 0; r < M; ++r) { + const auto* bytes = reinterpret_cast(C.Row(r)); + for (size_t c = 0; c < N * sizeof(TC); ++c) + digest = (digest ^ bytes[c]) * 1099511628211ull; + } + printf("DIGEST M=%zu K=%zu N=%zu block=%zu TC=%s %016llx\n", M, K, N, + block_size, TypeName(), static_cast(digest)); + } +} + +// Control: how much precision the *existing* BF16 kernel loses when `TC` is +// BF16 and `K` spans several kc ranges, so that `MMAddC` accumulates through +// BF16. Reported as a reference point for the int8 kernel's BF16-output +// tolerance, since both inherit this from `MMStoreHorizontalSumsIntoC`. +void ControlBF16OutputError(size_t M, size_t K, size_t N, ThreadingContext& ctx, + MatMulEnv& env) { + const Allocator& allocator = ctx.allocator; + MatStorageT A_f32("A", Extents2D(M, K), allocator, MatPadding::kOdd); + MatStorageT B_f32("B", Extents2D(N, K), allocator, MatPadding::kOdd); + FillOperands(M, K, N, A_f32, B_f32); + + MatStorageT A_bf("A_bf", Extents2D(M, K), allocator, MatPadding::kOdd); + MatStorageT B_bf("B_bf", Extents2D(N, K), allocator, MatPadding::kOdd); + CompressWorkingSet ws; + ws.tls.resize(ctx.pools.MaxWorkers()); + for (size_t r = 0; r < M; ++r) { + Compress(A_f32.Row(r), K, ws.tls[0], MakeSpan(A_bf.Row(r), K), 0); + } + for (size_t r = 0; r < N; ++r) { + Compress(B_f32.Row(r), K, ws.tls[0], MakeSpan(B_bf.Row(r), K), 0); + } + + MatStorageT C_f32("Cf", Extents2D(M, N), allocator, MatPadding::kOdd); + MatStorageT C_bf("Cb", Extents2D(M, N), allocator, MatPadding::kOdd); + C_f32.AllocateAndAttachRowPtrs(env.row_ptrs); + for (size_t iter = 0; iter < 4096; ++iter) { + if (MatMul(A_bf, B_bf, nullptr, env, C_f32)->autotune.Best()) break; + } + MatMul(A_bf, B_bf, nullptr, env, C_f32); + C_bf.AllocateAndAttachRowPtrs(env.row_ptrs); + MMPerKey* per_key = nullptr; + for (size_t iter = 0; iter < 4096; ++iter) { + per_key = MatMul(A_bf, B_bf, nullptr, env, C_bf); + if (per_key->autotune.Best()) break; + } + HWY_ASSERT(per_key->autotune.Best()); + const size_t kc = per_key->autotune.Best()->KC(); + const size_t k_ranges = per_key->autotune.Best()->RangesOfKC(K).NumTasks(); + MatMul(A_bf, B_bf, nullptr, env, C_bf); + + double max_abs = 0.0, sum_sq = 0.0; + for (size_t m = 0; m < M; ++m) { + for (size_t n = 0; n < N; ++n) { + const double f = C_f32.Row(m)[n]; + const double b = hwy::ConvertScalarTo(C_bf.Row(m)[n]); + max_abs = HWY_MAX(max_abs, hwy::ScalarAbs(f - b)); + sum_sq += f * f; + } + } + const double rms = std::sqrt(sum_sq / static_cast(M * N)); + printf( + "control M=%4zu K=%5zu N=%5zu kc=%5zu(x%zu) bf16 kernel, TC=bf16 vs " + "TC=f32: err/rms=%.2e\n", + M, K, N, kc, k_ranges, rms == 0.0 ? 0.0 : max_abs / rms); +} + +// Packing must preserve exact values, KC rounding, partial N tiles, row +// offsets, and first-KC bias semantics for both F32 and BF16 outputs. +template +void TestPackedMicro(ThreadingContext& ctx, size_t k, bool dense = false) { + constexpr size_t m = 4, n = 24; + MatMulEnv env(ctx); + MatStorageT a("a", Extents2D(m, k), ctx.allocator, MatPadding::kOdd); + MatStorageT b("b", Extents2D(n, k), ctx.allocator, MatPadding::kOdd); + FillOperands(m, k, n, a, b, 0.3f); + MatStorageT q("q", b.Extents(), ctx.allocator, MatPadding::kOdd); + MatStorageT packed("packed", b.Extents(), ctx.allocator, + dense ? MatPadding::kPacked : MatPadding::kOdd); + MatStorageT expected("expected", Extents2D(m, n), ctx.allocator, + MatPadding::kOdd); + MatStorageT actual("actual", expected.Extents(), ctx.allocator, + MatPadding::kOdd); + MMI8AStorage storage(m, k, ctx.allocator); + hwy::AlignedVector scales(n * k / 32), add(n), bias(n); + for (size_t c = 0; c < n; ++c) { + add[c] = static_cast(c % 5) * 0.0625f; + bias[c] = -static_cast(c % 7) * 0.09375f; + } + const MMConfig cfg(1, k, n, 1, 1, k, n, 1, 4, MMOrder::kNT, 1); + MMAutoTune tuner; + tuner.SetCandidates({cfg}, false); + size_t cases = 0; + bool ok = true; + for (size_t block : {size_t{32}, size_t{64}, size_t{128}}) { + auto ordinary = PackB(b, q, scales.data(), ctx, nullptr, block); + for (size_t r = 0; r < n; ++r) + hwy::CopyBytes(q.Row(r), packed.Row(r), k); + MMI8PackMicroB(packed); + auto interleaved = ordinary; + interleaved.data = &packed; + interleaved.packed_micro = true; + const auto av = QuantizeA(a, storage, ctx, 0, nullptr, block); + for (int bias_mode = 0; bias_mode < 4; ++bias_mode) { + ordinary.bias = interleaved.bias = bias_mode & 2 ? bias.data() : nullptr; + const MMArgs args(env, m, k, n, 1.0f, + bias_mode & 1 ? add.data() : nullptr, MMOptions(), + tuner, cfg); + for (size_t split : {size_t{0}, size_t{64}, size_t{128}, size_t{71}, + size_t{76}, size_t{576}}) { + if (split >= k) continue; + for (const IndexRange rm : {IndexRange(0, 1), IndexRange(1, m)}) { + for (const IndexRange rn : {IndexRange(0, n), IndexRange(4, n), + IndexRange(0, n - 4), + IndexRange(4, n - 4)}) { + for (size_t r = 0; r < m; ++r) + for (size_t c = 0; c < n; ++c) + expected.Row(r)[c] = actual.Row(r)[c] = + hwy::ConvertScalarTo(-123.5f); + const auto run = [&](const MMI8B& weights, MatStorageT& out) { + const StridedView cv(out.Row(rm.begin()) + rn.begin(), + rn.Num(), out.Stride()); + const IndexRange first(0, split ? split : k); + MMI8Kernel::B3A2C0(av, weights, rm, first, rn, args, MMSetC(), cv); + if (split) + MMI8Kernel::B3A2C0(av, weights, rm, IndexRange(split, k), rn, + args, MMAddC(), cv); + }; + run(ordinary, expected); + run(interleaved, actual); + for (size_t r = 0; r < m; ++r) + ok &= memcmp(expected.Row(r), actual.Row(r), n * sizeof(TC)) == 0; + ++cases; + } + } + } + } + } + if (!ok) ++g_failures; + printf("%s packed N8 exact outputs, splits, N tails, M offsets, bias " + "K=%zu TC=%s dense=%d (%zu cases)\n", ok ? " ok" : "FAIL", k, + TypeName(), dense, cases); +} + +// Scalar integer dots independently verify both biased-weight corrections. +// Use the target's F32 multiply-add semantics, then round once per KC range. +template +void DualMicroReference(const MMI8AView& a, const MMI8B& b, + const IndexRange& rm, const IndexRange& rk, + const IndexRange& rn, bool add_previous, + const float* add, bool dual, MatStorageT& out) { + const hn::CappedTag df; + const auto madd = [&](float x, float y, float z) { + return hn::GetLane( + hn::MulAdd(hn::Set(df, x), hn::Set(df, y), hn::Set(df, z))); + }; + for (size_t r : rm) { + for (size_t n : rn) { + float sum = 0; + for (size_t c = rk.begin(); c < rk.end();) { + const size_t g = c / b.block_size; + const size_t count = HWY_MIN(static_cast(rk.end()), (g + 1) * b.block_size) - c; + for (size_t stream = 0; stream < (dual ? 2 : 1); ++stream) { + const auto& av = stream ? *a.residual : a; + int32_t dot = 0, encoded_dot = 0; + const auto* q = reinterpret_cast(b.data->Row(n)); + for (size_t k = c; k < c + count; ++k) { + dot += av.data.Row(r)[k] * (static_cast(q[k]) - + (GEMMA_MM_I8_BIASED_B ? 128 : 0)); + encoded_dot += av.data.Row(r)[k] * static_cast(q[k]); + } + if constexpr (GEMMA_MM_I8_BIASED_B) { + HWY_ASSERT(dot == + encoded_dot - + 128 * av.ViewGroup(r, c, count, g).RowSum(0, count)); + } + sum = madd( + static_cast(dot), + av.scale[g * av.scale_stride + r] * b.scale[g * b.Rows() + n], + sum); + } + c += count; + } + if (add_previous) + sum += hwy::ConvertScalarTo(out.Row(r)[n]); + else if (const float* bias = MMI8Bias(b, add, n)) + sum += *bias; + out.Row(r)[n] = hwy::ConvertScalarTo(sum); + } + } +} + +template +void TestDualMicro(ThreadingContext& ctx, size_t k) { + constexpr size_t m = 4, n = 24; + MatMulEnv env(ctx); + env.autotune = false; + MatStorageT af("af", Extents2D(m, k), ctx.allocator, MatPadding::kOdd); + MatStorageT b("b", Extents2D(n, k), ctx.allocator, MatPadding::kOdd); + FillOperands(m, k, n, af, b, 0.7f); + MatStorageT a("a", af.Extents(), ctx.allocator, MatPadding::kOdd); + for (size_t r = 0; r < m; ++r) + for (size_t c = 0; c < k; ++c) + a.Row(r)[c] = hwy::ConvertScalarTo(af.Row(r)[c]); + a.SetScale(0.75f); + MatStorageT q("q", b.Extents(), ctx.allocator, MatPadding::kOdd); + MatStorageT packed("p", b.Extents(), ctx.allocator, + MatPadding::kPacked); + MatStorageT expected("expected", Extents2D(m, n), ctx.allocator, + MatPadding::kOdd); + MatStorageT actual("actual", expected.Extents(), ctx.allocator, + MatPadding::kOdd); + // Storage capacity deliberately exceeds the batch size: primary and residual + // scale strides differ, and both must survive later M=1 calls. + MMI8AStorage storage(m + 3, k, ctx.allocator), + primary(m + 3, k, ctx.allocator); + hwy::AlignedVector scales(n * k / 32), add(n), bias(n), pre(k), + rotated(k); + for (size_t c = 0; c < k; ++c) pre[c] = 0.75f + 0.25f * (c % 3); + for (size_t c = 0; c < n; ++c) { + add[c] = static_cast(c % 5) * 0.0625f; + bias[c] = -static_cast(c % 7) * 0.09375f; + } + const MMConfig cfg(m, k, n, 1, m, k, n, 1, 4, MMOrder::kNT, 1); + MMAutoTune tuner; + tuner.SetCandidates({cfg}, false); + bool ok = true; + size_t cases = 0; + for (size_t block : {size_t{32}, size_t{64}, size_t{128}}) { + auto ordinary = PackB(b, q, scales.data(), ctx, pre.data(), block); + for (size_t r = 0; r < n; ++r) hwy::CopyBytes(q.Row(r), packed.Row(r), k); + MMI8PackMicroB(packed); + auto weights = ordinary; + weights.data = &packed; + weights.packed_micro = true; + weights.dual_a = true; + MMI8AView residual; + const auto av = QuantizeA(a, storage, ctx, 0, pre.data(), block, &residual); + const auto original = QuantizeA(a, primary, ctx, 0, pre.data(), block); + double first_error = 0, residual_error = 0; + for (size_t r = 0; r < m; ++r) { + ok &= memcmp(av.data.Row(r), original.data.Row(r), k) == 0; + if constexpr (GEMMA_MM_I8_BIASED_B) + ok &= memcmp(storage.prefix(r), primary.prefix(r), + (k + 1) * sizeof(int32_t)) == 0; + MMI8PrepareInputRow(a.Row(r), k, pre.data(), + MMI8Flag("GEMMA_MM_I8_MATCH_BF16_A"), rotated.data()); + MMI8Rotate(rotated.data(), k); + for (size_t c = 0; c < k; ++c) { + const size_t g = c / block; + const float s = av.scale[g * av.scale_stride + r]; + ok &= s == original.scale[g * original.scale_stride + r]; + const double exact = rotated[c] * a.Scale(); + const double first = av.data.Row(r)[c] * s; + const double both = + first + residual.data.Row(r)[c] * + residual.scale[g * residual.scale_stride + r]; + first_error += (exact - first) * (exact - first); + residual_error += (exact - both) * (exact - both); + } + } + ok &= residual_error < first_error * 0.001; + for (bool dual : {false, true}) { + weights.dual_a = dual; + for (int bias_mode = 0; bias_mode < 4; ++bias_mode) { + ordinary.bias = weights.bias = bias_mode & 2 ? bias.data() : nullptr; + const float* add_row = bias_mode & 1 ? add.data() : nullptr; + const MMArgs args(env, m, k, n, 1.0f, add_row, MMOptions(), tuner, cfg); + for (size_t split : + {size_t{0}, size_t{64}, size_t{71}, size_t{76}, size_t{576}}) { + if (split >= k) continue; + for (const IndexRange rm : {IndexRange(0, 1), IndexRange(1, m)}) { + for (const IndexRange rn : + {IndexRange(0, n), IndexRange(4, n - 4)}) { + for (size_t r = 0; r < m; ++r) + for (size_t c = 0; c < n; ++c) + expected.Row(r)[c] = actual.Row(r)[c] = + hwy::ConvertScalarTo(-123.5f); + const StridedView cv(actual.Row(rm.begin()) + rn.begin(), + rn.Num(), actual.Stride()); + const IndexRange first(0, split ? split : k); + DualMicroReference(av, ordinary, rm, first, rn, false, add_row, + dual, expected); + MMI8Kernel::B3A2C0(av, weights, rm, first, rn, args, MMSetC(), + cv); + if (split) { + const IndexRange rest(split, k); + DualMicroReference(av, ordinary, rm, rest, rn, true, add_row, + dual, expected); + MMI8Kernel::B3A2C0(av, weights, rm, rest, rn, args, MMAddC(), + cv); + } + for (size_t r = 0; r < m; ++r) + ok &= + memcmp(expected.Row(r), actual.Row(r), n * sizeof(TC)) == 0; + ++cases; + } + } + } + } + } + weights.dual_a = true; + for (size_t rows : {size_t{1}, m, size_t{1}}) { + a.OverrideRows(rows); + actual.OverrideRows(rows); + MMI8AView rv; + const auto qa = QuantizeA(a, primary, ctx, 0, pre.data(), block, &rv); + const auto* key = MatMulI8(a, weights, add.data(), env, actual, storage); + const auto ranges = key->autotune.Best()->RangesOfKC(k); + for (size_t i = 0; i < ranges.NumTasks(); ++i) + DualMicroReference(qa, ordinary, IndexRange(0, rows), ranges.Range(i), + IndexRange(0, n), i != 0, add.data(), + MMI8UseDualA(weights, rows), expected); + for (size_t r = 0; r < rows; ++r) + ok &= memcmp(expected.Row(r), actual.Row(r), n * sizeof(TC)) == 0; + } + a.OverrideRows(m); + actual.OverrideRows(m); + } + if (!ok) ++g_failures; + printf( + "%s dual A scalar dots, A8 identity, prefixes, KC/N/M tails, bias, reuse " + "K=%zu TA=%s TC=%s (%zu cases)\n", + ok ? " ok" : "FAIL", k, TypeName(), TypeName(), cases); +} + +void TestDualFused(ThreadingContext& ctx) { + constexpr size_t m = 3, k = 1152, n = 24, block = 128; + MatMulEnv env(ctx); + env.autotune = false; + MatStorageT af("af", Extents2D(m, k), ctx.allocator, MatPadding::kOdd); + MatStorageT b("b", Extents2D(n, k), ctx.allocator, MatPadding::kOdd); + FillOperands(m, k, n, af, b, 0.5f); + MatStorageT a("a", af.Extents(), ctx.allocator, MatPadding::kOdd); + for (size_t r = 0; r < m; ++r) + for (size_t c = 0; c < k; ++c) + a.Row(r)[c] = hwy::ConvertScalarTo(af.Row(r)[c]); + MatStorageT q("q", b.Extents(), ctx.allocator, MatPadding::kOdd); + MatStorageT p("p", b.Extents(), ctx.allocator, MatPadding::kPacked); + MatStorageT c1("c1", Extents2D(m, n), ctx.allocator, MatPadding::kOdd); + MatStorageT c2("c2", c1.Extents(), ctx.allocator, MatPadding::kOdd); + MatStorageT expected("e", c1.Extents(), ctx.allocator, + MatPadding::kOdd); + hwy::AlignedVector scale(n * k / block), bias(n); + for (size_t c = 0; c < n; ++c) bias[c] = 0.125f * static_cast(c % 5); + auto ordinary = PackB(b, q, scale.data(), ctx, nullptr, block); + for (size_t r = 0; r < n; ++r) hwy::CopyBytes(q.Row(r), p.Row(r), k); + MMI8PackMicroB(p); + auto b1 = ordinary, b2 = ordinary; + b1.data = b2.data = &p; + b1.packed_micro = b2.packed_micro = true; + b1.bias = bias.data(); + const auto copy_second = [&](RowPtrsBF, IndexRange rm, IndexRange rn, + StridedViewBF tile, size_t) { + for (size_t r = 0; r < rm.Num(); ++r) + hwy::CopyBytes(tile.Row(r), c2.Row(rm.begin() + r) + rn.begin(), + rn.Num() * sizeof(BF16)); + }; + MMOptions options; + options.SetFunc(copy_second); + MMI8AStorage storage(m, k, ctx.allocator), reference(m, k, ctx.allocator); + MMI8AView residual; + const auto av = QuantizeA(a, reference, ctx, 0, nullptr, block, &residual); + bool ok = true; + for (int flags = 0; flags < 4; ++flags) { + b1.dual_a = flags & 1; + b2.dual_a = flags & 2; + const auto* key = TwoMatMulI8(a, b1, b2, env, c1, storage, options); + const auto ranges = key->autotune.Best()->RangesOfKC(k); + for (size_t branch = 0; branch < 2; ++branch) { + const auto& weights = branch ? b2 : b1; + ordinary.bias = weights.bias; + for (size_t i = 0; i < ranges.NumTasks(); ++i) + DualMicroReference(av, ordinary, IndexRange(0, m), ranges.Range(i), + IndexRange(0, n), i != 0, nullptr, + MMI8UseDualA(weights, m), expected); + for (size_t r = 0; r < m; ++r) + ok &= memcmp(expected.Row(r), (branch ? c2 : c1).Row(r), + n * sizeof(BF16)) == 0; + } + } + if (!ok) ++g_failures; + printf("%s dual A fused shared quantization and per-branch selection/bias\n", + ok ? " ok" : "FAIL"); +} +void TestPackedHeadScheduling(ThreadingContext& ctx) { + constexpr size_t k = 1152, n = 262144; + MatPtrT head("head_shape", Extents2D(n, k)); + MatPtrT small("small_shape", Extents2D(24, k)); + MMI8B weights{&head, nullptr, nullptr, 128}; + weights.packed_micro = true; + const bool enabled = MMI8Flag("GEMMA_MM_I8_PACKED_HEAD_FULL_K"); + bool ok = MMI8PreferFullHeadK(weights, 1, true) == enabled; + ok &= !MMI8PreferFullHeadK(weights, 2, true); + ok &= !MMI8PreferFullHeadK(weights, 1, false); + weights.packed_micro = false; + ok &= !MMI8PreferFullHeadK(weights, 1, true); + weights.packed_micro = true; + weights.data = &small; + ok &= !MMI8PreferFullHeadK(weights, 1, true); + + MatMulEnv env(ctx); + env.autotune = false; + const auto generic = MMCandidates(ctx.cache_info, 1, k, n, 1, 4, false); + const auto full = MMI8Candidates(env, 1, k, n, 1, 4, true); + ok &= full.front().RangesOfKC(k).NumTasks() == 1; + const auto defaults = MMI8Candidates(env, 1, k, n, 1, 4); + if (!MMI8Flag("GEMMA_MM_I8_MIN_K_SPLITS")) + ok &= defaults.front().KC() == generic.front().KC() && + defaults.front().Order() == generic.front().Order(); + env.autotune = true; + const auto tunable = MMI8Candidates(env, 1, k, n, 1, 4, true); + ok &= tunable.front().KC() == generic.front().KC() && + tunable.front().Order() == generic.front().Order(); + if (!ok) ++g_failures; + printf("%s optional packed F32 M1 head scheduling and unchanged defaults\n", + ok ? " ok" : "FAIL"); +} + +void TestMicroscaleIsolation(ThreadingContext& ctx) { + MatStorageT a("a", Extents2D(1, 384), ctx.allocator, MatPadding::kOdd); + for (size_t c = 0; c < 384; ++c) + a.Row(0)[c] = c < 128 ? 10000.0f : c < 256 ? 0.001f : 0.0f; + MMI8AStorage storage(1, 384, ctx.allocator); + const auto q = QuantizeA(a, storage, ctx, 0, nullptr, 128); + double recovered = 0.0; + bool zero = q.scale[2 * q.scale_stride] == 1.0f; + for (size_t c = 128; c < 256; ++c) { + const double v = q.data.Row(0)[c] * q.scale[q.scale_stride]; + recovered += v * v; + } + for (size_t c = 256; c < 384; ++c) zero &= q.data.Row(0)[c] == 0; + const bool ok = zero && std::abs(recovered / (128.0 * 1E-6) - 1.0) < 0.02; + if (!ok) ++g_failures; + printf("%s microscale isolates large outliers and zero blocks\n", + ok ? " ok" : "FAIL"); +} + +void TestF32MatchesBF16Inputs(ThreadingContext& ctx) { + constexpr size_t k = 384; + MatStorageT input("f32_input", Extents2D(2, k), ctx.allocator, + MatPadding::kOdd); + MatStorageT rounded("bf16_input", input.Extents(), ctx.allocator, + MatPadding::kOdd); + hwy::AlignedVector copied(k), pre_scale(k); + const hn::ScalableTag dbf; + bool ok = true; + for (size_t c = 0; c < k; ++c) + pre_scale[c] = 0.75f + static_cast(c % 9) * 0.0625f; + for (size_t r = 0; r < input.Rows(); ++r) { + for (size_t c = 0; c < k; ++c) + input.Row(r)[c] = static_cast(static_cast(c % 37) - 18) * + 0.0712345f + static_cast(r) * 0.012345f; + DecompressAndZeroPad(dbf, MakeConst(MakeSpan(input.Row(r), k)), 0, + rounded.Row(r), k); + MMI8PrepareInputRow(input.Row(r), k, pre_scale.data(), false, copied.data()); + for (size_t c = 0; c < k; ++c) + ok &= copied[c] == input.Row(r)[c] * pre_scale[c]; + MMI8PrepareInputRow(input.Row(r), k, pre_scale.data(), true, copied.data()); + for (size_t c = 0; c < k; ++c) + ok &= copied[c] == hwy::ConvertScalarTo(rounded.Row(r)[c]) * + pre_scale[c]; + } + if (MMI8Flag("GEMMA_MM_I8_MATCH_BF16_A")) { + MMI8AStorage f32_storage(2, k, ctx.allocator); + MMI8AStorage bf16_storage(2, k, ctx.allocator); + for (size_t block : {size_t{0}, size_t{64}, size_t{128}}) { + const auto a = + QuantizeA(input, f32_storage, ctx, 0, pre_scale.data(), block); + const auto b = + QuantizeA(rounded, bf16_storage, ctx, 0, pre_scale.data(), block); + const size_t groups = block ? k / block : 1; + for (size_t r = 0; r < input.Rows(); ++r) { + for (size_t c = 0; c < k; ++c) + ok &= a.data.Row(r)[c] == b.data.Row(r)[c]; + for (size_t g = 0; g < groups; ++g) + ok &= a.scale[g * a.scale_stride + r] == + b.scale[g * b.scale_stride + r]; + if constexpr (GEMMA_MM_I8_BIASED_B) + for (size_t c = 0; c <= k; ++c) + ok &= a.prefix[r * a.prefix_stride + c] == + b.prefix[r * b.prefix_stride + c]; + } + } + } + if (!ok) ++g_failures; + printf("%s F32 activation rounding matches SFP BF16 preparation\n", + ok ? " ok" : "FAIL"); +} + +void TestQuantizedPrefixes() { + if constexpr (!GEMMA_MM_I8_BIASED_B) return; + hwy::AlignedVector values(128); + hwy::AlignedVector quantized(128); + hwy::AlignedVector prefix(129); + bool ok = true; + for (size_t count : {size_t{7}, size_t{71}, size_t{128}}) { + for (int32_t base : {-4096, 0, 377}) { + for (size_t c = 0; c < count; ++c) + values[c] = base == 0 ? 0.0f : (static_cast(c % 17) - 8) * 0.13f; + QuantizeRowA(values.data(), count, quantized.data(), prefix.data(), + quantized.size(), base); + int32_t expected = base; + ok &= prefix[0] == expected; + for (size_t c = 0; c < count; ++c) { + expected += quantized[c]; + ok &= prefix[c + 1] == expected; + } + } + } + if (!ok) ++g_failures; + printf("%s quantized prefixes, incoming bases, zeros and tails\n", + ok ? " ok" : "FAIL"); +} + +void TestFixedTuningAndKeys() { + const auto bf = MMKeys::KeyFromDims(4, 256, 128, 1); + const auto i8 = MMKeys::KeyFromDims(4, 256, 128, 1, MMActivation::kI8); + const auto block = + MMKeys::KeyFromDims(4, 256, 128, 1, MMActivation::kI8Block); + MMAutoTune tuner; + tuner.SetCandidates({7, 11, 19}, false); + bool ok = bf != i8 && i8 != block && bf != block; + for (int i = 0; i < 32; ++i) { + ok &= tuner.Best() && *tuner.Best() == 7 && tuner.NextConfig() == 7; + tuner.NotifyTicks(32 - i); + } + if (!ok) ++g_failures; + printf("%s separate precision keys and fixed tuning\n", ok ? " ok" : "FAIL"); +} + +void TestModelScaling(ThreadingContext& ctx) { + auto& cache = MMI8WeightCache::Get(); + if (!cache.Enabled() || !MMI8Flag("GEMMA_MM_I8_L2_SCALE")) return; + const size_t k = 256, n = 256; + MatMulEnv env(ctx); + MatStorageT gate("test_gate", Extents2D(n, k), ctx.allocator, + MatPadding::kOdd); + MatStorageT up("test_up", Extents2D(n, k), ctx.allocator, + MatPadding::kOdd); + MatStorageT down("test_down", Extents2D(k, n), ctx.allocator, + MatPadding::kOdd); + MatStorageT norm("test_norm", Extents2D(1, k), ctx.allocator, + MatPadding::kOdd); + Rng rng(315); + for (size_t r = 0; r < n; ++r) + for (size_t c = 0; c < k; ++c) { + gate.Row(r)[c] = rng.Normal() * (0.01f + 0.01f * (r % 7)); + up.Row(r)[c] = rng.Normal() * 0.04f; + down.Row(r)[c] = rng.Normal() * 0.03f; + } + for (size_t c = 0; c < k; ++c) norm.Row(0)[c] = 0.25f; + const MatPtr& folded = cache.NormWeights(norm, {&gate, &up}, env); + cache.PrepareFFN(gate, up, down, env); + const auto* packed_down = cache.Lookup(down, env); + const auto* packed_up = cache.Lookup(up, env); + const size_t groups = packed_up->block_size ? k / packed_up->block_size : 1; + bool ok = + &folded != &norm && !packed_down->a_pre_scale && !packed_up->a_pre_scale; + const MatPtrT folded_t(folded); + MatStorageT compensated("compensated", up.Extents(), ctx.allocator, + MatPadding::kOdd); + for (size_t c = 0; c < k; ++c) { + double sum_sq = 0.0; + for (size_t r = 0; r < n; ++r) + sum_sq += double(gate.Row(r)[c]) * gate.Row(r)[c] + + double(up.Row(r)[c]) * up.Row(r)[c]; + const float scale = MMI8L2Scale(1.25, std::sqrt(sum_sq)); + ok &= std::abs((folded_t.Row(0)[c] + 1.0f) - 1.25f * scale) < 1E-6f; + for (size_t r = 0; r < n; ++r) compensated.Row(r)[c] = up.Row(r)[c] / scale; + } + MatStorageT bytes("bytes", up.Extents(), ctx.allocator, + MatPadding::kOdd); + hwy::AlignedVector scales(n * groups); + PackB(compensated, bytes, scales.data(), ctx, nullptr, packed_up->block_size); + for (size_t r = 0; r < n; ++r) { + double gs = 0.0, us = 0.0, ds = 0.0; + for (size_t c = 0; c < k; ++c) { + gs += double(gate.Row(r)[c]) * gate.Row(r)[c]; + us += double(up.Row(r)[c]) * up.Row(r)[c]; + ds += double(down.Row(c)[r]) * down.Row(c)[r]; + } + const float expected = + MMI8L2Scale(std::sqrt(gs) * std::sqrt(us), std::sqrt(ds)); + for (size_t g = 0; g < groups; ++g) + ok &= std::abs(packed_up->scale[g * n + r] / scales[g * n + r] - + expected) < 2E-5f; + } + // A partially ineligible consumer set must leave RMSNorm untouched. + MatStorageT bad("bad", Extents2D(3, k), ctx.allocator, + MatPadding::kOdd); + ok &= &cache.NormWeights(norm, {&gate, &bad}, env) == &norm; + const size_t odd_k = 3 * MMI8RotateBlockSize(); + MatStorageT odd_group("test_gate_odd", Extents2D(8, odd_k), + ctx.allocator, MatPadding::kOdd); + const size_t chosen = cache.QuantBlockSize(odd_group); + const size_t requested = MMI8QuantBlockSize(); + ok &= chosen == (requested && odd_k % requested != 0 + ? MMI8RotateBlockSize() + : requested); + for (size_t r = 0; r < odd_group.Rows(); ++r) + for (size_t c = 0; c < odd_k; ++c) + odd_group.Row(r)[c] = static_cast(static_cast(c % 7) - 3); + const auto* packed_odd = cache.Lookup(odd_group, env); + ok &= packed_odd && packed_odd->block_size == chosen; + ok &= env.weight_prepare_seconds > 0.0; + if (!ok) ++g_failures; + printf( + "%s RMSNorm compensation, two-branch FFN scales, folding and fallback\n", + ok ? " ok" : "FAIL"); +} + +void TestAll() { + TestFixedTuningAndKeys(); + ThreadingArgs threading_args; + ThreadingContext ctx(threading_args); + MatMulEnv env(ctx); + env.autotune = !MMI8Flag("GEMMA_MM_I8_TEST_FIXED"); + printf("target=%s biasedB=%d block=%zu hash=%zu vector bytes=%zu\n", + hwy::TargetName(HWY_TARGET), GEMMA_MM_I8_BIASED_B, + MMI8RotateBlockSize(), MMI8HashBits(), + hn::Lanes(hn::ScalableTag())); + for (size_t block : {size_t{64}, size_t{128}}) { + for (size_t hash : {size_t{16}, size_t{32}}) { + TestRotationMatchesScalar(block, hash); + TestRotationPreservesDotProducts(block, hash); + } + } + TestHash16(); + TestL2Scaling(); + + TestMicroscaleIsolation(ctx); + TestPackedHeadScheduling(ctx); + TestDualMicro(ctx, 1152); + TestDualMicro(ctx, 1152); + TestDualFused(ctx); + for (size_t k : {size_t{384}, size_t{1152}}) { + TestPackedMicro(ctx, k); + TestPackedMicro(ctx, k); + } + TestPackedMicro(ctx, 1152, true); + TestPackedMicro(ctx, 1152, true); + TestF32MatchesBF16Inputs(ctx); + TestQuantizedPrefixes(); + + // `kMaxKC` is 6 KiB, so K = 20096 forces several kc ranges and thus the + // MMSetC-then-MMAddC path where the bias correction must be applied once. + MMI8AStorage a_i8(/*max_M=*/64, /*max_K=*/20096, ctx.allocator); + + // Smallest supported K and multiple Hadamard block counts. + const size_t block = MMI8RotateBlockSize(); + for (size_t K : {block, 2 * block, 3 * block}) { + TestCase(4, K, 8, /*add=*/false, ctx, env, a_i8); + } + std::vector pre_scale(1152); + for (size_t i = 0; i < pre_scale.size(); ++i) + pre_scale[i] = 0.25f + 0.01f * static_cast(i % 100); + TestCase(4, 1152, 12, false, ctx, env, a_i8, 0.0f, pre_scale.data()); + + // `kRowsAC` 1/2/4 and the M remainder handling in `A2C0`. + for (size_t M : {size_t{1}, size_t{2}, size_t{3}, size_t{4}, size_t{5}, + size_t{7}, size_t{8}, size_t{13}, size_t{64}}) { + TestCase(M, 1152, 12, /*add=*/true, ctx, env, a_i8); + } + + // N is required to be a multiple of kNR. + for (size_t N : + {size_t{4}, size_t{8}, size_t{16}, size_t{100}, size_t{1536}}) { + TestCase(4, 512, N, /*add=*/false, ctx, env, a_i8); + } + + // Multiple kc ranges: exercises MMAddC accumulation and the once-only + // application of the u8 bias correction. + TestCase(1, 20096, 8, false, ctx, env, a_i8); + TestCase(4, 20096, 64, true, ctx, env, a_i8); + TestCase(32, 12416, 64, true, ctx, env, a_i8); + + // Local scales: signed/biased correction, output bias, partial M, and KC + // boundaries that need not coincide with a quantization group boundary. + for (size_t block : {size_t{32}, size_t{64}, size_t{128}}) { + TestCase(5, 1152, 12, true, ctx, env, a_i8, 3.0f, nullptr, block); + TestCase(4, 20096, 8, true, ctx, env, a_i8, 3.0f, nullptr, block); + TestCase(5, 1152, 12, true, ctx, env, a_i8, 3.0f, nullptr, block); + TestCase(5, 20096, 8, true, ctx, env, a_i8, 3.0f, nullptr, block); + } + TestModelScaling(ctx); + + // BF16 output. The tolerance is loose because `MMAddC` accumulates through + // `C`, so with several kc ranges the intermediate sums are rounded to BF16; + // the control below shows the existing kernel does the same. + TestCase(4, 1152, 64, false, ctx, env, a_i8); + TestCase(32, 20096, 64, false, ctx, env, a_i8); + TestCase(32, 20096, 64, true, ctx, env, a_i8); + ControlBF16OutputError(4, 1152, 64, ctx, env); + ControlBF16OutputError(32, 20096, 64, ctx, env); + + // Weights with a large nonzero channel mean, across several kc ranges. This + // is the case that a whole-K bias correction gets badly wrong. + TestCase(32, 20096, 64, true, ctx, env, a_i8, /*b_mean=*/3.0f); + TestCase(32, 20096, 64, true, ctx, env, a_i8, /*b_mean=*/3.0f); +} + +// NOLINTNEXTLINE(google-readability-namespace-comments) +} // namespace HWY_NAMESPACE +} // namespace gcpp +HWY_AFTER_NAMESPACE(); + +#if HWY_ONCE +namespace gcpp { +size_t g_failures = 0; +HWY_EXPORT(TestAll); +void RunTests() { HWY_DYNAMIC_DISPATCH(TestAll)(); } +} // namespace gcpp + +int main(int /*argc*/, char** /*argv*/) { + gcpp::RunTests(); + const size_t failures = gcpp::g_failures; + printf("%s (%zu failures)\n", failures == 0 ? "PASS" : "FAIL", failures); + return failures == 0 ? 0 : 1; +} +#endif // HWY_ONCE diff --git a/ops/ops-inl.h b/ops/ops-inl.h index 8e6b0113..c9e0d00c 100644 --- a/ops/ops-inl.h +++ b/ops/ops-inl.h @@ -53,6 +53,7 @@ #include "compression/compress-inl.h" #include "ops/dot-inl.h" +#include "ops/matmul_i8_model-inl.h" #include "ops/matmul_static.h" // includes highway.h #include "ops/sum-inl.h" #include "hwy/contrib/algo/transform-inl.h" @@ -72,6 +73,11 @@ MMPerKey* CallMatMul(const MatPtrT& A, const MatPtr& B, const float* HWY_RESTRICT add, MatMulEnv& env, MatPtrT& C, const MMOptions& options = MMOptions()) { return CallUpcasted(&B, [&](const auto* B_t) { + // Experiment: route through the W8A8 kernel if enabled for this tensor. + // Returns nullptr when disabled or ineligible, see `matmul_i8_model-inl.h`. + if (MMPerKey* per_key = MaybeMatMulI8(A, *B_t, add, env, C, options)) { + return per_key; + } return MatMulStatic(A, *B_t, add, env, C, options); }); } @@ -79,6 +85,8 @@ MMPerKey* CallMatMul(const MatPtrT& A, const MatPtr& B, static inline void CallTwoMatMul(const MatPtrT& A, const MatPtr& B1, const MatPtr& B2, MatMulEnv& env, MatPtrT& C, const MMOptions& options) { + // Experiment, see `matmul_i8_model-inl.h`; nullptr means not enabled here. + if (MaybeTwoMatMulI8(A, B1, B2, env, C, options) != nullptr) return; return CallUpcastedSame(&B1, &B2, [&](const auto* B1_t, const auto* B2_t) { return TwoMatMulStatic(A, *B1_t, *B2_t, env, C, options); }); diff --git a/util/zones.cc b/util/zones.cc index 9cd3475f..3bcfb822 100644 --- a/util/zones.cc +++ b/util/zones.cc @@ -177,6 +177,8 @@ const char* CallerName(Callers caller) { return "MM.ClusterForN"; case Callers::kMMClusterForSFC: return "MM.ClusterForSFC"; + case Callers::kMMQuantizeA: + return "MM.QuantizeA"; case Callers::kMMHierForMC: return "MM.HierForMC"; case Callers::kMMHierForMCNC: diff --git a/util/zones.h b/util/zones.h index 533182a9..8e426953 100644 --- a/util/zones.h +++ b/util/zones.h @@ -101,6 +101,7 @@ enum class Callers { // Keep sorted kMMClusterForMCNC, kMMClusterForN, kMMClusterForSFC, + kMMQuantizeA, kMMHierForMC, kMMHierForMCNC, kMMHierForN,