ajh-code commited on
Commit
f571ee8
·
verified ·
1 Parent(s): cd377d9

Add runtime/packed_nvfp4_linear.py

Browse files
Files changed (1) hide show
  1. runtime/packed_nvfp4_linear.py +485 -0
runtime/packed_nvfp4_linear.py ADDED
@@ -0,0 +1,485 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference-only resident NVFP4 linear prototype.
2
+
3
+ This module intentionally uses a narrow ctypes boundary. It proves packed
4
+ residency and Mage shape correctness; it is not yet a torch.compile/CUDA-graph
5
+ shipping operator.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import atexit
11
+ import ctypes
12
+ import threading
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Final
16
+
17
+ import torch
18
+ from torch import nn
19
+
20
+
21
+ ABI_VERSION: Final = 1
22
+ FP4_BLOCK_ELEMENTS: Final = 16
23
+ SCALE_TILE_OUTER: Final = 128
24
+ SCALE_TILE_INNER: Final = 4
25
+ RELEASE_ROOT: Final = Path(__file__).resolve().parents[1]
26
+ DEFAULT_LIBRARY_PATH: Final = RELEASE_ROOT / "runtime" / "libmage_nvfp4_linear.so"
27
+
28
+
29
+ def round_up(value: int, multiple: int) -> int:
30
+ if value <= 0 or multiple <= 0:
31
+ raise ValueError("value and multiple must be positive")
32
+ return ((value + multiple - 1) // multiple) * multiple
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ScaleLayout:
37
+ inner_dim: int
38
+ outer_tiles: int
39
+ num_bytes: int
40
+
41
+
42
+ def scale_layout(k: int, outer_columns: int) -> ScaleLayout:
43
+ if k <= 0 or k % FP4_BLOCK_ELEMENTS:
44
+ raise ValueError("K must be positive and divisible by 16")
45
+ if outer_columns <= 0:
46
+ raise ValueError("outer column count must be positive")
47
+ inner_dim = round_up(k // FP4_BLOCK_ELEMENTS, SCALE_TILE_INNER)
48
+ outer_tiles = (outer_columns + SCALE_TILE_OUTER - 1) // SCALE_TILE_OUTER
49
+ return ScaleLayout(
50
+ inner_dim=inner_dim,
51
+ outer_tiles=outer_tiles,
52
+ num_bytes=outer_tiles * inner_dim * SCALE_TILE_OUTER,
53
+ )
54
+
55
+
56
+ def packed_weight_num_bytes(out_features: int, in_features: int) -> int:
57
+ if out_features <= 0 or out_features % 8:
58
+ raise ValueError("out_features must be positive and divisible by 8")
59
+ if in_features <= 0 or in_features % 32:
60
+ raise ValueError("in_features must be positive and divisible by 32")
61
+ return out_features * in_features // 2
62
+
63
+
64
+ def padded_output_shape(input_shape: tuple[int, ...], out_features: int) -> tuple[int, int]:
65
+ if not input_shape:
66
+ raise ValueError("input must have at least one dimension")
67
+ logical_m = 1
68
+ for dimension in input_shape[:-1]:
69
+ if dimension <= 0:
70
+ raise ValueError("empty or negative leading dimensions are unsupported")
71
+ logical_m *= dimension
72
+ return round_up(logical_m, 8), out_features
73
+
74
+
75
+ def logical_output_shape(input_shape: tuple[int, ...], out_features: int) -> tuple[int, ...]:
76
+ if not input_shape:
77
+ raise ValueError("input must have at least one dimension")
78
+ return (*input_shape[:-1], out_features)
79
+
80
+
81
+ class NativeNvfp4Library:
82
+ """Typed ctypes access to the project-local native library."""
83
+
84
+ def __init__(self, path: str | Path = DEFAULT_LIBRARY_PATH):
85
+ self.path = Path(path).resolve()
86
+ if not self.path.is_file():
87
+ raise FileNotFoundError(
88
+ f"resident NVFP4 library is not built: {self.path}"
89
+ )
90
+ self._library = ctypes.CDLL(str(self.path))
91
+ self._bind()
92
+ version = int(self._library.mage_nvfp4_abi_version())
93
+ if version != ABI_VERSION:
94
+ raise RuntimeError(
95
+ f"resident NVFP4 ABI mismatch: Python={ABI_VERSION}, native={version}"
96
+ )
97
+
98
+ def _bind(self) -> None:
99
+ library = self._library
100
+ library.mage_nvfp4_abi_version.argtypes = []
101
+ library.mage_nvfp4_abi_version.restype = ctypes.c_int
102
+ library.mage_nvfp4_last_error.argtypes = []
103
+ library.mage_nvfp4_last_error.restype = ctypes.c_char_p
104
+ library.mage_nvfp4_packed_weight_bytes.argtypes = [
105
+ ctypes.c_int,
106
+ ctypes.c_int,
107
+ ]
108
+ library.mage_nvfp4_packed_weight_bytes.restype = ctypes.c_size_t
109
+ library.mage_nvfp4_weight_scale_bytes.argtypes = [
110
+ ctypes.c_int,
111
+ ctypes.c_int,
112
+ ]
113
+ library.mage_nvfp4_weight_scale_bytes.restype = ctypes.c_size_t
114
+ library.mage_nvfp4_pack_weight_bf16.argtypes = [
115
+ ctypes.c_void_p,
116
+ ctypes.c_int,
117
+ ctypes.c_int,
118
+ ctypes.c_void_p,
119
+ ctypes.c_size_t,
120
+ ctypes.c_void_p,
121
+ ctypes.c_size_t,
122
+ ctypes.POINTER(ctypes.c_float),
123
+ ]
124
+ library.mage_nvfp4_pack_weight_bf16.restype = ctypes.c_int
125
+ library.mage_nvfp4_create_context.argtypes = [
126
+ ctypes.c_int,
127
+ ctypes.POINTER(ctypes.c_void_p),
128
+ ]
129
+ library.mage_nvfp4_create_context.restype = ctypes.c_int
130
+ library.mage_nvfp4_destroy_context.argtypes = [ctypes.c_void_p]
131
+ library.mage_nvfp4_destroy_context.restype = ctypes.c_int
132
+ library.mage_nvfp4_context_reserved_bytes.argtypes = [ctypes.c_void_p]
133
+ library.mage_nvfp4_context_reserved_bytes.restype = ctypes.c_size_t
134
+ library.mage_nvfp4_linear_forward.argtypes = [
135
+ ctypes.c_void_p,
136
+ ctypes.c_void_p,
137
+ ctypes.c_void_p,
138
+ ctypes.c_size_t,
139
+ ctypes.c_void_p,
140
+ ctypes.c_size_t,
141
+ ctypes.c_void_p,
142
+ ctypes.c_void_p,
143
+ ctypes.c_void_p,
144
+ ctypes.c_int,
145
+ ctypes.c_int,
146
+ ctypes.c_int,
147
+ ctypes.c_size_t,
148
+ ]
149
+ library.mage_nvfp4_linear_forward.restype = ctypes.c_int
150
+
151
+ def _error(self, operation: str) -> RuntimeError:
152
+ raw = self._library.mage_nvfp4_last_error()
153
+ message = raw.decode("utf-8", errors="replace") if raw else "unknown error"
154
+ return RuntimeError(f"{operation}: {message}")
155
+
156
+ def pack_weight(
157
+ self, weight: torch.Tensor
158
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
159
+ if weight.device.type != "cpu":
160
+ raise ValueError("native weight packer requires a CPU tensor")
161
+ if weight.dtype != torch.bfloat16:
162
+ raise TypeError("native weight packer requires BF16")
163
+ if weight.ndim != 2 or not weight.is_contiguous():
164
+ raise ValueError("weight must be contiguous [N,K]")
165
+ n, k = map(int, weight.shape)
166
+ packed_bytes = packed_weight_num_bytes(n, k)
167
+ scale_bytes = scale_layout(k, n).num_bytes
168
+ native_packed = int(
169
+ self._library.mage_nvfp4_packed_weight_bytes(n, k)
170
+ )
171
+ native_scales = int(
172
+ self._library.mage_nvfp4_weight_scale_bytes(n, k)
173
+ )
174
+ if (native_packed, native_scales) != (packed_bytes, scale_bytes):
175
+ raise RuntimeError(
176
+ "Python/native packed metadata disagreement: "
177
+ f"Python={(packed_bytes, scale_bytes)}, "
178
+ f"native={(native_packed, native_scales)}"
179
+ )
180
+ packed = torch.empty(packed_bytes, dtype=torch.uint8, device="cpu")
181
+ scales = torch.empty(scale_bytes, dtype=torch.uint8, device="cpu")
182
+ tensor_scale = ctypes.c_float()
183
+ status = self._library.mage_nvfp4_pack_weight_bf16(
184
+ ctypes.c_void_p(weight.data_ptr()),
185
+ n,
186
+ k,
187
+ ctypes.c_void_p(packed.data_ptr()),
188
+ packed.numel(),
189
+ ctypes.c_void_p(scales.data_ptr()),
190
+ scales.numel(),
191
+ ctypes.byref(tensor_scale),
192
+ )
193
+ if status:
194
+ raise self._error("packing BF16 weight")
195
+ scale_tensor = torch.tensor(tensor_scale.value, dtype=torch.float32)
196
+ return packed, scales, scale_tensor
197
+
198
+
199
+ _LIBRARY_LOCK = threading.Lock()
200
+ _LIBRARY: NativeNvfp4Library | None = None
201
+
202
+
203
+ def native_library() -> NativeNvfp4Library:
204
+ global _LIBRARY
205
+ with _LIBRARY_LOCK:
206
+ if _LIBRARY is None:
207
+ _LIBRARY = NativeNvfp4Library()
208
+ return _LIBRARY
209
+
210
+
211
+ class ResidentContext:
212
+ def __init__(self, library: NativeNvfp4Library, device_index: int, stream: int):
213
+ self.library = library
214
+ self.device_index = int(device_index)
215
+ self.stream = int(stream)
216
+ self._pointer = ctypes.c_void_p()
217
+ self._lock = threading.Lock()
218
+ status = self.library._library.mage_nvfp4_create_context(
219
+ self.device_index, ctypes.byref(self._pointer)
220
+ )
221
+ if status:
222
+ raise self.library._error("creating resident context")
223
+ self._closed = False
224
+
225
+ @property
226
+ def pointer(self) -> ctypes.c_void_p:
227
+ if self._closed:
228
+ raise RuntimeError("resident NVFP4 context is closed")
229
+ return self._pointer
230
+
231
+ @property
232
+ def reserved_bytes(self) -> int:
233
+ return int(
234
+ self.library._library.mage_nvfp4_context_reserved_bytes(self.pointer)
235
+ )
236
+
237
+ def close(self) -> None:
238
+ with self._lock:
239
+ if self._closed:
240
+ return
241
+ status = self.library._library.mage_nvfp4_destroy_context(
242
+ self._pointer
243
+ )
244
+ if status:
245
+ raise self.library._error("destroying resident context")
246
+ self._closed = True
247
+ self._pointer = ctypes.c_void_p()
248
+
249
+ def forward(
250
+ self,
251
+ x: torch.Tensor,
252
+ packed_weight: torch.Tensor,
253
+ weight_scales: torch.Tensor,
254
+ weight_scale: torch.Tensor,
255
+ bias: torch.Tensor | None,
256
+ output: torch.Tensor,
257
+ logical_m: int,
258
+ in_features: int,
259
+ out_features: int,
260
+ ) -> None:
261
+ bias_pointer = (
262
+ ctypes.c_void_p(bias.data_ptr()) if bias is not None else None
263
+ )
264
+ with self._lock:
265
+ status = self.library._library.mage_nvfp4_linear_forward(
266
+ self.pointer,
267
+ ctypes.c_void_p(x.data_ptr()),
268
+ ctypes.c_void_p(packed_weight.data_ptr()),
269
+ packed_weight.numel(),
270
+ ctypes.c_void_p(weight_scales.data_ptr()),
271
+ weight_scales.numel(),
272
+ ctypes.c_void_p(weight_scale.data_ptr()),
273
+ bias_pointer,
274
+ ctypes.c_void_p(output.data_ptr()),
275
+ logical_m,
276
+ in_features,
277
+ out_features,
278
+ self.stream,
279
+ )
280
+ if status:
281
+ raise self.library._error("resident NVFP4 linear forward")
282
+
283
+
284
+ _CONTEXTS_LOCK = threading.Lock()
285
+ _CONTEXTS: dict[tuple[int, int], ResidentContext] = {}
286
+
287
+
288
+ def resident_context(device: torch.device) -> ResidentContext:
289
+ if device.type != "cuda":
290
+ raise ValueError("resident NVFP4 context requires CUDA")
291
+ device_index = (
292
+ torch.cuda.current_device() if device.index is None else int(device.index)
293
+ )
294
+ stream = int(torch.cuda.current_stream(device_index).cuda_stream)
295
+ key = (device_index, stream)
296
+ with _CONTEXTS_LOCK:
297
+ context = _CONTEXTS.get(key)
298
+ if context is None:
299
+ context = ResidentContext(native_library(), device_index, stream)
300
+ _CONTEXTS[key] = context
301
+ return context
302
+
303
+
304
+ def close_all_contexts() -> None:
305
+ with _CONTEXTS_LOCK:
306
+ contexts = list(_CONTEXTS.values())
307
+ _CONTEXTS.clear()
308
+ errors: list[Exception] = []
309
+ for context in contexts:
310
+ try:
311
+ context.close()
312
+ except Exception as error: # pragma: no cover - shutdown diagnostic
313
+ errors.append(error)
314
+ if errors:
315
+ raise RuntimeError(
316
+ "one or more resident NVFP4 contexts failed to close: "
317
+ + "; ".join(map(str, errors))
318
+ )
319
+
320
+
321
+ def _quiet_atexit_close() -> None:
322
+ try:
323
+ close_all_contexts()
324
+ except Exception:
325
+ # CUDA may already be shutting down. Explicit close_all_contexts() is
326
+ # the auditable path; atexit is only a best-effort fallback.
327
+ pass
328
+
329
+
330
+ atexit.register(_quiet_atexit_close)
331
+
332
+
333
+ class PackedNvfp4Linear(nn.Module):
334
+ """Packed inference replacement for one BF16 ``nn.Linear``."""
335
+
336
+ def __init__(
337
+ self,
338
+ in_features: int,
339
+ out_features: int,
340
+ packed_weight: torch.Tensor,
341
+ weight_scales: torch.Tensor,
342
+ weight_scale: torch.Tensor,
343
+ bias: torch.Tensor | None,
344
+ ):
345
+ super().__init__()
346
+ expected_weight = packed_weight_num_bytes(out_features, in_features)
347
+ expected_scales = scale_layout(in_features, out_features).num_bytes
348
+ if (
349
+ packed_weight.dtype != torch.uint8
350
+ or packed_weight.ndim != 1
351
+ or not packed_weight.is_contiguous()
352
+ or packed_weight.numel() != expected_weight
353
+ ):
354
+ raise ValueError("packed_weight has invalid dtype, shape, or size")
355
+ if (
356
+ weight_scales.dtype != torch.uint8
357
+ or weight_scales.ndim != 1
358
+ or not weight_scales.is_contiguous()
359
+ or weight_scales.numel() != expected_scales
360
+ ):
361
+ raise ValueError("weight_scales has invalid dtype, shape, or size")
362
+ if (
363
+ weight_scale.dtype != torch.float32
364
+ or weight_scale.numel() != 1
365
+ or not weight_scale.is_contiguous()
366
+ ):
367
+ raise ValueError("weight_scale must be one contiguous FP32 value")
368
+ if bias is not None and (
369
+ bias.dtype != torch.bfloat16
370
+ or bias.shape != (out_features,)
371
+ or not bias.is_contiguous()
372
+ ):
373
+ raise ValueError("bias must be contiguous BF16 [out_features]")
374
+ devices = {
375
+ tensor.device
376
+ for tensor in (packed_weight, weight_scales, weight_scale, bias)
377
+ if tensor is not None
378
+ }
379
+ if len(devices) != 1:
380
+ raise ValueError("all resident buffers must be on one device")
381
+
382
+ self.in_features = int(in_features)
383
+ self.out_features = int(out_features)
384
+ self.register_buffer("packed_weight", packed_weight)
385
+ self.register_buffer("weight_scales", weight_scales)
386
+ self.register_buffer("weight_scale", weight_scale.reshape(()))
387
+ self.register_buffer("bias", bias)
388
+
389
+ @classmethod
390
+ def from_linear(
391
+ cls,
392
+ linear: nn.Linear,
393
+ device: torch.device | str,
394
+ *,
395
+ library: NativeNvfp4Library | None = None,
396
+ ) -> "PackedNvfp4Linear":
397
+ if not isinstance(linear, nn.Linear):
398
+ raise TypeError("from_linear requires torch.nn.Linear")
399
+ library = native_library() if library is None else library
400
+ destination = torch.device(device)
401
+ if destination.type != "cuda":
402
+ raise ValueError("resident packed buffers must target CUDA")
403
+ weight_cpu = (
404
+ linear.weight.detach()
405
+ .to(device="cpu", dtype=torch.bfloat16)
406
+ .contiguous()
407
+ )
408
+ packed, scales, tensor_scale = library.pack_weight(weight_cpu)
409
+ bias_cpu = (
410
+ None
411
+ if linear.bias is None
412
+ else linear.bias.detach()
413
+ .to(device="cpu", dtype=torch.bfloat16)
414
+ .contiguous()
415
+ )
416
+ return cls(
417
+ linear.in_features,
418
+ linear.out_features,
419
+ packed.to(destination),
420
+ scales.to(destination),
421
+ tensor_scale.to(destination),
422
+ None if bias_cpu is None else bias_cpu.to(destination),
423
+ )
424
+
425
+ def forward(self, input: torch.Tensor) -> torch.Tensor:
426
+ if input.device.type != "cuda":
427
+ raise ValueError("PackedNvfp4Linear requires a CUDA input")
428
+ if input.device != self.packed_weight.device:
429
+ raise ValueError("input and packed buffers are on different devices")
430
+ if input.dtype != torch.bfloat16:
431
+ raise TypeError("PackedNvfp4Linear requires BF16 input")
432
+ if input.ndim < 1 or input.shape[-1] != self.in_features:
433
+ raise ValueError(
434
+ f"expected last dimension {self.in_features}, got "
435
+ f"{tuple(input.shape)}"
436
+ )
437
+ if input.requires_grad:
438
+ raise RuntimeError("resident NVFP4 prototype is inference-only")
439
+
440
+ contiguous = input.reshape(-1, self.in_features).contiguous()
441
+ logical_m = int(contiguous.shape[0])
442
+ if logical_m <= 0:
443
+ raise ValueError("empty inputs are unsupported")
444
+ padded_m = round_up(logical_m, 8)
445
+ padded_output = torch.empty(
446
+ (padded_m, self.out_features),
447
+ dtype=torch.bfloat16,
448
+ device=input.device,
449
+ )
450
+ current_stream = torch.cuda.current_stream(input.device)
451
+ # ctypes launches are invisible to the caching allocator. Explicitly
452
+ # record every CUDA allocation read or written by the native call so a
453
+ # tensor produced on another stream cannot be recycled while the
454
+ # resident kernel is still using it.
455
+ for tensor in (
456
+ contiguous,
457
+ self.packed_weight,
458
+ self.weight_scales,
459
+ self.weight_scale,
460
+ self.bias,
461
+ padded_output,
462
+ ):
463
+ if tensor is not None:
464
+ tensor.record_stream(current_stream)
465
+ context = resident_context(input.device)
466
+ context.forward(
467
+ contiguous,
468
+ self.packed_weight,
469
+ self.weight_scales,
470
+ self.weight_scale,
471
+ self.bias,
472
+ padded_output,
473
+ logical_m,
474
+ self.in_features,
475
+ self.out_features,
476
+ )
477
+ logical = padded_output[:logical_m]
478
+ return logical.view(*input.shape[:-1], self.out_features)
479
+
480
+ def extra_repr(self) -> str:
481
+ return (
482
+ f"in_features={self.in_features}, "
483
+ f"out_features={self.out_features}, "
484
+ f"bias={self.bias is not None}, inference_only=True"
485
+ )