Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 23 additions & 13 deletions docs/user_guide/udt.rst
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,13 @@ Anonymous UDTs (no ``name=`` argument) still take the JIT path: a synthetic
identifier. Pass ``name=`` only for readable JIT cache filenames and
introspection.

The first time auto-lift produces a non-JIT'd op for a given ``(op, dtype)``
pair in a process, a ``graphblas.exceptions.NoJITWarning`` (a subclass of
``UserWarning``) is emitted with the cause and the remediation. The warning
fires once per ``(op, dtype)`` rather than once per process, so distinct
fallback causes each surface a warning. Silence it by category or by
message::
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
process, a ``graphblas.exceptions.NoJITWarning`` (a subclass of
``UserWarning``) names the cause. An unusable compiler does not warn;
``gb.ss.fix_jit_config()`` returns ``False`` when it cannot compile, and
``gb.ss.jit_compiler_is_usable()`` is the cheap check. Silence the warning by
category or by message::

import warnings
from graphblas.exceptions import NoJITWarning
Expand All @@ -281,16 +282,25 @@ machines. With the bogus default, SuiteSparse emits the JIT ``.c`` source
but never compiles a ``.dylib`` or ``.so``, and silently falls back to the
cfunc path. The 2-3x JIT speedup is silently lost.

python-graphblas auto-fixes this at import. If ``jit_c_compiler_name``
doesn't exist on disk, it is replaced with one from ``$CONDA_PREFIX/bin/``
(or from ``sysconfig`` for pure-pip installs), and ``jit_c_control`` is
bumped from the SS default ``'run'`` (run cached kernels only; no compile,
no load from disk) to ``'on'`` (compile, load, and run). When the default
config is already valid, only the mode bump applies.
python-graphblas repairs the compiler path when ``graphblas.ss`` is
imported, which the first access to ``gb.ss`` does. If
``jit_c_compiler_name`` doesn't exist on disk, it is replaced with one from
``$CONDA_PREFIX/bin/`` (or from ``sysconfig`` for pure-pip installs). The
import changes nothing else, so reading ``gb.ss.about`` does not change what
later operations compute.

``jit_c_control`` is raised from the SS default ``'run'`` (run cached kernels
only; no compile, no load from disk) to ``'on'`` (compile, load, and run) when
a UDT operator gets C source for SuiteSparse to compile, such as the first
``binary.plus[udt]``, or when ``gb.dtypes.ss.register_new`` registers a type
from a C typedef. Any other setting is kept, including ``'off'``, ``'pause'``,
and ``'load'`` (load and run cached kernels, never compile). SuiteSparse
itself sets ``'load'`` after a compile fails, so keeping it means a failing
compile is tried once, not again at every later UDT operation.

Call the helper manually to re-fix or verify::

gb.ss.fix_jit_config() # repair compiler path (full probe)
gb.ss.fix_jit_config() # repair compiler path, set 'on', probe
gb.ss.jit_compiler_is_usable() # cheap check: True iff path exists

Pickle and serialize
Expand Down
7 changes: 7 additions & 0 deletions graphblas/core/operator/udt_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,14 @@ def _set_jit_c_strings(gb_obj, c_name, c_defn, set_string_func):
Both strings are one-shot on SuiteSparse: a second set returns
``GrB_ALREADY_SET`` silently. The return is not checked here because
callers always pass a freshly-allocated handle.

