quantize-py 0.3.0__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.
Files changed (49) hide show
  1. {quantize_py-0.3.0 → quantize_py-0.3.2}/Cargo.lock +2 -2
  2. {quantize_py-0.3.0 → quantize_py-0.3.2}/Cargo.toml +3 -6
  3. {quantize_py-0.3.0 → quantize_py-0.3.2}/PKG-INFO +29 -7
  4. {quantize_py-0.3.0 → quantize_py-0.3.2}/README.md +13 -7
  5. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/README.md +28 -6
  6. quantize_py-0.3.2/python/src/gil.rs +49 -0
  7. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/src/input.rs +79 -8
  8. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/src/learned.rs +30 -14
  9. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/src/lib.rs +9 -2
  10. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/src/quantized/inner.rs +63 -27
  11. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/src/quantized/methods.rs +70 -46
  12. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/src/scale.rs +15 -4
  13. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/src/scheme.rs +31 -3
  14. quantize_py-0.3.2/python/tests/test_learned.py +324 -0
  15. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/tests/test_quantize.py +139 -7
  16. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/kernels/block.rs +38 -37
  17. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/kernels/i4.rs +106 -31
  18. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/kernels/i8.rs +37 -4
  19. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/kernels/mod.rs +2 -2
  20. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/lib.rs +7 -6
  21. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/methods/adaptive/mod.rs +2 -4
  22. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/methods/learned/mod.rs +29 -1
  23. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/methods/symmetric/mod.rs +50 -0
  24. quantize_py-0.3.2/src/shared/decode.rs +664 -0
  25. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/shared/error.rs +103 -5
  26. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/shared/packed.rs +11 -13
  27. quantize_py-0.3.2/src/shared/scheme.rs +272 -0
  28. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/shared/tensor.rs +98 -27
  29. quantize_py-0.3.0/python/tests/test_learned.py +0 -148
  30. quantize_py-0.3.0/src/shared/decode.rs +0 -402
  31. quantize_py-0.3.0/src/shared/scheme.rs +0 -69
  32. {quantize_py-0.3.0 → quantize_py-0.3.2}/LICENSE +0 -0
  33. {quantize_py-0.3.0 → quantize_py-0.3.2}/pyproject.toml +0 -0
  34. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/Cargo.toml +0 -0
  35. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/quantize/__init__.py +0 -0
  36. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/quantize/adaptive.py +0 -0
  37. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/quantize/asymmetric.py +0 -0
  38. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/quantize/learned.py +0 -0
  39. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/quantize/symmetric.py +0 -0
  40. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/src/error.rs +0 -0
  41. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/src/quantized/parts.rs +0 -0
  42. {quantize_py-0.3.0 → quantize_py-0.3.2}/python/src/quantized.rs +0 -0
  43. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/kernels/reduce.rs +0 -0
  44. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/methods/asymmetric/mod.rs +0 -0
  45. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/methods/mod.rs +0 -0
  46. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/shared/bytes.rs +0 -0
  47. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/shared/mod.rs +0 -0
  48. {quantize_py-0.3.0 → quantize_py-0.3.2}/src/shared/params.rs +0 -0
  49. {quantize_py-0.3.0 → 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.0"
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.0"
1421
+ version = "0.3.2"
1422
1422
  dependencies = [
1423
1423
  "half",
1424
1424
  "numpy",
@@ -17,16 +17,13 @@ include = [
17
17
  "/README.md",
18
18
  ]
19
19
 
20
- [package.metadata.docs.rs]
21
- features = ["std"]
22
-
23
20
  [workspace]
24
21
  members = ["python"]
25
22
  resolver = "3"
26
23
 
27
24
  [workspace.package]
28
25
  # Shared with the Python package. Between releases it ends in -dev.
29
- version = "0.3.0"
26
+ version = "0.3.2"
30
27
 
31
28
  [workspace.dependencies]
32
29
  half = "2"
@@ -38,8 +35,8 @@ all = { level = "deny", priority = -1 }
38
35
 
39
36
  [features]
40
37
  default = ["std"]
41
- # Reserved for future no_std support. For now it only implements
42
- # std::error::Error for Error.
38
+ # Does nothing, since the crate always needs the standard library. Kept so
39
+ # that a Cargo.toml that names it still builds.
43
40
  std = []
44
41
 
45
42
  [dependencies]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantize-py
3
- Version: 0.3.0
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.
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,13 +47,17 @@ 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
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
 
56
56
  ```python
57
+ import numpy as np
58
+ from quantize import ToleranceTooTightError, adaptive
59
+
60
+ weights = np.array(weights) # a list has no .std(). skip this line for a pytorch tensor
57
61
  try:
58
62
  q = adaptive.quantize(weights, tolerance=0.1 * weights.std())
59
63
  except ToleranceTooTightError as error:
@@ -71,7 +75,25 @@ np.savez("layer.npz", **{name: part for name, part in parts.items() if part is n
71
75
  q = Quantized.from_parts(**np.load("layer.npz"))
72
76
  ```
73
77
 
74
- to load quantized values from a `torch.save` checkpoint, call `torch.serialization.add_safe_globals([Quantized])` before `torch.load`.
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
+
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.
75
97
 
76
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.
77
99
 
@@ -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 a little accuracy for a lot less memory.
5
+ quantization stores numbers in fewer bits, trading some accuracy for a lot less memory.
6
6
 
7
7
  ## learn
8
8
 
@@ -59,7 +59,7 @@ against [candle](https://github.com/huggingface/candle)'s `Q4_0`, `Q5_0`, and `Q
59
59
  ### quality
60
60
 
61
61
  ```
62
- cargo run --release --example compare
62
+ cargo run --release -p benchmarks --example compare
63
63
  ```
64
64
 
65
65
  quantize two random matrices, reconstruct them, then matmul. mse is the mean squared error against the exact f32 result: smaller is better, and it varies a little between runs. bits/value counts the scales too: 4-bit codes plus one f16 scale per 32 values is 4 + 16/32 = 4.5.
@@ -74,15 +74,21 @@ 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 with SmolLM-135M, 4-bit perplexity is 26.44 against candle `Q4_0`'s 26.46, and fp32 is 18.93. lower is better, and `cargo run --release --example wikitext --features benchmarks/workload` reproduces them.
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
+
79
+ ```
80
+ cargo run --release -p benchmarks --example wikitext --features workload
81
+ ```
82
+
83
+ add `-- --max-tokens 512` to score only the first 512 tokens. that takes about a minute, though its numbers won't match.
78
84
 
79
85
  ### speed
80
86
 
81
87
  ```
82
- cargo run --release --example throughput
88
+ cargo run --release -p benchmarks --example throughput
83
89
  ```
84
90
 
85
- 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.
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.
86
92
 
87
93
  <!-- speed:start -->
88
94
 
@@ -90,8 +96,8 @@ quantize and dequantize with f16 scales. both libraries allocate their output on
90
96
  | --- | ---: | ---: | ---: | ---: |
91
97
  | quantize, aarch64 | 0.30 | 0.30 | 0.08 | 0.07 |
92
98
  | candle, aarch64 | 0.41 | 0.43 | 0.20 | 0.25 |
93
- | quantize, x86_64 | 3.90 | 3.74 | 1.06 | 0.36 |
94
- | candle, x86_64 | 2.22 | 4.29 | 0.45 | 0.45 |
99
+ | quantize, x86_64 | 3.93 | 3.77 | 0.37 | 0.26 |
100
+ | candle, x86_64 | 2.25 | 4.28 | 0.45 | 0.44 |
95
101
 
96
102
  <!-- speed:end -->
97
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.
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,13 +26,17 @@ 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
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
 
35
35
  ```python
36
+ import numpy as np
37
+ from quantize import ToleranceTooTightError, adaptive
38
+
39
+ weights = np.array(weights) # a list has no .std(). skip this line for a pytorch tensor
36
40
  try:
37
41
  q = adaptive.quantize(weights, tolerance=0.1 * weights.std())
38
42
  except ToleranceTooTightError as error:
@@ -50,7 +54,25 @@ np.savez("layer.npz", **{name: part for name, part in parts.items() if part is n
50
54
  q = Quantized.from_parts(**np.load("layer.npz"))
51
55
  ```
52
56
 
53
- to load quantized values from a `torch.save` checkpoint, call `torch.serialization.add_safe_globals([Quantized])` before `torch.load`.
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
+
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.
54
76
 
55
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.
56
78
 
@@ -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 writable C-contiguous native-endian float32 array";
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
- Ok((typed.try_readonly()?, array.shape().to_vec()))
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
- /// Borrow `out` for writing, after checking it has exactly `shape`.
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 arr = obj
214
- .cast::<PyArrayDyn<f32>>()
215
- .map_err(|_| PyTypeError::new_err(OUT_TYPE))?;
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(PyTypeError::new_err(OUT_TYPE));
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(OUT_CONTIG))
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 tensor_shape = quantized.borrow().inner.shape();
26
- check_shape(values.py(), "values", &tensor_shape, &values_shape)?;
27
- quantized
28
- .borrow_mut()
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. Unlike `refine`, it returns
44
- /// whether the codes settled: `True` once no code moves, or `False` if it
45
- /// stopped after 100 passes. Call it again while it returns `False`.
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 tensor_shape = quantized.borrow().inner.shape();
50
- check_shape(values.py(), "values", &tensor_shape, &values_shape)?;
51
- quantized
52
- .borrow_mut()
53
- .alternate(array.as_slice()?)
54
- .map_err(from_quantize)
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.detach(|| PyQuantized::from_scheme(scheme, values, &shape, scale))
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,7 +151,10 @@ fn adaptive_quantize(
147
151
  quantize_values(py, values, scale, |_| Scheme::Adaptive { block, tolerance })
148
152
  }
149
153
 
150
- #[pymodule]
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)]
151
158
  #[pyo3(name = "_native")]
152
159
  fn native(m: &Bound<'_, PyModule>) -> PyResult<()> {
153
160
  m.add("__version__", env!("CARGO_PKG_VERSION"))?;