File size: 10,840 Bytes
f7eb3fa
 
 
b050a89
57c2394
 
 
f7eb3fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b050a89
 
 
 
 
 
 
57c2394
b050a89
57c2394
 
b050a89
57c2394
b050a89
 
57c2394
 
 
 
 
 
 
 
 
 
f7eb3fa
57c2394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b050a89
 
 
 
 
f7eb3fa
c34d985
 
 
 
cb0ceb1
 
 
 
 
f7eb3fa
b050a89
 
f7eb3fa
 
 
 
 
 
 
b050a89
f7eb3fa
b050a89
c34d985
b050a89
 
c34d985
 
 
57c2394
 
 
 
b050a89
 
c34d985
 
57c2394
 
c34d985
 
 
 
57c2394
c34d985
 
 
 
57c2394
 
 
 
 
b050a89
 
c34d985
b050a89
57c2394
 
 
 
 
b050a89
57c2394
b050a89
57c2394
 
 
 
 
 
 
 
b050a89
 
57c2394
b050a89
 
 
 
57c2394
 
 
 
 
 
b050a89
 
57c2394
 
 
 
c34d985
57c2394
 
 
 
c34d985
57c2394
 
 
 
cb0ceb1
 
 
 
 
 
 
 
 
c34d985
 
 
 
 
 
 
 
 
 
b050a89
 
 
57c2394
 
 
c34d985
 
 
 
 
 
 
b050a89
57c2394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f7eb3fa
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
from __future__ import annotations

import argparse
import json
import os
import platform
import statistics
import time

import torch
from orbitquant_packed_matmul import matmul_packed_weight