Arming an op with C source is the point at which this process has
committed to wanting a JIT compiler, so it is also where SuiteSparse's
non-compiling default gets raised.
"""
from ..ss.jit_config import _enable_jit_for_udt

_enable_jit_for_udt()
set_string_func(gb_obj, ffi.new("char[]", c_name.encode()), lib.GxB_JIT_C_NAME)
set_string_func(gb_obj, ffi.new("char[]", c_defn.encode()), lib.GxB_JIT_C_DEFINITION)

Expand Down
6 changes: 6 additions & 0 deletions graphblas/core/ss/dtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ def register_new(name, jit_c_definition, *, np_type=None):
if "struct" not in jit_c_definition:
raise ValueError("Only struct typedefs are currently allowed for JIT dtypes")

# Registering a type by its C typedef is a request to compile C, so it is
# a fair place to raise SuiteSparse's non-compiling default.
from .jit_config import _enable_jit_for_udt

_enable_jit_for_udt()

gb_obj = ffi.new("GrB_Type*")
status = lib.GxB_Type_new(
gb_obj, 0, ffi_new("char[]", name.encode()), ffi_new("char[]", jit_c_definition.encode())
Expand Down
211 changes: 138 additions & 73 deletions graphblas/core/ss/jit_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ def fix_jit_config(*, use_sysconfig=True, probe=True):

Replaces the baked-in compiler path (which often points at a conda-build
host that doesn't exist in user environments) with one from
``$CONDA_PREFIX/bin/``, and strips build-time-only flags (``-isysroot``,
``-fdebug-prefix-map``).
``$CONDA_PREFIX/bin/``, strips build-time-only flags (``-isysroot``,
``-fdebug-prefix-map``), and sets ``jit_c_control`` to ``'on'``.

Parameters
----------
Expand All @@ -73,41 +73,58 @@ def fix_jit_config(*, use_sysconfig=True, probe=True):
to restrict the repair to a conda environment only.
probe : bool, default True
After fixing the config, try to JIT-register a trivial UDT to verify
the compiler actually works. SuiteSparse auto-flips ``jit_c_control``
from ``'on'`` to ``'load'`` on a failed compile; the probe absorbs
that first failure so user-visible ops afterwards see a stable
``'load'`` (cache-only) state and punt to generic cleanly.
the compiler actually works. A failed compile makes SuiteSparse lower
``jit_c_control`` to ``'load'``, and from SuiteSparse 9.4 on it also
raises ``JitError`` that one time; the probe takes that failure, so
later operations use the generic kernels without raising.

Returns
-------
True
Fix applied and (if ``probe``) verified working.
False
Fix attempted but the probe failed. ``jit_c_control`` is now
whatever SuiteSparse left it at (typically ``'load'``).
whatever SuiteSparse left it at.
None
No environment available to fix from. There's no ``$CONDA_PREFIX``,
and either ``use_sysconfig=False`` or no sysconfig compiler is set.
"""
cfg = _ss_config()
if conda_prefix := os.environ.get("CONDA_PREFIX", ""):
rv = _fix_from_conda(cfg, conda_prefix)
elif use_sysconfig:
rv = _fix_from_sysconfig(cfg)
else:
return None
rv = _repair_jit_compiler(cfg, use_sysconfig=use_sysconfig)
if rv is None:
return None
# Enabling compilation is what the caller asked for. The import-time
# repair deliberately stops short of this; see ``_auto_fix_jit_at_import``.
cfg["jit_c_control"] = "on"
# An explicit user-driven fix is a clean opportunity to re-arm
# ``NoJITWarning``: if the repair worked, the next UDT auto-lift that
# *still* falls back to cfunc (different cause: UDT layout, etc.)
# deserves a fresh notification rather than silent suppression.
_warned_no_jit_for.clear()
# Re-arm the cached probe answer for the same reason: a pre-repair False
# is stale now, so let the next UDT op re-derive it against the fixed
# toolchain instead of trusting the old verdict.
global _jit_enabled_for_udt
_jit_enabled_for_udt = None
if not probe:
return True
return _probe_jit(cfg)


def _repair_jit_compiler(cfg, *, use_sysconfig=True):
"""Point the JIT compiler settings at something that exists on this host.

Only rewrites the compiler name and flags; ``jit_c_control`` is left
alone, so on its own this changes nothing about what SuiteSparse will
do. Callers that want compilation enabled set the control themselves.
"""
if conda_prefix := os.environ.get("CONDA_PREFIX", ""):
return _fix_from_conda(cfg, conda_prefix)
if use_sysconfig:
return _fix_from_sysconfig(cfg)
return None


def _fix_from_conda(cfg, conda_prefix):
"""Conda-aware fix; swap the compiler path with one from $CONDA_PREFIX/bin/."""
jit_cc = cfg["jit_c_compiler_name"]
Expand All @@ -122,7 +139,6 @@ def _fix_from_conda(cfg, conda_prefix):
else:
return None # nothing usable
_fix_compiler_flags(cfg)
cfg["jit_c_control"] = "on"
return True


Expand All @@ -139,7 +155,6 @@ def _fix_from_sysconfig(cfg):
cfg["jit_c_compiler_flags"] = f"{cflags} -I{include}"
if libs := sysconfig.get_config_var("LIBS"):
cfg["jit_c_libraries"] = libs
cfg["jit_c_control"] = "on"
return True


Expand Down Expand Up @@ -182,11 +197,13 @@ def _strip_mismatched_arch(flags):
def _probe_jit(cfg):
"""Probe a trivial JIT compile to verify the config works.

On failure, SuiteSparse will have flipped ``jit_c_control`` from
``'on'`` to ``'load'`` (its built-in response to a failed compile);
we leave that state alone. Both ``'load'`` and ``'off'`` cause
downstream ops to punt to the generic kernel, but ``'load'`` preserves
any pre-compiled kernels in the cache.
``jit_c_control`` is left wherever SuiteSparse puts it, and that is not
predictable from here. A library built without the JIT clamps every write
down to ``'run'`` (``GB_jitifyer_set_control``), so ``'on'`` never takes on
such a build no matter how good the compiler is; separately, a compile
failure drops it to ``'load'``, a load failure to ``'run'``, and a failed
hash insert to ``'pause'``. The contract here is only "did this work?", so
the caller gets a bool.
"""
from ... import dtypes as _dtypes

