Skip to content
29 changes: 28 additions & 1 deletion graphblas/core/automethods.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,38 @@

from .. import config

# Scalar read-only attributes whose value is a plain read of the underlying C
# scalar. For an index-extract expression (v[i], A[i, j]) these resolve with a
# single extractElement straight into a cscalar via ScalarIndexExpr._extract_fast,
# skipping the extra GrB_Scalar round-trip that `.new()` + Scalar.value performs.
# Only ScalarIndexExpr defines the hook, and no Vector/Matrix expression routes
# these attrs through _get_value, so their resolution short-circuits on the
# membership test.
_fast_scalar_attrs = frozenset(
{
"value",
"__float__",
"__int__",
"__complex__",
"__index__",
"__bool__",
"__array__",
"is_empty",
"_is_empty",
}
)


def _get_value(self, attr=None, default=None):
if config.get("autocompute"):
if self._value is None:
self._value = self.new()
if (
attr in _fast_scalar_attrs
and (extract_fast := getattr(self, "_extract_fast", None)) is not None
):
self._value = extract_fast()
else:
self._value = self.new()
if attr is None:
return self._value
return getattr(self._value, attr)
Expand Down
5 changes: 5 additions & 0 deletions graphblas/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ def record_raw(text):
rec.record_raw(text)


def _is_recording():
"""Whether a Recorder is active; fast paths that bypass ``call`` must check this."""
return _recorder.get(_prev_recorder) is not None


def call(cfunc_name, args):
call_args = [getattr(x, "_carg", x) if x is not None else NULL for x in args]
cfunc = libget(cfunc_name)
Expand Down
15 changes: 15 additions & 0 deletions graphblas/core/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,21 @@ def parse_indices(self, indices, shape):
def parse_index(self, index, typ, size):
from .scalar import _as_scalar

if typ is int:
# Fast lane for a plain Python int, the overwhelmingly common case.
# The two np.issubdtype checks below cost ~125ns each (measured), a
# meaningful slice of single-element index parsing. output_type maps
# only the exact `int` type to `int` (bool -> bool, numpy ints -> their
# own type), so bool and numpy scalars never enter here and keep their
# existing handling. A plain int is always signed, so the negative
# branch always applies, matching the signedinteger branch below.
if index >= size:
raise IndexError(f"Index out of range: index={index}, size={size}")
if index < 0:
index = index + size
if index < 0:
raise IndexError(f"Index out of range: index={index - size}, size={size}")
return AxisIndex(None, _as_scalar(index, _INDEX, is_cscalar=True), None, size)
if np.issubdtype(typ, np.integer):
if index >= size:
raise IndexError(f"Index out of range: index={index}, size={size}")
Expand Down
135 changes: 133 additions & 2 deletions graphblas/core/matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@

from .. import backend, binary, monoid, select, semiring
from ..dtypes import _INDEX, FP64, INT64, lookup_dtype, unify
from ..exceptions import DimensionMismatch, InvalidValue, NoValue, check_status
from ..exceptions import (
DimensionMismatch,
GrB_NO_VALUE,
InvalidValue,
NoValue,
check_status,
check_status_carg,
)
from . import _supports_udfs, automethods, ffi, lib, utils
from .base import BaseExpression, BaseType, _check_mask, call
from .base import BaseExpression, BaseType, _check_mask, _is_recording, call
from .descriptor import lookup as descriptor_lookup
from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, InfixExprBase, Updater
from .mask import Mask, StructuralMask, ValueMask
Expand Down Expand Up @@ -333,6 +340,50 @@ def __setitem__(self, keys, expr, **opts):
M[0, 0:3] = 17

