ulpwise 0.3.1__tar.gz → 0.3.3__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 (38) hide show
  1. {ulpwise-0.3.1 → ulpwise-0.3.3}/CHANGELOG.md +78 -0
  2. {ulpwise-0.3.1 → ulpwise-0.3.3}/Cargo.lock +1 -1
  3. {ulpwise-0.3.1 → ulpwise-0.3.3}/Cargo.toml +1 -1
  4. {ulpwise-0.3.1 → ulpwise-0.3.3}/PKG-INFO +37 -6
  5. {ulpwise-0.3.1 → ulpwise-0.3.3}/README.md +36 -5
  6. {ulpwise-0.3.1 → ulpwise-0.3.3}/pyproject.toml +1 -1
  7. {ulpwise-0.3.1 → ulpwise-0.3.3}/python/ulpwise/__init__.py +1 -1
  8. {ulpwise-0.3.1 → ulpwise-0.3.3}/python/ulpwise/corpus/cases.json +149 -1
  9. {ulpwise-0.3.1 → ulpwise-0.3.3}/python/ulpwise/scan.py +330 -9
  10. {ulpwise-0.3.1 → ulpwise-0.3.3}/python/ulpwise/survey.py +3 -1
  11. ulpwise-0.3.3/studies/accuracy-survey-2026-10-half/README.md +133 -0
  12. ulpwise-0.3.3/studies/accuracy-survey-2026-10-half/results.csv +91 -0
  13. ulpwise-0.3.3/studies/accuracy-survey-2026-10-half/results.md +105 -0
  14. {ulpwise-0.3.1 → ulpwise-0.3.3}/tests/test_scan.py +119 -4
  15. {ulpwise-0.3.1 → ulpwise-0.3.3}/tests/test_survey.py +3 -0
  16. {ulpwise-0.3.1 → ulpwise-0.3.3}/.github/dependabot.yml +0 -0
  17. {ulpwise-0.3.1 → ulpwise-0.3.3}/.github/workflows/ci.yml +0 -0
  18. {ulpwise-0.3.1 → ulpwise-0.3.3}/.github/workflows/release.yml +0 -0
  19. {ulpwise-0.3.1 → ulpwise-0.3.3}/.gitignore +0 -0
  20. {ulpwise-0.3.1 → ulpwise-0.3.3}/CONTRIBUTING.md +0 -0
  21. {ulpwise-0.3.1 → ulpwise-0.3.3}/LICENSE-APACHE +0 -0
  22. {ulpwise-0.3.1 → ulpwise-0.3.3}/LICENSE-MIT +0 -0
  23. {ulpwise-0.3.1 → ulpwise-0.3.3}/examples/sqrt_knife_values.py +0 -0
  24. {ulpwise-0.3.1 → ulpwise-0.3.3}/examples/torch_sqrt_conformance.py +0 -0
  25. {ulpwise-0.3.1 → ulpwise-0.3.3}/python/ulpwise/__main__.py +0 -0
  26. {ulpwise-0.3.1 → ulpwise-0.3.3}/python/ulpwise/corpus/__init__.py +0 -0
  27. {ulpwise-0.3.1 → ulpwise-0.3.3}/python/ulpwise/plugin.py +0 -0
  28. {ulpwise-0.3.1 → ulpwise-0.3.3}/src/edge.rs +0 -0
  29. {ulpwise-0.3.1 → ulpwise-0.3.3}/src/exact.rs +0 -0
  30. {ulpwise-0.3.1 → ulpwise-0.3.3}/src/knife.rs +0 -0
  31. {ulpwise-0.3.1 → ulpwise-0.3.3}/src/lib.rs +0 -0
  32. {ulpwise-0.3.1 → ulpwise-0.3.3}/src/py.rs +0 -0
  33. {ulpwise-0.3.1 → ulpwise-0.3.3}/src/ulp.rs +0 -0
  34. {ulpwise-0.3.1 → ulpwise-0.3.3}/studies/accuracy-survey-2026-09/README.md +0 -0
  35. {ulpwise-0.3.1 → ulpwise-0.3.3}/studies/accuracy-survey-2026-09/results.csv +0 -0
  36. {ulpwise-0.3.1 → ulpwise-0.3.3}/studies/accuracy-survey-2026-09/results.md +0 -0
  37. {ulpwise-0.3.1 → ulpwise-0.3.3}/tests/test_core.py +0 -0
  38. {ulpwise-0.3.1 → ulpwise-0.3.3}/tests/test_corpus.py +0 -0
