From e265a16c2f723798e90931ebe207f67878c0d4c5 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 3 Nov 2026 11:58:45 -0800 Subject: [PATCH 1/9] Skip the JIT for a UDT field named like a standard C macro A record field named like an object-like macro of the C headers the JIT kernels include, such as INT_MAX, SIZE_MAX, BUFSIZ, EXIT_FAILURE or PRId64, is expanded by the preprocessor inside the struct, so the kernel did not compile. SuiteSparse 9.4 and later raised JitError (with no message) for that op and set jit_c_control to 'load', so the process compiled no more kernels. Main did the same. The comment beside _C_RESERVED said SuiteSparse swallowed the failure silently. _C_RESERVED now also has the object-like macros the C standard defines in , , , , , , and , so such a field skips the JIT: the op runs through the Numba cfunc with a NoJITWarning, as udt.rst says. Macros a platform adds (POSIX errno codes and the like) are not listed and still fail to compile. --- graphblas/core/operator/udt_codegen.py | 57 ++++++++++++++++++++++++-- graphblas/tests/test_ssjit.py | 24 +++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/graphblas/core/operator/udt_codegen.py b/graphblas/core/operator/udt_codegen.py index 41fba757a..68df0ecdc 100644 --- a/graphblas/core/operator/udt_codegen.py +++ b/graphblas/core/operator/udt_codegen.py @@ -163,9 +163,10 @@ def _is_signed_int(np_dtype): # their own namespace), but our gate runs the same check at both # positions for simplicity. # -# Either way the JIT compile fails, SuiteSparse swallows the failure -# silently, and the op runs through the slower Numba cfunc path. Block -# these eagerly so the user sees the "without JIT" warning instead. +# Either way the JIT compile fails. SuiteSparse 9.4 and later then raise +# JitError for that call and set ``jit_c_control`` to 'load', so the process +# compiles no more kernels. Block these eagerly: the op runs through the +# slower Numba cfunc path, and the user sees the "without JIT" warning. _C_RESERVED = frozenset( { # C keywords @@ -280,6 +281,56 @@ def _is_signed_int(np_dtype): # "errno", } +) | frozenset( + # Object-like macros the C standard defines in the other headers of the JIT + # include chain. A platform adds its own (POSIX errno codes, ...), which + # still fail to compile. + [ + # + *("CHAR_BIT", "SCHAR_MIN", "SCHAR_MAX", "UCHAR_MAX", "CHAR_MIN", "CHAR_MAX"), + *("MB_LEN_MAX", "SHRT_MIN", "SHRT_MAX", "USHRT_MAX", "INT_MIN", "INT_MAX"), + *("UINT_MAX", "LONG_MIN", "LONG_MAX", "ULONG_MAX", "LLONG_MIN", "LLONG_MAX"), + "ULLONG_MAX", + # + *( + f"{u}INT{kind}{bits}_{end}" + for kind in ("", "_LEAST", "_FAST") + for bits in (8, 16, 32, 64) + for u, end in (("", "MIN"), ("", "MAX"), ("U", "MAX")) + ), + *("INTPTR_MIN", "INTPTR_MAX", "UINTPTR_MAX", "INTMAX_MIN", "INTMAX_MAX"), + *("UINTMAX_MAX", "PTRDIFF_MIN", "PTRDIFF_MAX", "SIG_ATOMIC_MIN", "SIG_ATOMIC_MAX"), + *("SIZE_MAX", "WCHAR_MIN", "WCHAR_MAX", "WINT_MIN", "WINT_MAX"), + # + *( + f"{prefix}{conv}{size}" + for prefix, convs in (("PRI", "diouxX"), ("SCN", "dioux")) + for conv in convs + for size in ( + *(f"{kind}{bits}" for kind in ("", "LEAST", "FAST") for bits in (8, 16, 32, 64)), + "MAX", + "PTR", + ) + ), + # + *("BUFSIZ", "FILENAME_MAX", "FOPEN_MAX", "L_tmpnam", "SEEK_CUR", "SEEK_END"), + *("SEEK_SET", "TMP_MAX", "_IOFBF", "_IOLBF", "_IONBF"), + # + *("EXIT_FAILURE", "EXIT_SUCCESS", "RAND_MAX", "MB_CUR_MAX"), + # + *("EDOM", "ERANGE", "EILSEQ"), + # + *("FP_INFINITE", "FP_NAN", "FP_NORMAL", "FP_SUBNORMAL", "FP_ZERO", "FP_FAST_FMA"), + *("FP_FAST_FMAF", "FP_FAST_FMAL", "FP_ILOGB0", "FP_ILOGBNAN", "MATH_ERRNO"), + *("MATH_ERREXCEPT", "math_errhandling"), + # + *( + f"ATOMIC_{kind}_LOCK_FREE" + for kind in ("BOOL", "CHAR", "CHAR16_T", "CHAR32_T", "WCHAR_T", "SHORT", "INT") + ), + *("ATOMIC_LONG_LOCK_FREE", "ATOMIC_LLONG_LOCK_FREE", "ATOMIC_POINTER_LOCK_FREE"), + "ATOMIC_FLAG_INIT", + ] ) diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index 3721eee1f..e07d482bd 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -2636,3 +2636,27 @@ def test_udt_jit_c_name_too_long_keeps_the_cfunc(): np.testing.assert_array_equal(got["tl_x"], np.arange(n) / 2) np.testing.assert_array_equal(got["tl_y"], 2 * np.arange(n)) assert same.reduce(monoid.land).new() + + +@pytest.mark.skipif("not supports_udfs") +@pytest.mark.skipif("not _has_jit_set") +def test_udt_field_named_like_a_standard_macro_skips_the_jit(): + """A field named like a macro of the C standard headers the JIT includes skips the JIT. + + The preprocessor expands it inside the struct, so the kernel did not compile: + SuiteSparse raised ``JitError`` (with no message) for that op and set + ``jit_c_control`` to 'load' for the rest of the process. Asked of the C name + only, so nothing compiles here. + """ + from graphblas.exceptions import NoJITWarning + + for i, field in enumerate( + ["INT_MAX", "UINT8_MAX", "SIZE_MAX", "BUFSIZ", "EXIT_FAILURE", "PRId64", "EDOM"] + ): + record = np.dtype([(field, np.float64), (f"macro_other_{i}", np.float64)], align=True) + udt = dtypes.register_anonymous(record) + assert udt.jit_c_name is None, field + v = Vector.from_coo([0], np.array([(1.0, 2.0)], dtype=record), dtype=udt, size=2) + with pytest.warns(NoJITWarning, match="without JIT"): + result = v.ewise_mult(v, binary.plus).new() + assert result[0].new().value.tolist() == (2.0, 4.0), field From 4046c3dc1cd772d8fb1603b92fef4bf1bd7fdd50 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 3 Nov 2026 12:29:38 -0800 Subject: [PATCH 2/9] Give each UDT the C name gbudt_ and its name, which no kernel identifier collides with SuiteSparse's JIT puts a UDT's C name in each kernel beside its own identifiers, and that name was the UDT's Python name. A UDT named like one of them did not compile: SuiteSparse raised JitError (with no message) for that op and set jit_c_control to 'load', so the process compiled no more kernels, although the code comment and udt.rst say such a UDT falls back to the cfunc. Main did the same. Names that failed, each in a fresh process: z and x (parameters of our op definitions), A, B, C, M, p, i, j, k, Ax, Cx, cnz, tid and result (locals of SuiteSparse's templates; which kernels fail depends on the name), div, abs, exp, round, free, index and printf (declared by the C headers), and GB_void and GrB_Info (SuiteSparse's). A list of reserved names cannot cover the template locals. Now the C name of a named UDT is gbudt_ and its name (_pick_c_type_name), so every one of those compiles and computes; a C keyword as a name (class) now takes the JIT too, where it got a synthetic name. A name that is not a C identifier, or would make a C name of 128 characters or more, gets a synthetic _gbudt_NNN name: SuiteSparse refuses such a long name and keeps the type with an empty one, and its first kernel (A.T.new() say) raised JitError, on main too for a UDT name of 128 characters or more. jit_c_name is unreleased (it came with #576, after 2025.2.0), so changing it breaks no release. udt.rst's example and the tests that pin C names say gbudt_. The test of an op whose C name SuiteSparse refuses now uses a 119-character UDT name: its C name (125 characters) fits, and plus_ and that does not. New tests: UDTs named z, A, p, i, div and result take the JIT for ewise_mult, reduce and mxv; UDT names of 121, 122 and 200 characters get C names that fit, and transpose under the JIT. --- docs/user_guide/udt.rst | 32 ++++++----- graphblas/core/dtypes.py | 22 ++++---- graphblas/core/operator/udt_codegen.py | 73 ++++++++++++++------------ graphblas/tests/test_dtype.py | 10 ++-- graphblas/tests/test_op.py | 8 +-- graphblas/tests/test_ssjit.py | 72 +++++++++++++++++++++---- 6 files changed, 142 insertions(+), 75 deletions(-) diff --git a/docs/user_guide/udt.rst b/docs/user_guide/udt.rst index 96d8e1f91..aa8531305 100644 --- a/docs/user_guide/udt.rst +++ b/docs/user_guide/udt.rst @@ -376,9 +376,9 @@ from Numba's typing pass instead of a 200-line traceback. JIT and introspection --------------------- -When the UDT name and all field names are valid C identifiers (not C reserved -words), and the field types map to C primitives, SuiteSparse JIT-compiles a -dedicated C kernel for each ``op[udt]`` lookup. The kernel is cached on disk +When all field names are valid C identifiers (not C reserved words), and the +field types map to C primitives, SuiteSparse JIT-compiles a dedicated C kernel +for each ``op[udt]`` lookup. The kernel is cached on disk and reused across processes (keyed by content hash, so renaming a UDT doesn't invalidate). JIT lets SuiteSparse inline the kernel into its eWise and reduce templates, eliminating the per-element function-call overhead the Numba @@ -389,11 +389,11 @@ Inspect what SuiteSparse is JIT-ing from Python: .. code-block:: python - udt.jit_c_name # 'Edge', or None if not JIT-able - udt.jit_c_definition # 'typedef struct { ... } Edge ;' + udt.jit_c_name # 'gbudt_Edge', or None if not JIT-able + udt.jit_c_definition # 'typedef struct { ... } gbudt_Edge ;' op = binary.plus[udt] - op.jit_c_name # 'plus_Edge' + op.jit_c_name # 'plus_gbudt_Edge' op.jit_c_source # full C source SS will compile monoid.plus[udt].jit_c_source # same kernel @@ -404,9 +404,9 @@ Inspect what SuiteSparse is JIT-ing from Python: JIT is skipped when: -- A field name (or the UDT name, if you supplied one) is a C reserved word - (``class``, ``return``, ...) or a stdlib macro/typedef pulled in by - ``GraphBLAS.h`` (``NULL``, ``FILE``, ``M_PI``, ``complex``, ...). The op +- A field name is a C reserved word (``class``, ``return``, ...), or a macro + or typedef of the C headers the kernels include (``NULL``, ``FILE``, + ``M_PI``, ``complex``, ``INT_MAX``, ``SIZE_MAX``, ``BUFSIZ``, ...). The op falls through to the Numba cfunc path. - A field type isn't in the numpy-to-C map (rare; the standard numeric scalar types all map). @@ -419,10 +419,12 @@ JIT is skipped when: which auto-aligns. - The JIT compiler isn't usable after auto-fix (see below). -Anonymous UDTs (no ``name=`` argument) still take the JIT path: a synthetic -``_gbudt_NNN`` C name is minted so SuiteSparse always has a registerable -identifier. Pass ``name=`` only for readable JIT cache filenames and -introspection. +SuiteSparse knows a UDT by ``gbudt_`` and its name, which cannot collide with +an identifier in the C code of a kernel, whatever the name. A UDT without a +name, or whose name is not a C identifier or would make that C name too long +for SuiteSparse (128 characters or more), gets a synthetic ``_gbudt_NNN`` C +name and still takes the JIT path. Pass ``name=`` only for readable JIT cache +filenames and introspection. When the cause is the UDT itself (the first four cases above), the first time auto-lift produces a non-JIT'd op for a given ``(op, dtype)`` pair in a @@ -439,7 +441,9 @@ category or by message:: warnings.filterwarnings("ignore", message="UDT operator running without JIT") In every skipped case the operation still works correctly via the Numba -function-pointer path; you only lose the JIT speedup. +function-pointer path; you only lose the JIT speedup. A field named like a +macro that only some platform's C headers define (beyond the C standard's) +is not skipped, and its kernel fails to compile; rename the field. JIT compiler auto-fix ~~~~~~~~~~~~~~~~~~~~~ diff --git a/graphblas/core/dtypes.py b/graphblas/core/dtypes.py index 2a85f46a1..9f759a63d 100644 --- a/graphblas/core/dtypes.py +++ b/graphblas/core/dtypes.py @@ -108,14 +108,14 @@ def _deserialize(name, dtype, is_anonymous): def _get_udt_c_type_info(datatype): """Return ``(c_name, c_typedef)`` for a UDT, or ``None`` if not expressible in C. - The returned ``c_name`` may differ from ``datatype.name``: if the user - didn't supply a name, or supplied one that isn't a valid C identifier, - ``_udt_c_typedef`` synthesizes a fresh ``_gbudt_NNN`` name so the UDT - can still take the JIT path. Returns ``None`` only for the cases that - remain unrepresentable in C: an unsupported field type (object, - datetime, string), or a field name that collides with a C reserved - word or isn't an identifier. Supports record UDTs (including nested - records) and array UDTs (e.g., ``FP64[3]``). + The returned ``c_name`` is ``gbudt_`` and ``datatype.name``, or a fresh + ``_gbudt_NNN`` name when the user didn't supply a name, or supplied one + that isn't a valid C identifier or is too long for SuiteSparse + (``udt_codegen._pick_c_type_name``), so the UDT can still take the JIT + path. Returns ``None`` only for the cases that remain unrepresentable in + C: an unsupported field type (object, datetime, string), or a field name + that collides with a C reserved word or isn't an identifier. Supports + record UDTs (including nested records) and array UDTs (e.g., ``FP64[3]``). """ from .operator.udt_codegen import _udt_c_typedef @@ -276,9 +276,9 @@ def register_anonymous(dtype, name=None): string, and nested-UDT fields are not. name : str, optional A human-readable name for this UDT. Used in error messages and - ``repr``. Must be a valid C identifier (and not a C reserved word) - to be usable on the SuiteSparse JIT path; otherwise JIT is skipped - silently and ops fall back to the Numba cfunc path. + ``repr``. Any name takes the SuiteSparse JIT path: its C name is + ``gbudt_`` and the name, or a synthetic one when that is not a C + identifier or is too long. Returns ------- diff --git a/graphblas/core/operator/udt_codegen.py b/graphblas/core/operator/udt_codegen.py index 68df0ecdc..7a1ec5438 100644 --- a/graphblas/core/operator/udt_codegen.py +++ b/graphblas/core/operator/udt_codegen.py @@ -147,21 +147,15 @@ def _is_signed_int(np_dtype): # ``hasattr`` would lie; gate on the backend too. _has_jit_set = backend == "suitesparse" and hasattr(lib, "GxB_JIT_C_NAME") -# Names that can't be used as UDT type names or record field names in the -# JIT C source. Covers two failure modes from the JIT include chain -# (``GraphBLAS.h`` transitively pulls in ````, ````, -# ````, ````, ````, ````): -# -# 1. **Macro names**: the preprocessor expands them inside any declarator -# or expression they appear in, so a field declared ``double M_PI ;`` -# becomes ``double 3.14159... ;`` and won't compile. The C-standard -# separate namespace for struct members doesn't help here. -# 2. **Typedef names** at the *outer* type-name position: emitting -# ``typedef struct { ... } FILE ;`` collides with the existing -# ``FILE`` typedef in scope (most compilers reject as a redefinition). -# Struct member names of the same spelling are fine (members live in -# their own namespace), but our gate runs the same check at both -# positions for simplicity. +# Names that can't be used as record field names in the JIT C source: C +# keywords, and the macros of the JIT include chain (``GraphBLAS.h`` +# transitively pulls in ````, ````, ````, +# ````, ````, ```` and more). The preprocessor +# expands a macro inside any declarator or expression it appears in, so a field +# declared ``double M_PI ;`` becomes ``double 3.14159... ;`` and won't compile; +# the separate namespace for struct members doesn't help. (A UDT's own name +# never collides: its C name has a prefix, ``_C_TYPE_NAME_PREFIX``. The typedef +# names listed here mattered when the type's C name was its Python name.) # # Either way the JIT compile fails. SuiteSparse 9.4 and later then raise # JitError for that call and set ``jit_c_control`` to 'load', so the process @@ -340,8 +334,8 @@ def _is_valid_c_identifier(name): # Process-global counter for synthesizing C names when the user-supplied -# Python name isn't a valid C identifier (or is a C reserved word, or the -# UDT was registered without ``name=`` at all). The Python-side ``DataType.name`` +# Python name doesn't make a valid C identifier of 127 characters or fewer (or +# the UDT was registered without ``name=`` at all). The Python-side ``DataType.name`` # is left alone; this only affects ``GxB_JIT_C_NAME``. ``itertools.count`` is # atomic enough for our purposes; collisions across processes are harmless # because the SS JIT cache files are keyed by ``(c_name, content_hash)`` per @@ -355,19 +349,33 @@ def _is_valid_c_identifier(name): _jit_c_type_defs = {} +# The C name of a UDT is its name after this prefix. SuiteSparse's JIT puts the type's name in +# a kernel beside its own identifiers, so a UDT named like one of them did not compile: a +# local of its templates (``A``, ``C``, ``p``, ``i``, ``result``), a parameter of the op +# definitions made here (``z``, ``x``), or anything the C headers declare (``div``, +# ``index``, ``GB_void``). No identifier there starts with the prefix. +_C_TYPE_NAME_PREFIX = "gbudt_" + + def _pick_c_type_name(python_name): - """Return a valid C identifier for use as ``GxB_JIT_C_NAME`` on a UDT. - - When ``python_name`` is already a valid C identifier (and not reserved), - return it unchanged so introspection and JIT cache filenames remain - readable. Otherwise mint a fresh ``_gbudt_NNN`` name. This lets UDTs - that were anonymous in the Python sense (no ``name=`` passed, name with - dots / special chars, name that collides with a C keyword) still take - the JIT path; users never see the synthetic name unless they read - ``DataType.jit_c_name``. + """Return a valid C identifier for use as ``GxB_JIT_C_NAME`` on a UDT named ``python_name``. + + ``gbudt_`` and the name (see ``_C_TYPE_NAME_PREFIX``), which stays readable in + introspection and JIT cache file names. A name made here (``gbudt_...``, or a synthetic + ``_gbudt_NNN`` or ``_gbnest_NNN``) is kept as it is, so a pinned C name maps to itself. + When the result is not a C identifier (a name with dots, or none given), or would not fit + SuiteSparse's ``GxB_MAX_NAME_LEN`` (it refuses a name of 128 characters or more, and the + type is left with an empty one), mint a fresh ``_gbudt_NNN`` name instead, so the UDT + still takes the JIT path. """ - if _is_valid_c_identifier(python_name): - return python_name + if not isinstance(python_name, str): + c_name = "" + elif python_name.startswith((_C_TYPE_NAME_PREFIX, "_gbudt_", "_gbnest_")): + c_name = python_name + else: + c_name = _C_TYPE_NAME_PREFIX + python_name + if c_name.isidentifier() and len(c_name) < lib.GxB_MAX_NAME_LEN: + return c_name return f"_gbudt_{next(_synthetic_udt_counter)}" @@ -375,9 +383,8 @@ def _udt_c_typedef(python_name, np_type): """Build the JIT C typedef for a record or array UDT, or return ``None``. Returns ``(type_name, typedef)`` on success, where ``type_name`` is the - C identifier we register with SuiteSparse (may differ from - ``python_name`` if that wasn't a valid C identifier; see - ``_pick_c_type_name``). Returns ``None`` when the UDT still can't be + C identifier we register with SuiteSparse (``gbudt_`` and ``python_name``, or + a synthetic name; see ``_pick_c_type_name``). Returns ``None`` when the UDT still can't be expressed in C after synthesizing the top-level name (a field name collides with a C reserved word, or a leaf field type isn't in ``NP_TO_C_TYPES``, e.g. object or datetime). @@ -1425,8 +1432,8 @@ def _make_jit_c_definition(op_name, py_op, dtype, arity): # propagate to SS. If the op's signature referenced ``dtype.name`` it would # use an undefined struct name, and SS would silently fall back to the # Numba cfunc instead of JIT-compiling. ``jit_c_name`` is also where the - # synthetic ``_gbudt_NNN`` name (for UDTs whose Python name isn't a valid - # C identifier) is recorded; using it keeps op codegen consistent. + # ``gbudt_`` or synthetic ``_gbudt_NNN`` name (see ``_pick_c_type_name``) is + # recorded; using it keeps op codegen consistent. pinned_name = dtype.jit_c_name typedef_info = _udt_c_typedef(pinned_name or dtype.name, dtype.np_type) if typedef_info is None: diff --git a/graphblas/tests/test_dtype.py b/graphblas/tests/test_dtype.py index e2df81017..a7edc6d7b 100644 --- a/graphblas/tests/test_dtype.py +++ b/graphblas/tests/test_dtype.py @@ -565,13 +565,13 @@ def test_udt_jit_c_info_pinned_at_first_register(): # stable. record_dtype = np.dtype([("pin_a", np.int64), ("pin_b", np.int64)], align=True) udt = dtypes.register_anonymous(record_dtype, "_PinUDT_first") - assert udt.jit_c_name == "_PinUDT_first" + assert udt.jit_c_name == "gbudt__PinUDT_first" udt2 = dtypes.register_anonymous(record_dtype, "_PinUDT_renamed") assert udt2 is udt assert udt.name == "_PinUDT_renamed" # SS-side name is frozen at first register; introspection must reflect that. - assert udt.jit_c_name == "_PinUDT_first" + assert udt.jit_c_name == "gbudt__PinUDT_first" assert "_PinUDT_first" in udt.jit_c_definition @@ -587,12 +587,14 @@ def test_udt_layouts_under_one_name_get_their_own_jit_c_names(): """ small = dtypes.register_anonymous(np.dtype((np.int16, (1, 3, 1))), "_SameJitName") big = dtypes.register_anonymous(np.dtype((np.int16, (2, 1, 7))), "_SameJitName") - assert small.jit_c_name == "_SameJitName" + assert small.jit_c_name == "gbudt__SameJitName" assert big.jit_c_name != small.jit_c_name assert big.jit_c_name in big.jit_c_definition # Nor may a typedef registered in C take a name a UDT already has. with pytest.raises(ValueError, match="unavailable"): - dtypes.ss.register_new("_SameJitName", "typedef struct { int8_t q ; } _SameJitName ;") + dtypes.ss.register_new( + small.jit_c_name, f"typedef struct {{ int8_t q ; }} {small.jit_c_name} ;" + ) # The rest compiles kernels for both layouts, which needs a C compiler the JIT can use. # Asked under 'on': a 'run' the session set (GRAPHBLAS_JIT_C_CONTROL) would answer no. from graphblas.core.ss.jit_config import _enable_jit_for_udt diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index abb2bfd48..64bc1880c 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -2387,7 +2387,7 @@ def test_udt_jit_op_definitions(): typed = getattr(binary, op_name)[udt] lib.GrB_BinaryOp_get_String(typed.gb_obj, buf, lib.GxB_JIT_C_DEFINITION) defn = ffi.string(buf).decode() - assert f"{op_name}_JitOpTest" in defn + assert f"{op_name}_gbudt_JitOpTest" in defn assert "jp" in defn assert "jq" in defn assert expected_c_op in defn @@ -2396,7 +2396,7 @@ def test_udt_jit_op_definitions(): typed = unary.ainv[udt] lib.GrB_UnaryOp_get_String(typed.gb_obj, buf, lib.GxB_JIT_C_DEFINITION) defn = ffi.string(buf).decode() - assert "ainv_JitOpTest" in defn + assert "ainv_gbudt_JitOpTest" in defn assert "jp" in defn # Array UDT JIT definitions, of a length no other test uses (see test_udt_jit_typedef) @@ -2405,14 +2405,14 @@ def test_udt_jit_op_definitions(): typed = binary.plus[arr_udt] lib.GrB_BinaryOp_get_String(typed.gb_obj, buf, lib.GxB_JIT_C_DEFINITION) defn = ffi.string(buf).decode() - assert "plus_Vec37Jit" in defn + assert "plus_gbudt_Vec37Jit" in defn assert "z->v[i] = (x->v[i]) + (y->v[i])" in defn assert "i < 37" in defn typed = unary.ainv[arr_udt] lib.GrB_UnaryOp_get_String(typed.gb_obj, buf, lib.GxB_JIT_C_DEFINITION) defn = ffi.string(buf).decode() - assert "ainv_Vec37Jit" in defn + assert "ainv_gbudt_Vec37Jit" in defn @pytest.mark.skipif("not supports_udfs") diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index e07d482bd..c9070a013 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -1327,7 +1327,7 @@ def test_udt_jit_c_source_introspection(): udt = dtypes.register_anonymous(record_dtype, "_IntrospectUDT") # dtype-level introspection - assert udt.jit_c_name == "_IntrospectUDT" + assert udt.jit_c_name == "gbudt__IntrospectUDT" assert "typedef struct" in udt.jit_c_definition assert "int64_t introsp_a" in udt.jit_c_definition assert "double introsp_b" in udt.jit_c_definition @@ -1338,7 +1338,7 @@ def test_udt_jit_c_source_introspection(): # Auto-lifted record UDT binary op plus_udt = binary.plus[udt] - assert plus_udt.jit_c_name == "plus__IntrospectUDT" + assert plus_udt.jit_c_name == "plus_gbudt__IntrospectUDT" src = plus_udt.jit_c_source assert src is not None assert "z->introsp_a = (x->introsp_a) + (y->introsp_a)" in src @@ -1346,7 +1346,7 @@ def test_udt_jit_c_source_introspection(): # Auto-lifted record UDT unary op ainv_udt = unary.ainv[udt] - assert ainv_udt.jit_c_name == "ainv__IntrospectUDT" + assert ainv_udt.jit_c_name == "ainv_gbudt__IntrospectUDT" assert "z->introsp_a = -(x->introsp_a)" in ainv_udt.jit_c_source # Array UDT auto-lifted op. Use a shape no other test uses: ``np.dtype`` @@ -1392,12 +1392,12 @@ def test_op_jit_signature_uses_pinned_type_name(): udt2 = dtypes.register_anonymous(record_dtype, "_PinnedRenamed") assert udt2 is udt assert udt.name == "_PinnedRenamed" - assert udt.jit_c_name == "_PinnedOrig" + assert udt.jit_c_name == "gbudt__PinnedOrig" op = binary.plus[udt] # Op signature must reference the pinned struct name, not the renamed one. - assert op.jit_c_name == "plus__PinnedOrig" - assert "_PinnedOrig *" in op.jit_c_source + assert op.jit_c_name == "plus_gbudt__PinnedOrig" + assert "gbudt__PinnedOrig *" in op.jit_c_source assert "_PinnedRenamed" not in op.jit_c_source @@ -2349,8 +2349,9 @@ def test_udt_scalar_broadcast_jit_routing(): assert binary.eq[record, record].jit_c_source is not None # Mixed arithmetic does, with the scalar read once for every leaf assert binary.plus[record, dtypes.INT64].jit_c_source == ( - "void plus_11__BcastJitUV_int64_t (_BcastJitUV *z, const _BcastJitUV *x, " - "const int64_t *y) { z->bcast_u = (x->bcast_u) + (((double) ((*y)))) ; " + "void plus_17_gbudt__BcastJitUV_int64_t (gbudt__BcastJitUV *z, " + "const gbudt__BcastJitUV *x, const int64_t *y) { " + "z->bcast_u = (x->bcast_u) + (((double) ((*y)))) ; " "z->bcast_v = (x->bcast_v) + (((double) ((*y)))) ; }" ) assert binary.plus[dtypes.INT64, record].jit_c_source is not None @@ -2619,7 +2620,9 @@ def test_udt_jit_c_name_too_long_keeps_the_cfunc(): pytest.skip("requires SuiteSparse:GraphBLAS >= 9") _require_jit_on() rec = np.dtype([("tl_x", np.float64), ("tl_y", np.int64)], align=True) - t = dtypes.register_new("TooLongName" + "t" * 120, rec) + # Its C name, gbudt_ and the name, has 125 characters and fits; plus_ and that does not. + t = dtypes.register_new("TooLongName" + "t" * 108, rec) + assert len(t.jit_c_name) == 125 n = 40 vals = np.zeros(n, t.np_type) vals["tl_x"] = np.arange(n) / 4 @@ -2660,3 +2663,54 @@ def test_udt_field_named_like_a_standard_macro_skips_the_jit(): with pytest.warns(NoJITWarning, match="without JIT"): result = v.ewise_mult(v, binary.plus).new() assert result[0].new().value.tolist() == (2.0, 4.0), field + + +@pytest.mark.skipif("not supports_udfs") +@pytest.mark.skipif("not _has_jit_set") +def test_udt_named_like_an_identifier_in_the_kernel_takes_the_jit(): + """A UDT named like an identifier in a JIT kernel compiles, under its ``gbudt_`` C name. + + SuiteSparse's kernels name the type beside their own identifiers. A UDT named like a + parameter of our op definitions (``z``), a local of its templates (``A``, ``p``, ``i``, + ``result``) or something its headers declare (``div``) failed to compile: an empty + ``JitError``, and 'load' for the rest of the process. + """ + _require_jit_on() + for name in ["z", "A", "p", "i", "div", "result"]: + record = np.dtype([(f"kn_{name}_a", np.float64), (f"kn_{name}_b", np.int64)], align=True) + udt = dtypes.register_anonymous(record, name) + assert udt.jit_c_name == f"gbudt_{name}" + vals = np.array([(1.0, 2), (3.0, 4)], dtype=record) + v = Vector.from_coo([0, 1], vals, dtype=udt) + A = gb.Matrix.from_coo([0, 1], [1, 0], vals, dtype=udt) + with _jit_mode("on"): + plus = v.ewise_mult(v, binary.plus).new() + biggest = v.reduce(monoid.max).new() + product = A.mxv(v, semiring.plus_times).new() + assert gb.ss.config["jit_c_control"] == "on", name + assert plus.to_coo()[1].tolist() == [(2.0, 4), (6.0, 8)], name + assert biggest.value.tolist() == (3.0, 4), name + assert product.to_coo()[1].tolist() == [(3.0, 8), (3.0, 8)], name + + +@pytest.mark.skipif("not supports_udfs") +@pytest.mark.skipif("not _has_jit_set") +def test_udt_long_name_gets_a_c_name_that_fits(): + """A UDT whose ``gbudt_`` C name would reach 128 characters gets a short synthetic one. + + SuiteSparse refuses a type name of 128 characters or more and keeps the type with an + empty one, so its first kernel, a transpose say, raised ``JitError``. + """ + _require_jit_on() + for length in [121, 122, 200]: + record = np.dtype([(f"fit{length}_a", np.float64), (f"fit{length}_b", np.int64)]) + udt = dtypes.register_anonymous(record, "n" * length) + if length == 121: + assert udt.jit_c_name == "gbudt_" + "n" * 121 # 127 characters + else: + assert udt.jit_c_name.startswith("_gbudt_"), length + vals = np.array([(1.0, 2), (3.0, 4)], dtype=record) + A = gb.Matrix.from_coo([0, 1], [1, 0], vals, dtype=udt) + with _jit_mode("on"): + B = A.T.new() + assert B.to_coo()[2].tolist() == [(3.0, 4), (1.0, 2)], length From 93e5e51571211312361a19ae296e609cbc36b746 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 3 Nov 2026 12:30:58 -0800 Subject: [PATCH 3/9] Say in the JIT docs how mixed UDT ops compile and how long the JIT stays on udt.rst said SuiteSparse compiles a kernel for each op[udt]. A lifted op on a UDT and a built-in type, or on two UDT types that pair, compiles too, and these mixed cases keep the Numba cfunc without a warning: an array UDT that broadcasts to another shape, a record with an array field, eq and ne on two types, and an op on one UDT that gives another type (v / v on INT8[3]). The JIT section now says so, with the mixed C name. UDT work raises the default 'run' to 'on' the first time it needs a kernel, for the rest of the process and for every operation, built-in ones included; a mixed op does too now that it has C source. init.rst and udt.rst read as if the raise were per op. The concurrency bullet now says such processes race too. init.rst says python-graphblas checks the C compiler under "on" and falls back to "run". Only a configured "on" is checked: an "on" written later to gb.ss.config is taken as it is, and with a compiler that does not work the first new kernel raises JitError and SuiteSparse lowers the control to "load" (checked with /usr/bin/false as the compiler). It says so, and that gb.ss.fix_jit_config() checks. --- docs/user_guide/init.rst | 17 ++++++++++++----- docs/user_guide/udt.rst | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/user_guide/init.rst b/docs/user_guide/init.rst index 141e1f250..306b596a4 100644 --- a/docs/user_guide/init.rst +++ b/docs/user_guide/init.rst @@ -69,8 +69,9 @@ often several times slower (``Matrix.reduce_rowwise`` with ``lor`` on ``BOOL`` o ``plus`` on ``UINT64`` ran 2.7 to 8 times slower on 10 million entries). The JIT control, ``gb.ss.config["jit_c_control"]``, is ``"run"`` by default: run only kernels already loaded in the process, and compile nothing. UDT operators, and types and operators -registered from C source, raise this default to ``"on"`` when they need a kernel (see -:doc:`udt`), but leave a control you set as it is. To opt in for everything, set the +registered from C source, raise this default to ``"on"`` the first time they need a +kernel, for the rest of the process and for every operation (see :doc:`udt`), but leave +a control you set as it is. To opt in for everything, set the control to ``"on"`` (compile, load and run): .. code-block:: bash @@ -96,7 +97,11 @@ later to ``gb.ss.config`` is that same default, so to keep UDT work from compili directory and compiler), and falls back to ``"run"``, with no error, where the JIT cannot work: SuiteSparse built without the JIT (as in the PyPI wheels), SuiteSparse 7, no working C compiler (in an activated conda environment, ``conda install c-compiler``), or a cache -directory it cannot write to. On Windows, SuiteSparse built with MSVC (as conda-forge +directory it cannot write to. That check is for a configured ``"on"`` only: an ``"on"`` +written later to ``gb.ss.config`` is taken as it is, and with a C compiler that does not +work, the first operation that needs a new kernel raises ``JitError``, and SuiteSparse +lowers the control to ``"load"``. ``gb.ss.fix_jit_config()`` repairs the compiler setting +where it can and checks it. On Windows, SuiteSparse built with MSVC (as conda-forge builds it) compiles JIT kernels only through cmake, and python-graphblas does not turn the JIT on there yet, so a configured ``"on"`` becomes ``"run"``. @@ -109,8 +114,10 @@ With the JIT on: (`GraphBLAS issue #449 `_). With multiprocessing, dask, pytest-xdist and the like, set ``GRAPHBLAS_JIT_C_CONTROL=run`` in the environment the workers start in, so that none - of them compiles: UDT work raises only the default ``"run"``, not one you set. That - costs UDTs their compiled kernels: UDT operators call their Numba functions instead + of them compiles: UDT work raises only the default ``"run"``, not one you set. (At the + default, the first UDT operator that needs a kernel turns the JIT on for the whole + process, so such processes race too.) Setting ``"run"`` costs UDTs their compiled + kernels: UDT operators call their Numba functions instead (typically 2 to 3 times slower for elementwise operations), and registering types or operators from C source fails. To keep the kernels, give each process its own cache instead, by setting the ``GRAPHBLAS_CACHE_PATH`` environment variable before graphblas diff --git a/docs/user_guide/udt.rst b/docs/user_guide/udt.rst index aa8531305..60aa6a9d1 100644 --- a/docs/user_guide/udt.rst +++ b/docs/user_guide/udt.rst @@ -378,7 +378,9 @@ JIT and introspection When all field names are valid C identifiers (not C reserved words), and the field types map to C primitives, SuiteSparse JIT-compiles a dedicated C kernel -for each ``op[udt]`` lookup. The kernel is cached on disk +for each ``op[udt]`` lookup. A lifted op on a UDT and a built-in type, or on two +UDT types that pair, compiles too, under a name such as +``plus_7_int64_t_gbudt_Edge``. The kernel is cached on disk and reused across processes (keyed by content hash, so renaming a UDT doesn't invalidate). JIT lets SuiteSparse inline the kernel into its eWise and reduce templates, eliminating the per-element function-call overhead the Numba @@ -418,6 +420,10 @@ JIT is skipped when: Pass ``align=True`` to ``np.dtype``, or use the dict / dataclass form, which auto-aligns. - The JIT compiler isn't usable after auto-fix (see below). +- An op on a UDT and another type is an array UDT that broadcasts to another + shape, a record with an array-valued field, or ``eq`` or ``ne``; or an op on + one UDT gives another type (``v / v`` on ``INT8[3]`` is ``FP64[3]``). These + keep the Numba cfunc without a warning. SuiteSparse knows a UDT by ``gbudt_`` and its name, which cannot collide with an identifier in the C code of a kernel, whatever the name. A UDT without a @@ -465,9 +471,12 @@ later operations compute. ``jit_c_control`` starts at ``graphblas.config["jit_c_control"]``, ``'run'`` by default (run cached kernels only; no compile, no load from disk); see :ref:`suitesparse_jit`. That default ``'run'`` is raised to ``'on'`` (compile, -load, and run) by each of these, since each asks SuiteSparse to compile C source: +load, and run) by each of these, since each asks SuiteSparse to compile C source. +It stays ``'on'`` for the rest of the process, for every operation, built-in ones +included: -- a UDT operator that gets C source, such as the first ``binary.plus[udt]``; +- a UDT operator that gets C source, such as the first ``binary.plus[udt]`` or + ``binary.plus[udt, "INT64"]``; - ``gb.dtypes.ss.register_new``, which registers a type from a C typedef; - ``gb.binary.ss.register_new`` and its ``unary``, ``indexunary``, ``select``, and ``indexbinary`` siblings, which register an operator from a C definition. From cc93ad631f9c5262ba0c75fe084787126a4060ea Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 3 Nov 2026 14:58:44 -0800 Subject: [PATCH 4/9] Keep a name starting with _gb as a UDT's C name, unprefixed python-graphblas names the types it makes itself with _gb (the synthetic _gbudt_NNN and _gbnest_NNN, and the tensor engine's _gbtensor_fp64xR), and no kernel identifier starts with it. Only _gbudt_ and _gbnest_ were kept as they are, so the tensor engine's type became gbudt__gbtensor_fp64x16, which its test of the name it claims caught. Keep every _gb name. --- graphblas/core/operator/udt_codegen.py | 7 ++++--- graphblas/tests/test_ssjit.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/graphblas/core/operator/udt_codegen.py b/graphblas/core/operator/udt_codegen.py index 7a1ec5438..4698c7d9a 100644 --- a/graphblas/core/operator/udt_codegen.py +++ b/graphblas/core/operator/udt_codegen.py @@ -361,8 +361,9 @@ def _pick_c_type_name(python_name): """Return a valid C identifier for use as ``GxB_JIT_C_NAME`` on a UDT named ``python_name``. ``gbudt_`` and the name (see ``_C_TYPE_NAME_PREFIX``), which stays readable in - introspection and JIT cache file names. A name made here (``gbudt_...``, or a synthetic - ``_gbudt_NNN`` or ``_gbnest_NNN``) is kept as it is, so a pinned C name maps to itself. + introspection and JIT cache file names. A name made here (``gbudt_...``) or one starting + with ``_gb``, which python-graphblas keeps for the types it makes itself (a synthetic + ``_gbudt_NNN`` or ``_gbnest_NNN``, say), is kept as it is, so a pinned C name maps to itself. When the result is not a C identifier (a name with dots, or none given), or would not fit SuiteSparse's ``GxB_MAX_NAME_LEN`` (it refuses a name of 128 characters or more, and the type is left with an empty one), mint a fresh ``_gbudt_NNN`` name instead, so the UDT @@ -370,7 +371,7 @@ def _pick_c_type_name(python_name): """ if not isinstance(python_name, str): c_name = "" - elif python_name.startswith((_C_TYPE_NAME_PREFIX, "_gbudt_", "_gbnest_")): + elif python_name.startswith((_C_TYPE_NAME_PREFIX, "_gb")): c_name = python_name else: c_name = _C_TYPE_NAME_PREFIX + python_name diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index c9070a013..465c5b28e 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -2714,3 +2714,15 @@ def test_udt_long_name_gets_a_c_name_that_fits(): with _jit_mode("on"): B = A.T.new() assert B.to_coo()[2].tolist() == [(3.0, 4), (1.0, 2)], length + + +def test_udt_c_name_keeps_python_graphblas_own_names(): + # A type python-graphblas makes itself has a name starting with _gb, which no kernel + # identifier starts with either, so it keeps that name as its C name; a user's name gets + # the gbudt_ prefix. + from graphblas.core.operator.udt_codegen import _pick_c_type_name + + assert _pick_c_type_name("Edge") == "gbudt_Edge" + assert _pick_c_type_name("gbudt_Edge") == "gbudt_Edge" + for name in ["_gbudt_7", "_gbnest_3", "_gbinternal_fp64x16"]: + assert _pick_c_type_name(name) == name From 2aa7e7b69d4f8ae638adef6d66c1461f2bbec421 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 3 Nov 2026 14:17:06 -0800 Subject: [PATCH 5/9] Name a C-source type by its own C name in the ops lifted to it The prefix that keeps a numpy UDT's C name clear of kernel identifiers was put on the name of every type an op definition names, and a type registered from C source (dtypes.ss.register_new) has no jit_c_name, so its Python name stood in, and got the prefix too: binary.plus[t] on a type cpt_t defined plus_gbudt_cpt_t on a type gbudt_cpt_t that SuiteSparse does not know. The kernel failed to compile, JitError, and the JIT was 'load' for the rest of the process. Before the prefix it was plus_cpt_t, and right. Now a C-source type's jit_c_name is the name it was registered under (and jit_c_definition its definition), and the op code generators name a type by its jit_c_name as it is (_dtype_c_typedef, is_c_name=True), so only a numpy UDT's Python name goes through _pick_c_type_name. That function no longer needs to map a pinned gbudt_ name to itself, so a user's name gbudt_Foo is now gbudt_gbudt_Foo and cannot collide with one made here (rBj N1). A name starting with _gb, which python-graphblas keeps for the types it makes itself (_gbudt_NNN, _gbnest_NNN, the tensor engine's _gbtensor_...), is still kept as it is. --- graphblas/core/dtypes.py | 3 +- graphblas/core/operator/udt_codegen.py | 47 ++++++++++++++++---------- graphblas/core/ss/dtypes.py | 2 ++ graphblas/tests/test_ssjit.py | 33 ++++++++++++++++-- 4 files changed, 65 insertions(+), 20 deletions(-) diff --git a/graphblas/core/dtypes.py b/graphblas/core/dtypes.py index 9f759a63d..97cbf1f62 100644 --- a/graphblas/core/dtypes.py +++ b/graphblas/core/dtypes.py @@ -159,7 +159,8 @@ def _set_udt_jit_c_definition(datatype): # A name already given to another layout (two UDTs registered under one # name) gets a fresh one: SuiteSparse would share their JIT kernels. while _jit_c_type_defs.setdefault(c_name, typedef) != typedef: - info = _udt_c_typedef(f"_gbudt_{next(_synthetic_udt_counter)}", datatype.np_type) + c_name = f"_gbudt_{next(_synthetic_udt_counter)}" + info = _udt_c_typedef(c_name, datatype.np_type, is_c_name=True) c_name, typedef = info lib.GrB_Type_set_String(datatype._carg, ffi.new("char[]", c_name.encode()), lib.GxB_JIT_C_NAME) lib.GrB_Type_set_String( diff --git a/graphblas/core/operator/udt_codegen.py b/graphblas/core/operator/udt_codegen.py index 4698c7d9a..59ecf05df 100644 --- a/graphblas/core/operator/udt_codegen.py +++ b/graphblas/core/operator/udt_codegen.py @@ -361,17 +361,18 @@ def _pick_c_type_name(python_name): """Return a valid C identifier for use as ``GxB_JIT_C_NAME`` on a UDT named ``python_name``. ``gbudt_`` and the name (see ``_C_TYPE_NAME_PREFIX``), which stays readable in - introspection and JIT cache file names. A name made here (``gbudt_...``) or one starting - with ``_gb``, which python-graphblas keeps for the types it makes itself (a synthetic - ``_gbudt_NNN`` or ``_gbnest_NNN``, say), is kept as it is, so a pinned C name maps to itself. - When the result is not a C identifier (a name with dots, or none given), or would not fit - SuiteSparse's ``GxB_MAX_NAME_LEN`` (it refuses a name of 128 characters or more, and the - type is left with an empty one), mint a fresh ``_gbudt_NNN`` name instead, so the UDT - still takes the JIT path. + introspection and JIT cache file names. A name starting with ``_gb``, which + python-graphblas keeps for the types it makes itself (a synthetic ``_gbudt_NNN`` or + ``_gbnest_NNN``, the tensor engine's ``_gbtensor_...``), and which no kernel identifier + starts with, is kept as it is. When the result is not a C identifier (a name with dots, + or none given), or would not fit SuiteSparse's ``GxB_MAX_NAME_LEN`` (it refuses a name + of 128 characters or more, and the type is left with an empty one), mint a fresh + ``_gbudt_NNN`` name instead, so the UDT still takes the JIT path. A name SuiteSparse + already has for a type goes to ``_udt_c_typedef`` with ``is_c_name`` instead. """ if not isinstance(python_name, str): c_name = "" - elif python_name.startswith((_C_TYPE_NAME_PREFIX, "_gb")): + elif python_name.startswith("_gb"): c_name = python_name else: c_name = _C_TYPE_NAME_PREFIX + python_name @@ -380,12 +381,14 @@ def _pick_c_type_name(python_name): return f"_gbudt_{next(_synthetic_udt_counter)}" -def _udt_c_typedef(python_name, np_type): +def _udt_c_typedef(python_name, np_type, *, is_c_name=False): """Build the JIT C typedef for a record or array UDT, or return ``None``. Returns ``(type_name, typedef)`` on success, where ``type_name`` is the C identifier we register with SuiteSparse (``gbudt_`` and ``python_name``, or - a synthetic name; see ``_pick_c_type_name``). Returns ``None`` when the UDT still can't be + a synthetic name; see ``_pick_c_type_name``). With ``is_c_name``, + ``python_name`` is already that C name: one SuiteSparse has for the type, or a + synthetic one made here. Returns ``None`` when the UDT still can't be expressed in C after synthesizing the top-level name (a field name collides with a C reserved word, or a leaf field type isn't in ``NP_TO_C_TYPES``, e.g. object or datetime). @@ -404,7 +407,7 @@ def _udt_c_typedef(python_name, np_type): # than register a broken typedef; the cfunc path is still correct. if np_type.names is not None and not _is_c_compatible_layout(np_type): return None - type_name = _pick_c_type_name(python_name) + type_name = python_name if is_c_name else _pick_c_type_name(python_name) if np_type.names is not None: # Inner typedefs (for nested structured fields) are prepended to the # final definition string so the outer struct's field types are in @@ -425,7 +428,7 @@ def _udt_c_typedef(python_name, np_type): # The inner C name is synthesized to avoid collisions with # user-supplied type names elsewhere in the process. inner_name = f"_gbnest_{next(_synthetic_udt_counter)}" - inner_info = _udt_c_typedef(inner_name, field_dtype) + inner_info = _udt_c_typedef(inner_name, field_dtype, is_c_name=True) if inner_info is None: return None inner_resolved_name, inner_typedef = inner_info @@ -1435,8 +1438,7 @@ def _make_jit_c_definition(op_name, py_op, dtype, arity): # Numba cfunc instead of JIT-compiling. ``jit_c_name`` is also where the # ``gbudt_`` or synthetic ``_gbudt_NNN`` name (see ``_pick_c_type_name``) is # recorded; using it keeps op codegen consistent. - pinned_name = dtype.jit_c_name - typedef_info = _udt_c_typedef(pinned_name or dtype.name, dtype.np_type) + typedef_info = _dtype_c_typedef(dtype) if typedef_info is None: return None type_name, _typedef = typedef_info @@ -1485,10 +1487,22 @@ def _c_type_name(dtype): """ if dtype.np_type.names is None and dtype.np_type.subdtype is None: return NP_TO_C_TYPES.get(dtype.np_type) - typedef_info = _udt_c_typedef(dtype.jit_c_name or dtype.name, dtype.np_type) + typedef_info = _dtype_c_typedef(dtype) return None if typedef_info is None else typedef_info[0] +def _dtype_c_typedef(dtype): + """``_udt_c_typedef`` for UDT ``dtype`` under the C name SuiteSparse has for it. + + That is its ``jit_c_name``: pinned when a UDT from numpy registers, or the name of a + type registered from C source. A UDT with none (not expressible in C) gets the name + ``_pick_c_type_name`` would give it. + """ + if (c_name := dtype.jit_c_name) is not None: + return _udt_c_typedef(c_name, dtype.np_type, is_c_name=True) + return _udt_c_typedef(dtype.name, dtype.np_type) + + def _c_operand(side, path, stored, computed): """C source reading operand ``side`` (``*x``, or the element at ``path``) in the type the op computes in. A conversion in C (integer to floating, a real to complex, a narrower @@ -1601,10 +1615,9 @@ def _make_jit_c_comparison_definition(op_name, dtype, *, is_eq): semantic comparison. """ np_type = dtype.np_type - pinned_name = dtype.jit_c_name # ``_udt_c_typedef`` returns None when the numpy layout doesn't match a # C compiler's layout for the same struct. - typedef_info = _udt_c_typedef(pinned_name or dtype.name, np_type) + typedef_info = _dtype_c_typedef(dtype) if typedef_info is None: return None type_name, _typedef = typedef_info diff --git a/graphblas/core/ss/dtypes.py b/graphblas/core/ss/dtypes.py index 0ccdd8264..54d3fb812 100644 --- a/graphblas/core/ss/dtypes.py +++ b/graphblas/core/ss/dtypes.py @@ -104,4 +104,6 @@ def register_new(name, jit_c_definition, *, np_type=None): # array of bool, say) leaves the name free to register again. The type it # made is never used, so no JIT kernel knows its name. _jit_c_type_defs[name] = jit_c_definition + # Its C name and definition are the user's, which ops lifted to it name it by. + rv._jit_c_info = (name, jit_c_definition) return rv diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index 465c5b28e..218845a6b 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -2719,10 +2719,39 @@ def test_udt_long_name_gets_a_c_name_that_fits(): def test_udt_c_name_keeps_python_graphblas_own_names(): # A type python-graphblas makes itself has a name starting with _gb, which no kernel # identifier starts with either, so it keeps that name as its C name; a user's name gets - # the gbudt_ prefix. + # the gbudt_ prefix, even one that starts with it. from graphblas.core.operator.udt_codegen import _pick_c_type_name assert _pick_c_type_name("Edge") == "gbudt_Edge" - assert _pick_c_type_name("gbudt_Edge") == "gbudt_Edge" + assert _pick_c_type_name("gbudt_Edge") == "gbudt_gbudt_Edge" for name in ["_gbudt_7", "_gbnest_3", "_gbinternal_fp64x16"]: assert _pick_c_type_name(name) == name + + +@pytest.mark.skipif("not supports_udfs") +@pytest.mark.skipif("not _has_jit_set") +def test_udt_from_c_source_lifts_ops_under_its_own_c_name(): + """An op lifted to a type registered from C source names the type as its C source does. + + The prefix that keeps a numpy UDT's C name clear of kernel identifiers was also put on + a C-source type's name, so ``binary.plus[t]`` defined ``plus_gbudt_cpt_t`` on a type + ``gbudt_cpt_t`` that SuiteSparse does not know: ``JitError``, then 'load'. + """ + _require_jit_on() + np_type = np.dtype([("cs_x", np.float64), ("cs_y", np.float64)], align=True) + t = dtypes.ss.register_new( + "cs_lift_t", "typedef struct { double cs_x ; double cs_y ; } cs_lift_t ;", np_type=np_type + ) + assert t.jit_c_name == "cs_lift_t" + assert binary.plus[t].jit_c_name == "plus_cs_lift_t" + assert binary.eq[t].jit_c_name == "eq_cs_lift_t" + v = Vector.from_coo([0, 1], np.array([(1.0, 2.0), (3.0, 4.0)], dtype=np_type), dtype=t) + with _jit_mode("on"): + r = v.ewise_mult(v, binary.plus).new() + same = (v == v).new() + assert gb.ss.config["jit_c_control"] == "on" + assert r.to_coo()[1].tolist() == [(2.0, 4.0), (6.0, 8.0)] + assert same.to_coo()[1].tolist() == [True, True] + # A numpy UDT's own name always gets the prefix, even one that starts with it + udt = dtypes.register_anonymous(np.dtype([("cs_pre", np.float64)]), "gbudt_CsPre") + assert udt.jit_c_name == "gbudt_gbudt_CsPre" From d40a4fbab7d89e54ff209e5bbe180d8fe2b06830 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 3 Nov 2026 14:17:54 -0800 Subject: [PATCH 6/9] Give a UDT a synthetic C name when its ops' names would not fit A numpy UDT kept the C name gbudt_ and its name while that was under SuiteSparse's 128 characters. But its one-type ops are named {op}_{type}, so a name of 113 to 121 characters left a C name its ops did not fit beside: SuiteSparse refused floordiv_ or plus_ and it, the op kept its cfunc, and the NoJITWarning said the UDT was not expressible as a C struct, blaming its fields. Now the C name may be at most _max_type_c_name_len() characters, which leaves room for the longest prefix of an op built from it (GxB_MAX_NAME_LEN - 1 - len("floordiv_") = 118, from the lifted ops, eq and ne), and a longer name gets a synthetic _gbudt_NNN, so every op on the UDT keeps its JIT. Mixed ops shorten their own names already. The test of a refused op name now registers a type from C source, whose 125-character name is the user's, so plus_ and eq_ and it still do not fit. The long-name test checks names of 112 (C name 118, kept), 113, 119 and 200 characters: floordiv, plus, eq and ainv all have C source, and transpose and plus run under the JIT. --- graphblas/core/operator/udt_codegen.py | 25 +++++++++++++++++----- graphblas/tests/test_ssjit.py | 29 +++++++++++++++++++------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/graphblas/core/operator/udt_codegen.py b/graphblas/core/operator/udt_codegen.py index 59ecf05df..fe0fa2715 100644 --- a/graphblas/core/operator/udt_codegen.py +++ b/graphblas/core/operator/udt_codegen.py @@ -357,6 +357,21 @@ def _is_valid_c_identifier(name): _C_TYPE_NAME_PREFIX = "gbudt_" +# The ops whose C name is ``{op}_{type}`` (``set_jit_c_on_op``, +# ``set_jit_c_comparison_on_op``): the lifted arithmetic, eq and ne. A mixed op +# shortens its own name (``_make_jit_c_mixed_definition``). +_ONE_TYPE_OP_NAMES = (*BUILTIN_UDT_BINARY_OPS, *BUILTIN_UDT_UNARY_OPS, "eq", "ne") + + +def _max_type_c_name_len(): + """The longest C name a UDT may have, so every ``{op}_{type}`` built from it fits. + + SuiteSparse refuses a name of ``GxB_MAX_NAME_LEN`` (128) characters or more; the + longest op prefix is ``floordiv_``, so this is 118. + """ + return lib.GxB_MAX_NAME_LEN - 1 - max(len(f"{name}_") for name in _ONE_TYPE_OP_NAMES) + + def _pick_c_type_name(python_name): """Return a valid C identifier for use as ``GxB_JIT_C_NAME`` on a UDT named ``python_name``. @@ -365,10 +380,10 @@ def _pick_c_type_name(python_name): python-graphblas keeps for the types it makes itself (a synthetic ``_gbudt_NNN`` or ``_gbnest_NNN``, the tensor engine's ``_gbtensor_...``), and which no kernel identifier starts with, is kept as it is. When the result is not a C identifier (a name with dots, - or none given), or would not fit SuiteSparse's ``GxB_MAX_NAME_LEN`` (it refuses a name - of 128 characters or more, and the type is left with an empty one), mint a fresh - ``_gbudt_NNN`` name instead, so the UDT still takes the JIT path. A name SuiteSparse - already has for a type goes to ``_udt_c_typedef`` with ``is_c_name`` instead. + or none given), or is too long for each op built from it to fit SuiteSparse's + ``GxB_MAX_NAME_LEN`` (``_max_type_c_name_len``), mint a fresh ``_gbudt_NNN`` name + instead, so the UDT and its ops still take the JIT path. A name SuiteSparse already has + for a type goes to ``_udt_c_typedef`` with ``is_c_name`` instead. """ if not isinstance(python_name, str): c_name = "" @@ -376,7 +391,7 @@ def _pick_c_type_name(python_name): c_name = python_name else: c_name = _C_TYPE_NAME_PREFIX + python_name - if c_name.isidentifier() and len(c_name) < lib.GxB_MAX_NAME_LEN: + if c_name.isidentifier() and len(c_name) <= _max_type_c_name_len(): return c_name return f"_gbudt_{next(_synthetic_udt_counter)}" diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index 218845a6b..29401cf9d 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -2620,8 +2620,12 @@ def test_udt_jit_c_name_too_long_keeps_the_cfunc(): pytest.skip("requires SuiteSparse:GraphBLAS >= 9") _require_jit_on() rec = np.dtype([("tl_x", np.float64), ("tl_y", np.int64)], align=True) - # Its C name, gbudt_ and the name, has 125 characters and fits; plus_ and that does not. - t = dtypes.register_new("TooLongName" + "t" * 108, rec) + # A numpy UDT's C name leaves room for its ops' names, but one registered from C + # source keeps the name given: 125 characters fits, plus_ or eq_ and that does not. + name = "TooLongName" + "t" * 114 + t = dtypes.ss.register_new( + name, f"typedef struct {{ double tl_x ; int64_t tl_y ; }} {name} ;", np_type=rec + ) assert len(t.jit_c_name) == 125 n = 40 vals = np.zeros(n, t.np_type) @@ -2696,24 +2700,33 @@ def test_udt_named_like_an_identifier_in_the_kernel_takes_the_jit(): @pytest.mark.skipif("not supports_udfs") @pytest.mark.skipif("not _has_jit_set") def test_udt_long_name_gets_a_c_name_that_fits(): - """A UDT whose ``gbudt_`` C name would reach 128 characters gets a short synthetic one. + """A UDT whose ``gbudt_`` C name would not leave room for its ops' names gets a synthetic one. - SuiteSparse refuses a type name of 128 characters or more and keeps the type with an - empty one, so its first kernel, a transpose say, raised ``JitError``. + SuiteSparse refuses a name of 128 characters or more. A type with such a name was kept + with an empty one, so its first kernel, a transpose say, raised ``JitError``; and with a + C name of 119 to 127 characters, ``floordiv_`` or ``plus_`` and it did not fit, so the + op lost its JIT with a NoJITWarning that blamed the fields. """ + from graphblas.core.operator.udt_codegen import _max_type_c_name_len + _require_jit_on() - for length in [121, 122, 200]: + assert _max_type_c_name_len() == 118 # 127 less floordiv_ + for length in [112, 113, 119, 200]: record = np.dtype([(f"fit{length}_a", np.float64), (f"fit{length}_b", np.int64)]) udt = dtypes.register_anonymous(record, "n" * length) - if length == 121: - assert udt.jit_c_name == "gbudt_" + "n" * 121 # 127 characters + if length == 112: + assert udt.jit_c_name == "gbudt_" + "n" * 112 # 118 characters else: assert udt.jit_c_name.startswith("_gbudt_"), length + for op in [binary.floordiv[udt], binary.plus[udt], binary.eq[udt], unary.ainv[udt]]: + assert op.jit_c_source is not None, (length, op) vals = np.array([(1.0, 2), (3.0, 4)], dtype=record) A = gb.Matrix.from_coo([0, 1], [1, 0], vals, dtype=udt) with _jit_mode("on"): B = A.T.new() + C = A.ewise_mult(A, binary.plus).new() assert B.to_coo()[2].tolist() == [(3.0, 4), (1.0, 2)], length + assert C.to_coo()[2].tolist() == [(2.0, 4), (6.0, 8)], length def test_udt_c_name_keeps_python_graphblas_own_names(): From a7053ab86b402ea115f9c81995052e188f6dfb86 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 3 Nov 2026 14:18:18 -0800 Subject: [PATCH 7/9] Say that C source names a numpy UDT by its jit_c_name A UDT from numpy is known to SuiteSparse as gbudt_ and its name, so C source passed to gb.binary.ss.register_new and its siblings must name it so: written with its Python name, the operator failed to compile, raised JitError and left the JIT at 'load' for the rest of the process (rBj L1). The five register_new docstrings and udt.rst now say to use jit_c_name, and that a type registered from C source keeps its name. udt.rst also gives the new threshold for a synthetic C name (over 118 characters) and the exception for a name starting with _gb, which is kept as it is. --- docs/user_guide/udt.rst | 15 ++++++++++----- graphblas/core/ss/binary.py | 2 ++ graphblas/core/ss/indexbinary.py | 2 ++ graphblas/core/ss/indexunary.py | 2 ++ graphblas/core/ss/select.py | 2 ++ graphblas/core/ss/unary.py | 2 ++ 6 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/user_guide/udt.rst b/docs/user_guide/udt.rst index 60aa6a9d1..940a969bb 100644 --- a/docs/user_guide/udt.rst +++ b/docs/user_guide/udt.rst @@ -426,11 +426,16 @@ JIT is skipped when: keep the Numba cfunc without a warning. SuiteSparse knows a UDT by ``gbudt_`` and its name, which cannot collide with -an identifier in the C code of a kernel, whatever the name. A UDT without a -name, or whose name is not a C identifier or would make that C name too long -for SuiteSparse (128 characters or more), gets a synthetic ``_gbudt_NNN`` C -name and still takes the JIT path. Pass ``name=`` only for readable JIT cache -filenames and introspection. +an identifier in the C code of a kernel, whatever the name. A name starting +with ``_gb``, which python-graphblas keeps for the types it makes itself, is +the exception: it is the C name as it is. A UDT without a name, or whose name +is not a C identifier or would make that C name too long to leave room for its +ops' names (over 118 characters), gets a synthetic ``_gbudt_NNN`` C name and +still takes the JIT path. Pass ``name=`` only for +readable JIT cache filenames and introspection. To name such a UDT in C source +of your own, as for ``gb.binary.ss.register_new``, use its ``jit_c_name``, not +its Python name, which SuiteSparse does not know. A type registered from C +source (``gb.dtypes.ss.register_new``) keeps the name you give it. When the cause is the UDT itself (the first four cases above), the first time auto-lift produces a non-JIT'd op for a given ``(op, dtype)`` pair in a diff --git a/graphblas/core/ss/binary.py b/graphblas/core/ss/binary.py index 547d06ccf..ea344fa57 100644 --- a/graphblas/core/ss/binary.py +++ b/graphblas/core/ss/binary.py @@ -61,6 +61,8 @@ def register_new(name, jit_c_definition, left_type, right_type, ret_type): jit_c_definition : str The C definition as a string of the user-defined function. For example: ``"void absdiff (double *z, double *x, double *y) { (*z) = fabs ((*x) - (*y)) ; }"``. + A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (``gbudt_`` and + its name), not its Python name. left_type : dtype The dtype of the left operand of the binary operator. right_type : dtype diff --git a/graphblas/core/ss/indexbinary.py b/graphblas/core/ss/indexbinary.py index 5aeed9f11..9c86da54d 100644 --- a/graphblas/core/ss/indexbinary.py +++ b/graphblas/core/ss/indexbinary.py @@ -67,6 +67,8 @@ def register_new(name, jit_c_definition, x_type, y_type, theta_type, ret_type): ``"void my_idxbin (double *z, double *x, GrB_Index ix, GrB_Index jx, "`` ``"double *y, GrB_Index iy, GrB_Index jy, double *theta) "`` ``"{ (*z) = (*x) + (*y) + (*theta) ; }"`` + A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (``gbudt_`` and + its name), not its Python name. x_type : dtype The dtype of the first operand (x). y_type : dtype diff --git a/graphblas/core/ss/indexunary.py b/graphblas/core/ss/indexunary.py index a3a462f59..7db6c02d6 100644 --- a/graphblas/core/ss/indexunary.py +++ b/graphblas/core/ss/indexunary.py @@ -60,6 +60,8 @@ def register_new(name, jit_c_definition, input_type, thunk_type, ret_type): The C definition as a string of the user-defined function. For example: ``"void diffy (double *z, double *x, GrB_Index i, GrB_Index j, double *y) "`` ``"{ (*z) = (i + j) * fabs ((*x) - (*y)) ; }"`` + A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (``gbudt_`` and + its name), not its Python name. input_type : dtype The dtype of the operand of the indexunary operator. thunk_type : dtype diff --git a/graphblas/core/ss/select.py b/graphblas/core/ss/select.py index 48d16b740..6f7f43f35 100644 --- a/graphblas/core/ss/select.py +++ b/graphblas/core/ss/select.py @@ -57,6 +57,8 @@ def register_new(name, jit_c_definition, input_type, thunk_type): The C definition as a string of the user-defined function. For example: ``"void woot (bool *z, const int32_t *x, GrB_Index i, GrB_Index j, int32_t *y) "`` ``"{ (*z) = ((*x) + i + j == (*y)) ; }"`` + A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (``gbudt_`` and + its name), not its Python name. input_type : dtype The dtype of the operand of the select operator. thunk_type : dtype diff --git a/graphblas/core/ss/unary.py b/graphblas/core/ss/unary.py index cee7a16a4..b078fa9bd 100644 --- a/graphblas/core/ss/unary.py +++ b/graphblas/core/ss/unary.py @@ -55,6 +55,8 @@ def register_new(name, jit_c_definition, input_type, ret_type): jit_c_definition : str The C definition as a string of the user-defined function. For example: ``"void square (float *z, float *x) { (*z) = (*x) * (*x) ; } ;"`` + A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (``gbudt_`` and + its name), not its Python name. input_type : dtype The dtype of the operand of the unary operator. ret_type : dtype From 2c611543a51cbcd9fc0eff2e3f6ebfc2d881393c Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 3 Nov 2026 15:32:54 -0800 Subject: [PATCH 8/9] Skip the JIT for a field named like a signal macro or with GraphBLAS's prefix A record field named like one of 's nine C-standard macros (SIGINT, SIG_DFL, ...), which the C library headers bring into the kernels on macOS, or like one of GraphBLAS's own macros (GrB_INDEX_MAX, GxB_IMPLEMENTATION_MAJOR) was expanded by the preprocessor inside the struct, so the kernel failed to compile: JitError, then 'load' for the rest of the process (rBj N2). udt.rst said only macros beyond the C standard's still failed. Now _C_RESERVED has the nine, and a field that starts with GB_, GrB_ or GxB_ skips the JIT too: GraphBLAS keeps those prefixes for its own names, and each version adds more of them. The op runs through the Numba cfunc with a NoJITWarning, as for the other macros. --- docs/user_guide/udt.rst | 9 +++++---- graphblas/core/operator/udt_codegen.py | 17 +++++++++++++++-- graphblas/tests/test_ssjit.py | 7 ++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/user_guide/udt.rst b/docs/user_guide/udt.rst index 940a969bb..98df8df2f 100644 --- a/docs/user_guide/udt.rst +++ b/docs/user_guide/udt.rst @@ -406,10 +406,11 @@ Inspect what SuiteSparse is JIT-ing from Python: JIT is skipped when: -- A field name is a C reserved word (``class``, ``return``, ...), or a macro - or typedef of the C headers the kernels include (``NULL``, ``FILE``, - ``M_PI``, ``complex``, ``INT_MAX``, ``SIZE_MAX``, ``BUFSIZ``, ...). The op - falls through to the Numba cfunc path. +- A field name is a C reserved word (``class``, ``return``, ...), a macro or + typedef of the C headers the kernels include (``NULL``, ``FILE``, ``M_PI``, + ``complex``, ``INT_MAX``, ``SIZE_MAX``, ``BUFSIZ``, ``SIGINT``, ...), or + starts with ``GB_``, ``GrB_`` or ``GxB_``, which GraphBLAS keeps for its own + names. The op falls through to the Numba cfunc path. - A field type isn't in the numpy-to-C map (rare; the standard numeric scalar types all map). - A record has an array-valued field. Built-in operators still lift to it diff --git a/graphblas/core/operator/udt_codegen.py b/graphblas/core/operator/udt_codegen.py index fe0fa2715..0ac54404a 100644 --- a/graphblas/core/operator/udt_codegen.py +++ b/graphblas/core/operator/udt_codegen.py @@ -324,13 +324,26 @@ def _is_signed_int(np_dtype): ), *("ATOMIC_LONG_LOCK_FREE", "ATOMIC_LLONG_LOCK_FREE", "ATOMIC_POINTER_LOCK_FREE"), "ATOMIC_FLAG_INIT", + # , which the C library headers bring in on some platforms + *("SIGABRT", "SIGFPE", "SIGILL", "SIGINT", "SIGSEGV", "SIGTERM"), + *("SIG_DFL", "SIG_ERR", "SIG_IGN"), ] ) +# GraphBLAS's own names, many of them macros of ``GraphBLAS.h`` and its JIT headers +# (``GrB_INDEX_MAX``, ``GxB_IMPLEMENTATION_MAJOR``), and more with each version, so a +# field may not start with any of these. +_C_RESERVED_PREFIXES = ("GB_", "GrB_", "GxB_") + def _is_valid_c_identifier(name): - """True if ``name`` is a valid C identifier and not a reserved word.""" - return isinstance(name, str) and name.isidentifier() and name not in _C_RESERVED + """True if ``name`` is a valid C identifier and not a reserved word or GraphBLAS's name.""" + return ( + isinstance(name, str) + and name.isidentifier() + and name not in _C_RESERVED + and not name.startswith(_C_RESERVED_PREFIXES) + ) # Process-global counter for synthesizing C names when the user-supplied diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index 29401cf9d..573d0f236 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -2657,9 +2657,10 @@ def test_udt_field_named_like_a_standard_macro_skips_the_jit(): """ from graphblas.exceptions import NoJITWarning - for i, field in enumerate( - ["INT_MAX", "UINT8_MAX", "SIZE_MAX", "BUFSIZ", "EXIT_FAILURE", "PRId64", "EDOM"] - ): + fields = ["INT_MAX", "UINT8_MAX", "SIZE_MAX", "BUFSIZ", "EXIT_FAILURE", "PRId64", "EDOM"] + # and GraphBLAS's own macros, as GrB_INDEX_MAX, failed the same way + fields += ["SIGINT", "SIG_DFL", "GrB_INDEX_MAX", "GxB_IMPLEMENTATION_MAJOR", "GB_ANY"] + for i, field in enumerate(fields): record = np.dtype([(field, np.float64), (f"macro_other_{i}", np.float64)], align=True) udt = dtypes.register_anonymous(record) assert udt.jit_c_name is None, field From b69c7a905f56d6637d2cdd55640489ad45fb29b4 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 3 Nov 2026 15:38:54 -0800 Subject: [PATCH 9/9] Say how long a C-source type's name may be, and that gbudt_ is usual The five ss.register_new docstrings said a numpy UDT's C name is gbudt_ and its name; a synthetic _gbudt_NNN is the exception, so they now say usually. A type registered from C source keeps the name given, and with 119 to 127 characters the built-in ops lifted to it get C names SuiteSparse refuses, so they lose the JIT, with a NoJITWarning that blames the fields. udt.rst now says to keep such a name to 118 characters (rBj round 2). --- docs/user_guide/udt.rst | 5 ++++- graphblas/core/ss/binary.py | 4 ++-- graphblas/core/ss/indexbinary.py | 4 ++-- graphblas/core/ss/indexunary.py | 4 ++-- graphblas/core/ss/select.py | 4 ++-- graphblas/core/ss/unary.py | 4 ++-- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/user_guide/udt.rst b/docs/user_guide/udt.rst index 98df8df2f..88b77086a 100644 --- a/docs/user_guide/udt.rst +++ b/docs/user_guide/udt.rst @@ -436,7 +436,10 @@ still takes the JIT path. Pass ``name=`` only for readable JIT cache filenames and introspection. To name such a UDT in C source of your own, as for ``gb.binary.ss.register_new``, use its ``jit_c_name``, not its Python name, which SuiteSparse does not know. A type registered from C -source (``gb.dtypes.ss.register_new``) keeps the name you give it. +source (``gb.dtypes.ss.register_new``) keeps the name you give it, so give it +118 characters or fewer: the built-in operators lifted to it are named with +that name after them (``floordiv_``, ``plus_``, ...), and a longer one leaves +them names SuiteSparse refuses, so they run without the JIT. When the cause is the UDT itself (the first four cases above), the first time auto-lift produces a non-JIT'd op for a given ``(op, dtype)`` pair in a diff --git a/graphblas/core/ss/binary.py b/graphblas/core/ss/binary.py index ea344fa57..d940ac342 100644 --- a/graphblas/core/ss/binary.py +++ b/graphblas/core/ss/binary.py @@ -61,8 +61,8 @@ def register_new(name, jit_c_definition, left_type, right_type, ret_type): jit_c_definition : str The C definition as a string of the user-defined function. For example: ``"void absdiff (double *z, double *x, double *y) { (*z) = fabs ((*x) - (*y)) ; }"``. - A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (``gbudt_`` and - its name), not its Python name. + A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (usually ``gbudt_`` + and its name), not its Python name. left_type : dtype The dtype of the left operand of the binary operator. right_type : dtype diff --git a/graphblas/core/ss/indexbinary.py b/graphblas/core/ss/indexbinary.py index 9c86da54d..8fd53a27e 100644 --- a/graphblas/core/ss/indexbinary.py +++ b/graphblas/core/ss/indexbinary.py @@ -67,8 +67,8 @@ def register_new(name, jit_c_definition, x_type, y_type, theta_type, ret_type): ``"void my_idxbin (double *z, double *x, GrB_Index ix, GrB_Index jx, "`` ``"double *y, GrB_Index iy, GrB_Index jy, double *theta) "`` ``"{ (*z) = (*x) + (*y) + (*theta) ; }"`` - A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (``gbudt_`` and - its name), not its Python name. + A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (usually ``gbudt_`` + and its name), not its Python name. x_type : dtype The dtype of the first operand (x). y_type : dtype diff --git a/graphblas/core/ss/indexunary.py b/graphblas/core/ss/indexunary.py index 7db6c02d6..77eafe067 100644 --- a/graphblas/core/ss/indexunary.py +++ b/graphblas/core/ss/indexunary.py @@ -60,8 +60,8 @@ def register_new(name, jit_c_definition, input_type, thunk_type, ret_type): The C definition as a string of the user-defined function. For example: ``"void diffy (double *z, double *x, GrB_Index i, GrB_Index j, double *y) "`` ``"{ (*z) = (i + j) * fabs ((*x) - (*y)) ; }"`` - A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (``gbudt_`` and - its name), not its Python name. + A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (usually ``gbudt_`` + and its name), not its Python name. input_type : dtype The dtype of the operand of the indexunary operator. thunk_type : dtype diff --git a/graphblas/core/ss/select.py b/graphblas/core/ss/select.py index 6f7f43f35..323af9f40 100644 --- a/graphblas/core/ss/select.py +++ b/graphblas/core/ss/select.py @@ -57,8 +57,8 @@ def register_new(name, jit_c_definition, input_type, thunk_type): The C definition as a string of the user-defined function. For example: ``"void woot (bool *z, const int32_t *x, GrB_Index i, GrB_Index j, int32_t *y) "`` ``"{ (*z) = ((*x) + i + j == (*y)) ; }"`` - A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (``gbudt_`` and - its name), not its Python name. + A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (usually ``gbudt_`` + and its name), not its Python name. input_type : dtype The dtype of the operand of the select operator. thunk_type : dtype diff --git a/graphblas/core/ss/unary.py b/graphblas/core/ss/unary.py index b078fa9bd..695dec5c1 100644 --- a/graphblas/core/ss/unary.py +++ b/graphblas/core/ss/unary.py @@ -55,8 +55,8 @@ def register_new(name, jit_c_definition, input_type, ret_type): jit_c_definition : str The C definition as a string of the user-defined function. For example: ``"void square (float *z, float *x) { (*z) = (*x) * (*x) ; } ;"`` - A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (``gbudt_`` and - its name), not its Python name. + A UDT made from a numpy dtype is named in C by its ``jit_c_name`` (usually ``gbudt_`` + and its name), not its Python name. input_type : dtype The dtype of the operand of the unary operator. ret_type : dtype