"""
# Fast path for `A[i, j] = scalar`: a plain (row, col) integer pair and
# an exact-fit Python scalar, with no mask/accum/opts, a non-UDT dtype,
# and no active Recorder. Mirrors Updater -> _assign_element for a single
# element while skipping the resolver, Updater, and Scalar objects. Only
# int/float/bool/complex are taken here so dtype inference and cffi
# coercion match _assign_element exactly; everything else (slices, fancy
# indexing, numpy or Scalar values, `A(mask)[i, j] << x`) falls back to
# the full assign path, leaving mask/accum and coercion unchanged.
if (
not opts
and type(keys) is tuple
and len(keys) == 2
and type(expr) in (int, float, bool, complex)
and not self.dtype._is_udt
and not _is_recording()
):
row, col = keys
# Only int and np.integer keys take the fast lane (bools excluded),
# matching parse_index; other __index__ objects fall through to the
# expression path and its canonical errors.
if (type(row) is int or isinstance(row, np.integer)) and (
type(col) is int or isinstance(col, np.integer)
):
rowidx = row.__index__()
colidx = col.__index__()
nrows = self._nrows
ncols = self._ncols
if rowidx < 0:
rowidx += nrows
if rowidx < 0 or rowidx >= nrows:
raise IndexError(f"Index out of range: index={row}, size={nrows}")
if colidx < 0:
colidx += ncols
if colidx < 0 or colidx >= ncols:
raise IndexError(f"Index out of range: index={col}, size={ncols}")
vdtype = lookup_dtype(type(expr), expr)
cvalue = ffi_new(f"{vdtype.c_type}*")
cvalue[0] = expr # cffi coercion, identical to the Scalar.value setter
err_code = utils.libget(f"GrB_Matrix_setElement_{vdtype.name}")(
self.gb_obj[0], cvalue[0], rowidx, colidx
)
if err_code:
check_status_carg(err_code, "Matrix", self.gb_obj[0])
return
Updater(self, opts=opts)[keys] = expr

def __contains__(self, index):
Expand All @@ -345,6 +396,46 @@ def __contains__(self, index):
(10, 15) in M

"""
# Fast path for a plain (row, col) integer pair: probe with
# GrB_Matrix_extractElement directly instead of building an extract
# expression and Scalar. An out-of-range index falls through to the
# expression path so it raises the same IndexError as the slow path.
# Fall back for a UDT dtype and an active Recorder (so the call is
# recorded), mirroring Matrix.get. TransposedMatrix reuses this method.
if (
type(index) is tuple
and len(index) == 2
and not self.dtype._is_udt
and not _is_recording()
):
row, col = index
# Only int and np.integer take the fast lane (bools excluded),
# matching parse_index; other __index__ objects fall through to the
# expression path and its canonical errors.
if (type(row) is int or isinstance(row, np.integer)) and (
type(col) is int or isinstance(col, np.integer)
):
rowidx = row.__index__()
colidx = col.__index__()
nrows = self._nrows
ncols = self._ncols
if rowidx < 0:
rowidx += nrows
if colidx < 0:
colidx += ncols
if 0 <= rowidx < nrows and 0 <= colidx < ncols:
if self._is_transposed:
rowidx, colidx = colidx, rowidx
dtype = self.dtype
res = ffi_new(f"{dtype.c_type}*")
err_code = utils.libget(f"GrB_Matrix_extractElement_{dtype.name}")(
res, self.gb_obj[0], rowidx, colidx
)
if err_code:
if err_code == GrB_NO_VALUE:
return False
check_status_carg(err_code, "Matrix", self.gb_obj[0])
return True
extractor = self[index]
if not extractor._is_scalar:
raise TypeError(
Expand Down Expand Up @@ -801,6 +892,46 @@ def get(self, row, col, default=None):
Python scalar

"""
# Fast path for plain integer indices: call GrB_Matrix_extractElement
# directly instead of building an extract expression, which costs ~10x
# more than the C call for single-element access. Fall back when a
# Recorder is active (so the call is recorded) and for UDTs (whose
# values need numpy-based conversion in Scalar.value).
# Only int and np.integer take the fast lane (bools excluded), matching
# parse_index; other __index__ objects fall through to the expression
# path and its canonical errors.
if (
(type(row) is int or isinstance(row, np.integer))
and (type(col) is int or isinstance(col, np.integer))
and not self.dtype._is_udt
and not _is_recording()
):
rowidx = row.__index__()
colidx = col.__index__()
nrows = self._nrows
ncols = self._ncols
if rowidx < 0:
rowidx += nrows
if rowidx < 0 or rowidx >= nrows:
raise IndexError(f"Index out of range: index={row}, size={nrows}")
if colidx < 0:
colidx += ncols
if colidx < 0 or colidx >= ncols:
raise IndexError(f"Index out of range: index={col}, size={ncols}")
if self._is_transposed:
# TransposedMatrix reuses this method; gb_obj is the
# untransposed parent, so extract the mirrored element.
rowidx, colidx = colidx, rowidx
dtype = self.dtype
res = ffi_new(f"{dtype.c_type}*")
err_code = utils.libget(f"GrB_Matrix_extractElement_{dtype.name}")(
res, self.gb_obj[0], rowidx, colidx
)
if err_code:
if err_code == GrB_NO_VALUE:
return default
check_status_carg(err_code, "Matrix", self.gb_obj[0])
return res[0]
expr = self[row, col]
if expr._is_scalar:
rv = expr.new().value
Expand Down
49 changes: 47 additions & 2 deletions graphblas/core/scalar.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from ..dtypes import _INDEX, FP64, _index_dtypes, lookup_dtype, unify
from ..exceptions import EmptyObject, check_status
from . import _has_numba, _supports_udfs, automethods, ffi, lib, utils
from .base import BaseExpression, BaseType, call
from .base import BaseExpression, BaseType, _is_recording, call
from .expr import AmbiguousAssignOrExtract
from .operator import get_typed_op
from .utils import _Pointer, output_type, wrapdoc
Expand Down Expand Up @@ -1082,7 +1082,37 @@ class ScalarIndexExpr(AmbiguousAssignOrExtract):
def new(self, dtype=None, *, is_cscalar=None, name=None, **opts):
if is_cscalar is None:
is_cscalar = False
return self.parent._extract_element(
parent = self.parent
# Fast path for the default `expr.new()`: extract a single element
# straight into a fresh GrB_Scalar via GrB_*_extractElement_Scalar,
# skipping the `call` wrapper's per-arg _carg marshalling. Falls back
# for a dtype cast, cscalar output, opts, UDTs, and an active Recorder
# (so the call is recorded). Result is a GrB_Scalar (is_cscalar=False),
# empty exactly when the element is missing, same as _extract_element.
if (
dtype is None
and not is_cscalar
and not opts
and not parent.dtype._is_udt
and not _is_recording()
):
indices = self.resolved_indexes.indices
result = Scalar(parent.dtype, is_cscalar=False, name=name) # pragma: is_grbscalar
if len(indices) == 1:
err_code = lib.GrB_Vector_extractElement_Scalar(
result.gb_obj[0], parent.gb_obj[0], indices[0].index._carg
)
else:
rowidx, colidx = indices
if parent._is_transposed:
rowidx, colidx = colidx, rowidx
err_code = lib.GrB_Matrix_extractElement_Scalar(
result.gb_obj[0], parent.gb_obj[0], rowidx.index._carg, colidx.index._carg
)
if err_code:
check_status(err_code, [result])
return result
return parent._extract_element(
self.resolved_indexes, dtype, opts, is_cscalar=is_cscalar, name=name
)

Expand All @@ -1094,6 +1124,21 @@ def dup(self, dtype=None, *, clear=False, is_cscalar=False, name=None, **opts):
return Scalar(dtype, is_cscalar=is_cscalar, name=name)
return self.new(dtype, is_cscalar=is_cscalar, name=name, **opts)

def _extract_fast(self):
"""Resolve a value read (``.value``, ``float(...)``, ...) with one extract.

Those readers only need the raw element, so extract it straight into a
cscalar and skip the extra GrB_Scalar round-trip that ``.new()`` followed
by ``Scalar.value`` would perform. Defer to the full ``.new()`` for UDTs
(whose values need numpy conversion in ``Scalar.value``) and while a
Recorder is active (so it observes the same calls as the expression path).
``automethods._get_value`` consults this hook for ``_fast_scalar_attrs``.
"""
parent = self.parent
if parent.dtype._is_udt or _is_recording():
return self.new()
return parent._extract_element(self.resolved_indexes, None, {}, is_cscalar=True)

is_cscalar = Scalar.is_cscalar
is_grbscalar = Scalar.is_grbscalar
__hash__ = None
Expand Down
Loading