def _pack(values: torch.Tensor, bits: int) -> torch.Tensor:
    flat = values.detach().to(device="cpu", dtype=torch.uint8).flatten()
    packed = torch.zeros((flat.numel() * bits + 7) // 8, dtype=torch.uint8)
    for value_index, value in enumerate(flat.tolist()):
        bit_start = value_index * bits
        byte_index = bit_start // 8
        shift = bit_start % 8
        packed[byte_index] |= (value << shift) & 0xFF
        if shift + bits > 8:
            packed[byte_index + 1] |= value >> (8 - shift)
    return packed


def _synchronize(device: str) -> None:
    if device == "cuda":
        torch.cuda.synchronize()
    elif device == "mps":
        torch.mps.synchronize()


def _time_call(device: str, fn) -> float:
    _synchronize(device)
    start = time.perf_counter_ns()
    fn()
    _synchronize(device)
    return (time.perf_counter_ns() - start) / 1_000_000_000


def _time_distribution(device: str, iters: int, fn) -> dict[str, float]:
    samples = []
    for _ in range(iters):
        samples.append(_time_call(device, fn))
    samples.sort()
    return {
        "mean": statistics.fmean(samples),
        "median": statistics.median(samples),
        "p95": samples[min(len(samples) - 1, int(len(samples) * 0.95))],
    }


def _parse_rows(raw: str) -> list[int]:
    rows_values = []
    for chunk in raw.split(","):
        chunk = chunk.strip()
        if not chunk:
            continue
        rows = int(chunk)
        if rows <= 0:
            raise argparse.ArgumentTypeError("--rows values must be positive")
        rows_values.append(rows)
    if not rows_values:
        raise argparse.ArgumentTypeError("--rows must list at least one row count")
    return rows_values


_DTYPES = {
    "float32": torch.float32,
    "float16": torch.float16,
    "bfloat16": torch.bfloat16,
}


def _benchmark_rows(args, rows: int, dtype: torch.dtype, weights) -> dict:
    packed, row_norms, centroids, indices_device = weights
    x = torch.randn(rows, args.in_features, device=args.device, dtype=dtype)
    bias = (
        torch.randn(args.out_features, device=args.device, dtype=dtype)
        if args.with_bias
        else None
    )

    def materialize_reference_weight() -> torch.Tensor:
        return (row_norms[:, None] * centroids[indices_device]).to(dtype)

    reference_weight = materialize_reference_weight()
    packed_weight_indices_bytes = packed.numel() * packed.element_size()
    row_norms_bytes = row_norms.numel() * row_norms.element_size()
    centroid_bytes = centroids.numel() * centroids.element_size()
    packed_weight_path_bytes = packed_weight_indices_bytes + row_norms_bytes + centroid_bytes
    materialized_weight_bytes = reference_weight.numel() * reference_weight.element_size()

    def packed_call() -> torch.Tensor:
        return matmul_packed_weight(
            x,
            packed,
            row_norms,
            centroids,
            bits=args.bits,
            out_features=args.out_features,
            in_features=args.in_features,
            bias=bias,
        )

    def predequantized_linear_call() -> torch.Tensor:
        return torch.nn.functional.linear(x, reference_weight, bias)

    def dequantize_then_linear_call() -> torch.Tensor:
        return torch.nn.functional.linear(x, materialize_reference_weight(), bias)

    packed_first_call_seconds = _time_call(args.device, packed_call)
    predequantized_first_call_seconds = _time_call(args.device, predequantized_linear_call)
    dequantize_then_first_call_seconds = _time_call(args.device, dequantize_then_linear_call)

    for _ in range(args.warmup):
        packed_call()
        predequantized_linear_call()
        dequantize_then_linear_call()
    packed_distribution = _time_distribution(args.device, args.iters, packed_call)
    predequantized_distribution = _time_distribution(
        args.device,
        args.iters,
        predequantized_linear_call,
    )
    dequantize_then_distribution = _time_distribution(
        args.device,
        args.iters,
        dequantize_then_linear_call,
    )
    # Headline numbers are hot-loop medians; the mean is retained alongside the
    # median/p95 so noisy first-iteration outliers cannot skew comparisons.
    packed_seconds = packed_distribution["median"]
    predequantized_linear_seconds = predequantized_distribution["median"]
    dequantize_then_linear_seconds = dequantize_then_distribution["median"]

    packed_output = packed_call()
    reference_output = predequantized_linear_call()
    _synchronize(args.device)
    error = packed_output.float() - reference_output.float()
    max_abs_error = error.abs().max().item()
    rmse = error.square().mean().sqrt().item()
    reference_rms = reference_output.float().square().mean().sqrt().item()
    relative_rmse = rmse / max(reference_rms, 1e-12)

    return {
        "device": args.device,
        "device_name": (
            torch.cuda.get_device_name(0)
            if args.device == "cuda"
            else "mps"
            if args.device == "mps"
            else f"{platform.processor() or platform.machine()} "
            f"({torch.backends.cpu.get_cpu_capability()})"
        ),
        "dtype": str(dtype).replace("torch.", ""),
        "bits": args.bits,
        "rows": rows,
        "in_features": args.in_features,
        "out_features": args.out_features,
        "iters": args.iters,
        "warmup": args.warmup,
        "threads": (
            os.environ.get("ORBITQUANT_CPU_THREADS", "runtime default")
            if args.device == "cpu"
            else None
        ),
        "torch_threads": torch.get_num_threads() if args.device == "cpu" else None,
        "with_bias": args.with_bias,
        "packed_seconds_per_iter": packed_seconds,
        "packed_first_call_seconds": packed_first_call_seconds,
        "packed_hot_mean_seconds": packed_distribution["mean"],
        "packed_hot_median_seconds": packed_distribution["median"],
        "packed_hot_p95_seconds": packed_distribution["p95"],
        "predequantized_f_linear_seconds_per_iter": predequantized_linear_seconds,
        "predequantized_first_call_seconds": predequantized_first_call_seconds,
        "predequantized_hot_mean_seconds": predequantized_distribution["mean"],
        "predequantized_hot_median_seconds": predequantized_distribution["median"],
        "predequantized_hot_p95_seconds": predequantized_distribution["p95"],
        "dequantize_then_f_linear_seconds_per_iter": dequantize_then_linear_seconds,
        "dequantize_then_first_call_seconds": dequantize_then_first_call_seconds,
        "dequantize_then_hot_mean_seconds": dequantize_then_distribution["mean"],
        "dequantize_then_hot_median_seconds": dequantize_then_distribution["median"],
        "dequantize_then_hot_p95_seconds": dequantize_then_distribution["p95"],
        "packed_weight_indices_bytes": packed_weight_indices_bytes,
        "row_norms_bytes": row_norms_bytes,
        "centroid_bytes": centroid_bytes,
        "packed_weight_path_bytes": packed_weight_path_bytes,
        "materialized_weight_bytes": materialized_weight_bytes,
        "packed_weight_path_vs_materialized_weight_ratio": packed_weight_path_bytes
        / materialized_weight_bytes
        if materialized_weight_bytes > 0
        else None,
        "packed_vs_predequantized_f_linear_speedup": predequantized_linear_seconds
        / packed_seconds
        if packed_seconds > 0
        else None,
        "packed_vs_dequantize_then_f_linear_speedup": dequantize_then_linear_seconds
        / packed_seconds
        if packed_seconds > 0
        else None,
        "reference_seconds_per_iter": predequantized_linear_seconds,
        "packed_vs_reference_speedup": predequantized_linear_seconds / packed_seconds
        if packed_seconds > 0
        else None,
        "max_abs_error": max_abs_error,
        "rmse": rmse,
        "relative_rmse": relative_rmse,
        "timing_headline": "hot-loop median seconds per iteration",
        "reference": (
            "predequantized PyTorch F.linear over a materialized dequantized "
            "weight matrix"
        ),
        "dequantize_reference": (
            "materialize the dequantized weight matrix, then call PyTorch F.linear"
        ),
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--device", choices=["cpu", "cuda", "mps"], default="cuda")
    parser.add_argument("--bits", type=int, default=4)
    parser.add_argument(
        "--rows",
        type=_parse_rows,
        default=[1, 8, 512, 4096],
        help="comma-separated row counts to sweep (default covers decode-bound "
        "small batches and GEMM-bound large batches)",
    )
    parser.add_argument(
        "--dtype",
        choices=["auto", *sorted(_DTYPES)],
        default="auto",
        help="activation dtype; auto picks float16 on mps and bfloat16 elsewhere",
    )
    parser.add_argument("--in-features", type=int, default=3072)
    parser.add_argument("--out-features", type=int, default=3072)
    parser.add_argument("--iters", type=int, default=20)
    parser.add_argument("--warmup", type=int, default=3)
    parser.add_argument("--seed", type=int, default=0)
    parser.add_argument("--threads", type=int, default=0)
    parser.add_argument("--with-bias", action="store_true")
    args = parser.parse_args()

    if args.threads < 0:
        parser.error("--threads must be non-negative")
    if args.iters <= 0 or args.warmup < 0:
        parser.error("--iters must be positive and --warmup must be non-negative")
    if args.device == "cpu" and args.threads > 0:
        os.environ["ORBITQUANT_CPU_THREADS"] = str(args.threads)
        torch.set_num_threads(args.threads)

    torch.manual_seed(args.seed)
    if args.dtype == "auto":
        dtype = torch.float16 if args.device == "mps" else torch.bfloat16
    else:
        dtype = _DTYPES[args.dtype]
    indices = torch.randint(
        0,
        2**args.bits,
        (args.out_features, args.in_features),
        dtype=torch.uint8,
    )
    packed = _pack(indices, args.bits).to(args.device)
    row_norms = torch.ones(args.out_features, device=args.device, dtype=torch.bfloat16)
    centroids = torch.linspace(-1.0, 1.0, 2**args.bits, device=args.device)
    indices_device = indices.long().to(args.device)
    weights = (packed, row_norms, centroids, indices_device)

    payloads = [_benchmark_rows(args, rows, dtype, weights) for rows in args.rows]
    if len(payloads) == 1:
        print(json.dumps(payloads[0], indent=2, sort_keys=True))
    else:
        print(json.dumps(payloads, indent=2, sort_keys=True))


if __name__ == "__main__":
    main()