nulldtype 0.1.0__tar.gz → 0.1.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nulldtype
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Missing values as a NumPy DType: NA stored as a reserved bit pattern of the wrapped type
5
5
  License-Expression: BSD-3-Clause
6
6
  Project-URL: Source, https://github.com/tuan2k33/NullableDType
@@ -44,6 +44,7 @@ two layers compose. In the terms of the 2012 NumPy discussion, this is
44
44
  | `LAYOUTS.md` | the NA pattern of every type, and what each gives up |
45
45
  | `VS-NUMPY-MA.md` | all 218 names in `numpy.ma.__all__` that numpy 2 still spells that way, side by side with `nd`, with real calls and results |
46
46
  | `DISCUSSION.md` | how it works and why: the borrowed loops, performance, the bugs found on the way, leak checking |
47
+ | `NUMPY-MA-PITFALLS.md` | `numpy.ma` behaviours that leak, mislead or raise, with causes and issue links |
47
48
  | `NUMPY-PATCHES.md` | where numpy's array functions fail on Nullable, and patches that leave plain arrays unchanged |
48
49
  | `AGENTS.md` | notes for coding agents working on the repo |
49
50
  | `archive/flag-layout/` | the earlier two-layout version, frozen |
@@ -67,7 +68,7 @@ builds and runs pytest (extra arguments go to pytest). They use `python3`;
67
68
  set `PYTHON`, and `NUMPY_SITE` for a numpy dev tree that is not installed, or
68
69
  put both in an uncommitted `local.env`.
69
70
 
70
- The suite has 582 tests plus 2 expected failures that pin known gaps. It also
71
+ The suite has 637 tests plus 2 expected failures that pin known gaps. It also
71
72
  passes on an ASAN + UBSAN build, which CI runs on every push.
72
73
 
73
74
  ## What works
@@ -128,6 +129,7 @@ nd.Nullable(">i4") # Nullable(int32) — stored in native byte order
128
129
  | Results follow numpy's rules | `resolve` asks the wrapped ufunc, so `Nullable[i8] / Nullable[i8]` is `Nullable[f8]` and `Nullable[i8] + Nullable[f8]` is `Nullable[f8]` |
129
130
  | Mixed operands | `Nullable[T] op T` and Python scalars, both ways round, through promoters |
130
131
  | Comparisons | answer `Nullable[bool]`; `NA == NA` is `NA`, not `True` |
132
+ | Answers that do not depend on the gap | `1 ** NA` and `NA ** 0` are 1, `hypot(inf, NA)` is inf, `heaviside(3, NA)` is 1 — as in R and pandas; `fmax(NA, 2)` and `copysign(1, NA)` do depend on it and are NA |
131
133
  | Three-valued logic | `logical_and/or/xor` and `&`, `\|`, `^` on `Nullable[bool]` are Kleene: `NA & False = False`, `NA \| True = True`; on ints `&`, `\|`, `^` propagate |
132
134
  | Reductions and `accumulate` | propagate by default, like R's `na.rm = FALSE`, over several axes at once too; `cumsum` is NA from the first gap on |
133
135
  | `argmax`, `argmin` | the position of the first gap, numpy's rule for NaN, so `a[a.argmax()]` is NA exactly when `a.max()` is |
@@ -241,8 +243,8 @@ plain arrays, is in `NUMPY-PATCHES.md`.
241
243
  widening to `int64`.
242
244
  3. **A real `longdouble`** through `numpy-quaddtype` instead of substituting
243
245
  `float64`.
244
- 4. **Remove the fixed cost of `get_loop`** — two Python calls per operation, most
245
- visible on small arrays.
246
+ 4. **Cache the loop lookups** — about 1.4 µs of the ~2.5 µs fixed cost per
247
+ operation, most visible on small arrays.
246
248
 
247
249
  ## Settled design notes
248
250
 
@@ -26,6 +26,7 @@ two layers compose. In the terms of the 2012 NumPy discussion, this is
26
26
  | `LAYOUTS.md` | the NA pattern of every type, and what each gives up |
27
27
  | `VS-NUMPY-MA.md` | all 218 names in `numpy.ma.__all__` that numpy 2 still spells that way, side by side with `nd`, with real calls and results |
28
28
  | `DISCUSSION.md` | how it works and why: the borrowed loops, performance, the bugs found on the way, leak checking |
29
+ | `NUMPY-MA-PITFALLS.md` | `numpy.ma` behaviours that leak, mislead or raise, with causes and issue links |
29
30
  | `NUMPY-PATCHES.md` | where numpy's array functions fail on Nullable, and patches that leave plain arrays unchanged |
30
31
  | `AGENTS.md` | notes for coding agents working on the repo |
31
32
  | `archive/flag-layout/` | the earlier two-layout version, frozen |
@@ -49,7 +50,7 @@ builds and runs pytest (extra arguments go to pytest). They use `python3`;
49
50
  set `PYTHON`, and `NUMPY_SITE` for a numpy dev tree that is not installed, or
50
51
  put both in an uncommitted `local.env`.
51
52
 
52
- The suite has 582 tests plus 2 expected failures that pin known gaps. It also
53
+ The suite has 637 tests plus 2 expected failures that pin known gaps. It also
53
54
  passes on an ASAN + UBSAN build, which CI runs on every push.
54
55
 
55
56
  ## What works
@@ -110,6 +111,7 @@ nd.Nullable(">i4") # Nullable(int32) — stored in native byte order
110
111
  | Results follow numpy's rules | `resolve` asks the wrapped ufunc, so `Nullable[i8] / Nullable[i8]` is `Nullable[f8]` and `Nullable[i8] + Nullable[f8]` is `Nullable[f8]` |
111
112
  | Mixed operands | `Nullable[T] op T` and Python scalars, both ways round, through promoters |
112
113
  | Comparisons | answer `Nullable[bool]`; `NA == NA` is `NA`, not `True` |
114
+ | Answers that do not depend on the gap | `1 ** NA` and `NA ** 0` are 1, `hypot(inf, NA)` is inf, `heaviside(3, NA)` is 1 — as in R and pandas; `fmax(NA, 2)` and `copysign(1, NA)` do depend on it and are NA |
113
115
  | Three-valued logic | `logical_and/or/xor` and `&`, `\|`, `^` on `Nullable[bool]` are Kleene: `NA & False = False`, `NA \| True = True`; on ints `&`, `\|`, `^` propagate |
114
116
  | Reductions and `accumulate` | propagate by default, like R's `na.rm = FALSE`, over several axes at once too; `cumsum` is NA from the first gap on |
115
117
  | `argmax`, `argmin` | the position of the first gap, numpy's rule for NaN, so `a[a.argmax()]` is NA exactly when `a.max()` is |
@@ -223,8 +225,8 @@ plain arrays, is in `NUMPY-PATCHES.md`.
223
225
  widening to `int64`.
224
226
  3. **A real `longdouble`** through `numpy-quaddtype` instead of substituting
225
227
  `float64`.
226
- 4. **Remove the fixed cost of `get_loop`** — two Python calls per operation, most
227
- visible on small arrays.
228
+ 4. **Cache the loop lookups** — about 1.4 µs of the ~2.5 µs fixed cost per
229
+ operation, most visible on small arrays.
228
230
 
229
231
  ## Settled design notes
230
232
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nulldtype
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Missing values as a NumPy DType: NA stored as a reserved bit pattern of the wrapped type
5
5
  License-Expression: BSD-3-Clause
6
6
  Project-URL: Source, https://github.com/tuan2k33/NullableDType
@@ -44,6 +44,7 @@ two layers compose. In the terms of the 2012 NumPy discussion, this is
44
44
  | `LAYOUTS.md` | the NA pattern of every type, and what each gives up |
45
45
  | `VS-NUMPY-MA.md` | all 218 names in `numpy.ma.__all__` that numpy 2 still spells that way, side by side with `nd`, with real calls and results |
46
46
  | `DISCUSSION.md` | how it works and why: the borrowed loops, performance, the bugs found on the way, leak checking |
47
+ | `NUMPY-MA-PITFALLS.md` | `numpy.ma` behaviours that leak, mislead or raise, with causes and issue links |
47
48
  | `NUMPY-PATCHES.md` | where numpy's array functions fail on Nullable, and patches that leave plain arrays unchanged |
48
49
  | `AGENTS.md` | notes for coding agents working on the repo |
49
50
  | `archive/flag-layout/` | the earlier two-layout version, frozen |
@@ -67,7 +68,7 @@ builds and runs pytest (extra arguments go to pytest). They use `python3`;
67
68
  set `PYTHON`, and `NUMPY_SITE` for a numpy dev tree that is not installed, or
68
69
  put both in an uncommitted `local.env`.
69
70
 
70
- The suite has 582 tests plus 2 expected failures that pin known gaps. It also
71
+ The suite has 637 tests plus 2 expected failures that pin known gaps. It also
71
72
  passes on an ASAN + UBSAN build, which CI runs on every push.
72
73
 
73
74
  ## What works
@@ -128,6 +129,7 @@ nd.Nullable(">i4") # Nullable(int32) — stored in native byte order
128
129
  | Results follow numpy's rules | `resolve` asks the wrapped ufunc, so `Nullable[i8] / Nullable[i8]` is `Nullable[f8]` and `Nullable[i8] + Nullable[f8]` is `Nullable[f8]` |
129
130
  | Mixed operands | `Nullable[T] op T` and Python scalars, both ways round, through promoters |
130
131
  | Comparisons | answer `Nullable[bool]`; `NA == NA` is `NA`, not `True` |
132
+ | Answers that do not depend on the gap | `1 ** NA` and `NA ** 0` are 1, `hypot(inf, NA)` is inf, `heaviside(3, NA)` is 1 — as in R and pandas; `fmax(NA, 2)` and `copysign(1, NA)` do depend on it and are NA |
131
133
  | Three-valued logic | `logical_and/or/xor` and `&`, `\|`, `^` on `Nullable[bool]` are Kleene: `NA & False = False`, `NA \| True = True`; on ints `&`, `\|`, `^` propagate |
132
134
  | Reductions and `accumulate` | propagate by default, like R's `na.rm = FALSE`, over several axes at once too; `cumsum` is NA from the first gap on |
133
135
  | `argmax`, `argmin` | the position of the first gap, numpy's rule for NaN, so `a[a.argmax()]` is NA exactly when `a.max()` is |
@@ -241,8 +243,8 @@ plain arrays, is in `NUMPY-PATCHES.md`.
241
243
  widening to `int64`.
242
244
  3. **A real `longdouble`** through `numpy-quaddtype` instead of substituting
243
245
  `float64`.
244
- 4. **Remove the fixed cost of `get_loop`** — two Python calls per operation, most
245
- visible on small arrays.
246
+ 4. **Cache the loop lookups** — about 1.4 µs of the ~2.5 µs fixed cost per
247
+ operation, most visible on small arrays.
246
248
 
247
249
  ## Settled design notes
248
250
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nulldtype"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Missing values as a NumPy DType: NA stored as a reserved bit pattern of the wrapped type"
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -0,0 +1,31 @@
1
+ """The C extension; everything else is in pyproject.toml."""
2
+ import platform
3
+
4
+ import numpy as np
5
+ from setuptools import Extension, setup
6
+ from setuptools.command.build_ext import build_ext
7
+
8
+
9
+ class BuildExt(build_ext):
10
+ """numpy >= 2.5 already requires x86-64-v2 (SSE4.2) on x86-64, so the NA
11
+ scans may use it: SSE2 has no 64-bit compare, and they vectorise badly
12
+ without one. GCC and Clang only; MSVC and other CPUs keep their default."""
13
+
14
+ def build_extensions(self):
15
+ x86_64 = platform.machine().lower() in ("x86_64", "amd64")
16
+ if x86_64 and self.compiler.compiler_type != "msvc":
17
+ for ext in self.extensions:
18
+ ext.extra_compile_args += ["-march=x86-64-v2", "-mtune=generic"]
19
+ super().build_extensions()
20
+
21
+
22
+ setup(
23
+ ext_modules=[
24
+ Extension(
25
+ "_nulldtype",
26
+ sources=["src/nulldtype.c"],
27
+ include_dirs=[np.get_include()],
28
+ ),
29
+ ],
30
+ cmdclass={"build_ext": BuildExt},
31
+ )
@@ -38,6 +38,11 @@
38
38
  #include "numpy/ufuncobject.h"
39
39
  #include "numpy/dtype_api.h"
40
40
 
41
+ /* MSVC's C mode spells C99 `restrict` as `__restrict` */
42
+ #if defined(_MSC_VER) && !defined(__cplusplus) && !defined(restrict)
43
+ #define restrict __restrict
44
+ #endif
45
+
41
46
  static PyArray_DTypeMeta NullableDType;
42
47
 
43
48
  /* the plain dtypes a Nullable one is the common dtype of */
@@ -642,22 +647,80 @@ typedef struct {
642
647
  * failure rather than as a stray warning in someone's output.
643
648
  */
644
649
  static int
645
- binop_signals_on_gap(int idx)
650
+ binop_signals_on_gap(int idx, int complex_operand)
646
651
  {
647
652
  const char *n = binop_names[idx];
648
- return strcmp(n, "logaddexp") == 0 || strcmp(n, "logaddexp2") == 0;
653
+ if (strcmp(n, "logaddexp") == 0 || strcmp(n, "logaddexp2") == 0) {
654
+ return 1;
655
+ }
656
+ /*
657
+ * The division family raises on a gap in several ways: an integer gap is
658
+ * INT_MIN, so `NA // -1` overflows, and `NA // 0` divides by zero for a
659
+ * value that is not there; MSVC's float32 `floor_divide` and `fmod`
660
+ * signal "invalid" on inf and NaN operands. A real zero divisor still
661
+ * warns, since the masked path computes every element that has a value.
662
+ */
663
+ if (strcmp(n, "floor_divide") == 0 || strcmp(n, "remainder") == 0
664
+ || strcmp(n, "fmod") == 0) {
665
+ return 1;
666
+ }
667
+ /*
668
+ * Complex kernels compare magnitudes almost everywhere: division scales by
669
+ * the larger component, `power` special-cases, ordering is lexicographic
670
+ * with `<`. Only the plain componentwise ops stay quiet over a NaN.
671
+ */
672
+ if (complex_operand) {
673
+ return !(strcmp(n, "add") == 0 || strcmp(n, "subtract") == 0
674
+ || strcmp(n, "multiply") == 0 || strcmp(n, "equal") == 0
675
+ || strcmp(n, "not_equal") == 0);
676
+ }
677
+ return 0;
649
678
  }
650
679
 
651
680
  /*
652
- * Ops that return the non-NaN operand instead of propagating. A float gap is
653
- * a NaN, so for these a gap vanishes from the output and cannot be recovered
654
- * from the result alone.
681
+ * Ops whose result can be a number although an operand is NaN, so a float gap
682
+ * can leave no NaN in the output: `fmax`/`fmin` return the other operand,
683
+ * `copysign` reads only the sign, and the ones in `binop_keeps_determined`.
684
+ * The fast path may skip a float block whose output holds no NaN only for the
685
+ * other ops. `test_every_binop_keeps_a_gap` fails if one is missing here.
655
686
  */
687
+ static int binop_keeps_determined(int idx);
688
+
656
689
  static int
657
690
  binop_swallows_nan(int idx)
658
691
  {
659
692
  const char *n = binop_names[idx];
660
- return strcmp(n, "fmax") == 0 || strcmp(n, "fmin") == 0;
693
+ return strcmp(n, "fmax") == 0 || strcmp(n, "fmin") == 0
694
+ || strcmp(n, "copysign") == 0 || binop_keeps_determined(idx);
695
+ }
696
+
697
+ /*
698
+ * Ops whose IEEE 754 kernel already answers "does the result depend on the
699
+ * NaN operand?": it returns a number exactly when it does not. A gap is a
700
+ * value nobody knows, so the same rule applies to it, as in R and pandas:
701
+ *
702
+ * power, float_power 1 ** NA == 1, NA ** 0 == 1
703
+ * heaviside heaviside(x, NA) is 0 or 1 when x != 0
704
+ * hypot hypot(±inf, NA) == inf
705
+ *
706
+ * For these a float gap is stamped only where the output came out NaN. Other
707
+ * ops that turn a NaN into a number (`fmax`, `fmin`, `copysign`) do depend on
708
+ * the missing value, so they are not listed and always give NA. Integer `power` gets the same two rules
709
+ * in `nullable_int_power_rules`.
710
+ */
711
+ static int
712
+ binop_keeps_determined(int idx)
713
+ {
714
+ const char *n = binop_names[idx];
715
+ return strcmp(n, "power") == 0 || strcmp(n, "float_power") == 0
716
+ || strcmp(n, "heaviside") == 0 || strcmp(n, "hypot") == 0;
717
+ }
718
+
719
+ static int
720
+ binop_is_power(int idx)
721
+ {
722
+ const char *n = binop_names[idx];
723
+ return strcmp(n, "power") == 0 || strcmp(n, "float_power") == 0;
661
724
  }
662
725
 
663
726
  /*
@@ -1833,114 +1896,42 @@ nullable_computed_na(const NullableDescr *out)
1833
1896
  }
1834
1897
 
1835
1898
 
1836
- #define NA_FIXUP(CTYPE, MASKEXPR, CHECK) \
1837
- do { \
1838
- const CTYPE *p0 = (const CTYPE *)data[0]; \
1839
- const CTYPE *p1 = (const CTYPE *)data[1]; \
1840
- CTYPE *po = (CTYPE *)data[2]; \
1841
- CTYPE na; \
1842
- memcpy(&na, out->na_bytes, sizeof(CTYPE)); \
1843
- for (npy_intp i = 0; i < N; i++) { \
1844
- CTYPE x = p0[i], y = p1[i]; \
1845
- if (MASKEXPR) { \
1846
- po[i] = na; \
1847
- } \
1848
- else if (CHECK) { \
1849
- made |= (po[i] == na); \
1850
- } \
1851
- } \
1852
- } while (0)
1899
+ #define ISNA_F8(v) (((v) & 0x7FFFFFFFFFFFFFFFULL) == 0x7FFFFFFFFFFFFFFFULL)
1900
+ #define ISNA_F4(v) (((v) & 0x7FFFFFFFU) == 0x7FFFFFFFU)
1901
+ #define ISNA_EQ(v) ((v) == na)
1902
+ /* NaN: exponent all ones, mantissa not zero */
1903
+ #define ISNAN_F8(v) ((((v) & 0x7FF0000000000000ULL) == 0x7FF0000000000000ULL) \
1904
+ & (((v) & 0x000FFFFFFFFFFFFFULL) != 0))
1905
+ #define ISNAN_F4(v) ((((v) & 0x7F800000U) == 0x7F800000U) & (((v) & 0x007FFFFFU) != 0))
1906
+ /* where may a gap be stamped: everywhere, or only where the op gave a NaN */
1907
+ #define STAMP_F8(v) ((!keep_numbers) | ISNAN_F8(v))
1908
+ #define STAMP_F4(v) ((!keep_numbers) | ISNAN_F4(v))
1909
+ #define STAMP_ANY(v) 1
1853
1910
 
1911
+ /* the output cell is a NaN; kinds without a NaN say yes, so they are stamped */
1854
1912
  static int
1855
- nullable_fixup(NullableDescr *in0, NullableDescr *in1, NullableDescr *out,
1856
- char *const data[], npy_intp const strides[], npy_intp N,
1857
- int propagates_nan)
1913
+ nullable_out_is_nan(const NullableDescr *out, const char *p)
1858
1914
  {
1859
- npy_intp item = out->base.elsize;
1860
- int check = na_can_be_computed(out->kind);
1861
- int made = 0;
1862
- int packed = (strides[0] == in0->base.elsize)
1863
- && (strides[1] == in1->base.elsize)
1864
- && (strides[2] == item)
1865
- && in0->kind == out->kind && in1->kind == out->kind;
1866
-
1867
- if (packed) {
1868
- /*
1869
- * For floats the hardware has already put a NaN everywhere an input
1870
- * was NA, so a single pass over the freshly written output — still hot
1871
- * in cache — tells us whether there is anything to fix at all. Most
1872
- * arrays have no gaps and stop here.
1873
- *
1874
- * This rests on the op propagating NaN. `fmax`/`fmin` do not: they
1875
- * return the non-NaN side on purpose, so a gap leaves no trace in the
1876
- * output and this scan would skip a fixup that is needed. They are
1877
- * flagged by `binop_swallows_nan`, which turns this scan off and
1878
- * forces the full pass; anything else added to `binop_names` that
1879
- * swallows NaN must be listed there too.
1880
- */
1881
- if (propagates_nan && out->kind == NA_KIND_F8) {
1882
- const double *po = (const double *)data[2];
1883
- int any = 0;
1884
- for (npy_intp i = 0; i < N; i++) {
1885
- any |= (po[i] != po[i]);
1886
- }
1887
- if (!any) {
1888
- return 0;
1889
- }
1890
- }
1891
- else if (propagates_nan && out->kind == NA_KIND_F4) {
1892
- const float *po = (const float *)data[2];
1893
- int any = 0;
1894
- for (npy_intp i = 0; i < N; i++) {
1895
- any |= (po[i] != po[i]);
1896
- }
1897
- if (!any) {
1898
- return 0;
1899
- }
1900
- }
1901
- switch (out->kind) {
1902
- case NA_KIND_F8:
1903
- NA_FIXUP(npy_uint64,
1904
- ((x & 0x7FFFFFFFFFFFFFFFULL) == 0x7FFFFFFFFFFFFFFFULL) ||
1905
- ((y & 0x7FFFFFFFFFFFFFFFULL) == 0x7FFFFFFFFFFFFFFFULL), 0);
1906
- return 0;
1907
- case NA_KIND_F4:
1908
- NA_FIXUP(npy_uint32,
1909
- ((x & 0x7FFFFFFFU) == 0x7FFFFFFFU) ||
1910
- ((y & 0x7FFFFFFFU) == 0x7FFFFFFFU), 0);
1911
- return 0;
1912
- case NA_KIND_EXACT:
1913
- if (item == 8) { NA_FIXUP(npy_uint64, x == na || y == na, 1); goto done; }
1914
- if (item == 4) { NA_FIXUP(npy_uint32, x == na || y == na, 1); goto done; }
1915
- if (item == 2) { NA_FIXUP(npy_uint16, x == na || y == na, 1); goto done; }
1916
- if (item == 1) { NA_FIXUP(npy_uint8, x == na || y == na, 1); goto done; }
1917
- break;
1918
- default:
1919
- /* f16 and complex: correctness first, via the loop below */
1920
- break;
1921
- }
1915
+ if (out->kind == NA_KIND_F8) {
1916
+ npy_uint64 v; memcpy(&v, p, 8); return ISNAN_F8(v);
1922
1917
  }
1923
- for (npy_intp i = 0; i < N; i++) {
1924
- if (nullable_is_na(in0, data[0] + i * strides[0])
1925
- || nullable_is_na(in1, data[1] + i * strides[1])) {
1926
- nullable_put_na(out, data[2] + i * strides[2]);
1927
- }
1928
- else if (check && nullable_is_na(out, data[2] + i * strides[2])) {
1929
- made = 1;
1930
- }
1918
+ if (out->kind == NA_KIND_F4) {
1919
+ npy_uint32 v; memcpy(&v, p, 4); return ISNAN_F4(v);
1931
1920
  }
1932
- done:
1933
- return made ? nullable_computed_na(out) : 0;
1921
+ if (out->kind == NA_KIND_F2) {
1922
+ npy_uint16 v; memcpy(&v, p, 2);
1923
+ return ((v & 0x7C00U) == 0x7C00U) && ((v & 0x03FFU) != 0);
1924
+ }
1925
+ return 1;
1934
1926
  }
1935
1927
 
1936
-
1937
1928
  /* ------------------------------------------------------- ufunc loops */
1938
1929
  /*
1939
1930
  * The values are contiguous and aligned, so the wrapped loop runs at full
1940
- * speed on the whole chunk. Afterwards we stamp NA over the positions where
1941
- * an input was missing. That second pass is what makes `nan + NA` come out
1942
- * as NA regardless of operand order, and it means we never rely on the CPU
1943
- * carrying a NaN payload through the arithmetic.
1931
+ * speed. The gaps are recorded before it runs and stamped after, so
1932
+ * `nan + NA` comes out as NA regardless of operand order, `a += 1` keeps its
1933
+ * gaps, and nothing relies on the CPU carrying a NaN payload through the
1934
+ * arithmetic or on an op propagating NaN at all.
1944
1935
  */
1945
1936
 
1946
1937
  /*
@@ -2163,6 +2154,326 @@ nullable_binary_resolve_impl(PyObject *ufunc,
2163
2154
  }
2164
2155
 
2165
2156
 
2157
+ /*
2158
+ * Gaps are recorded *before* the wrapped loop runs. Reading the inputs
2159
+ * afterwards is wrong whenever the output is one of them (`a += 1`,
2160
+ * `np.square(a, out=a)`): the loop has already overwritten the gap with
2161
+ * INT_MIN + 1 or 0, and the NA was lost to an ordinary number.
2162
+ *
2163
+ * The work goes in blocks small enough for L1, so each block is scanned,
2164
+ * computed and stamped while it is hot: one trip through memory instead of
2165
+ * one for the wrapped loop and another for the fixup. The typed scans are
2166
+ * branch-free integer tests, which the compiler vectorises at -O2.
2167
+ */
2168
+ #define FIX_BLOCK 1024
2169
+
2170
+ /*
2171
+ * One small function per width, with `restrict` parameters: `mask` is a char
2172
+ * pointer, which may alias anything, and GCC at -O2 will not add a runtime
2173
+ * alias check, so without these it vectorises none of this. The two inputs
2174
+ * may be the same array (`a + a`); `restrict` allows that because neither is
2175
+ * written through.
2176
+ */
2177
+ #define DEFINE_SCAN(NAME, CTYPE, ISNA) \
2178
+ static unsigned char \
2179
+ NAME(const CTYPE *restrict p0, const CTYPE *restrict p1, \
2180
+ unsigned char *restrict mk, npy_intp k, CTYPE na) \
2181
+ { \
2182
+ unsigned char gap = 0; \
2183
+ (void)na; \
2184
+ for (npy_intp i = 0; i < k; i++) { \
2185
+ CTYPE x = p0[i], y = p1[i]; \
2186
+ unsigned char m = (unsigned char)(ISNA(x) | ISNA(y)); \
2187
+ mk[i] = m; \
2188
+ gap |= m; \
2189
+ } \
2190
+ return gap; \
2191
+ }
2192
+ #define DEFINE_SCAN1(NAME, CTYPE, ISNA) \
2193
+ static unsigned char \
2194
+ NAME(const CTYPE *restrict p0, unsigned char *restrict mk, npy_intp k, \
2195
+ CTYPE na) \
2196
+ { \
2197
+ unsigned char gap = 0; \
2198
+ (void)na; \
2199
+ for (npy_intp i = 0; i < k; i++) { \
2200
+ CTYPE x = p0[i]; \
2201
+ unsigned char m = (unsigned char)ISNA(x); \
2202
+ mk[i] = m; \
2203
+ gap |= m; \
2204
+ } \
2205
+ return gap; \
2206
+ }
2207
+ DEFINE_SCAN1(scan1_f8, npy_uint64, ISNA_F8)
2208
+ DEFINE_SCAN1(scan1_f4, npy_uint32, ISNA_F4)
2209
+ DEFINE_SCAN1(scan1_u64, npy_uint64, ISNA_EQ)
2210
+ DEFINE_SCAN1(scan1_u32, npy_uint32, ISNA_EQ)
2211
+ DEFINE_SCAN1(scan1_u16, npy_uint16, ISNA_EQ)
2212
+ DEFINE_SCAN1(scan1_u8, npy_uint8, ISNA_EQ)
2213
+ #undef DEFINE_SCAN1
2214
+
2215
+ /* any NaN or inf (exponent all ones) in a float output block */
2216
+ static unsigned char
2217
+ special_f8(const npy_uint64 *restrict po, npy_intp k)
2218
+ {
2219
+ unsigned char any = 0;
2220
+ for (npy_intp i = 0; i < k; i++) {
2221
+ any |= (unsigned char)((po[i] & 0x7FF0000000000000ULL)
2222
+ == 0x7FF0000000000000ULL);
2223
+ }
2224
+ return any;
2225
+ }
2226
+
2227
+ static unsigned char
2228
+ special_f4(const npy_uint32 *restrict po, npy_intp k)
2229
+ {
2230
+ unsigned char any = 0;
2231
+ for (npy_intp i = 0; i < k; i++) {
2232
+ any |= (unsigned char)((po[i] & 0x7F800000U) == 0x7F800000U);
2233
+ }
2234
+ return any;
2235
+ }
2236
+
2237
+ DEFINE_SCAN(scan_f8, npy_uint64, ISNA_F8)
2238
+ DEFINE_SCAN(scan_f4, npy_uint32, ISNA_F4)
2239
+ DEFINE_SCAN(scan_u64, npy_uint64, ISNA_EQ)
2240
+ DEFINE_SCAN(scan_u32, npy_uint32, ISNA_EQ)
2241
+ DEFINE_SCAN(scan_u16, npy_uint16, ISNA_EQ)
2242
+ DEFINE_SCAN(scan_u8, npy_uint8, ISNA_EQ)
2243
+ #undef DEFINE_SCAN
2244
+
2245
+ /* the reserved pattern of `D` as a `CTYPE`; needs a local `na_<CTYPE>` */
2246
+ #define NA_OF(CTYPE, D) (memcpy(&na_##CTYPE, (D)->na_bytes, sizeof(CTYPE)), na_##CTYPE)
2247
+
2248
+ /* mask[i] = "an input of element i is a gap"; returns whether any is */
2249
+ static int
2250
+ nullable_scan_pair(const NullableDescr *in0, const NullableDescr *in1,
2251
+ char *const d[], npy_intp const strides[], npy_intp k,
2252
+ unsigned char *mask)
2253
+ {
2254
+ npy_intp item = in0->base.elsize;
2255
+ int packed = strides[0] == item && strides[1] == item
2256
+ && in1->base.elsize == item && in0->kind == in1->kind
2257
+ && memcmp(in0->na_bytes, in1->na_bytes, item) == 0;
2258
+ /* `a + a`, and the unary loops, pass one array twice: read it once */
2259
+ if (packed && d[0] == d[1]) {
2260
+ npy_uint64 na_npy_uint64;
2261
+ npy_uint32 na_npy_uint32;
2262
+ npy_uint16 na_npy_uint16;
2263
+ npy_uint8 na_npy_uint8;
2264
+ switch (in0->kind) {
2265
+ case NA_KIND_F8:
2266
+ return scan1_f8((npy_uint64 *)d[0], mask, k, 0);
2267
+ case NA_KIND_F4:
2268
+ return scan1_f4((npy_uint32 *)d[0], mask, k, 0);
2269
+ case NA_KIND_EXACT:
2270
+ if (item == 8) {
2271
+ return scan1_u64((npy_uint64 *)d[0], mask, k,
2272
+ NA_OF(npy_uint64, in0));
2273
+ }
2274
+ if (item == 4) {
2275
+ return scan1_u32((npy_uint32 *)d[0], mask, k,
2276
+ NA_OF(npy_uint32, in0));
2277
+ }
2278
+ if (item == 2) {
2279
+ return scan1_u16((npy_uint16 *)d[0], mask, k,
2280
+ NA_OF(npy_uint16, in0));
2281
+ }
2282
+ if (item == 1) {
2283
+ return scan1_u8((npy_uint8 *)d[0], mask, k,
2284
+ NA_OF(npy_uint8, in0));
2285
+ }
2286
+ break;
2287
+ default:
2288
+ break;
2289
+ }
2290
+ }
2291
+ if (packed) {
2292
+ npy_uint64 na_npy_uint64;
2293
+ npy_uint32 na_npy_uint32;
2294
+ npy_uint16 na_npy_uint16;
2295
+ npy_uint8 na_npy_uint8;
2296
+ switch (in0->kind) {
2297
+ case NA_KIND_F8:
2298
+ return scan_f8((npy_uint64 *)d[0], (npy_uint64 *)d[1], mask, k, 0);
2299
+ case NA_KIND_F4:
2300
+ return scan_f4((npy_uint32 *)d[0], (npy_uint32 *)d[1], mask, k, 0);
2301
+ case NA_KIND_EXACT:
2302
+ if (item == 8) {
2303
+ return scan_u64((npy_uint64 *)d[0], (npy_uint64 *)d[1], mask,
2304
+ k, NA_OF(npy_uint64, in0));
2305
+ }
2306
+ if (item == 4) {
2307
+ return scan_u32((npy_uint32 *)d[0], (npy_uint32 *)d[1], mask,
2308
+ k, NA_OF(npy_uint32, in0));
2309
+ }
2310
+ if (item == 2) {
2311
+ return scan_u16((npy_uint16 *)d[0], (npy_uint16 *)d[1], mask,
2312
+ k, NA_OF(npy_uint16, in0));
2313
+ }
2314
+ if (item == 1) {
2315
+ return scan_u8((npy_uint8 *)d[0], (npy_uint8 *)d[1], mask,
2316
+ k, NA_OF(npy_uint8, in0));
2317
+ }
2318
+ break;
2319
+ default:
2320
+ break;
2321
+ }
2322
+ }
2323
+ unsigned char gap = 0;
2324
+ for (npy_intp i = 0; i < k; i++) {
2325
+ mask[i] = (unsigned char)(nullable_is_na(in0, d[0] + i * strides[0])
2326
+ | nullable_is_na(in1, d[1] + i * strides[1]));
2327
+ gap |= mask[i];
2328
+ }
2329
+ return gap;
2330
+ }
2331
+
2332
+ /*
2333
+ * The output side: returns 1 if an element without a gap landed on the
2334
+ * reserved value (only looked for when `check`), and stamps NA where `mk`
2335
+ * says -- for floats with `keep_numbers`, only where the op gave a NaN.
2336
+ */
2337
+ #define DEFINE_STAMP(NAME, CTYPE, STAMP) \
2338
+ static unsigned char \
2339
+ NAME(CTYPE *restrict po, const unsigned char *restrict mk, npy_intp k, \
2340
+ CTYPE na, int check, int gap, int keep_numbers) \
2341
+ { \
2342
+ (void)keep_numbers; \
2343
+ if (check) { \
2344
+ unsigned char hit = 0; \
2345
+ for (npy_intp i = 0; i < k; i++) { \
2346
+ hit |= (unsigned char)(!mk[i] & (po[i] == na)); \
2347
+ } \
2348
+ if (hit) { \
2349
+ return 1; \
2350
+ } \
2351
+ } \
2352
+ if (gap) { \
2353
+ for (npy_intp i = 0; i < k; i++) { \
2354
+ CTYPE v = po[i]; \
2355
+ unsigned char m = (unsigned char)(mk[i] & STAMP(v)); \
2356
+ po[i] = m ? na : v; \
2357
+ } \
2358
+ } \
2359
+ return 0; \
2360
+ }
2361
+ DEFINE_STAMP(stamp_f8, npy_uint64, STAMP_F8)
2362
+ DEFINE_STAMP(stamp_f4, npy_uint32, STAMP_F4)
2363
+ DEFINE_STAMP(stamp_u64, npy_uint64, STAMP_ANY)
2364
+ DEFINE_STAMP(stamp_u32, npy_uint32, STAMP_ANY)
2365
+ DEFINE_STAMP(stamp_u16, npy_uint16, STAMP_ANY)
2366
+ DEFINE_STAMP(stamp_u8, npy_uint8, STAMP_ANY)
2367
+ #undef DEFINE_STAMP
2368
+
2369
+ /*
2370
+ * Put NA where `mask` says, and refuse a result that landed on the reserved
2371
+ * value elsewhere. With `keep_numbers` a float gap is stamped only where the
2372
+ * op gave a NaN (`binop_keeps_determined`).
2373
+ */
2374
+ static int
2375
+ nullable_stamp(const NullableDescr *out, char *o, npy_intp stride, npy_intp k,
2376
+ const unsigned char *mask, int gap, int keep_numbers)
2377
+ {
2378
+ int check = na_can_be_computed(out->kind);
2379
+ if (!gap && !check) {
2380
+ return 0;
2381
+ }
2382
+ npy_intp item = out->base.elsize;
2383
+ if (stride == item) {
2384
+ npy_uint64 na_npy_uint64;
2385
+ npy_uint32 na_npy_uint32;
2386
+ npy_uint16 na_npy_uint16;
2387
+ npy_uint8 na_npy_uint8;
2388
+ int hit = -1;
2389
+ switch (out->kind) {
2390
+ case NA_KIND_F8:
2391
+ hit = stamp_f8((npy_uint64 *)o, mask, k, NA_OF(npy_uint64, out),
2392
+ 0, gap, keep_numbers);
2393
+ break;
2394
+ case NA_KIND_F4:
2395
+ hit = stamp_f4((npy_uint32 *)o, mask, k, NA_OF(npy_uint32, out),
2396
+ 0, gap, keep_numbers);
2397
+ break;
2398
+ case NA_KIND_EXACT:
2399
+ if (item == 8) {
2400
+ hit = stamp_u64((npy_uint64 *)o, mask, k,
2401
+ NA_OF(npy_uint64, out), check, gap, 0);
2402
+ }
2403
+ else if (item == 4) {
2404
+ hit = stamp_u32((npy_uint32 *)o, mask, k,
2405
+ NA_OF(npy_uint32, out), check, gap, 0);
2406
+ }
2407
+ else if (item == 2) {
2408
+ hit = stamp_u16((npy_uint16 *)o, mask, k,
2409
+ NA_OF(npy_uint16, out), check, gap, 0);
2410
+ }
2411
+ else if (item == 1) {
2412
+ hit = stamp_u8((npy_uint8 *)o, mask, k,
2413
+ NA_OF(npy_uint8, out), check, gap, 0);
2414
+ }
2415
+ break;
2416
+ default:
2417
+ break;
2418
+ }
2419
+ if (hit >= 0) {
2420
+ return hit ? nullable_computed_na(out) : 0;
2421
+ }
2422
+ }
2423
+ for (npy_intp i = 0; i < k; i++) {
2424
+ char *po = o + i * stride;
2425
+ if (mask[i]) {
2426
+ if (!keep_numbers || nullable_out_is_nan(out, po)) {
2427
+ nullable_put_na(out, po);
2428
+ }
2429
+ }
2430
+ else if (check && nullable_is_na(out, po)) {
2431
+ return nullable_computed_na(out);
2432
+ }
2433
+ }
2434
+ return 0;
2435
+ }
2436
+
2437
+
2438
+ /*
2439
+ * Integer `power` over a gap: `1 ** NA` and `NA ** 0` are 1 whatever the gap
2440
+ * holds (see `binop_keeps_determined`). The masked path has already put NA in
2441
+ * every gap lane; this puts the 1 back where it is determined.
2442
+ */
2443
+ static int
2444
+ nullable_int_power_rules(NullableDescr *in0, NullableDescr *in1,
2445
+ NullableDescr *out, char *const data[], npy_intp const strides[],
2446
+ npy_intp N)
2447
+ {
2448
+ char one0[16] = {0}, zero1[16] = {0}, one_out[16] = {0};
2449
+ if (in0->wrapped->elsize > 16 || in1->wrapped->elsize > 16
2450
+ || out->wrapped->elsize > 16) {
2451
+ return 0;
2452
+ }
2453
+ PyObject *one = PyLong_FromLong(1);
2454
+ PyObject *zero = PyLong_FromLong(0);
2455
+ int rc = (one && zero
2456
+ && PyArray_Pack(in0->wrapped, one0, one) >= 0
2457
+ && PyArray_Pack(in1->wrapped, zero1, zero) >= 0
2458
+ && PyArray_Pack(out->wrapped, one_out, one) >= 0) ? 0 : -1;
2459
+ Py_XDECREF(one);
2460
+ Py_XDECREF(zero);
2461
+ if (rc < 0) {
2462
+ return -1;
2463
+ }
2464
+ for (npy_intp i = 0; i < N; i++) {
2465
+ const char *x = data[0] + i * strides[0];
2466
+ const char *y = data[1] + i * strides[1];
2467
+ int xna = nullable_is_na(in0, x), yna = nullable_is_na(in1, y);
2468
+ if ((yna && !xna && memcmp(x, one0, in0->wrapped->elsize) == 0)
2469
+ || (xna && !yna && memcmp(y, zero1, in1->wrapped->elsize) == 0)) {
2470
+ memcpy(data[2] + i * strides[2], one_out, out->wrapped->elsize);
2471
+ }
2472
+ }
2473
+ return 0;
2474
+ }
2475
+
2476
+
2166
2477
  static int
2167
2478
  nullable_binary_loop_impl(int idx, PyArrayMethod_Context *context,
2168
2479
  char *const data[], npy_intp const dimensions[],
@@ -2204,10 +2515,10 @@ nullable_binary_loop_impl(int idx, PyArrayMethod_Context *context,
2204
2515
  Py_DECREF(a); Py_DECREF(b); Py_DECREF(o);
2205
2516
  return -1;
2206
2517
  }
2207
- char *mask = PyArray_DATA((PyArrayObject *)both);
2518
+ unsigned char *mask = (unsigned char *)PyArray_DATA((PyArrayObject *)both);
2519
+ int gap = nullable_scan_pair(in0, in1, data, strides, N, mask);
2208
2520
  for (npy_intp i = 0; i < N; i++) {
2209
- mask[i] = !nullable_is_na(in0, data[0] + i * strides[0])
2210
- && !nullable_is_na(in1, data[1] + i * strides[1]);
2521
+ mask[i] = !mask[i]; /* `where=`: the valid ones */
2211
2522
  }
2212
2523
 
2213
2524
  PyObject *args = PyTuple_Pack(2, a, b);
@@ -2216,25 +2527,37 @@ nullable_binary_loop_impl(int idx, PyArrayMethod_Context *context,
2216
2527
  ? PyObject_Call(binop_ufuncs[idx], args, kwargs) : NULL;
2217
2528
  Py_XDECREF(args); Py_XDECREF(kwargs);
2218
2529
  Py_DECREF(a); Py_DECREF(b); Py_DECREF(o);
2219
- Py_DECREF(both);
2220
2530
  if (res == NULL) {
2531
+ Py_DECREF(both);
2221
2532
  return -1;
2222
2533
  }
2223
2534
  Py_DECREF(res);
2224
2535
 
2225
2536
  /*
2226
- * `where=` left the gap lanes untouched, so the output holds whatever the
2227
- * buffer held before -- never a reliable NaN. The pre-scan must not run.
2537
+ * `where=` left the gap lanes untouched, so they hold whatever the buffer
2538
+ * held before -- never a reliable NaN, so the "keep a determined number"
2539
+ * rule must not read them.
2228
2540
  */
2229
- return nullable_fixup(in0, in1, out, data, strides, N, 0);
2541
+ for (npy_intp i = 0; i < N; i++) {
2542
+ mask[i] = !mask[i]; /* back to "is a gap" */
2543
+ }
2544
+ int rc = nullable_stamp(out, data[2], strides[2], N, mask, gap, 0);
2545
+ Py_DECREF(both);
2546
+ if (rc < 0) {
2547
+ return -1;
2548
+ }
2549
+ if (binop_is_power(idx) && out->kind == NA_KIND_EXACT) {
2550
+ return nullable_int_power_rules(in0, in1, out, data, strides, N);
2551
+ }
2552
+ return 0;
2230
2553
  }
2231
2554
 
2232
2555
 
2233
2556
  /*
2234
2557
  * The fast path: values are contiguous and aligned, so the wrapped loop runs
2235
- * on the whole chunk at native speed, gaps included -- a gap holds a NaN or
2236
- * INT_MIN, and computing with those is harmless and quiet. One pass afterwards
2237
- * stamps NA wherever an input had one.
2558
+ * at native speed, gaps included -- a gap holds a NaN or INT_MIN, and computing
2559
+ * with those is harmless and quiet (`binop_signals_on_gap` lists the ops for
2560
+ * which it is not). See `nullable_scan_pair` and `nullable_stamp`.
2238
2561
  */
2239
2562
  typedef struct {
2240
2563
  NpyAuxData base;
@@ -2270,15 +2593,55 @@ nullable_fast_loop(PyArrayMethod_Context *context, char *const data[],
2270
2593
  return nullable_accumulate_chunk(aux->idx, context, data, N, strides);
2271
2594
  }
2272
2595
 
2273
- npy_intp count = N;
2274
- if (aux->inner(aux->inner_context, data, &count, strides,
2275
- aux->inner_auxdata) < 0) {
2276
- return -1;
2596
+ NullableDescr *in0 = NULLABLE_DESCR(context->descriptors[0]);
2597
+ NullableDescr *in1 = NULLABLE_DESCR(context->descriptors[1]);
2598
+ NullableDescr *out = NULLABLE_DESCR(context->descriptors[2]);
2599
+ int keep = binop_keeps_determined(aux->idx);
2600
+ unsigned char mask[FIX_BLOCK];
2601
+ /*
2602
+ * Floats that propagate NaN can do better than scanning both inputs up
2603
+ * front: compute, look at the output only (one stream), and scan the
2604
+ * inputs only in a block that turned up a NaN. That needs the inputs
2605
+ * intact afterwards, so not when the output is one of them.
2606
+ */
2607
+ npy_intp item = out->base.elsize;
2608
+ int post = (out->kind == NA_KIND_F8 || out->kind == NA_KIND_F4)
2609
+ && in0->kind == out->kind && in1->kind == out->kind
2610
+ && strides[2] == item
2611
+ && !binop_swallows_nan(aux->idx)
2612
+ && data[2] != data[0] && data[2] != data[1];
2613
+ for (npy_intp s = 0; s < N; s += FIX_BLOCK) {
2614
+ npy_intp k = (N - s < FIX_BLOCK) ? N - s : FIX_BLOCK;
2615
+ char *d[3] = {data[0] + s * strides[0], data[1] + s * strides[1],
2616
+ data[2] + s * strides[2]};
2617
+ npy_intp count = k;
2618
+ if (post) {
2619
+ if (aux->inner(aux->inner_context, d, &count, strides,
2620
+ aux->inner_auxdata) < 0) {
2621
+ return -1;
2622
+ }
2623
+ unsigned char special = (out->kind == NA_KIND_F8)
2624
+ ? special_f8((npy_uint64 *)d[2], k)
2625
+ : special_f4((npy_uint32 *)d[2], k);
2626
+ if (!special) {
2627
+ continue;
2628
+ }
2629
+ int gap = nullable_scan_pair(in0, in1, d, strides, k, mask);
2630
+ if (nullable_stamp(out, d[2], strides[2], k, mask, gap, keep) < 0) {
2631
+ return -1;
2632
+ }
2633
+ continue;
2634
+ }
2635
+ int gap = nullable_scan_pair(in0, in1, d, strides, k, mask);
2636
+ if (aux->inner(aux->inner_context, d, &count, strides,
2637
+ aux->inner_auxdata) < 0) {
2638
+ return -1;
2639
+ }
2640
+ if (nullable_stamp(out, d[2], strides[2], k, mask, gap, keep) < 0) {
2641
+ return -1;
2642
+ }
2277
2643
  }
2278
- return nullable_fixup(NULLABLE_DESCR(context->descriptors[0]),
2279
- NULLABLE_DESCR(context->descriptors[1]),
2280
- NULLABLE_DESCR(context->descriptors[2]),
2281
- data, strides, N, !binop_swallows_nan(aux->idx));
2644
+ return 0;
2282
2645
  }
2283
2646
 
2284
2647
 
@@ -2297,7 +2660,17 @@ nullable_get_loop_impl(int idx, PyArrayMethod_Context *context,
2297
2660
  return 1;
2298
2661
  }
2299
2662
  /* these must not run over a gap at all; the masked path handles them */
2300
- if (binop_signals_on_gap(idx)) {
2663
+ int k0 = NULLABLE_DESCR(context->descriptors[0])->kind;
2664
+ int k1 = NULLABLE_DESCR(context->descriptors[1])->kind;
2665
+ int complex_operand = k0 == NA_KIND_C8 || k0 == NA_KIND_C16
2666
+ || k1 == NA_KIND_C8 || k1 == NA_KIND_C16;
2667
+ /*
2668
+ * An integer gap is INT_MIN, which integer `power` reads as a negative
2669
+ * exponent and refuses; it must not see the gap at all.
2670
+ */
2671
+ int int_power = binop_is_power(idx)
2672
+ && (k0 == NA_KIND_EXACT || k1 == NA_KIND_EXACT);
2673
+ if (binop_signals_on_gap(idx, complex_operand) || int_power) {
2301
2674
  *out_auxdata = NULL;
2302
2675
  return 1;
2303
2676
  }
@@ -2574,34 +2947,37 @@ nullable_unary_loop_impl(int idx, PyArrayMethod_Context *context,
2574
2947
  NullableDescr *in = NULLABLE_DESCR(context->descriptors[0]);
2575
2948
  NullableDescr *out = NULLABLE_DESCR(context->descriptors[1]);
2576
2949
 
2577
- PyObject *a = value_view(in->wrapped, data[0], N, strides[0]);
2578
- PyObject *o = value_view(out->wrapped, data[1], N, strides[1]);
2579
- if (a == NULL || o == NULL) {
2580
- Py_XDECREF(a); Py_XDECREF(o);
2950
+ /*
2951
+ * Record the gaps first: with `out=` the same array (`np.square(a,
2952
+ * out=a)`) the op overwrites them, and INT_MIN squared is 0.
2953
+ */
2954
+ unsigned char *mask = PyMem_Malloc(N > 0 ? N : 1);
2955
+ if (mask == NULL) {
2956
+ PyErr_NoMemory();
2581
2957
  return -1;
2582
2958
  }
2583
- PyObject *args = PyTuple_Pack(1, a);
2584
- PyObject *kwargs = Py_BuildValue("{s:O}", "out", o);
2959
+ char *const twice[2] = {data[0], data[0]};
2960
+ npy_intp const twice_strides[2] = {strides[0], strides[0]};
2961
+ int gap = nullable_scan_pair(in, in, twice, twice_strides, N, mask);
2962
+
2963
+ PyObject *a = value_view(in->wrapped, data[0], N, strides[0]);
2964
+ PyObject *o = value_view(out->wrapped, data[1], N, strides[1]);
2965
+ PyObject *args = (a && o) ? PyTuple_Pack(1, a) : NULL;
2966
+ PyObject *kwargs = (args != NULL) ? Py_BuildValue("{s:O}", "out", o) : NULL;
2585
2967
  PyObject *res = (args && kwargs)
2586
2968
  ? PyObject_Call(unop_ufuncs[idx], args, kwargs) : NULL;
2587
2969
  Py_XDECREF(args); Py_XDECREF(kwargs);
2588
- Py_DECREF(a); Py_DECREF(o);
2970
+ Py_XDECREF(a); Py_XDECREF(o);
2589
2971
  if (res == NULL) {
2972
+ PyMem_Free(mask);
2590
2973
  return -1;
2591
2974
  }
2592
2975
  Py_DECREF(res);
2593
2976
 
2594
2977
  /* a unary op cannot create or remove a gap, it just carries it over */
2595
- int check = na_can_be_computed(out->kind);
2596
- for (npy_intp i = 0; i < N; i++) {
2597
- if (nullable_is_na(in, data[0] + i * strides[0])) {
2598
- nullable_put_na(out, data[1] + i * strides[1]);
2599
- }
2600
- else if (check && nullable_is_na(out, data[1] + i * strides[1])) {
2601
- return nullable_computed_na(out);
2602
- }
2603
- }
2604
- return 0;
2978
+ int rc = nullable_stamp(out, data[1], strides[1], N, mask, gap, 0);
2979
+ PyMem_Free(mask);
2980
+ return rc;
2605
2981
  }
2606
2982
 
2607
2983
 
@@ -2419,3 +2419,225 @@ def test_numpy_mean_of_nullable_int_is_not_truncated():
2419
2419
  i = np.array([1, 2], dtype=nd.Nullable(np.int64))
2420
2420
  assert nd.mean(i) == 1.5 # the nd version is right
2421
2421
  assert np.mean(i) == 1.5 # numpy's gives 1
2422
+
2423
+
2424
+ @pytest.mark.parametrize("t", [np.float64, np.float32, np.int64, np.int32,
2425
+ np.int16, np.uint8])
2426
+ def test_block_scan_edges(t):
2427
+ """The fixup scans in blocks of 2048 and skips blocks with no gap; a gap on
2428
+ either side of a block edge, in the last short block, or in only one
2429
+ operand must still come out as NA, and nothing else may."""
2430
+ n = 2048 * 3 + 5
2431
+ dt = nd.Nullable(t)
2432
+ a = np.ones(n, dtype=dt)
2433
+ b = np.ones(n, dtype=dt)
2434
+ gaps_a = [0, 2047, 2048, 4095, n - 1]
2435
+ gaps_b = [100, 4096, n - 3]
2436
+ for i in gaps_a:
2437
+ a[i] = nd.NA
2438
+ for i in gaps_b:
2439
+ b[i] = nd.NA
2440
+ got = nd.isna(a + b)
2441
+ want = np.zeros(n, dtype=bool)
2442
+ want[gaps_a + gaps_b] = True
2443
+ np.testing.assert_array_equal(got, want)
2444
+ assert (nd.filled(a + b, 0)[~want] == 2).all()
2445
+
2446
+
2447
+ def test_block_scan_still_catches_a_computed_na_after_a_clean_block():
2448
+ """A block without gaps still checks whether the result landed on the
2449
+ reserved value."""
2450
+ n = 2048 * 2 + 1
2451
+ a = np.zeros(n, dtype=nd.Nullable(np.int32))
2452
+ a[-1] = np.iinfo(np.int32).max
2453
+ with pytest.raises(ValueError, match="reserved value"):
2454
+ a + np.ones(n, dtype=nd.Nullable(np.int32))
2455
+
2456
+
2457
+ _SPECIALS = [0.0, -0.0, 1.0, -1.0, 2.0, 0.5, np.inf, -np.inf, np.nan]
2458
+ # ops whose answer depends on the missing value although IEEE gives a number
2459
+ _DEPENDS_ON_GAP = {"fmax", "fmin", "copysign"}
2460
+
2461
+
2462
+ @pytest.mark.parametrize("t", [np.float64, np.float32, np.float16,
2463
+ np.complex128, np.complex64])
2464
+ @pytest.mark.parametrize("n", [1, 5000])
2465
+ def test_every_binop_keeps_a_gap(t, n):
2466
+ """A gap is a value nobody knows. Where the result would differ for some
2467
+ value of it, the result is NA; where IEEE 754 already says it cannot --
2468
+ `1 ** x == x ** 0 == 1`, `hypot(inf, x) == inf`, `heaviside(y != 0, x)` --
2469
+ the number stands, as in R and pandas. So the expected answer is IEEE's
2470
+ with a NaN in place of the gap, NA wherever that is NaN, and NA always for
2471
+ `fmax`, `fmin` and `copysign`, which do look at the missing value.
2472
+ Complex operands conservatively always give NA.
2473
+
2474
+ Every element involves a gap, so any floating-point warning is a spurious
2475
+ one (`binop_signals_on_gap`); complex division, power and ordering used to
2476
+ raise "invalid value" this way."""
2477
+ dt = nd.Nullable(t)
2478
+ is_complex = np.dtype(t).kind == "c"
2479
+ gap = np.empty(n, dtype=dt)
2480
+ gap[...] = nd.NA
2481
+ nan = np.full(n, np.nan, dtype=t)
2482
+ wrong = []
2483
+ for name in _c.binop_names:
2484
+ f = getattr(np, name)
2485
+ for v in _SPECIALS:
2486
+ plain = np.full(n, v, dtype=t)
2487
+ other = plain.astype(dt)
2488
+ for x, y, px, py, side in ((other, gap, plain, nan, "right"),
2489
+ (gap, other, nan, plain, "left")):
2490
+ try:
2491
+ with warnings.catch_warnings():
2492
+ warnings.simplefilter("error", RuntimeWarning)
2493
+ r = f(x, y)
2494
+ except RuntimeWarning as w:
2495
+ wrong.append((name, v, side, str(w)))
2496
+ continue
2497
+ except (TypeError, ValueError):
2498
+ continue # op not defined for this dtype
2499
+ if not nd.is_nullable(r.dtype):
2500
+ continue
2501
+ with np.errstate(all="ignore"):
2502
+ ref = np.asarray(f(px, py))
2503
+ if ref.dtype.kind in "fc" and not is_complex \
2504
+ and name not in _DEPENDS_ON_GAP:
2505
+ expect_na = np.isnan(ref)
2506
+ else:
2507
+ expect_na = np.ones(n, dtype=bool)
2508
+ got_na = nd.isna(r)
2509
+ if not (got_na == expect_na).all():
2510
+ wrong.append((name, v, side, "NA where", got_na[0], "expected", expect_na[0]))
2511
+ continue
2512
+ if (~expect_na).any():
2513
+ kept = nd.to_numpy(r[~expect_na])
2514
+ if not (kept == ref[~expect_na]).all():
2515
+ wrong.append((name, v, side, "value", kept[0], ref[~expect_na][0]))
2516
+ assert not wrong
2517
+
2518
+
2519
+ @pytest.mark.parametrize("t", [np.int64, np.int32, np.int8, np.uint8, np.uint64])
2520
+ @pytest.mark.parametrize("n", [1, 5000])
2521
+ def test_integer_power_over_a_gap(t, n):
2522
+ """An integer gap is INT_MIN (or UINT_MAX); integer `power` used to read it
2523
+ as a negative exponent and raise. Now it never sees the gap, and the two
2524
+ determined cases give 1 as for floats. A real negative exponent still
2525
+ raises, as for a plain array."""
2526
+ dt = nd.Nullable(t)
2527
+ base = np.array([1, 2, 0, 0, 3], dtype=t).astype(dt)
2528
+ expo = np.array([0, 0, 0, 2, 0], dtype=t).astype(dt)
2529
+ base[2] = base[3] = nd.NA
2530
+ expo[0] = expo[1] = nd.NA
2531
+ base, expo = np.tile(base, n), np.tile(expo, n)
2532
+ got = np.power(base, expo)
2533
+ assert nd.isna(got).tolist() == [False, True, False, True, False] * n
2534
+ assert got[0] == 1 and got[2] == 1 and got[4] == 1
2535
+
2536
+ if np.dtype(t).kind == "i":
2537
+ with pytest.raises(ValueError, match="negative integer powers"):
2538
+ np.power(np.array([2], dtype=t).astype(dt),
2539
+ np.array([-1], dtype=t).astype(dt))
2540
+
2541
+
2542
+ _INPLACE_TYPES = [np.int8, np.int32, np.int64, np.uint8, np.uint64,
2543
+ np.float16, np.float32, np.float64, np.complex128]
2544
+
2545
+
2546
+ @pytest.mark.parametrize("t", _INPLACE_TYPES)
2547
+ @pytest.mark.parametrize("n", [3, 5000])
2548
+ def test_inplace_ops_keep_their_gaps(t, n):
2549
+ """With `out=` one of the inputs, the wrapped loop overwrites the gap
2550
+ before anything looks at it: `a += 1` turned an int32 gap into
2551
+ -2147483647, `np.square(a, out=a)` into 0. Gaps are now recorded before
2552
+ the loop runs."""
2553
+ dt = nd.Nullable(t)
2554
+
2555
+ def with_gaps():
2556
+ a = np.full(n, 3, dtype=t).astype(dt)
2557
+ a[1] = a[n - 1] = nd.NA
2558
+ return a
2559
+
2560
+ gaps = np.zeros(n, dtype=bool)
2561
+ gaps[[1, n - 1]] = True
2562
+ ones = np.ones(n, dtype=t).astype(dt)
2563
+
2564
+ with warnings.catch_warnings():
2565
+ warnings.simplefilter("ignore", RuntimeWarning) # e.g. 3 ** 3 in int8
2566
+ for name in ("add", "subtract", "multiply", "maximum"):
2567
+ a = with_gaps()
2568
+ getattr(np, name)(a, ones, out=a)
2569
+ np.testing.assert_array_equal(nd.isna(a), gaps, err_msg=name)
2570
+ b = with_gaps()
2571
+ getattr(np, name)(ones, b, out=b)
2572
+ np.testing.assert_array_equal(nd.isna(b), gaps, err_msg=name)
2573
+ c = with_gaps()
2574
+ getattr(np, name)(c, c, out=c)
2575
+ np.testing.assert_array_equal(nd.isna(c), gaps, err_msg=name)
2576
+ if np.dtype(t).kind in "fc":
2577
+ # x86 keeps the left NaN's payload, so writing `nan + NA` over the
2578
+ # right operand replaces the gap with an ordinary NaN
2579
+ nan = np.full(n, np.nan, dtype=t).astype(dt)
2580
+ b = with_gaps()
2581
+ np.add(nan, b, out=b)
2582
+ np.testing.assert_array_equal(nd.isna(b), gaps, err_msg="nan + NA")
2583
+ for name in ("square", "negative", "absolute", "invert", "sign",
2584
+ "sqrt", "reciprocal"):
2585
+ a = with_gaps()
2586
+ try:
2587
+ getattr(np, name)(a, out=a)
2588
+ except (TypeError, ValueError):
2589
+ continue # not defined for this dtype, or unsafe out
2590
+ np.testing.assert_array_equal(nd.isna(a), gaps, err_msg=name)
2591
+
2592
+
2593
+ @pytest.mark.parametrize("t", [np.int8, np.int32, np.int64, np.uint8, np.uint64])
2594
+ @pytest.mark.parametrize("n", [1, 5000])
2595
+ def test_every_binop_keeps_a_gap_integers(t, n):
2596
+ """The integer side of `test_every_binop_keeps_a_gap`: a gap is INT_MIN or
2597
+ UINT_MAX, so `NA // -1` overflowed and `NA // 0` divided by zero -- warnings
2598
+ about a value that is not there. Every result is NA except the two
2599
+ determined powers, and a real zero divisor still warns."""
2600
+ dt = nd.Nullable(t)
2601
+ gap = np.empty(n, dtype=dt)
2602
+ gap[...] = nd.NA
2603
+ values = [0, 1, 2, 3] + ([-1, -2] if np.dtype(t).kind == "i" else [])
2604
+ wrong = []
2605
+ for name in _c.binop_names:
2606
+ f = getattr(np, name)
2607
+ for v in values:
2608
+ other = np.full(n, v, dtype=t).astype(dt)
2609
+ for x, y, side in ((other, gap, "right"), (gap, other, "left")):
2610
+ try:
2611
+ with warnings.catch_warnings():
2612
+ warnings.simplefilter("error", RuntimeWarning)
2613
+ r = f(x, y)
2614
+ except RuntimeWarning as w:
2615
+ wrong.append((name, v, side, str(w)))
2616
+ continue
2617
+ except (TypeError, ValueError):
2618
+ continue
2619
+ if not nd.is_nullable(r.dtype):
2620
+ continue
2621
+ if r.dtype.wrapped.kind == "f":
2622
+ # computed in floats (`heaviside`, `float_power`, ...):
2623
+ # IEEE decides, as in `test_every_binop_keeps_a_gap`
2624
+ fv = np.full(n, v, dtype=np.float64)
2625
+ nan = np.full(n, np.nan)
2626
+ with np.errstate(all="ignore"):
2627
+ ref = f(fv, nan) if side == "right" else f(nan, fv)
2628
+ expect_na = np.isnan(ref) | (name in _DEPENDS_ON_GAP)
2629
+ if not (nd.isna(r) == expect_na).all():
2630
+ wrong.append((name, v, side, "float rule"))
2631
+ continue
2632
+ determined = name == "power" and (
2633
+ (side == "right" and v == 1) or (side == "left" and v == 0))
2634
+ if determined:
2635
+ if nd.isna(r).any() or not (nd.filled(r, 0) == 1).all():
2636
+ wrong.append((name, v, side, "expected 1"))
2637
+ elif not nd.isna(r).all():
2638
+ wrong.append((name, v, side, "expected NA"))
2639
+ assert not wrong
2640
+
2641
+ x = np.ones(3, dtype=dt)
2642
+ with pytest.warns(RuntimeWarning, match="divide by zero"):
2643
+ np.floor_divide(x, np.zeros(3, dtype=dt))
nulldtype-0.1.0/setup.py DELETED
@@ -1,11 +0,0 @@
1
- """The C extension; everything else is in pyproject.toml."""
2
- import numpy as np
3
- from setuptools import Extension, setup
4
-
5
- setup(ext_modules=[
6
- Extension(
7
- "_nulldtype",
8
- sources=["src/nulldtype.c"],
9
- include_dirs=[np.get_include()],
10
- ),
11
- ])
File without changes
File without changes
File without changes
File without changes