@@ -1,5 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3 (2026-10-08)
4
+
5
+ - Two regression corpus cases for peft, both open upstream with a fix PR: peft #3769, `add_weighted_adapter` with
6
+ `combination_type='svd'` crashed for Conv1d and Conv3d LoRA layers because only Conv2d deltas were flattened before
7
+ the SVD (the case combines two rank 2 adapters at `svd_rank=4` and expects the output of both adapters active, which
8
+ the fix matches to 2e-6), and peft #3830, OFT `module_dropout` was never applied, so ten training forwards of the
9
+ same input were identical where the fix spreads them by 0.56.
10
+ - `ulpwise scan`: new `where-nan-gradient` rule (medium) for `where(d > eps, f(d), other)` with `f` a division by `d`
11
+ or a `sqrt`, `log`, `acos` or `asin` of it. `where` evaluates both branches and hands the discarded one a zero
12
+ gradient, and the backward of `f` at the singularity turns that zero into `0 / 0 = nan`, so the guard protects the
13
+ value and not the gradient. kornia #5579, found by the kornia conventions audit, is the example: the mutual
14
+ information losses divided by the range of the signal inside `where(diff > eps, ...)` and a constant input or
15
+ target got a nan gradient. The condition may be a comparison, a name bound to one, or `~`, `&`, `|` and a subscript
16
+ of those, and the method form `a.where(cond, b)` counts. Quiet for `numpy.where`, which has no autograd, for a
17
+ comparison against a number above 1 (`strength < 50` in kornia's JPEG scale is a piecewise definition), for a
18
+ floor named `eps`, `tol`, `floor`, `thresh`, `tiny`, `bound` or `limit` on the other side of the comparison, and
19
+ once `d` is re-bound to a `where`, `clamp` or `maximum` of itself, which is the recommended fix and the shape of
20
+ kornia's `fit_line` and `rotation_matrix_to_quaternion`. On kornia main d15741e2 the rule reports four lines, on
21
+ ultralytics main 8df3534 two, both in metric code, and nothing in torchvision.
22
+ - `ulpwise scan`: new `clamp-at-singularity` rule (medium) for `clamp(x, min=0).sqrt()`, `sqrt(clamp(x, min=0))`,
23
+ `clamp(c, -1, 1).acos()` and the `clip`, `clamp_min`, `clamp_max`, `asin` and `log` forms: the bound is the point
24
+ where the next function has an infinite derivative, and clamp's derivative at its own bound is 1 on torch 2.5.1 and
25
+ 2.9.1 and 0 on 2.14, so the gradient there is `inf` or `nan` on the older half of a supported range. kornia #4229,
26
+ found by the kornia conventions audit, is the example, `_cdist`'s `clamp(min=0.0).sqrt()` with identical float16
27
+ descriptors, and kornia #5500's `clamp(-1, 1).acos()` the second. A bound strictly inside the domain, `min=1e-8`,
28
+ is a floor and is not reported, a bound given by a name is not read, and numpy is quiet. On kornia main d15741e2
29
+ the rule reports the three lines of `_solve_cubic_real`, on ultralytics main 8df3534 one line of `DepthMetrics`.
30
+ - `ulpwise scan`: a finding inside a nested function is reported once, under the nested function, instead of once
31
+ more under the enclosing one, and the enclosing function's call and division counts no longer include it.
32
+ - `ulpwise scan`: new `eps-floor` rule (medium) for `x * (1 - eps) + eps`, in either order and with `self.eps` too: the
33
+ floor moves every value, 0 becomes `eps` and 1 stays 1, so the entries of a one-hot sum to `1 + (C - 1) eps` and a
34
+ perfect prediction scores a loss that grows with the image. kornia #5538, found by the kornia conventions audit, is
35
+ the example: `one_hot` floored its zeros with 1e-6 and a perfect prediction of a 256 × 384 image with a one-pixel
36
+ class scored a macro Dice loss of 0.0234. On kornia main 9e188e08 the rule reports that line and nothing else, and
37
+ nothing in ultralytics or torchvision; `(p + eps) / (q + eps)` is not a floor and stays quiet.
38
+ - `ulpwise scan`: the `hypot-by-hand` advice no longer offers `norm` as the replacement. `torch.linalg.vector_norm` and
39
+ `numpy.linalg.norm` square the entries first too, so in float32 both return `inf` for a vector with two entries of
40
+ 1.8e19 while `hypot` returns 2.5e19; the advice now says to divide a vector by its largest absolute entry when the
41
+ point is a direction. The kornia maintainer caught the case in review of kornia #5505, where `v / v.norm()` turned
42
+ such vectors into zeros and the angle between them read 0.
43
+ - `ulpwise scan`: `acos-for-angle` moves from `info` to `medium` and names the mechanism: next to 1 the cosine has no
44
+ digits left for a small angle, so `acos` recovers the angle with an absolute error of `sqrt(eps)`, 0.02 degrees in
45
+ float32, whatever the angle. The kornia scan listed `kornia/metrics/pose.py` under this rule, and both functions
46
+ there, `angle_error_mat` and `angle_error_vec`, return exactly 0 for every rotation below 0.03 degrees in float32
47
+ and 180.0 for 179.99 degrees (kornia #5500); the rule cites it.
48
+ - Regression corpus case for kornia #5500: a 0.01 degree rotation scores 0.0 in both metrics on kornia main 050ac77f.
49
+ - The kornia #5500 corpus case records its fix, kornia #5505, merged 2026-10-06: `angle_error_mat` reads the sine from
50
+ the skew part of the relative rotation and `angle_error_vec` from the cross product of the scaled vectors, and both
51
+ return `atan2(sin, cos)`, so a 0.01 degree rotation scores 0.0099990 in float32. The case stays an expected failure
52
+ until a kornia release carries the fix.
53
+ - `ulpwise scan`: `logsumexp-by-hand` no longer fires on the stable form, where every `exp` argument has its
54
+ maximum subtracted first (`log(exp(x - x.max()).sum())`, or `s = x - m` with `m` bound to a `max`, `amax`,
55
+ `maximum` or `torch.max(...).values`). The lm-evaluation-harness scan listed the numerically stable `_log_softmax`
56
+ of `lm_eval/models/_onnx_base.py` next to a real `log(exp(a) + exp(b))`; it now reports only the real one.
57
+
58
+ ## 0.3.2 (2026-10-06)
59
+
60
+ - Two regression corpus cases for pytorch #199850: `torch.erf` in bfloat16 and float16 on CPU returns 0 at and
61
+ below 1.8e-7 and loses relative accuracy below 1e-3 (13404 bfloat16 ulps, 5 float16 ulps at worst), found with
62
+ the half precision rows of `ulpwise survey --functions erf --backends torch --dtypes f16,bf16`.
63
+ - Two regression corpus cases for pytorch #199867: `torch.special.logit` in float16 and bfloat16 on CPU rounds `1 - x`
64
+ and `x / (1 - x)` to the input dtype before the log, so logit(0.499756) in float16 is -0.000488 for an exact -0.000977
65
+ (512 ulp, 64 bfloat16 ulp at worst), found with `ulpwise survey --functions logit --backends torch --dtypes f16,bf16`.
66
+ - `studies/accuracy-survey-2026-10-half`: the second accuracy survey, torch 2.14.0+cpu in float16 and bfloat16 for the
67
+ 45 functions with half precision CPU kernels. 33 float16 and 34 bfloat16 rows of 45 are correctly rounded; `erf`
68
+ (pytorch #199850) and `logit` (pytorch #199867) lose their digits before the rounding step, `polygamma(2, x)` in
69
+ float16 is off at every half integer in `(-1024, -256)` because the Hurwitz zeta sum accumulates in float for the
70
+ reduced types, and the activation tails and the `rsqrt` and `i0e` vector versus scalar disagreements of September
71
+ show again.
72
+ - `ulpwise survey`: the `polygamma_1` and `polygamma_2` references at a non positive integer are now the signed
73
+ infinity of the pole, `+inf` for an odd order (the limit from both sides) and `-inf` for an even one (the sign of
74
+ `(-1) ** (n + 1) * n! * zeta(n + 1, x)`, which is what scipy, torch and jax return), instead of a domain error
75
+ that expected `nan`. The f16 and bf16 grids reach the integers from 2048 and 256 up, so every negative point
76
+ past there was a pole and counted as a nonfinite mismatch: on torch 2.14 the two functions had 354 of 3546
77
+ float16 points and 492 of 4016 bfloat16 points each, `polygamma_2` has 0 now in every dtype and `polygamma_1`
78
+ keeps 491 bfloat16, 4 float32 and 2 float16 points where torch returns a large finite value instead of `inf`
79
+ at a negative integer (pytorch #198663).
80
+
3
81
  ## 0.3.1 (2026-10-05)
4
82
 
5
83
  - `ulpwise survey` takes `f16` and `bf16` in `--dtypes` (the default stays `f32,f64`): torch and jax get half
@@ -120,7 +120,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
120
120
 
121
121
  [[package]]
122
122
  name = "ulpwise"
123
- version = "0.3.1"
123
+ version = "0.3.3"
124
124
  dependencies = [
125
125
  "pyo3",
126
126
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ulpwise"
3
- version = "0.3.1"
3
+ version = "0.3.3"
4
4
  edition = "2021"
5
5
  rust-version = "1.86"
6
6
  license = "MIT OR Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ulpwise
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Framework :: Pytest
6
6
  Classifier: Intended Audience :: Developers
@@ -226,6 +226,11 @@ PYTHONPATH=/tmp/kornia-0.8.3 ulpwise corpus --repo kornia # 31 present, 0 fi
226
226
  | torchvision #9676 `clamp_bounding_boxes` collapses slightly tilted rotated boxes to a point | geometry | open |
227
227
  | pytorch #198663 `polygamma(1, x)` float64 keeps 9 digits (series stops at `1/42`), float32 loses all for large negative `x` | truncation, rounding | open |
228
228
  | pytorch #198664 `erfcx` off by `x*x/2` ulps for negative `x` (`exp` at the rounded square) | rounding | open |
229
+ | pytorch #199850 `torch.erf` in bfloat16 and float16 on CPU returns 0 at and below 1.8e-7 and loses relative accuracy below 1e-3 (13404 bfloat16 ulps, 5 float16 ulps) | cancellation | open |
230
+ | pytorch #199867 `torch.special.logit` in float16 and bfloat16 on CPU rounds `1 - x` and `x / (1 - x)` to the input dtype before the log, logit(0.499756) is -0.000488 for -0.000977 (512 float16 ulps, 64 bfloat16 ulps) | rounding | open |
231
+ | kornia #5505 `angle_error_mat` and `angle_error_vec` returned exactly 0 for every rotation below 0.03 degrees in float32 (`acos` next to 1, issue #5500) | cancellation | 2026-10-06 |
232
+ | peft #3769 `add_weighted_adapter` with `combination_type='svd'` crashed for Conv1d and Conv3d LoRA layers | crash | open |
233
+ | peft #3830 OFT `module_dropout` was never applied, every training forward used all rotation blocks | no-op | open |
229
234
 
230
235
  Cases marked `open` have an issue with the complete patch attached and no merged fix yet. Every case
231
236
  is an expected failure until the installed release contains the fix (`fixed_in_release` in
@@ -244,22 +249,26 @@ ulpwise scan path/to/repo --rules one-minus-cos,small-angle-division --top 40
244
249
  ```
245
250
 
246
251
  The scan is static and needs nothing installed: it parses each file with `ast`, walks every
247
- function and matches twelve patterns, each with a severity, the reason it loses digits or
248
- overflows, the usual replacement and, where one exists, the upstream bug it comes from.
252
+ function and matches fifteen patterns, each with a severity, the reason it loses digits,
253
+ overflows or loses its gradient, the usual replacement and, where one exists, the upstream bug it
254
+ comes from.
249
255
 
250
256
  | rule | severity | pattern |
251
257
  |---|---|---|
252
258
  | `exp-of-square` | high | `exp(x * x)`, `exp(x ** 2)`, `(-x.pow(2)).exp()`: the rounding error of the square is multiplied by `x * x / 2` ulps (pytorch #198664) |
253
259
  | `sin-of-pi-times` | high | `sin(pi * x)`, `cos(pi * x)`: the product is rounded before the argument reduction (pytorch #198663) |
254
260
  | `softplus-by-hand` | high | `log(1 + exp(x))` |
255
- | `logsumexp-by-hand` | high | `log(exp(a) + exp(b))`, `log(sum(exp(x)))`, `log(exp(x).sum(-1))` |
256
- | `hypot-by-hand` | high | `sqrt(a * a + b * b)` |
261
+ | `logsumexp-by-hand` | high | `log(exp(a) + exp(b))`, `log(sum(exp(x)))`, `log(exp(x).sum(-1))`; quiet when every `exp` argument has its maximum subtracted first, `log(exp(x - x.max()).sum())`, the stable form |
262
+ | `hypot-by-hand` | high | `sqrt(a * a + b * b)`; the advice no longer says `norm`, which squares first too and overflows at 1.8e19 in float32 (kornia #5505 review) |
257
263
  | `sqrt-of-difference` | medium | `sqrt(a - b)` |
258
264
  | `one-minus-cos` | medium | `1 - cos(x)` (kornia #4897) |
259
265
  | `log1p-by-hand`, `expm1-by-hand` | medium | `log(1 + x)`, `exp(x) - 1` |
260
266
  | `atan-of-quotient` | medium | `atan(y / x)` |
261
267
  | `small-angle-division` | medium | `/ theta`, `/ theta ** 2`, `/ sin(theta)` in a function that takes `sin` or `cos` of `theta` and has no `where`, `clamp`, `eps` or series in sight (kornia #4838, #4897) |
262
- | `acos-for-angle` | info | `acos`, `asin` used to recover an angle |
268
+ | `acos-for-angle` | medium | `acos`, `asin` used to recover an angle: the angle comes back with an absolute error of `sqrt(eps)`, 0.02 degrees in float32 (kornia #5500) |
269
+ | `eps-floor` | medium | `x * (1 - eps) + eps`: every value moves, 0 becomes `eps`, so a one-hot's entries sum to `1 + (C - 1) eps` and a perfect prediction scores a loss that grows with the image (kornia #5538, found by the kornia conventions audit) |
270
+ | `where-nan-gradient` | medium | `where(d > eps, f(d), other)` with `f` a division by `d` or a `sqrt`, `log`, `acos` or `asin` of it: `where` evaluates both branches and hands the discarded one a zero gradient, and the backward of `f` at the singularity turns that zero into `0 / 0 = nan`, so the guard protects the value and not the gradient; quiet for `numpy.where`, for a comparison against a number above 1, for a floor named `eps`, `tol` or `floor` on the other side, and once `d` is re-bound to a `where`, `clamp` or `maximum` of itself (kornia #5579, found by the kornia conventions audit) |
271
+ | `clamp-at-singularity` | medium | `clamp(x, min=0).sqrt()`, `sqrt(clamp(x, min=0))`, `clamp(c, -1, 1).acos()`: the bound is the point where the next function has an infinite derivative, and clamp's derivative at its own bound is not the same across torch versions, 1 on 2.5.1 and 2.9.1 and 0 on 2.14, so the gradient there is `inf` or `nan` on the older half of a supported range; a bound strictly inside the domain, `min=1e-8`, is a floor and is not reported, and `numpy` is quiet (kornia #4229, found by the kornia conventions audit; kornia #5500) |
263
272
 
264
273
  On kornia `main` at `e05b0ee` the scan takes 4 s for 506 files and reports 35 findings. The
265
274
  `one-minus-cos` and `small-angle-division` findings are the four lines of `So3.right_jacobian` and
@@ -268,6 +277,13 @@ and `Se3.exp` (also #4897) is under `one-minus-cos`. The scan puts `ellipse_to_l
268
277
  on the list too, for a `sqrt` of a difference; the bug there was a different one, so that entry is
269
278
  what the scan is: a reading list, not a verdict.
270
279
 
280
+ The two gradient rules are reading lists in the same sense. On kornia `main` at `d15741e2` they
281
+ report seven lines: three `clamp` bounds in `_solve_cubic_real`, and four `where` guards, in
282
+ `PatchDominantGradientOrientation`, `compute_correspond_epilines`, `_crop_scale_translation` and
283
+ `_get_convex_edges`, of which the first two sit on a differentiable path and the last two draw
284
+ boxes and polygons. On ultralytics `main` at `8df3534` they report three lines, all in metrics or
285
+ inference code that is never differentiated, and nothing in torchvision.
286
+
271
287
  `--run` adds the dynamic half. The module level functions with the most elementary math are
272
288
  imported and called with the same 91 point grid (both signs of `1e-8` to `1e3`, and zero) for every
273
289
  required argument, in float32 and in float64, torch first and numpy second, and the float32 result
@@ -341,6 +357,21 @@ logged and skipped.
341
357
  - scipy's float64 `lgamma` does not handle the zeros at 1 and 2, and its Bessel functions lose the
342
358
  phase at large `x`.
343
359
 
360
+ [`studies/accuracy-survey-2026-10-half`](https://github.com/Nicholas022400701/ulpwise/blob/main/studies/accuracy-survey-2026-10-half/README.md)
361
+ is the second run, the 45 functions with torch float16 and bfloat16 CPU kernels measured in the ulps of
362
+ those dtypes on the same build. 33 of the 45 float16 rows and 34 of the bfloat16 rows are correctly
363
+ rounded over the whole grid; the exceptions:
364
+
365
+ - `erf` goes through a formula with 1.5e-7 absolute error, 254 bfloat16 ulps and `-0` below
366
+ `|x| = 1.8e-7` (pytorch #199850).
367
+ - `logit` rounds `1 - x` and `x / (1 - x)` to the input dtype before the log, 512 float16 ulps at
368
+ `x = 0.499756` while the CUDA kernel computes in float (pytorch #199867).
369
+ - `polygamma(2, x)` in float16 is off at every half integer in `(-1024, -256)`, 1,540 ulps at
370
+ `x = -1023.5`, because the Hurwitz zeta sum accumulates in float for the reduced types and in
371
+ double for float32.
372
+ - the `gelu`, `gelu_tanh`, `silu` and `sigmoid` tails and the `rsqrt` and `i0e` vector versus scalar
373
+ disagreements are the float32 findings of September seen through coarser ulps.
374
+
344
375
  ## How the exact oracle works
345
376
 
346
377
  For `sqrt(x)` the candidate `r` and the midpoint `m` between `r` and its neighbour are written as
@@ -197,6 +197,11 @@ PYTHONPATH=/tmp/kornia-0.8.3 ulpwise corpus --repo kornia # 31 present, 0 fi
197
197
  | torchvision #9676 `clamp_bounding_boxes` collapses slightly tilted rotated boxes to a point | geometry | open |
198
198
  | pytorch #198663 `polygamma(1, x)` float64 keeps 9 digits (series stops at `1/42`), float32 loses all for large negative `x` | truncation, rounding | open |
199
199
  | pytorch #198664 `erfcx` off by `x*x/2` ulps for negative `x` (`exp` at the rounded square) | rounding | open |
200
+ | pytorch #199850 `torch.erf` in bfloat16 and float16 on CPU returns 0 at and below 1.8e-7 and loses relative accuracy below 1e-3 (13404 bfloat16 ulps, 5 float16 ulps) | cancellation | open |
201
+ | pytorch #199867 `torch.special.logit` in float16 and bfloat16 on CPU rounds `1 - x` and `x / (1 - x)` to the input dtype before the log, logit(0.499756) is -0.000488 for -0.000977 (512 float16 ulps, 64 bfloat16 ulps) | rounding | open |
202
+ | kornia #5505 `angle_error_mat` and `angle_error_vec` returned exactly 0 for every rotation below 0.03 degrees in float32 (`acos` next to 1, issue #5500) | cancellation | 2026-10-06 |
203
+ | peft #3769 `add_weighted_adapter` with `combination_type='svd'` crashed for Conv1d and Conv3d LoRA layers | crash | open |
204
+ | peft #3830 OFT `module_dropout` was never applied, every training forward used all rotation blocks | no-op | open |
200
205
 
201
206
  Cases marked `open` have an issue with the complete patch attached and no merged fix yet. Every case
202
207
  is an expected failure until the installed release contains the fix (`fixed_in_release` in
@@ -215,22 +220,26 @@ ulpwise scan path/to/repo --rules one-minus-cos,small-angle-division --top 40
215
220
  ```
216
221
 
217
222
  The scan is static and needs nothing installed: it parses each file with `ast`, walks every
218
- function and matches twelve patterns, each with a severity, the reason it loses digits or
219
- overflows, the usual replacement and, where one exists, the upstream bug it comes from.
223
+ function and matches fifteen patterns, each with a severity, the reason it loses digits,
224
+ overflows or loses its gradient, the usual replacement and, where one exists, the upstream bug it
225
+ comes from.
220
226
 
221
227
  | rule | severity | pattern |
222
228
  |---|---|---|
223
229
  | `exp-of-square` | high | `exp(x * x)`, `exp(x ** 2)`, `(-x.pow(2)).exp()`: the rounding error of the square is multiplied by `x * x / 2` ulps (pytorch #198664) |
224
230
  | `sin-of-pi-times` | high | `sin(pi * x)`, `cos(pi * x)`: the product is rounded before the argument reduction (pytorch #198663) |
225
231
  | `softplus-by-hand` | high | `log(1 + exp(x))` |
226
- | `logsumexp-by-hand` | high | `log(exp(a) + exp(b))`, `log(sum(exp(x)))`, `log(exp(x).sum(-1))` |
227
- | `hypot-by-hand` | high | `sqrt(a * a + b * b)` |
232
+ | `logsumexp-by-hand` | high | `log(exp(a) + exp(b))`, `log(sum(exp(x)))`, `log(exp(x).sum(-1))`; quiet when every `exp` argument has its maximum subtracted first, `log(exp(x - x.max()).sum())`, the stable form |
233
+ | `hypot-by-hand` | high | `sqrt(a * a + b * b)`; the advice no longer says `norm`, which squares first too and overflows at 1.8e19 in float32 (kornia #5505 review) |
228
234
  | `sqrt-of-difference` | medium | `sqrt(a - b)` |
229
235
  | `one-minus-cos` | medium | `1 - cos(x)` (kornia #4897) |
230
236
  | `log1p-by-hand`, `expm1-by-hand` | medium | `log(1 + x)`, `exp(x) - 1` |
231
237
  | `atan-of-quotient` | medium | `atan(y / x)` |
232
238
  | `small-angle-division` | medium | `/ theta`, `/ theta ** 2`, `/ sin(theta)` in a function that takes `sin` or `cos` of `theta` and has no `where`, `clamp`, `eps` or series in sight (kornia #4838, #4897) |
233
- | `acos-for-angle` | info | `acos`, `asin` used to recover an angle |
239
+ | `acos-for-angle` | medium | `acos`, `asin` used to recover an angle: the angle comes back with an absolute error of `sqrt(eps)`, 0.02 degrees in float32 (kornia #5500) |
240
+ | `eps-floor` | medium | `x * (1 - eps) + eps`: every value moves, 0 becomes `eps`, so a one-hot's entries sum to `1 + (C - 1) eps` and a perfect prediction scores a loss that grows with the image (kornia #5538, found by the kornia conventions audit) |
241
+ | `where-nan-gradient` | medium | `where(d > eps, f(d), other)` with `f` a division by `d` or a `sqrt`, `log`, `acos` or `asin` of it: `where` evaluates both branches and hands the discarded one a zero gradient, and the backward of `f` at the singularity turns that zero into `0 / 0 = nan`, so the guard protects the value and not the gradient; quiet for `numpy.where`, for a comparison against a number above 1, for a floor named `eps`, `tol` or `floor` on the other side, and once `d` is re-bound to a `where`, `clamp` or `maximum` of itself (kornia #5579, found by the kornia conventions audit) |
242
+ | `clamp-at-singularity` | medium | `clamp(x, min=0).sqrt()`, `sqrt(clamp(x, min=0))`, `clamp(c, -1, 1).acos()`: the bound is the point where the next function has an infinite derivative, and clamp's derivative at its own bound is not the same across torch versions, 1 on 2.5.1 and 2.9.1 and 0 on 2.14, so the gradient there is `inf` or `nan` on the older half of a supported range; a bound strictly inside the domain, `min=1e-8`, is a floor and is not reported, and `numpy` is quiet (kornia #4229, found by the kornia conventions audit; kornia #5500) |
234
243
 
235
244
  On kornia `main` at `e05b0ee` the scan takes 4 s for 506 files and reports 35 findings. The
236
245
  `one-minus-cos` and `small-angle-division` findings are the four lines of `So3.right_jacobian` and
@@ -239,6 +248,13 @@ and `Se3.exp` (also #4897) is under `one-minus-cos`. The scan puts `ellipse_to_l
239
248
  on the list too, for a `sqrt` of a difference; the bug there was a different one, so that entry is
240
249
  what the scan is: a reading list, not a verdict.
241
250
 
251
+ The two gradient rules are reading lists in the same sense. On kornia `main` at `d15741e2` they
252
+ report seven lines: three `clamp` bounds in `_solve_cubic_real`, and four `where` guards, in
253
+ `PatchDominantGradientOrientation`, `compute_correspond_epilines`, `_crop_scale_translation` and
254
+ `_get_convex_edges`, of which the first two sit on a differentiable path and the last two draw
255
+ boxes and polygons. On ultralytics `main` at `8df3534` they report three lines, all in metrics or
256
+ inference code that is never differentiated, and nothing in torchvision.
257
+
242
258
  `--run` adds the dynamic half. The module level functions with the most elementary math are
243
259
  imported and called with the same 91 point grid (both signs of `1e-8` to `1e3`, and zero) for every
244
260
  required argument, in float32 and in float64, torch first and numpy second, and the float32 result
@@ -312,6 +328,21 @@ logged and skipped.
312
328
  - scipy's float64 `lgamma` does not handle the zeros at 1 and 2, and its Bessel functions lose the
313
329
  phase at large `x`.
314
330
 
331
+ [`studies/accuracy-survey-2026-10-half`](https://github.com/Nicholas022400701/ulpwise/blob/main/studies/accuracy-survey-2026-10-half/README.md)
332
+ is the second run, the 45 functions with torch float16 and bfloat16 CPU kernels measured in the ulps of
333
+ those dtypes on the same build. 33 of the 45 float16 rows and 34 of the bfloat16 rows are correctly
334
+ rounded over the whole grid; the exceptions:
335
+
336
+ - `erf` goes through a formula with 1.5e-7 absolute error, 254 bfloat16 ulps and `-0` below
337
+ `|x| = 1.8e-7` (pytorch #199850).
338
+ - `logit` rounds `1 - x` and `x / (1 - x)` to the input dtype before the log, 512 float16 ulps at
339
+ `x = 0.499756` while the CUDA kernel computes in float (pytorch #199867).
340
+ - `polygamma(2, x)` in float16 is off at every half integer in `(-1024, -256)`, 1,540 ulps at
341
+ `x = -1023.5`, because the Hurwitz zeta sum accumulates in float for the reduced types and in
342
+ double for float32.
343
+ - the `gelu`, `gelu_tanh`, `silu` and `sigmoid` tails and the `rsqrt` and `i0e` vector versus scalar
344
+ disagreements are the float32 findings of September seen through coarser ulps.
345
+
315
346
  ## How the exact oracle works
316
347
 
317
348
  For `sqrt(x)` the candidate `r` and the midpoint `m` between `r` and its neighbour are written as
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ulpwise"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  description = "Numerical conformance testing for ML code: float edge values, exact rounding oracles, knife-edge finders and a pytest plugin"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT OR Apache-2.0" }
@@ -30,7 +30,7 @@ from ._core import special as _special_core
30
30
  from ._core import ulp_distance as _ulp_distance_core
31
31
  from ._core import ulp_distances as _ulp_distances_core
32
32
 
33
- __version__ = "0.3.1"
33
+ __version__ = "0.3.3"
34
34
 
35
35
  __all__ = [
36
36
  "UNARY_OPS",
@@ -1019,6 +1019,154 @@
1019
1019
  "rtol": 0.0
1020
1020
  },
1021
1021
  "before_fix": "[1] on ultralytics 8.4.163: nc = 1 and no labels, \"ignoring corrupt image/label: non-normalized or out of bounds coordinates [1.15 1.15 ...]\""
1022
+ },
1023
+ {
1024
+ "id": "pytorch-199850-erf-bfloat16-small-arguments",
1025
+ "repo": "pytorch/pytorch",
1026
+ "pr": null,
1027
+ "issue": 199850,
1028
+ "merged_at": null,
1029
+ "kind": "cancellation",
1030
+ "title": "torch.erf in bfloat16 on CPU returns 0 for |x| <= 1.8e-7 and loses relative accuracy below 1e-3",
1031
+ "symptom": "bfloat16 and float16 erf go through Vectorized<float>::erf(), the Abramowitz and Stegun 7.1.26 formula 1 - r * t * exp(-x * x), whose absolute error is 1.5e-7; below |x| = 1e-3 that error is a visible fraction of erf(x) ~ 1.128 x and below 1.5e-7 the 1 - 1 rounds to exactly 0. Of the 16128 bfloat16 inputs in (0, 0.5], 14102 are more than 1 ulp off, 13379 return 0, the worst is 13404 ulp. The MKL float32 and float64 paths are correct; float32 without MKL and under torch.compile takes the same code (issue #198183). The patch adds a Taylor series branch for |x| < 0.5 (Draft PR #199851).",
1032
+ "requires": [
1033
+ "torch"
1034
+ ],
1035
+ "fixed_in_release": null,
1036
+ "repro": "import torch\nxs = torch.tensor([1e-7, 5e-7, 1e-6, 1e-5], dtype=torch.bfloat16)\nactual = torch.erf(xs).tolist()\n# erf at the bfloat16 inputs in float64, rounded to bfloat16\nexpected = [1.1315569281578064e-07, 5.62518835067749e-07, 1.125037670135498e-06, 1.1324882507324219e-05]\n",
1037
+ "check": {
1038
+ "type": "max_ulp",
1039
+ "dtype": "bf16",
1040
+ "max_ulp": 1
1041
+ },
1042
+ "before_fix": "[0.0, 4.172325134277344e-07, 1.1920928955078125e-06, 1.1563301086425781e-05] on torch 2.14.0 (13299, 55, 9 and 4 ulp)"
1043
+ },
1044
+ {
1045
+ "id": "pytorch-199850-erf-float16-small-arguments",
1046
+ "repo": "pytorch/pytorch",
1047
+ "pr": null,
1048
+ "issue": 199850,
1049
+ "merged_at": null,
1050
+ "kind": "cancellation",
1051
+ "title": "torch.erf in float16 on CPU returns 0 for |x| <= 1.8e-7 and is up to 5 ulp off below 1e-3",
1052
+ "symptom": "Same Vectorized<float>::erf() path as the bfloat16 case; float16 has three more bits, so the 1.5e-7 absolute error shows as 1240 of the 14336 inputs in (0, 0.5] more than 1 ulp off, at most 5 ulp, and the three inputs at or below 1.8e-7 return 0.",
1053
+ "requires": [
1054
+ "torch"
1055
+ ],
1056
+ "fixed_in_release": null,
1057
+ "repro": "import torch\nxs = torch.tensor([1e-7, 5e-7, 1e-6, 1e-5], dtype=torch.float16)\nactual = torch.erf(xs).tolist()\n# erf at the float16 inputs in float64, rounded to float16\nexpected = [1.1920928955078125e-07, 5.364418029785156e-07, 1.1324882507324219e-06, 1.1324882507324219e-05]\n",
1058
+ "check": {
1059
+ "type": "max_ulp",
1060
+ "dtype": "f16",
1061
+ "max_ulp": 1
1062
+ },
1063
+ "before_fix": "[0.0, 4.172325134277344e-07, 1.1920928955078125e-06, 1.1563301086425781e-05] on torch 2.14.0 (2, 2, 1 and 4 ulp)"
1064
+ },
1065
+ {
1066
+ "id": "pytorch-199867-logit-bfloat16-intermediate-rounding",
1067
+ "repo": "pytorch/pytorch",
1068
+ "pr": null,
1069
+ "issue": 199867,
1070
+ "merged_at": null,
1071
+ "kind": "rounding",
1072
+ "title": "torch.special.logit in bfloat16 on CPU rounds 1 - x and x / (1 - x) to bfloat16 before the log, 64 ulp at x = 0.498047",
1073
+ "symptom": "The CPU kernel computes 1 - x and x / (1 - x) in the input dtype and only the log in float, so near x = 0.5, where logit is small, the two roundings take most of the digits: logit(0.499756) in float16 is -0.000488 for an exact -0.000977 (512 ulp), 582 of the 15358 float16 values in (0, 1) are more than 1 ulp off and 59 of the 16254 bfloat16 values, 64 ulp at worst. The CUDA kernel computes in float. The patch computes in float and rounds once, as sigmoid_kernel does for the reduced types.",
1074
+ "requires": [
1075
+ "torch"
1076
+ ],
1077
+ "fixed_in_release": null,
1078
+ "repro": "import torch\nxs = torch.tensor([0.498046875, 0.490234375, 0.470703125, 0.51953125], dtype=torch.bfloat16)\nactual = torch.special.logit(xs).tolist()\n# logit at the bfloat16 inputs in float64, rounded to bfloat16\nexpected = [-0.0078125, -0.0390625, -0.1171875, 0.078125]\n",
1079
+ "check": {
1080
+ "type": "max_ulp",
1081
+ "dtype": "bf16",
1082
+ "max_ulp": 1
1083
+ },
1084
+ "before_fix": "[-0.00390625, -0.035888671875, -0.1201171875, 0.0751953125] on torch 2.14.0 (64, 13, 5.7 and 6.1 ulp)"
1085
+ },
1086
+ {
1087
+ "id": "pytorch-199867-logit-float16-intermediate-rounding",
1088
+ "repo": "pytorch/pytorch",
1089
+ "pr": null,
1090
+ "issue": 199867,
1091
+ "merged_at": null,
1092
+ "kind": "rounding",
1093
+ "title": "torch.special.logit in float16 on CPU rounds 1 - x and x / (1 - x) to float16 before the log, 512 ulp at x = 0.499756",
1094
+ "symptom": "The CPU kernel computes 1 - x and x / (1 - x) in the input dtype and only the log in float, so near x = 0.5, where logit is small, the two roundings take most of the digits: logit(0.499756) in float16 is -0.000488 for an exact -0.000977 (512 ulp), 582 of the 15358 float16 values in (0, 1) are more than 1 ulp off and 59 of the 16254 bfloat16 values, 64 ulp at worst. The CUDA kernel computes in float. The patch computes in float and rounds once, as sigmoid_kernel does for the reduced types.",
1095
+ "requires": [
1096
+ "torch"
1097
+ ],
1098
+ "fixed_in_release": null,
1099
+ "repro": "import torch\nxs = torch.tensor([0.499755859375, 0.497314453125, 0.489990234375, 0.52001953125], dtype=torch.float16)\nactual = torch.special.logit(xs).tolist()\n# logit at the float16 inputs in float64, rounded to float16\nexpected = [-0.0009765625, -0.0107421875, -0.0400390625, 0.08013916015625]\n",
1100
+ "check": {
1101
+ "type": "max_ulp",
1102
+ "dtype": "f16",
1103
+ "max_ulp": 1
1104
+ },
1105
+ "before_fix": "[-0.00048828125, -0.01129150390625, -0.039337158203125, 0.0797119140625] on torch 2.14.0 (512, 72, 23 and 6.7 ulp)"
1106
+ },
1107
+ {
1108
+ "id": "kornia-5500-angle-error-small-angle-acos",
1109
+ "repo": "kornia/kornia",
1110
+ "pr": 5505,
1111
+ "issue": 5500,
1112
+ "merged_at": "2026-10-06T17:27:13Z",
1113
+ "kind": "cancellation",
1114
+ "title": "angle_error_mat and angle_error_vec return exactly 0 for every rotation below 0.03 degrees in float32",
1115
+ "symptom": "Both metrics recover the angle as acos of a cosine, (tr(R1^T R2) - 1) / 2 and v1 . v2 / (|v1| |v2|). Next to 1 the float32 cosine has no digits left for a small angle, so a 0.01 degree rotation scores 0.0, a 0.05 degree one is 3 to 21 % off depending on the rotations and 179.99 degrees comes back as 180.0; the clamp(-1, 1).acos() also has a -inf / nan backward at exactly 0 and 180 degrees on torch 2.5.1. The patch reads sin(theta) from the skew part of the relative rotation, the cross product of the unit vectors for angle_error_vec, and returns atan2(sin, cos).",
1116
+ "requires": [
1117
+ "torch",
1118
+ "kornia"
1119
+ ],
1120
+ "fixed_in_release": null,
1121
+ "repro": "import torch\nimport kornia\nR1 = kornia.geometry.axis_angle_to_rotation_matrix(torch.tensor([[0.3, -0.5, 0.8]], dtype=torch.float64))\nR2 = R1 @ kornia.geometry.axis_angle_to_rotation_matrix(torch.deg2rad(torch.tensor([[0.0, 0.0, 0.01]], dtype=torch.float64)))\nv1 = torch.tensor([[0.0, 0.6, 0.8]], dtype=torch.float64)\nv2 = (kornia.geometry.axis_angle_to_rotation_matrix(torch.deg2rad(torch.tensor([[0.01, 0.0, 0.0]], dtype=torch.float64))) @ v1[..., None])[..., 0]\nactual = [kornia.metrics.angle_error_mat(R1.float(), R2.float()).item(), kornia.metrics.angle_error_vec(v1.float(), v2.float()).item()]\nexpected = [0.01, 0.01]\n",
1122
+ "check": {
1123
+ "type": "allclose",
1124
+ "atol": 0.0,
1125
+ "rtol": 0.001
1126
+ },
1127
+ "before_fix": "[0.0, 0.0] on kornia main 050ac77f"
1128
+ },
1129
+ {
1130
+ "id": "peft-3769-add-weighted-adapter-svd-conv1d-conv3d",
1131
+ "repo": "huggingface/peft",
1132
+ "pr": null,
1133
+ "issue": 3769,
1134
+ "merged_at": null,
1135
+ "kind": "crash",
1136
+ "title": "add_weighted_adapter with combination_type='svd' crashed for Conv1d and Conv3d LoRA layers",
1137
+ "symptom": "_svd_generalized_task_arithmetic_weighted_adapter checked isinstance(target, Conv2d) and flattened only that, so a Conv1d or Conv3d delta weight reached the SVD with its kernel dims in place and the matmul raised. The fix tests _ConvNd and always flattens from dim 1; PR 3903 is open with it.",
1138
+ "requires": [
1139
+ "torch",
1140
+ "peft"
1141
+ ],
1142
+ "fixed_in_release": null,
1143
+ "repro": "import torch\nfrom peft import LoraConfig, get_peft_model\n\nclass Net(torch.nn.Module):\n def __init__(self):\n super().__init__()\n self.conv = torch.nn.Conv1d(4, 8, kernel_size=3)\n\n def forward(self, x):\n return self.conv(x)\n\ntorch.manual_seed(0)\nconfig = LoraConfig(target_modules=['conv'], r=2, init_lora_weights=False)\nmodel = get_peft_model(Net(), config, adapter_name='a')\nmodel.add_adapter('b', config)\nx = torch.randn(1, 4, 10)\nwith torch.no_grad():\n model.base_model.set_adapter(['a', 'b'])\n both = model(x)\n # svd_rank=4 holds the rank 2 + 2 sum of the two deltas exactly\n model.add_weighted_adapter(['a', 'b'], [1.0, 1.0], 'ab', combination_type='svd', svd_rank=4)\n model.set_adapter('ab')\n combined = model(x)\nactual = combined.flatten().tolist()\nexpected = both.flatten().tolist()\n",
1144
+ "check": {
1145
+ "type": "allclose",
1146
+ "atol": 1e-05,
1147
+ "rtol": 1e-05
1148
+ },
1149
+ "before_fix": "RuntimeError inside _svd_generalized_task_arithmetic_weighted_adapter on peft main 91ca7945: Expected size for first two dimensions of batch2 tensor to be: [8, 4] but got: [8, 3]"
1150
+ },
1151
+ {
1152
+ "id": "peft-3830-oft-module-dropout-never-applied",
1153
+ "repo": "huggingface/peft",
1154
+ "pr": null,
1155
+ "issue": 3830,
1156
+ "merged_at": null,
1157
+ "kind": "no-op",
1158
+ "title": "OFT module_dropout was never applied, every training forward used all rotation blocks",
1159
+ "symptom": "OFTLayer built self.oft_dropout from module_dropout but OFTRotationModule.forward never called it, so module_dropout=0.5 behaved like 0 and ten training forwards of the same input were identical. The fix passes the dropout into the rotation module and applies it to the Cayley blocks; PR 3905 is open with it.",
1160
+ "requires": [
1161
+ "torch",
1162
+ "peft"
1163
+ ],
1164
+ "fixed_in_release": null,
1165
+ "repro": "import torch\nfrom peft import OFTConfig, get_peft_model\n\nclass Net(torch.nn.Module):\n def __init__(self):\n super().__init__()\n self.lin = torch.nn.Linear(16, 12)\n\n def forward(self, x):\n return self.lin(x)\n\ntorch.manual_seed(0)\nconfig = OFTConfig(target_modules=['lin'], r=4, oft_block_size=0, module_dropout=0.5, init_weights=False)\nmodel = get_peft_model(Net(), config)\nmodel.train()\nx = torch.randn(8, 16)\nwith torch.no_grad():\n outputs = [model(x) for _ in range(10)]\n# module_dropout replaces half of the four rotation blocks by the identity in each training forward,\n# so ten forwards cannot all agree\nactual = [max((outputs[0] - out).abs().max().item() for out in outputs[1:])]\nexpected = [1.0] # any positive spread\n",
1166
+ "check": {
1167
+ "type": "sign"
1168
+ },
1169
+ "before_fix": "[0.0] on peft main 91ca7945; with the fix the ten forwards spread by 0.56"
1022
1170
  }
1023
1171
  ]
1024
- }
1172
+ }