quantize-py 0.3.1__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {quantize_py-0.3.1 → quantize_py-0.3.2}/Cargo.lock +2 -2
- {quantize_py-0.3.1 → quantize_py-0.3.2}/Cargo.toml +1 -1
- {quantize_py-0.3.1 → quantize_py-0.3.2}/PKG-INFO +24 -6
- {quantize_py-0.3.1 → quantize_py-0.3.2}/README.md +5 -5
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/README.md +23 -5
- quantize_py-0.3.2/python/src/gil.rs +49 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/src/input.rs +79 -8
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/src/learned.rs +30 -14
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/src/lib.rs +9 -6
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/src/quantized/inner.rs +35 -16
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/src/quantized/methods.rs +64 -44
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/src/scheme.rs +8 -2
- quantize_py-0.3.2/python/tests/test_learned.py +324 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/tests/test_quantize.py +83 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/kernels/i4.rs +93 -28
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/kernels/i8.rs +23 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/kernels/mod.rs +2 -2
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/shared/decode.rs +245 -20
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/shared/error.rs +39 -3
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/shared/scheme.rs +25 -5
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/shared/tensor.rs +15 -0
- quantize_py-0.3.1/python/tests/test_learned.py +0 -158
- {quantize_py-0.3.1 → quantize_py-0.3.2}/LICENSE +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/pyproject.toml +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/Cargo.toml +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/quantize/__init__.py +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/quantize/adaptive.py +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/quantize/asymmetric.py +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/quantize/learned.py +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/quantize/symmetric.py +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/src/error.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/src/quantized/parts.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/src/quantized.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/python/src/scale.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/kernels/block.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/kernels/reduce.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/lib.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/methods/adaptive/mod.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/methods/asymmetric/mod.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/methods/learned/mod.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/methods/mod.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/methods/symmetric/mod.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/shared/bytes.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/shared/mod.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/shared/packed.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/shared/params.rs +0 -0
- {quantize_py-0.3.1 → quantize_py-0.3.2}/src/shared/scale.rs +0 -0
|
@@ -1411,14 +1411,14 @@ dependencies = [
|
|
|
1411
1411
|
|
|
1412
1412
|
[[package]]
|
|
1413
1413
|
name = "quantize"
|
|
1414
|
-
version = "0.3.
|
|
1414
|
+
version = "0.3.2"
|
|
1415
1415
|
dependencies = [
|
|
1416
1416
|
"half",
|
|
1417
1417
|
]
|
|
1418
1418
|
|
|
1419
1419
|
[[package]]
|
|
1420
1420
|
name = "quantize-py"
|
|
1421
|
-
version = "0.3.
|
|
1421
|
+
version = "0.3.2"
|
|
1422
1422
|
dependencies = [
|
|
1423
1423
|
"half",
|
|
1424
1424
|
"numpy",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quantize-py
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Classifier: Programming Language :: Python :: 3
|
|
5
5
|
Classifier: Programming Language :: Python :: 3.12
|
|
6
6
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -27,7 +27,7 @@ python bindings for [quantize](https://github.com/aksheyd/quantize), a simple, f
|
|
|
27
27
|
pip install quantize-py
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
requires python 3.12 or newer. numpy is installed with it. where no wheel fits, like
|
|
30
|
+
requires python 3.12 or newer, or 3.14 or newer for free-threaded python. numpy is installed with it. where no wheel fits, like alpine linux, pip builds it from source, which needs rust 1.88 or newer.
|
|
31
31
|
|
|
32
32
|
```python
|
|
33
33
|
from quantize import Scale, quantize
|
|
@@ -39,7 +39,7 @@ back = q.dequantize() # [0.421, -0.098, 0.700, -0.498]
|
|
|
39
39
|
dot = q.dot(weights) # 0.926
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
`bits` is the width of each code, from 2 to 16. `block` is how many values share one scale, and `scale` is how that scale is stored: `Scale.F32` (the default), `Scale.F16`, or `Scale.BF16`. values can be a list, a numpy array, or anything else `np.asarray` reads, like a pytorch tensor. a 2-d array keeps its shape, so `q.dequantize()` gives back a matrix and `q.matmul(x)` computes `x @ W.T`, like a linear layer. `q.shape` gives its rows and columns, and `len(q)` the number of values, as in rust.
|
|
42
|
+
`bits` is the width of each code, from 2 to 16. `block` is how many values share one scale, and `scale` is how that scale is stored: `Scale.F32` (the default), `Scale.F16`, or `Scale.BF16`. values can be a list, a numpy array, or anything else `np.asarray` reads, like a pytorch tensor. a 2-d array keeps its shape, so `q.dequantize()` gives back a matrix and `q.matmul(x)` computes `x @ W.T`, like a linear layer. both can write into a float32 numpy array or pytorch tensor you pass, like `q.matmul(x, out=y)`, so a loop can reuse it. `q.shape` gives its rows and columns, and `len(q)` the number of values, as in rust.
|
|
43
43
|
|
|
44
44
|
the scales count toward the size: 4-bit codes with one f16 scale per 32 values cost 4.5 bits per value, or 5 with the default f32 scale. `q.bits_per_element` reports it. above about 10 bits, use f32 scales with `asymmetric.quantize`, since f16 and bf16 zero-points cap its accuracy.
|
|
45
45
|
|
|
@@ -47,9 +47,9 @@ the other schemes return the same `Quantized` type:
|
|
|
47
47
|
|
|
48
48
|
- `asymmetric.quantize(weights, bits=8, block=32)` adds a zero-point per block, for values that aren't centered on zero
|
|
49
49
|
- `adaptive.quantize(weights, tolerance=0.1 * weights.std())` gives each block the fewest bits, from 2 to 8, that round every weight within `tolerance`, in the weights' own units. a tenth of their standard deviation gives about 5 bits a block. for a list, use `np.std(weights)`
|
|
50
|
-
- `learned.refine(q, weights)` refits each block's scale, and its zero-point if it has one, to lower the mean squared error. it changes `q` in place, so call `q.copy()` first to keep the original
|
|
51
|
-
- `learned.alternate(q, weights)` refits too, then rounds each value to the nearest code on its block's new line, and repeats until no code moves. it also changes `q` in place. both can raise the worst error past an adaptive tensor's tolerance, and lowering the error of the weights doesn't always lower the error of a model's outputs, so check those too
|
|
52
|
-
- `Scheme.Q4_32.quantize(weights)` picks a scheme at run time
|
|
50
|
+
- `learned.refine(q, weights)` refits each block's scale, and its zero-point if it has one, to lower the mean squared error. it changes `q` in place, so call `q.copy()` first to keep the original. it lets other threads run while it works, so threads can refit several layers at once
|
|
51
|
+
- `learned.alternate(q, weights)` refits too, then rounds each value to the nearest code on its block's new line, and repeats until no code moves. it also changes `q` in place and lets other threads run. both can raise the worst error past an adaptive tensor's tolerance, and lowering the error of the weights doesn't always lower the error of a model's outputs, so check those too
|
|
52
|
+
- `Scheme.Q4_32.quantize(weights)` picks a scheme at run time, and `Scheme("symmetric(bits=4)")` reads one from text, like a config value, which `str(scheme)` writes
|
|
53
53
|
|
|
54
54
|
a block with outliers can need more than 8 bits, which raises `ToleranceTooTightError`. retrying with its `smallest_tolerance` works, but loosens every block, not just that one:
|
|
55
55
|
|
|
@@ -77,6 +77,24 @@ q = Quantized.from_parts(**np.load("layer.npz"))
|
|
|
77
77
|
|
|
78
78
|
to keep quantized values in a `torch.save` checkpoint, store `torch.frombuffer(bytearray(q.to_bytes()), dtype=torch.uint8)`, and load each back with `Quantized(t)`. the checkpoint is then as small as the bytes, and `torch.load` reads it without `add_safe_globals`. pickling `q` itself makes the checkpoint about 1.5 times larger, since `torch.save` stores bytes as text, and needs `torch.serialization.add_safe_globals([Quantized])` before `torch.load`.
|
|
79
79
|
|
|
80
|
+
`q.matmul` runs on one core, but it lets other threads run while it multiplies, so threads can share out a batch. on an 8-core intel xeon, this multiplies a batch of 512 by a 4-bit 1536 × 576 matrix in 6 ms instead of 33 ms, with the same result, bit for bit:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import os
|
|
84
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
85
|
+
|
|
86
|
+
pool = ThreadPoolExecutor() # make it once, and reuse it for every layer
|
|
87
|
+
|
|
88
|
+
def linear(q, x): # x has shape (batch, columns)
|
|
89
|
+
# a piece per core, or pieces of 64 rows for a big batch, which stay in a core's cache
|
|
90
|
+
pieces = np.array_split(x, max(os.cpu_count(), len(x) // 64))
|
|
91
|
+
return np.concatenate(list(pool.map(q.matmul, pieces)))
|
|
92
|
+
|
|
93
|
+
out = linear(q, x)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
with a gil, while another thread keeps running python, each piece can wait up to `sys.getswitchinterval()`, 5 ms by default, to get the gil back, so splitting pays off only while your other threads are idle or in native code, or on free-threaded python. `dequantize`, `dot`, and `matmul` on 65,536 values or fewer, like one vector times a 256 × 256 matrix, keep the gil, so they don't wait. quantizing and refitting keep it only up to 4,096 values, so threads can quantize a model's layers in parallel.
|
|
97
|
+
|
|
80
98
|
each value decodes as `code * scale`, or `(code - zero_point) * scale` with zero-points, using the scale and zero-point of its block. codes are signed and `bits` wide, and `q.codes` packs them low bits first. scales can be negative, since a symmetric block puts its value farthest from zero on the most negative code. `help(Quantized)` has the details.
|
|
81
99
|
|
|
82
100
|
to build and test from a clone of the repo, with rust 1.88 or newer and [just](https://github.com/casey/just):
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
a simple, fast quantization library usable as a [rust crate](https://crates.io/crates/quantize) or [python package](https://pypi.org/project/quantize-py/).
|
|
4
4
|
|
|
5
|
-
quantization stores numbers in fewer bits, trading
|
|
5
|
+
quantization stores numbers in fewer bits, trading some accuracy for a lot less memory.
|
|
6
6
|
|
|
7
7
|
## learn
|
|
8
8
|
|
|
@@ -74,7 +74,7 @@ quantize two random matrices, reconstruct them, then matmul. mse is the mean squ
|
|
|
74
74
|
|
|
75
75
|
<!-- comparison:end -->
|
|
76
76
|
|
|
77
|
-
on WikiText-2
|
|
77
|
+
on WikiText-2, a set of wikipedia articles, the small language model SmolLM-135M has a 4-bit perplexity of 26.44 against candle `Q4_0`'s 26.46, and 18.93 in fp32. perplexity is roughly how many tokens the model is choosing between when it guesses the next one, so lower is better. reproducing these numbers downloads the model and dataset from hugging face, then scores the whole test set, which takes hours:
|
|
78
78
|
|
|
79
79
|
```
|
|
80
80
|
cargo run --release -p benchmarks --example wikitext --features workload
|
|
@@ -88,7 +88,7 @@ add `-- --max-tokens 512` to score only the first 512 tokens. that takes about a
|
|
|
88
88
|
cargo run --release -p benchmarks --example throughput
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
quantize and dequantize with f16 scales. both libraries allocate their output on every call. aarch64 is an apple M5 Max and x86_64 an intel xeon. the hand-written simd only targets aarch64, so x86_64 runs plain loops and is slower. the run also times this crate's `dot`, `matmul` on 16 inputs, and row-by-row decoding of an adaptive matrix, which aren't compared with candle.
|
|
91
|
+
quantize and dequantize with f16 scales. both libraries allocate their output on every call. aarch64 is an apple M5 Max and x86_64 an intel xeon. the hand-written simd only targets aarch64, so x86_64 runs plain loops and is slower than aarch64. the run also times this crate's `dot`, `matmul` on 16 inputs and on one, and row-by-row decoding of an adaptive matrix, which aren't compared with candle.
|
|
92
92
|
|
|
93
93
|
<!-- speed:start -->
|
|
94
94
|
|
|
@@ -96,8 +96,8 @@ quantize and dequantize with f16 scales. both libraries allocate their output on
|
|
|
96
96
|
| --- | ---: | ---: | ---: | ---: |
|
|
97
97
|
| quantize, aarch64 | 0.30 | 0.30 | 0.08 | 0.07 |
|
|
98
98
|
| candle, aarch64 | 0.41 | 0.43 | 0.20 | 0.25 |
|
|
99
|
-
| quantize, x86_64 | 3.
|
|
100
|
-
| candle, x86_64 | 2.
|
|
99
|
+
| quantize, x86_64 | 3.93 | 3.77 | 0.37 | 0.26 |
|
|
100
|
+
| candle, x86_64 | 2.25 | 4.28 | 0.45 | 0.44 |
|
|
101
101
|
|
|
102
102
|
<!-- speed:end -->
|
|
103
103
|
|
|
@@ -6,7 +6,7 @@ python bindings for [quantize](https://github.com/aksheyd/quantize), a simple, f
|
|
|
6
6
|
pip install quantize-py
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
requires python 3.12 or newer. numpy is installed with it. where no wheel fits, like
|
|
9
|
+
requires python 3.12 or newer, or 3.14 or newer for free-threaded python. numpy is installed with it. where no wheel fits, like alpine linux, pip builds it from source, which needs rust 1.88 or newer.
|
|
10
10
|
|
|
11
11
|
```python
|
|
12
12
|
from quantize import Scale, quantize
|
|
@@ -18,7 +18,7 @@ back = q.dequantize() # [0.421, -0.098, 0.700, -0.498]
|
|
|
18
18
|
dot = q.dot(weights) # 0.926
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
`bits` is the width of each code, from 2 to 16. `block` is how many values share one scale, and `scale` is how that scale is stored: `Scale.F32` (the default), `Scale.F16`, or `Scale.BF16`. values can be a list, a numpy array, or anything else `np.asarray` reads, like a pytorch tensor. a 2-d array keeps its shape, so `q.dequantize()` gives back a matrix and `q.matmul(x)` computes `x @ W.T`, like a linear layer. `q.shape` gives its rows and columns, and `len(q)` the number of values, as in rust.
|
|
21
|
+
`bits` is the width of each code, from 2 to 16. `block` is how many values share one scale, and `scale` is how that scale is stored: `Scale.F32` (the default), `Scale.F16`, or `Scale.BF16`. values can be a list, a numpy array, or anything else `np.asarray` reads, like a pytorch tensor. a 2-d array keeps its shape, so `q.dequantize()` gives back a matrix and `q.matmul(x)` computes `x @ W.T`, like a linear layer. both can write into a float32 numpy array or pytorch tensor you pass, like `q.matmul(x, out=y)`, so a loop can reuse it. `q.shape` gives its rows and columns, and `len(q)` the number of values, as in rust.
|
|
22
22
|
|
|
23
23
|
the scales count toward the size: 4-bit codes with one f16 scale per 32 values cost 4.5 bits per value, or 5 with the default f32 scale. `q.bits_per_element` reports it. above about 10 bits, use f32 scales with `asymmetric.quantize`, since f16 and bf16 zero-points cap its accuracy.
|
|
24
24
|
|
|
@@ -26,9 +26,9 @@ the other schemes return the same `Quantized` type:
|
|
|
26
26
|
|
|
27
27
|
- `asymmetric.quantize(weights, bits=8, block=32)` adds a zero-point per block, for values that aren't centered on zero
|
|
28
28
|
- `adaptive.quantize(weights, tolerance=0.1 * weights.std())` gives each block the fewest bits, from 2 to 8, that round every weight within `tolerance`, in the weights' own units. a tenth of their standard deviation gives about 5 bits a block. for a list, use `np.std(weights)`
|
|
29
|
-
- `learned.refine(q, weights)` refits each block's scale, and its zero-point if it has one, to lower the mean squared error. it changes `q` in place, so call `q.copy()` first to keep the original
|
|
30
|
-
- `learned.alternate(q, weights)` refits too, then rounds each value to the nearest code on its block's new line, and repeats until no code moves. it also changes `q` in place. both can raise the worst error past an adaptive tensor's tolerance, and lowering the error of the weights doesn't always lower the error of a model's outputs, so check those too
|
|
31
|
-
- `Scheme.Q4_32.quantize(weights)` picks a scheme at run time
|
|
29
|
+
- `learned.refine(q, weights)` refits each block's scale, and its zero-point if it has one, to lower the mean squared error. it changes `q` in place, so call `q.copy()` first to keep the original. it lets other threads run while it works, so threads can refit several layers at once
|
|
30
|
+
- `learned.alternate(q, weights)` refits too, then rounds each value to the nearest code on its block's new line, and repeats until no code moves. it also changes `q` in place and lets other threads run. both can raise the worst error past an adaptive tensor's tolerance, and lowering the error of the weights doesn't always lower the error of a model's outputs, so check those too
|
|
31
|
+
- `Scheme.Q4_32.quantize(weights)` picks a scheme at run time, and `Scheme("symmetric(bits=4)")` reads one from text, like a config value, which `str(scheme)` writes
|
|
32
32
|
|
|
33
33
|
a block with outliers can need more than 8 bits, which raises `ToleranceTooTightError`. retrying with its `smallest_tolerance` works, but loosens every block, not just that one:
|
|
34
34
|
|
|
@@ -56,6 +56,24 @@ q = Quantized.from_parts(**np.load("layer.npz"))
|
|
|
56
56
|
|
|
57
57
|
to keep quantized values in a `torch.save` checkpoint, store `torch.frombuffer(bytearray(q.to_bytes()), dtype=torch.uint8)`, and load each back with `Quantized(t)`. the checkpoint is then as small as the bytes, and `torch.load` reads it without `add_safe_globals`. pickling `q` itself makes the checkpoint about 1.5 times larger, since `torch.save` stores bytes as text, and needs `torch.serialization.add_safe_globals([Quantized])` before `torch.load`.
|
|
58
58
|
|
|
59
|
+
`q.matmul` runs on one core, but it lets other threads run while it multiplies, so threads can share out a batch. on an 8-core intel xeon, this multiplies a batch of 512 by a 4-bit 1536 × 576 matrix in 6 ms instead of 33 ms, with the same result, bit for bit:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import os
|
|
63
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
64
|
+
|
|
65
|
+
pool = ThreadPoolExecutor() # make it once, and reuse it for every layer
|
|
66
|
+
|
|
67
|
+
def linear(q, x): # x has shape (batch, columns)
|
|
68
|
+
# a piece per core, or pieces of 64 rows for a big batch, which stay in a core's cache
|
|
69
|
+
pieces = np.array_split(x, max(os.cpu_count(), len(x) // 64))
|
|
70
|
+
return np.concatenate(list(pool.map(q.matmul, pieces)))
|
|
71
|
+
|
|
72
|
+
out = linear(q, x)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
with a gil, while another thread keeps running python, each piece can wait up to `sys.getswitchinterval()`, 5 ms by default, to get the gil back, so splitting pays off only while your other threads are idle or in native code, or on free-threaded python. `dequantize`, `dot`, and `matmul` on 65,536 values or fewer, like one vector times a 256 × 256 matrix, keep the gil, so they don't wait. quantizing and refitting keep it only up to 4,096 values, so threads can quantize a model's layers in parallel.
|
|
76
|
+
|
|
59
77
|
each value decodes as `code * scale`, or `(code - zero_point) * scale` with zero-points, using the scale and zero-point of its block. codes are signed and `bits` wide, and `q.codes` packs them low bits first. scales can be negative, since a symmetric block puts its value farthest from zero on the most negative code. `help(Quantized)` has the details.
|
|
60
78
|
|
|
61
79
|
to build and test from a clone of the repo, with rust 1.88 or newer and [just](https://github.com/casey/just):
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//! When calls let other threads run.
|
|
2
|
+
|
|
3
|
+
use pyo3::marker::Ungil;
|
|
4
|
+
use pyo3::prelude::*;
|
|
5
|
+
|
|
6
|
+
/// Calls that read a tensor, like `dot` and `matmul`, keep the GIL when they
|
|
7
|
+
/// go through at most this many values, like one vector times a 256 × 256
|
|
8
|
+
/// matrix. They take a tenth of a millisecond or less, so holding it barely
|
|
9
|
+
/// delays other threads. Giving it up would make them wait to get it back
|
|
10
|
+
/// while another thread keeps running Python, for up to the switch interval,
|
|
11
|
+
/// `sys.getswitchinterval()`, which is 5 ms by default.
|
|
12
|
+
const MOST_VALUES_READ_KEEPING_THE_GIL: usize = 65_536;
|
|
13
|
+
|
|
14
|
+
/// Quantizing or refitting a value takes 10 to 20 times as long as reading
|
|
15
|
+
/// it, so those calls keep the GIL only up to this many values, like a
|
|
16
|
+
/// 64 × 64 matrix, which take about as long as the largest reads that keep
|
|
17
|
+
/// it. Larger ones give it up, so that threads quantizing a model's layers
|
|
18
|
+
/// run in parallel instead of taking turns.
|
|
19
|
+
const MOST_VALUES_QUANTIZED_KEEPING_THE_GIL: usize = 4_096;
|
|
20
|
+
|
|
21
|
+
/// Run `work`, which reads a tensor, letting other threads run meanwhile
|
|
22
|
+
/// unless it's small. `values` counts each value it goes through, once for
|
|
23
|
+
/// each vector it's multiplied by.
|
|
24
|
+
pub fn detach_if_large<T, F>(py: Python<'_>, values: usize, work: F) -> T
|
|
25
|
+
where
|
|
26
|
+
F: Ungil + FnOnce() -> T,
|
|
27
|
+
T: Ungil,
|
|
28
|
+
{
|
|
29
|
+
if values <= MOST_VALUES_READ_KEEPING_THE_GIL {
|
|
30
|
+
work()
|
|
31
|
+
} else {
|
|
32
|
+
py.detach(work)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Run `work`, which quantizes or refits a tensor, letting other threads run
|
|
37
|
+
/// meanwhile unless it's tiny. `values` counts each value it goes through,
|
|
38
|
+
/// once for each pass over it.
|
|
39
|
+
pub fn detach_unless_tiny<T, F>(py: Python<'_>, values: usize, work: F) -> T
|
|
40
|
+
where
|
|
41
|
+
F: Ungil + FnOnce() -> T,
|
|
42
|
+
T: Ungil,
|
|
43
|
+
{
|
|
44
|
+
if values <= MOST_VALUES_QUANTIZED_KEEPING_THE_GIL {
|
|
45
|
+
work()
|
|
46
|
+
} else {
|
|
47
|
+
py.detach(work)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -13,8 +13,11 @@ use crate::error::{InvalidBitsError, InvalidBlockError, length_mismatch};
|
|
|
13
13
|
const CODES_TYPE: &str = "codes must be a 1-D signed integer array or a sequence of int; packed Quantized.codes is uint8 and must not be passed here — use unpacked_codes";
|
|
14
14
|
const PACKED_CODES_TYPE: &str = "codes must be a 1-D uint8 array, like Quantized.codes";
|
|
15
15
|
const BYTES_TYPE: &str = "data must be bytes, like to_bytes returns, or a 1-D uint8 array";
|
|
16
|
-
const OUT_TYPE: &str = "out must be a
|
|
16
|
+
const OUT_TYPE: &str = "out must be a float32 numpy array or pytorch tensor";
|
|
17
17
|
const OUT_CONTIG: &str = "out must be writable and C-contiguous";
|
|
18
|
+
const OUT_OVERLAPS_INPUTS: &str = "out can't share memory with inputs";
|
|
19
|
+
const OUT_IN_USE: &str =
|
|
20
|
+
"out is in use by another call, like one on another thread; give each call its own out";
|
|
18
21
|
|
|
19
22
|
fn is_native_dtype(arr: &Bound<'_, PyUntypedArray>) -> PyResult<bool> {
|
|
20
23
|
arr.dtype().getattr("isnative")?.extract()
|
|
@@ -75,7 +78,13 @@ fn read_f32<'py>(
|
|
|
75
78
|
let float32 = numpy.getattr("float32")?;
|
|
76
79
|
let contiguous = numpy.call_method1("ascontiguousarray", (array, float32))?;
|
|
77
80
|
let typed = contiguous.cast::<PyArrayDyn<f32>>()?;
|
|
78
|
-
|
|
81
|
+
// Reading fails only while another call writes into the array.
|
|
82
|
+
let readonly = typed.try_readonly().map_err(|_| {
|
|
83
|
+
PyValueError::new_err(format!(
|
|
84
|
+
"{argument} is being written by another call, like a matmul or dequantize with out= on another thread; read it once that call returns"
|
|
85
|
+
))
|
|
86
|
+
})?;
|
|
87
|
+
Ok((readonly, array.shape().to_vec()))
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
/// `obj`, or if it's a PyTorch tensor, a tensor that `numpy.asarray` reads:
|
|
@@ -205,16 +214,56 @@ fn read_uint8(obj: &Bound<'_, PyAny>, message: &'static str) -> PyResult<Vec<u8>
|
|
|
205
214
|
Ok(bytes.try_readonly()?.as_array().to_vec())
|
|
206
215
|
}
|
|
207
216
|
|
|
208
|
-
///
|
|
217
|
+
/// `out` as a NumPy array to write into: itself, or for a PyTorch tensor,
|
|
218
|
+
/// the array that `out.numpy()` gives, which shares the tensor's memory. A
|
|
219
|
+
/// tensor must be float32 and on the CPU, and can't require grad, since
|
|
220
|
+
/// writing into it would go around autograd.
|
|
221
|
+
fn out_array<'py>(out: &Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
|
|
222
|
+
if !is_torch_tensor(out)? {
|
|
223
|
+
return Ok(out.clone());
|
|
224
|
+
}
|
|
225
|
+
let device = out.getattr("device")?.str()?;
|
|
226
|
+
if device.to_cow()? != "cpu" {
|
|
227
|
+
return Err(PyTypeError::new_err(format!(
|
|
228
|
+
"out must be a tensor on the cpu, got one on {device}"
|
|
229
|
+
)));
|
|
230
|
+
}
|
|
231
|
+
if out.getattr("requires_grad")?.is_truthy()? {
|
|
232
|
+
return Err(PyTypeError::new_err(
|
|
233
|
+
"out can't be a tensor that requires grad",
|
|
234
|
+
));
|
|
235
|
+
}
|
|
236
|
+
if out.getattr("dtype")?.str()?.to_cow()? != "torch.float32" {
|
|
237
|
+
return Err(out_type_error(out)?);
|
|
238
|
+
}
|
|
239
|
+
out.call_method0("numpy")
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/// A `TypeError` that says what `out` must be, and what it is: its type,
|
|
243
|
+
/// and its dtype if it has one, like `numpy.ndarray with dtype float64`.
|
|
244
|
+
fn out_type_error(out: &Bound<'_, PyAny>) -> PyResult<PyErr> {
|
|
245
|
+
let mut description = out.get_type().fully_qualified_name()?.to_string();
|
|
246
|
+
if let Ok(dtype) = out.getattr("dtype") {
|
|
247
|
+
description += &format!(" with dtype {}", dtype.str()?);
|
|
248
|
+
}
|
|
249
|
+
let message = format!("{OUT_TYPE}, got {description}");
|
|
250
|
+
Ok(PyTypeError::new_err(message))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/// Borrow `out`, a NumPy array or PyTorch tensor, for writing, after
|
|
254
|
+
/// checking it has exactly `shape` and shares no memory with `inputs`, the
|
|
255
|
+
/// values the call reads while it writes `out`.
|
|
209
256
|
pub fn as_writable_f32_out<'py>(
|
|
210
257
|
obj: &Bound<'py, PyAny>,
|
|
211
258
|
shape: &[usize],
|
|
259
|
+
inputs: &[f32],
|
|
212
260
|
) -> PyResult<numpy::PyReadwriteArrayDyn<'py, f32>> {
|
|
213
|
-
let
|
|
214
|
-
|
|
215
|
-
|
|
261
|
+
let array = out_array(obj)?;
|
|
262
|
+
let Ok(arr) = array.cast::<PyArrayDyn<f32>>() else {
|
|
263
|
+
return Err(out_type_error(obj)?);
|
|
264
|
+
};
|
|
216
265
|
if !is_native_dtype(arr.as_untyped())? {
|
|
217
|
-
return Err(
|
|
266
|
+
return Err(out_type_error(obj)?);
|
|
218
267
|
}
|
|
219
268
|
let flags = arr.getattr("flags")?;
|
|
220
269
|
let c_contiguous: bool = flags.getattr("c_contiguous")?.extract()?;
|
|
@@ -223,8 +272,30 @@ pub fn as_writable_f32_out<'py>(
|
|
|
223
272
|
return Err(PyValueError::new_err(OUT_CONTIG));
|
|
224
273
|
}
|
|
225
274
|
check_shape(obj.py(), "out", shape, arr.shape())?;
|
|
275
|
+
// This comes before the borrow, which fails on an `out` that `inputs`
|
|
276
|
+
// was read from in place, as if another call held it.
|
|
277
|
+
if shares_memory(arr, inputs) {
|
|
278
|
+
return Err(PyValueError::new_err(OUT_OVERLAPS_INPUTS));
|
|
279
|
+
}
|
|
280
|
+
// Its flags are checked above, so this fails only while another call
|
|
281
|
+
// reads or writes it.
|
|
226
282
|
arr.try_readwrite()
|
|
227
|
-
.map_err(|_| PyValueError::new_err(
|
|
283
|
+
.map_err(|_| PyValueError::new_err(OUT_IN_USE))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// Whether `out` and `inputs`, both C-contiguous float32, share memory.
|
|
287
|
+
/// Each fills one unbroken run of bytes, so they share memory exactly when
|
|
288
|
+
/// the runs overlap. The memory is compared, not the arrays, since two
|
|
289
|
+
/// arrays over one buffer, like `t.numpy()` and the array read from a
|
|
290
|
+
/// tensor `t`, are separate objects. Comparing addresses keeps the GIL,
|
|
291
|
+
/// which `numpy.may_share_memory` gives up.
|
|
292
|
+
fn shares_memory(out: &Bound<'_, PyArrayDyn<f32>>, inputs: &[f32]) -> bool {
|
|
293
|
+
let out_start = out.data().addr();
|
|
294
|
+
let out_end = out_start + out.len() * size_of::<f32>();
|
|
295
|
+
let inputs_start = inputs.as_ptr().addr();
|
|
296
|
+
let inputs_end = inputs_start + size_of_val(inputs);
|
|
297
|
+
// The bytes both fill run from the later start to the earlier end.
|
|
298
|
+
out_start.max(inputs_start) < out_end.min(inputs_end)
|
|
228
299
|
}
|
|
229
300
|
|
|
230
301
|
/// Check that `argument`, which came in with shape `got`, has exactly the
|
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
use pyo3::prelude::*;
|
|
4
4
|
|
|
5
5
|
use crate::error::{from_quantize, length_mismatch};
|
|
6
|
+
use crate::gil::detach_unless_tiny;
|
|
6
7
|
use crate::input::{as_f32_array, as_f32_values, as_i32_codes, check_shape};
|
|
7
8
|
use crate::quantized::PyQuantized;
|
|
8
9
|
|
|
10
|
+
// `refine` and `alternate` refit a snapshot of the tensor, with the GIL
|
|
11
|
+
// released unless it's small, then store it. `Arc::make_mut` copies the
|
|
12
|
+
// shared tensor before the refit changes it, so until it's stored, other
|
|
13
|
+
// threads read the tensor as it was.
|
|
14
|
+
|
|
9
15
|
/// Refit each block's scale, and its zero-point if it has one, to lower the
|
|
10
16
|
/// mean squared error against `values`, the numbers `quantized` was
|
|
11
17
|
/// quantized from, in the same shape. The codes don't move, so the tensor
|
|
@@ -16,18 +22,23 @@ use crate::quantized::PyQuantized;
|
|
|
16
22
|
///
|
|
17
23
|
/// This changes `quantized` in place and returns it. Call
|
|
18
24
|
/// `quantized.copy()` first to keep the original.
|
|
25
|
+
///
|
|
26
|
+
/// Other threads keep running while it refits, unless the tensor is small,
|
|
27
|
+
/// and see `quantized` as it was until it returns. If two threads refit the
|
|
28
|
+
/// same tensor at once, both start from the tensor as it was, and it keeps
|
|
29
|
+
/// the result of whichever finishes last.
|
|
19
30
|
#[pyfunction]
|
|
20
31
|
pub fn refine<'py>(
|
|
21
32
|
quantized: Bound<'py, PyQuantized>,
|
|
22
33
|
values: Bound<'py, PyAny>,
|
|
23
34
|
) -> PyResult<Bound<'py, PyQuantized>> {
|
|
24
35
|
let (array, values_shape) = as_f32_array(&values)?;
|
|
25
|
-
let
|
|
26
|
-
check_shape(values.py(), "values", &
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.refine(array.as_slice()?)
|
|
36
|
+
let mut refined = quantized.get().snapshot();
|
|
37
|
+
check_shape(values.py(), "values", &refined.shape(), &values_shape)?;
|
|
38
|
+
let values = array.as_slice()?;
|
|
39
|
+
detach_unless_tiny(quantized.py(), values.len(), || refined.refine(values))
|
|
30
40
|
.map_err(from_quantize)?;
|
|
41
|
+
quantized.get().store(refined);
|
|
31
42
|
Ok(quantized)
|
|
32
43
|
}
|
|
33
44
|
|
|
@@ -40,18 +51,23 @@ pub fn refine<'py>(
|
|
|
40
51
|
/// land past the tolerance it was quantized with.
|
|
41
52
|
///
|
|
42
53
|
/// Like `refine`, this changes `quantized` in place, so call
|
|
43
|
-
/// `quantized.copy()` first to keep the original
|
|
44
|
-
///
|
|
45
|
-
///
|
|
54
|
+
/// `quantized.copy()` first to keep the original, and other threads keep
|
|
55
|
+
/// running while it refits, as `refine` describes. Unlike `refine`, it
|
|
56
|
+
/// returns whether the codes settled: `True` once no code moves, or `False`
|
|
57
|
+
/// if it stopped after 100 passes. Call it again while it returns `False`.
|
|
46
58
|
#[pyfunction]
|
|
47
59
|
pub fn alternate(quantized: Bound<'_, PyQuantized>, values: Bound<'_, PyAny>) -> PyResult<bool> {
|
|
48
60
|
let (array, values_shape) = as_f32_array(&values)?;
|
|
49
|
-
let
|
|
50
|
-
check_shape(values.py(), "values", &
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
.
|
|
61
|
+
let mut alternated = quantized.get().snapshot();
|
|
62
|
+
check_shape(values.py(), "values", &alternated.shape(), &values_shape)?;
|
|
63
|
+
let values = array.as_slice()?;
|
|
64
|
+
let values_in_100_passes = values.len().saturating_mul(100);
|
|
65
|
+
let settled = detach_unless_tiny(quantized.py(), values_in_100_passes, || {
|
|
66
|
+
alternated.alternate(values)
|
|
67
|
+
})
|
|
68
|
+
.map_err(from_quantize)?;
|
|
69
|
+
quantized.get().store(alternated);
|
|
70
|
+
Ok(settled)
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
/// The `(scale, zero_point)` that best fit
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
//! Native Python bindings.
|
|
2
2
|
|
|
3
3
|
mod error;
|
|
4
|
+
mod gil;
|
|
4
5
|
mod input;
|
|
5
6
|
mod learned;
|
|
6
7
|
mod quantized;
|
|
@@ -15,6 +16,7 @@ use crate::error::{
|
|
|
15
16
|
NotAMatrixError, QuantizeError, ScaleOutOfRangeError, ShapeMismatchError,
|
|
16
17
|
ToleranceTooTightError,
|
|
17
18
|
};
|
|
19
|
+
use crate::gil::detach_unless_tiny;
|
|
18
20
|
use crate::input::as_f32_array;
|
|
19
21
|
use crate::learned::{alternate, fit_scale_and_zero_point, refine};
|
|
20
22
|
use crate::quantized::PyQuantized;
|
|
@@ -30,7 +32,9 @@ fn quantize_values(
|
|
|
30
32
|
let (array, shape) = as_f32_array(&values)?;
|
|
31
33
|
let values = array.as_slice()?;
|
|
32
34
|
let scheme = scheme(values.len());
|
|
33
|
-
py
|
|
35
|
+
detach_unless_tiny(py, values.len(), || {
|
|
36
|
+
PyQuantized::from_scheme(scheme, values, &shape, scale)
|
|
37
|
+
})
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
// Each `text_signature` repeats its `signature` so that `help()` shows the
|
|
@@ -147,11 +151,10 @@ fn adaptive_quantize(
|
|
|
147
151
|
quantize_values(py, values, scale, |_| Scheme::Adaptive { block, tolerance })
|
|
148
152
|
}
|
|
149
153
|
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
#[pymodule(gil_used = true)]
|
|
154
|
+
// Threads share each tensor through a lock instead of relying on the GIL, so
|
|
155
|
+
// free-threaded Python leaves the GIL off when it imports this module, and
|
|
156
|
+
// its threads call into it in parallel.
|
|
157
|
+
#[pymodule(gil_used = false)]
|
|
155
158
|
#[pyo3(name = "_native")]
|
|
156
159
|
fn native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
157
160
|
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
use std::sync::Arc;
|
|
1
|
+
use std::sync::{Arc, Mutex, PoisonError};
|
|
2
2
|
|
|
3
3
|
use half::{bf16, f16};
|
|
4
4
|
use pyo3::prelude::*;
|
|
@@ -11,8 +11,8 @@ use crate::scale::PyScale;
|
|
|
11
11
|
/// The tensor, with one of three scale types, behind an `Arc` so that a
|
|
12
12
|
/// clone shares its codes and scales instead of copying them. `refine` and
|
|
13
13
|
/// `alternate` change it through `Arc::make_mut`, which copies a shared
|
|
14
|
-
/// tensor first, so a `copy()`, or a `dot` or `
|
|
15
|
-
/// the values it had.
|
|
14
|
+
/// tensor first, so a `copy()`, or a `dot`, `matmul`, or `dequantize` still
|
|
15
|
+
/// running, keeps the values it had.
|
|
16
16
|
#[derive(Clone, PartialEq)]
|
|
17
17
|
pub(crate) enum QuantizedInner {
|
|
18
18
|
F32(Arc<Quantized<f32>>),
|
|
@@ -160,10 +160,15 @@ impl QuantizedInner {
|
|
|
160
160
|
/// `Quantized(data)` loads a tensor that `to_bytes` saved, like `from_bytes`.
|
|
161
161
|
/// Pickles load through it, so `torch.load` accepts them once
|
|
162
162
|
/// `torch.serialization.add_safe_globals([Quantized])` allows the class.
|
|
163
|
-
#[pyclass(name = "Quantized", module = "quantize", eq)]
|
|
164
|
-
#[derive(PartialEq)]
|
|
163
|
+
#[pyclass(name = "Quantized", module = "quantize", eq, frozen)]
|
|
165
164
|
pub struct PyQuantized {
|
|
166
|
-
|
|
165
|
+
// `frozen` drops PyO3's borrow flag, which makes a refit and another
|
|
166
|
+
// thread's read fail when they overlap without the GIL. Threads share the
|
|
167
|
+
// tensor through this lock instead, held only to clone the `Arc` or to put
|
|
168
|
+
// a refit in its place, never while computing or calling Python. Each
|
|
169
|
+
// store replaces the tensor in one step, so even a lock that a panic
|
|
170
|
+
// poisoned holds a whole tensor.
|
|
171
|
+
inner: Mutex<QuantizedInner>,
|
|
167
172
|
}
|
|
168
173
|
|
|
169
174
|
impl PyQuantized {
|
|
@@ -173,24 +178,38 @@ impl PyQuantized {
|
|
|
173
178
|
shape: &[usize],
|
|
174
179
|
scale: PyScale,
|
|
175
180
|
) -> PyResult<Self> {
|
|
176
|
-
|
|
177
|
-
inner: QuantizedInner::from_scheme(scheme, values, shape, scale)?,
|
|
178
|
-
})
|
|
181
|
+
QuantizedInner::from_scheme(scheme, values, shape, scale).map(Self::from)
|
|
179
182
|
}
|
|
180
183
|
|
|
181
184
|
pub fn len(&self) -> usize {
|
|
182
|
-
self.
|
|
185
|
+
self.snapshot().len()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// The tensor as it is now, sharing its codes and scales. A refit stored
|
|
189
|
+
/// later puts a new tensor in its place, so this one keeps its values.
|
|
190
|
+
pub(crate) fn snapshot(&self) -> QuantizedInner {
|
|
191
|
+
self.inner
|
|
192
|
+
.lock()
|
|
193
|
+
.unwrap_or_else(PoisonError::into_inner)
|
|
194
|
+
.clone()
|
|
183
195
|
}
|
|
184
196
|
|
|
185
|
-
|
|
186
|
-
|
|
197
|
+
/// Put `inner`, such as a refit of a snapshot, in place of the tensor.
|
|
198
|
+
pub(crate) fn store(&self, inner: QuantizedInner) {
|
|
199
|
+
*self.inner.lock().unwrap_or_else(PoisonError::into_inner) = inner;
|
|
187
200
|
}
|
|
201
|
+
}
|
|
188
202
|
|
|
189
|
-
|
|
190
|
-
|
|
203
|
+
impl From<QuantizedInner> for PyQuantized {
|
|
204
|
+
fn from(inner: QuantizedInner) -> Self {
|
|
205
|
+
Self {
|
|
206
|
+
inner: Mutex::new(inner),
|
|
207
|
+
}
|
|
191
208
|
}
|
|
209
|
+
}
|
|
192
210
|
|
|
193
|
-
|
|
194
|
-
|
|
211
|
+
impl PartialEq for PyQuantized {
|
|
212
|
+
fn eq(&self, other: &Self) -> bool {
|
|
213
|
+
self.snapshot() == other.snapshot()
|
|
195
214
|
}
|
|
196
215
|
}
|