-
Notifications
You must be signed in to change notification settings - Fork 0
Apple GPU: Gumbel-max inference sampler (#18-safe, reproducible) #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| """apple_gpu Gumbel-max sampler benchmark. | ||
|
|
||
| Times the GPU Gumbel-max sampler (`tessera_apple_gpu_gumbel_argmax_f32` — | ||
| per-row vocab argmax on-GPU) against the equivalent host numpy argmax, over a | ||
| sweep of (batch, vocab) shapes. The GPU path's win grows with the batch size | ||
| (many concurrent decode streams sampling at once). Same JSON schema as | ||
| ``benchmarks/benchmark_gemm.py``. | ||
|
|
||
| Shape spec: ``BxV`` (batch × vocab). | ||
|
|
||
| Usage: | ||
| python benchmarks/apple_gpu/benchmark_gumbel_sampler.py \\ | ||
| --shapes 1x128000 8x128000 64x128000 256x32000 --reps 50 | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import json | ||
| import statistics | ||
| import sys | ||
| import time | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| import numpy as np | ||
|
|
||
| from tessera import runtime as R | ||
| from tessera import rng as TR | ||
|
|
||
|
|
||
| def _parse(spec: str): | ||
| parts = spec.lower().split("x") | ||
| if len(parts) != 2: | ||
| raise ValueError(f"shape must be BxV, got {spec!r}") | ||
| return int(parts[0]), int(parts[1]) | ||
|
|
||
|
|
||
| def _time(fn, reps): | ||
| fn() | ||
| s = [] | ||
| for _ in range(reps): | ||
| t0 = time.perf_counter_ns() | ||
| fn() | ||
| s.append((time.perf_counter_ns() - t0) / 1e6) | ||
| return statistics.median(s), statistics.stdev(s) if reps > 1 else 0.0 | ||
|
|
||
|
|
||
| def main(argv: list[str] | None = None) -> int: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--shapes", nargs="+", | ||
| default=["1x128000", "8x128000", "64x128000", "256x32000"]) | ||
| parser.add_argument("--reps", type=int, default=50) | ||
| parser.add_argument("--output", type=Path, default=None) | ||
| args = parser.parse_args(argv) | ||
|
|
||
| if sys.platform != "darwin": | ||
| if args.output is not None: | ||
| args.output.write_text(json.dumps( | ||
| {"runs": [], "skipped_apple_gpu": "non-Darwin host"}, | ||
| indent=2, sort_keys=True)) | ||
| print("apple_gpu gumbel benchmark: skipping (non-Darwin)", file=sys.stderr) | ||
| return 0 | ||
|
|
||
| version = "dev" | ||
| try: | ||
| import importlib.metadata | ||
| version = importlib.metadata.version("tessera") | ||
| except Exception: | ||
| pass | ||
|
|
||
| rows: list[dict[str, Any]] = [] | ||
| for shape in args.shapes: | ||
| B, V = _parse(shape) | ||
| rng = np.random.RandomState(0) | ||
| logits = rng.randn(B, V).astype(np.float32) | ||
| key = TR.RNGKey.from_seed(0) | ||
| gumbel = R._gumbel_noise_from_key((B, V), key, np) | ||
|
|
||
| def gpu(): | ||
| return R._apple_gpu_gumbel_sample(logits, np, key=key, temperature=1.0) | ||
|
|
||
| def host(): | ||
| return np.argmax(logits + gumbel, axis=-1) | ||
|
|
||
| for mode, fn in (("gpu", gpu), ("host_numpy", host)): | ||
| ms, stdev_ms = _time(fn, args.reps) | ||
| rows.append({ | ||
| "backend": "apple_gpu", | ||
| "op": "gumbel_sample", | ||
| "shape": shape, | ||
| "dtype": "f32", | ||
| "mode": mode, | ||
| "reps": args.reps, | ||
| "latency_ms": ms, | ||
| "stdev_ms": stdev_ms, | ||
| "tflops": 0.0, | ||
| "memory_bw_gb_s": (B * V * 4 / (ms / 1000.0)) / 1e9 if ms > 0 else 0.0, | ||
| "device": "apple_silicon_metal", | ||
| "tessera_version": version, | ||
| }) | ||
|
|
||
| output = json.dumps({"runs": rows}, indent=2, sort_keys=True) | ||
| if args.output is not None: | ||
| args.output.write_text(output) | ||
| else: | ||
| print(output) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2482,6 +2482,97 @@ def _apple_gpu_mpsgraph_reduce_f32() -> Any: | |
| return sym | ||
|
|
||
|
|
||
| def _apple_gpu_gumbel_argmax_f32() -> Any: | ||
| runtime = _load_apple_gpu_runtime() | ||
| sym = getattr(runtime, "tessera_apple_gpu_gumbel_argmax_f32", None) | ||
| if sym is None: | ||
| return None | ||
| sym.argtypes = [ctypes.POINTER(ctypes.c_float), ctypes.POINTER(ctypes.c_float), | ||
| ctypes.POINTER(ctypes.c_int32), ctypes.c_int32, | ||
| ctypes.c_int32, ctypes.c_float] | ||
| sym.restype = None | ||
| return sym | ||
|
|
||
|
|
||
| def _gumbel_noise_from_key(shape: tuple, key: Any, np: Any) -> Any: | ||
| """Gumbel(0,1) noise g = -log(-log(u)) from the canonical Philox stream, so | ||
| sampling is deterministic + reproducible (and bit-exact vs a CPU reference) | ||
| without an on-GPU RNG. ``key`` is a ``tessera.rng.RNGKey``; if None, a | ||
| seed-0 key is used.""" | ||
| from . import rng as _rng | ||
| if key is None: | ||
| key = _rng.RNGKey.from_seed(0) | ||
| u = np.asarray(_rng.uniform(key, shape, dtype="fp32")) | ||
| u = np.clip(u, 1e-9, 1.0 - 1e-7).astype(np.float32) | ||
| return (-np.log(-np.log(u))).astype(np.float32) | ||
|
|
||
|
|
||
| def _apply_topk_topp_mask(logits: Any, top_k: int, top_p: float, np: Any) -> Any: | ||
| """Mask logits to -inf outside the top-k / top-p (nucleus) set, per row. | ||
| Operates on a [rows, vocab] f32 copy.""" | ||
| out = logits.astype(np.float32, copy=True) | ||
| neg_inf = np.float32(-1e30) | ||
| if top_k and top_k > 0 and top_k < out.shape[-1]: | ||
| # keep the top_k largest per row; threshold = k-th largest | ||
| kth = np.partition(out, -top_k, axis=-1)[:, -top_k][:, None] | ||
| out = np.where(out < kth, neg_inf, out) | ||
|
Comment on lines
+2517
to
+2518
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For inputs with ties at the kth logit, this threshold mask keeps every value equal to Useful? React with 👍 / 👎. |
||
| if top_p and 0.0 < top_p < 1.0: | ||
| order = np.argsort(-out, axis=-1) | ||
| sorted_logits = np.take_along_axis(out, order, axis=-1) | ||
| m = sorted_logits.max(-1, keepdims=True) | ||
| probs = np.exp(sorted_logits - m) | ||
| probs /= probs.sum(-1, keepdims=True) | ||
| cum = np.cumsum(probs, axis=-1) | ||
| # keep tokens up to and including the one that crosses top_p | ||
| keep = cum - probs <= top_p | ||
| keep[:, 0] = True # always keep the most probable token | ||
| mask_sorted = np.where(keep, sorted_logits, neg_inf) | ||
| out = np.empty_like(out) | ||
| np.put_along_axis(out, order, mask_sorted, axis=-1) | ||
| return out | ||
|
|
||
|
|
||
| def _apple_gpu_gumbel_sample(logits: Any, np: Any, *, key: Any = None, | ||
| temperature: float = 1.0, top_k: int = 0, | ||
| top_p: float = 0.0, greedy: bool = False) -> Any: | ||
| """GPU Gumbel-max categorical sampler — draws one token id per row of | ||
| ``logits`` ``[..., vocab]``. | ||
|
|
||
| ``argmax(logits/T + g)`` with Gumbel noise ``g`` (from the Philox ``key``) | ||
| is an exact draw from ``softmax(logits/T)``; the per-row argmax over the | ||
| vocab runs on-GPU (the throughput win for batched sampling). ``greedy=True`` | ||
| (or ``temperature==0``) returns the plain argmax. ``top_k`` / ``top_p`` | ||
| restrict the candidate set (host-side mask). Reproducible: same ``key`` + | ||
| logits ⇒ same tokens. Returns int64 ids shaped like the leading dims of | ||
| ``logits``; falls back to numpy when the GPU symbol is unavailable.""" | ||
| arr = np.asarray(logits, dtype=np.float32) | ||
| lead = arr.shape[:-1] | ||
| vocab = int(arr.shape[-1]) | ||
| rows2d = arr.reshape(-1, vocab) | ||
| rows = int(rows2d.shape[0]) | ||
|
|
||
| masked = _apply_topk_topp_mask(rows2d, top_k, top_p, np) | ||
| if greedy or temperature == 0.0: | ||
| gumbel = np.zeros((rows, vocab), np.float32) | ||
| inv_temp = 1.0 | ||
| else: | ||
| gumbel = _gumbel_noise_from_key((rows, vocab), key, np) | ||
| inv_temp = 1.0 / float(temperature) | ||
|
|
||
| sym = _apple_gpu_gumbel_argmax_f32() | ||
| masked = np.ascontiguousarray(masked, np.float32) | ||
| gumbel = np.ascontiguousarray(gumbel, np.float32) | ||
| if sym is not None: | ||
| out = np.zeros(rows, np.int32) | ||
| fp = lambda a: a.ctypes.data_as(ctypes.POINTER(ctypes.c_float)) | ||
| sym(fp(masked), fp(gumbel), out.ctypes.data_as(ctypes.POINTER(ctypes.c_int32)), | ||
| ctypes.c_int32(rows), ctypes.c_int32(vocab), ctypes.c_float(inv_temp)) | ||
| ids = out.astype(np.int64) | ||
| else: | ||
| ids = np.argmax(masked * inv_temp + gumbel, axis=-1).astype(np.int64) | ||
| return ids.reshape(lead) if lead else ids.reshape(()) | ||
|
|
||
|
|
||
| def _apple_gpu_mpsgraph_argreduce_f32() -> Any: | ||
| runtime = _load_apple_gpu_runtime() | ||
| sym = getattr(runtime, "tessera_apple_gpu_mpsgraph_argreduce_f32", None) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a developer already has
build/src/compiler/codegen/Tessera_Apple_Backend/libTesseraAppleRuntime.*from the previous revision,_load_apple_gpu_runtime()can accept that cached library because its acceptance gate was not updated to requiretessera_apple_gpu_gumbel_argmax_f32. In that environment this lookup returnsNone, so the advertised GPU sampler silently runs the host fallback andtest_gumbel_symbol_exportedfails until the user manually cleans/rebuilds; add the new symbol to the loader's required-symbol checks or otherwise force a rebuild when it is absent.Useful? React with 👍 / 👎.