Expand All @@ -197,6 +214,8 @@ def _probe_jit(cfg):
probe_name = "_jit_probe"
if hasattr(_dtypes.ss, probe_name):
return True
global _probing_jit
_probing_jit = True
try:
_dtypes.ss.register_new(probe_name, "typedef struct { int _probe ; } _jit_probe ;")
except Exception:
Expand All @@ -205,34 +224,94 @@ def _probe_jit(cfg):
# validation ``ValueError``s. The probe's contract is "did this
# work?", so absorb every failure mode here.
return False
finally:
_probing_jit = False
return True


def _auto_fix_jit_at_import():
"""Run :func:`fix_jit_config` at ``gb.ss`` import; designed not to raise.

Called unguarded from ``graphblas/ss/__init__.py``, so any exception
here breaks ``import graphblas.ss``. The body sticks to dict ops and
delegates the failure-prone work to ``_probe_jit``, which catches
everything internally.

The probe is the load-bearing piece: without it, SS would surface
``JitError`` on the first user-triggered JIT compile (a failed
compile is only converted to a silent ``'load'`` fallback on
subsequent calls).
"""Repair the JIT compiler path at ``gb.ss`` import; designed not to raise.

Called unguarded from ``graphblas/ss/__init__.py``, so any exception here
breaks ``import graphblas.ss``. The body sticks to dict ops and the
string rewriting in :func:`_repair_jit_compiler`.

Deliberately leaves ``jit_c_control`` at whatever SuiteSparse set. An
attribute access such as ``gb.ss.about["library_version"]`` imports this
submodule, and that must not change what any later operation computes or
which kernels SuiteSparse is willing to load from its on-disk cache.
Compilation is enabled later, by :func:`_enable_jit_for_udt`, when a UDT
actually needs a kernel built.
"""
cfg = _ss_config()
if "jit_c_control" not in cfg:
return
if jit_compiler_is_usable():
if cfg["jit_c_control"] in ("run", "load"):
cfg["jit_c_control"] = "on"
else:
fix_jit_config(use_sysconfig=True, probe=False)
if jit_compiler_is_usable() and cfg["jit_c_control"] in ("run", "load"):
if not jit_compiler_is_usable():
_repair_jit_compiler(cfg)


# Tri-state: ``None`` until a UDT first asks for a JIT kernel while the
# control allows compiling, then the answer to "can this process
# JIT-compile?" until ``fix_jit_config`` re-arms it.
_jit_enabled_for_udt = None

# True only while ``_probe_jit`` is registering its own UDT. That registration
# goes through ``dtypes.ss.register_new``, which asks to enable the JIT, which
# would probe again; the second probe would register the same name a second
# time and the first one would then die on cffi's "multiple declarations".
_probing_jit = False


def _enable_jit_for_udt():
"""Enable JIT compilation the first time a UDT needs a kernel built.

SuiteSparse defaults ``jit_c_control`` to ``'run'``, which runs kernels
already loaded but neither compiles nor loads any. UDT auto-lift wants
``'on'``; without it every UDT op falls back to the Numba
function-pointer path (typically 2-3x slower for elementwise ops).

This is where that bump belongs, rather than at import: registering a
type from its C typedef or arming an op with C source is an act that
plainly involves compiling C, so enabling the compiler is not a surprise.
Reading ``gb.ss.about`` is not, so it leaves the setting alone.

