Skip to content

Pass and receive numpy views in array-UDT UDFs - #589

Open
eriknw wants to merge 2 commits into
08-numba-numpy-error-modelfrom
09-array-udt-udf-views
Open

eriknw wants to merge 2 commits into
08-numba-numpy-error-modelfrom
09-array-udt-udf-views

Conversation

@eriknw

@eriknw eriknw commented Aug 4, 2026

Copy link
Copy Markdown
Member

A UDF over an array UDT wrote past the end of its output element. The cfunc
wrapper passed the raw element pointer and stored the return value with
z_ptr[0] = ..., so what landed in the buffer was Numba's NestedArray
descriptor (data pointer, shape, strides) rather than the element payload.
The descriptor is the wider of the two, so the store ran off the end of the
element into memory SuiteSparse owns. Measured against the code this
replaces, driving the wrapper's cfunc through ctypes over a guarded buffer:

(6,)    np itemsize 48 | numba value type size 56 | overrun 8 bytes
(2, 3)  np itemsize 48 | numba value type size 72 | overrun 24 bytes

z[:6] = [100.0, 101.0, 102.0, 103.0, 104.0, 105.0]
z[6:] = [5e-324, -1.0, -1.0, ...]       # -1.0 is the guard byte pattern
indices written past the element: [6]

Both operands and the output are now bound with numba.carray(ptr, shape)
in the UDT's declared shape, so the UDF receives a numpy view it can index
(x[i, j], x.shape) and the wrapper slice-assigns the result back with
z[:] = .... Array-typed record leaves slice-assign for a second reason:
Numba's record-field setitem copies the destination's extent regardless of
the source's, so a short source was read past its end.

The return-type resolution has to move with the wrapper. Once the UDF
receives a view, a UDF that builds its result (x + y) instead of returning
an operand types as a plain Numba Array, which lookup_dtype does not
recognize; _resolve_udt_return_type gains an Array branch matching it
back to an input array UDT by base element type and rank. Splitting the two
apart would leave array-UDT UDFs that build a result broken outright.

An Array type carries ndim but not its extents, so a return whose shape
does not fit the UDT is not a type error. It is now memory-safe, but still
silent: the slice-assign's ValueError is raised inside a cfunc, which Numba
prints and swallows, leaving the element as SuiteSparse found it.


Stack created with GitHub Stacks CLIGive Feedback 💬

@eriknw
eriknw marked this pull request as ready for review August 4, 2026 16:07
@eriknw
eriknw force-pushed the 09-array-udt-udf-views branch from 660faec to 8164778 Compare August 4, 2026 16:12
@eriknw
eriknw force-pushed the 09-array-udt-udf-views branch from 8164778 to 8960e9b Compare August 5, 2026 00:06
@eriknw
eriknw force-pushed the 09-array-udt-udf-views branch from 8960e9b to 3a7d0be Compare August 5, 2026 03:18
@eriknw
eriknw force-pushed the 09-array-udt-udf-views branch 2 times, most recently from af88351 to e3b42d6 Compare August 5, 2026 18:03
@eriknw
eriknw force-pushed the 09-array-udt-udf-views branch from e3b42d6 to e16fb44 Compare August 5, 2026 18:05
@eriknw
eriknw force-pushed the 09-array-udt-udf-views branch from e16fb44 to 4ba7ef5 Compare August 6, 2026 07:59
@eriknw
eriknw force-pushed the 09-array-udt-udf-views branch from 4ba7ef5 to 0a2ca8a Compare August 6, 2026 15:39
@eriknw
eriknw force-pushed the 09-array-udt-udf-views branch 2 times, most recently from 4bce522 to 9a39af4 Compare August 6, 2026 20:36
@eriknw
eriknw force-pushed the 09-array-udt-udf-views branch from 9a39af4 to 9cfc310 Compare August 6, 2026 20:41
@eriknw
eriknw force-pushed the 09-array-udt-udf-views branch from 9cfc310 to 6c21f4a Compare August 7, 2026 02:48
@eriknw
eriknw force-pushed the 09-array-udt-udf-views branch from 6c21f4a to fa7b6bc Compare August 7, 2026 05:09
eriknw added 2 commits August 26, 2026 12:29
A UDF over an array UDT wrote past the end of its output element. The cfunc
wrapper passed the raw element pointer and stored the return value with
`z_ptr[0] = ...`, so what landed in the buffer was Numba's NestedArray
descriptor (data pointer, shape, strides) rather than the element payload.
The descriptor is the wider of the two, so the store ran off the end of the
element into memory SuiteSparse owns. Measured against the code this
replaces, driving the wrapper's cfunc through ctypes over a guarded buffer:

    (6,)    np itemsize 48 | numba value type size 56 | overrun 8 bytes
    (2, 3)  np itemsize 48 | numba value type size 72 | overrun 24 bytes

    z[:6] = [100.0, 101.0, 102.0, 103.0, 104.0, 105.0]
    z[6:] = [5e-324, -1.0, -1.0, ...]       # -1.0 is the guard byte pattern
    indices written past the element: [6]

