From 03ff41fc33ab8801efd414d0db33809d920e7a55 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 3 Aug 2026 14:48:53 -0500 Subject: [PATCH] Stop building floordiv at import time when seeding UDT auto-lift The first access to any operator namespace paid a numba compile it did not need. BinaryOp._initialize ended with a loop over _BUILTIN_UDT_BINARY_OPS that seeded _udt_types/_udt_ops/_custom_dtype via getattr(binary, op_name). floordiv is the one name in that set registered with lazy=True, so the getattr materialized it, compiling every dtype signature on every process start. Seed those three attributes in __init__ instead. That covers every path that creates a BinaryOp (builtin enumeration, the specials in _initialize, delayed UDF materialization, anonymous), so the seeding is not lost, and laziness survives: floordiv still compiles on first use and UDT lift behavior is unchanged. Measured here, fresh process, first `binary.plus` access: ~648 ms before, ~152 ms after (medians of 3 and 5 runs). Treat these as indicative, not benchmark-grade; load average was ~4 on a shared machine throughout, so the absolute numbers move but the ~4x gap does not. This does not make imports free. The ~152 ms that remains is the rest of _initialize plus namespace setup and is untouched here. It also does not change what floordiv costs once you use it; the compile is deferred, not removed. The other four lazily-registered UDFs (rfloordiv, absfirst, abssecond, rpow) were never in _BUILTIN_UDT_BINARY_OPS and so were never force-built by this loop. test_initialize_does_not_build_lazy_udfs covers the invariant in a subprocess, since _initialize has already run by the time any in-process test executes. --- graphblas/core/operator/binary.py | 22 ++++++++++------- graphblas/tests/test_op.py | 40 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/graphblas/core/operator/binary.py b/graphblas/core/operator/binary.py index 5329b4415..bdcfccc71 100644 --- a/graphblas/core/operator/binary.py +++ b/graphblas/core/operator/binary.py @@ -1004,15 +1004,12 @@ def _initialize(cls): binary.eq._udt_ops = {} binary.ne._udt_types = {} binary.ne._udt_ops = {} - # Element-wise arithmetic ops are auto-generated from per-field / - # per-element scalar ops. - if _has_numba: - for op_name in _BUILTIN_UDT_BINARY_OPS: - binop = getattr(binary, op_name, None) - if binop is not None: - binop._udt_types = {} - binop._udt_ops = {} - binop._custom_dtype = _udt_dtype + # Element-wise arithmetic ops on UDTs are auto-generated from + # per-field / per-element scalar ops; the attributes that enable this + # are seeded in ``__init__`` (keyed on ``_BUILTIN_UDT_BINARY_OPS``). + # Do not seed them here with ``getattr(binary, op_name)``: that would + # force lazily-registered UDF ops like ``floordiv`` to numba-compile + # for every dtype at import time (about half a second of startup here). cls._initialized = True def __init__( @@ -1037,6 +1034,13 @@ def __init__( if is_udt: self._udt_types = {} # {(dtype, dtype): DataType} self._udt_ops = {} # {(dtype, dtype): TypedUserBinaryOp} + elif _has_numba and name in _BUILTIN_UDT_BINARY_OPS: + # Built-in arithmetic ops auto-lift to UDTs field-by-field. + # Seeded here rather than in ``_initialize`` so that delayed ops + # (e.g. ``floordiv``) are not force-built at import time. + self._udt_types = {} + self._udt_ops = {} + self._custom_dtype = _udt_dtype __call__ = TypedBuiltinBinaryOp.__call__ is_commutative = TypedBuiltinBinaryOp.is_commutative diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index 4406d3cfd..71543251d 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -1,4 +1,8 @@ import itertools +import os +import subprocess +import sys +from pathlib import Path import numpy as np import pytest @@ -3768,3 +3772,39 @@ def test_operator_namespace_typo_suggestions(): with pytest.raises(AttributeError): binary.pluss assert set(binary._delayed) == before + + +# Touching an operator namespace must not compile any lazily-registered UDF. +# Run in a subprocess: BinaryOp._initialize runs once per process, so by the +# time any test executes, the import-time behavior under test is long past. +_LAZY_UDF_PROBE = """ +import graphblas as gb + +gb.binary.plus # forces BinaryOp._initialize + +lazy_udfs = ("floordiv", "rfloordiv", "absfirst", "abssecond", "rpow") +print("package: " + gb.__file__) +print("still lazy: " + " ".join(n for n in lazy_udfs if n in gb.binary._delayed)) +""" + + +@pytest.mark.skipif("not supports_udfs") +def test_initialize_does_not_build_lazy_udfs(): + repo_root = Path(__file__).resolve().parents[2] + env = dict(os.environ, PYTHONPATH=str(repo_root)) + result = subprocess.run( + [sys.executable, "-c", _LAZY_UDF_PROBE], + capture_output=True, + text=True, + check=False, + env=env, + ) + report = f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + assert result.returncode == 0, report + + lines = dict(line.split(": ", 1) for line in result.stdout.splitlines() if ": " in line) + # Assert the child probed this tree before trusting what it reports about it. + assert lines.get("package") == str(repo_root / "graphblas" / "__init__.py"), report + + still_lazy = lines.get("still lazy", "").split() + assert still_lazy == ["floordiv", "rfloordiv", "absfirst", "abssecond", "rpow"], report