Only the default ``'run'`` is raised. An explicit ``'off'``, ``'pause'``,
or ``'load'`` is honored, and ``'load'`` is also where SuiteSparse leaves
the control after a compile fails, so raising it would retry that compile
at every later UDT op (raising ``JitError`` each time on SuiteSparse 9.4
and later). Returns True iff a kernel armed now will be compiled. Whether
this process can compile at all is probed once and cached.
"""
global _jit_enabled_for_udt
if _probing_jit:
# Re-entered from the probe's own registration. The probe is the thing
# deciding this answer, so say yes and let it finish rather than
# starting a second one inside it.
return True
cfg = _ss_config()
control = cfg.get("jit_c_control")
if control in (None, "off", "pause", "load"):
# ``None`` is a library with no JIT at all. The others are honored as
# set and not cached, so a saved ``'run'`` restored afterwards is
# still raised.
return False
if _jit_enabled_for_udt is None:
if not jit_compiler_is_usable():
_repair_jit_compiler(cfg)
if _jit_enabled_for_udt := jit_compiler_is_usable():
cfg["jit_c_control"] = "on"
if cfg.get("jit_c_control") == "on":
_probe_jit(cfg)
# A library built without the JIT clamps that write back down to
# ``'run'``. Otherwise the probe is load-bearing: a failed compile
# drops the control to ``'load'`` and, from SuiteSparse 9.4 on,
# also raises ``JitError``, so without the probe that one error
# would reach the first user operation that needs a kernel.
_jit_enabled_for_udt = cfg["jit_c_control"] == "on" and _probe_jit(cfg)
elif _jit_enabled_for_udt and control == "run":
# The cache answers "can this process compile?", which is settled once.
# The control is separate state that anything may have lowered since,
# and a kernel armed now still needs it raised.
cfg["jit_c_control"] = "on"
return _jit_enabled_for_udt


# Keyed by ``(op_name, dtype_name)`` so each distinct pair warns once.
Expand All @@ -244,50 +323,36 @@ def _auto_fix_jit_at_import():
def _maybe_warn_no_jit(*, op_name="", dtype_name=""):
"""Emit a ``NoJITWarning`` (once per ``(op_name, dtype_name)``) when UDT auto-lift falls back.

The most likely cause (bogus compiler path, ``jit_c_control`` off, or
UDT not C-expressible) is named in the message along with the remediation.
The only caller is ``udt_utils._maybe_warn_jit_skipped``, which fires
after C codegen returned nothing. Codegen reads the dtype and neither the
compiler path nor ``jit_c_control``, so the dtype is the cause here even
on a host that could not have compiled a kernel anyway. Do not re-derive
the cause from the live config; that is not the state codegen read.

The introspection properties (``DataType.jit_c_definition`` and
``TypedUserBinaryOp.jit_c_source``) show what was generated, or ``None``
when codegen was skipped.
"""
key = (op_name, dtype_name)
if key in _warned_no_jit_for:
return
_warned_no_jit_for.add(key)
import warnings as _warnings

cfg = _ss_config()
if not jit_compiler_is_usable():
cause = (
"the JIT compiler path is not usable "
f"({cfg.get('jit_c_compiler_name', '<unset>')!r}); "
"call ``gb.ss.fix_jit_config()`` to repair it"
)
elif cfg.get("jit_c_control") != "on":
cause = (
f"jit_c_control is {cfg.get('jit_c_control')!r} (must be 'on' to compile); "
"set ``gb.ss.config['jit_c_control'] = 'on'`` to enable compilation"
)
else:
# Compiler is usable and mode is OK; the UDT itself isn't C-expressible.
# The introspection properties (``DataType.jit_c_definition`` and
# ``TypedUserBinaryOp.jit_c_source``) show what was generated, or
# ``None`` when codegen was skipped.
loc = f" (op={op_name!r}, dtype={dtype_name!r})" if op_name else ""
cause = (
"this UDT is not expressible as a C struct"
f"{loc} (a field name is a C reserved word or stdlib macro, a "
"field type isn't in the numpy-to-C map, a field is array-typed, "
"or the record has a packed layout). The op still works via the "
"Numba cfunc path; only the JIT speedup is lost"
)
from ...exceptions import NoJITWarning

loc = f" (op={op_name!r}, dtype={dtype_name!r})" if op_name else ""
_warnings.warn(
f"UDT operator running without JIT compilation: {cause}. "
f"Operations will use the Numba function-pointer fallback "
f"(typically 2-3x slower for elementwise ops, since SuiteSparse "
f"can't inline the kernel into its eWise and reduce templates). "
f"This warning fires once per (op, dtype) per process; silence with "
f"``warnings.filterwarnings('ignore', category=gb.exceptions.NoJITWarning)`` "
f"or by message match.",
f"UDT operator running without JIT compilation: this UDT is not "
f"expressible as a C struct{loc} (a field name is a C reserved word or "
f"stdlib macro, a field type isn't in the numpy-to-C map, a field is "
f"array-typed, or the record has a packed layout). The op still works "
f"through the Numba function-pointer fallback; only the JIT speedup is "
f"lost (typically 2-3x slower for elementwise ops, since SuiteSparse "
f"can't inline the kernel into its eWise and reduce templates). This "
f"warning fires once per (op, dtype) per process; silence with "
f"``warnings.filterwarnings('ignore', "
f"category=gb.exceptions.NoJITWarning)`` or by message match.",
NoJITWarning,
stacklevel=3,
)
Loading
Loading