Both operands and the output are now bound with `numba.carray(ptr, shape)`
in the UDT's declared shape, so the UDF receives a numpy view it can index
(`x[i, j]`, `x.shape`) and the wrapper slice-assigns the result back with
`z[:] = ...`. Array-typed record leaves slice-assign for a second reason:
Numba's record-field setitem copies the destination's extent regardless of
the source's, so a short source was read past its end.

The return-type resolution has to move with the wrapper. Once the UDF
receives a view, a UDF that builds its result (`x + y`) instead of returning
an operand types as a plain Numba `Array`, which `lookup_dtype` does not
recognize; `_resolve_udt_return_type` gains an `Array` branch matching it
back to an input array UDT by base element type and rank. Splitting the two
apart would leave array-UDT UDFs that build a result broken outright.

An `Array` type carries `ndim` but not its extents, so a return whose shape
does not fit the UDT is not a type error. It is now memory-safe, but still
silent: the slice-assign's ValueError is raised inside a cfunc, which Numba
prints and swallows, leaving the element as SuiteSparse found it.
Binary ops outside _BUILTIN_UDT_BINARY_OPS (any, first, second) compile
through the generic _numba_func branch of BinaryOp._compile_udt. The old
wrapper there loaded and stored an array-UDT operand as a NestedArray
value, which Numba models as its full array descriptor (meminfo, parent,
nitems, itemsize, data, shape, strides): 56 bytes on 64-bit for a 1-D
element, regardless of the 32-byte payload. SuiteSparse's generic reduce
keeps a UDT accumulator in a stack array sized to the element, so each
fold overflowed it by 24 bytes and clobbered a spilled GrB_Type pointer:
segfault on some builds, SIGBUS or a silently wrong answer on others.
The carray-based wrapper rework on this branch writes exactly itemsize
bytes; this test keeps it that way.

The test builds the wrapper for binary.any the same way _compile_udt
does, compiles it with numba.cfunc, and calls it via ctypes on heap
buffers with slack, so a regression trips an assert instead of
corrupting a stack frame. The z guard sentinel (0xAB) must differ from
the sentinel in y's trailing slack (0xCD): the descriptor load/store is
a byte-preserving copy of the source element plus its trailing bytes, so
with one shared sentinel the overflow would rewrite z's guard with
identical values and go undetected. A public-path any-reduce smoke runs
after the byte-level checks, so a regression fails the assert before
reaching the code that can crash the process.

Verified both directions: passes here (host numba 0.65.1 and a conda env
with numba 0.66 plus python-suitesparse-graphblas 10.0.1.1), and on an
export of main the assert trips with exactly 24 overflow bytes while the
payload check still passes, demonstrating the blind spot a same-sentinel
check would have.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant