From 6e2890f6f4a2f2bf746f9b2ff6f17a9a430e1d42 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 12:54:25 -0500 Subject: [PATCH 1/3] Add a test fixture that pins the UDT operator execution path Each auto-lifted UDT operator carries both a JIT C definition and a Numba cfunc, and SuiteSparse chooses between them per call depending on whether a C compiler is available. A result measured on one path is not evidence about the other, so a machine with a compiler and a machine without run different code, and a single-path test reports on whichever the runner happened to get. `udt_op_path` parametrizes over both and pins `jit_c_control` for the duration of the test. Two details there are load-bearing: - It sets the control rather than reading whatever is in effect. SuiteSparse demotes `on` to `load` after a failed compile, and a demoted control routes to the cfunc silently, so a `jit` cell would run the cfunc and pass. - It re-reads the control before restoring it and fails the test when it was demoted mid-test. No assertion inside the test can see that happen. `_jit_can_compile` memoizes whether a usable compiler exists. It deliberately does not consult `jit_c_control`, which is the per-test state the fixture owns; folding the two together is what let a demoted control go unnoticed. This adds no production code and no test cells. A parametrized fixture creates cells only for tests that request it, and nothing requests it yet, so the suite count is unchanged. It lands on its own so that each commit which goes on to consume it carries only its own change in operator semantics. --- graphblas/tests/test_op.py | 76 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index 981ad2036..41d314165 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -1426,6 +1426,82 @@ def _ne_thunk(x, i, j, thunk): # pragma: no cover (numba) assert v.select(sel, 5).new().isequal(Vector.from_coo([0, 2], [1, 9], size=3)) +_jit_can_compile_cache = [] + + +def _jit_can_compile(): + """True when SuiteSparse has a C compiler it can actually use. + + Without one it falls back to the Numba cfunc and says nothing, so the + ``jit`` parameter would run the cfunc and report a pass. Any repair of + conda-baked compiler paths has already happened in + ``_auto_fix_jit_at_import``; calling ``fix_jit_config`` again from here + would rewrite process-wide compiler settings that no fixture restores. + + Deliberately does not consult ``jit_c_control``: that is per-test state + the fixture sets, and folding it into this cache is what let a demoted + control go unnoticed. + """ + if not _jit_can_compile_cache: + _jit_can_compile_cache.append(gb.ss.jit_compiler_is_usable()) + return _jit_can_compile_cache[0] + + +@pytest.fixture(params=["jit", "cfunc"]) +def udt_op_path(request): + """Pin SuiteSparse to one execution path for built-in UDT operators. + + Each auto-lifted UDT op carries both a JIT C definition and a Numba + cfunc, and SuiteSparse chooses between them per call depending on + whether a C compiler is available. A machine with one and a machine + without therefore run different code, so results have to hold on both. + """ + path = request.param + if backend != "suitesparse" or "jit_c_control" not in gb.ss.config: + if path == "jit": + pytest.skip("no SuiteSparse JIT on this backend") + yield path + return + previous = gb.ss.config["jit_c_control"] + if path == "jit": + if not _jit_can_compile(): + pytest.skip("JIT compilation not available (no usable compiler)") + # Set it rather than assume it. SuiteSparse demotes ``on`` to ``load`` + # after a failed compile, and a demoted control routes to the cfunc + # silently, so this parameter would pass while running the other path. + gb.ss.config["jit_c_control"] = "on" + else: + gb.ss.config["jit_c_control"] = "off" + try: + yield path + finally: + # Read before restoring: a demotion during the test is the signal that + # the kernel never compiled, which no assertion in the test can see. + demoted = path == "jit" and gb.ss.config["jit_c_control"] != "on" + gb.ss.config["jit_c_control"] = previous + if demoted: + pytest.fail("SuiteSparse demoted jit_c_control; the JIT path did not run") + + +def _udt_vectors(udt, xs, ys=None): + """Build one or two dense UDT vectors whose leaves all hold the given values. + + Values that repeat down the whole vector make it iso-valued, and + SuiteSparse answers those from a single element without reaching for a + JIT kernel, so callers pass varied data. + """ + names = udt.np_type.names + out = [] + for vals in (xs, ys): + if vals is None: + continue + v = Vector(udt, size=len(vals)) + for i, val in enumerate(vals): + v[i] = tuple(val for _ in names) if names else np.full(udt.np_type.subdtype[1], val) + out.append(v) + return out + + @pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_udt_tuple_return_binaryop(record_udt): From fa27d452f4dbc143c33943802177c831dc8a054c Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 4 Aug 2026 16:57:25 -0700 Subject: [PATCH 2/3] Gate the udt_op_path jit param on the real JIT probe --- graphblas/tests/test_op.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index 41d314165..916438114 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -1438,12 +1438,19 @@ def _jit_can_compile(): ``_auto_fix_jit_at_import``; calling ``fix_jit_config`` again from here would rewrite process-wide compiler settings that no fixture restores. - Deliberately does not consult ``jit_c_control``: that is per-test state - the fixture sets, and folding it into this cache is what let a demoted - control go unnoticed. + ``jit_compiler_is_usable`` alone is not enough: it only checks that the + configured compiler path exists on disk, and a runner can have the file + yet fail every compile (broken toolchain, missing headers). The + import-time probe already did a real compile, and SuiteSparse demotes + ``jit_c_control`` from ``'on'`` when that compile fails, so a control + still ``'on'`` here is the probe's success flag; ``test_ssjit`` keys its + skips on the same signal. The fixture calls this before it mutates the + control, and the cache keeps later per-test mutations from flipping it. """ if not _jit_can_compile_cache: - _jit_can_compile_cache.append(gb.ss.jit_compiler_is_usable()) + _jit_can_compile_cache.append( + gb.ss.jit_compiler_is_usable() and gb.ss.config["jit_c_control"] == "on" + ) return _jit_can_compile_cache[0] @@ -1465,7 +1472,7 @@ def udt_op_path(request): previous = gb.ss.config["jit_c_control"] if path == "jit": if not _jit_can_compile(): - pytest.skip("JIT compilation not available (no usable compiler)") + pytest.skip("JIT compilation not available (probe failed or compiler missing)") # Set it rather than assume it. SuiteSparse demotes ``on`` to ``load`` # after a failed compile, and a demoted control routes to the cfunc # silently, so this parameter would pass while running the other path. From df95506127dca4883e87b1097ea1b2353517aaa7 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 23 Sep 2026 12:36:29 -0500 Subject: [PATCH 3/3] Ask _enable_jit_for_udt whether the udt_op_path jit param can run The gate read jit_c_control == 'on' as the success flag of the JIT probe that importing graphblas.ss used to run. "Stop `gb.ss` import from turning the SuiteSparse JIT on" (#630) now sits below this branch and took that probe out of the import, so what the gate saw depended on whether anything had enabled the JIT before the fixture first asked. test_ssjit.py does so when it is collected. Without it, as when test_op.py runs on its own, the control still reads SuiteSparse's default 'run', and on a machine with a working compiler the fixture's first consumer (next branch up) skipped its jit case as "JIT compilation not available" and ran only the cfunc. _enable_jit_for_udt is the call the UDT auto-lift path makes, and it does a real compile once per process, so the gate asks it instead and no longer depends on collection order. This change was part of #630 while that PR sat above this one. --- graphblas/tests/test_op.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index 916438114..50093313a 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -1433,24 +1433,21 @@ def _jit_can_compile(): """True when SuiteSparse has a C compiler it can actually use. Without one it falls back to the Numba cfunc and says nothing, so the - ``jit`` parameter would run the cfunc and report a pass. Any repair of - conda-baked compiler paths has already happened in - ``_auto_fix_jit_at_import``; calling ``fix_jit_config`` again from here - would rewrite process-wide compiler settings that no fixture restores. + ``jit`` parameter would run the cfunc and report a pass. ``jit_compiler_is_usable`` alone is not enough: it only checks that the configured compiler path exists on disk, and a runner can have the file - yet fail every compile (broken toolchain, missing headers). The - import-time probe already did a real compile, and SuiteSparse demotes - ``jit_c_control`` from ``'on'`` when that compile fails, so a control - still ``'on'`` here is the probe's success flag; ``test_ssjit`` keys its - skips on the same signal. The fixture calls this before it mutates the - control, and the cache keeps later per-test mutations from flipping it. + yet fail every compile (broken toolchain, missing headers). + ``_enable_jit_for_udt`` is the same call the UDT auto-lift path makes, + and it does a real compile once per process, so its answer is the + honest one here; ``test_ssjit`` keys its skips on the same signal. The + fixture calls this before it mutates the control, and the cache keeps + later per-test mutations from flipping it. """ if not _jit_can_compile_cache: - _jit_can_compile_cache.append( - gb.ss.jit_compiler_is_usable() and gb.ss.config["jit_c_control"] == "on" - ) + from graphblas.core.ss.jit_config import _enable_jit_for_udt + + _jit_can_compile_cache.append(gb.ss.jit_compiler_is_usable() and _enable_jit_for_udt()) return _jit_can_compile_cache[0]