Skip to content

Add fast paths for scalar element access - #599

Open
eriknw wants to merge 17 commits into
mainfrom
19-scalar-fast-paths
Open

eriknw wants to merge 17 commits into
mainfrom
19-scalar-fast-paths

Conversation

@eriknw

@eriknw eriknw commented Aug 4, 2026 •

Copy link
Copy Markdown
Member

Single-element reads and writes previously built an extract expression, a
Scalar, and sometimes an Updater per call. This branch adds direct
GrB_*_extractElement / GrB_*_setElement fast lanes for the common cases,
plus a parity test suite that pins every fast lane to the expression path it
stands in for. UDTs, masks, accum, opts, and an active Recorder all fall back
to the expression path; semantics and error messages are unchanged.

  • Vector.get / Matrix.get (6b494d8): 13-14x for plain integer indices
    (v.get(i) 5.05us to 0.38us; A.get(i, j) 6.67us to 0.49us).
  • contains, scalar setitem, ScalarIndexExpr.new() (ea3def6):
    7-10x on membership tests and integer-index assignment.
  • Plain-int fast lane in parse_index (1c803da): skips two type checks
    on the common path; 10-19% on v[i] construction and .new().
  • Parity tests (569939b): 211 tests comparing each fast lane against
    the expression path across dtypes, hits and misses, negative and
    out-of-range indices, exception type and message parity, coercion and
    overflow edges, UDT and Recorder fallback, and TransposedMatrix.
  • v[i].value / float(v[i]) single-extract path (e2895b7): resolves the
    nine read-only scalar attrs with one extractElement instead of two
    extracts plus nvals; 1.4-1.5x. .get(i) remains the fast API.
  • Gate the lanes on real integers (bc16189): the fast lanes accepted
    any __index__-implementing object (for example a 0-d ndarray) that the
    expression path rejects. Both lanes now accept exactly int and
    np.integer; everything else falls through to the canonical error.
    17 new parity cases.

Timings were taken with timeit on a loaded Apple M5 Pro, so they are
indicative rather than clean-room; the commit messages carry the protocol
and caveats.

Added in the 2026-10-20 coverage round

  • A Recorder leaked through the test session. Two tests in test_recorder.py
    left a Recorder running (since Have the recorder log errors, which helps with debugging. #114 and Use _repr_html_ for rich display of recorder. #263). From this PR on, an active
    Recorder sends every fast lane to the expression path, so every test after
    test_recorder.py (in file order, or by chance under random order) silently
    tested the expression path only. Both tests now use with, and an autouse
    check in conftest.py fails any test that leaves a Recorder running.
  • GraphBLAS errors with C scalars. Extracting into a C scalar from an object
    whose data was given away raised EmptyObject ("Empty C scalar is invalid
    ...") instead of GraphBLAS's NullPointer (main too), and this PR's
    single-extract path made v[i].value do the same. Matrix and Vector nvals,
    nrows, ncols and size had the same masking (main too); Scalar reads,
    which cannot fail this way, changed to match. All of them now report
    GraphBLAS's error, and parity tests check that the fast lanes and the
    expression path raise the same errors.
  • "Give a scalar index expression's other attributes a GrB_Scalar after a value read." A
    value read of v[i] (.value, float(), ...) extracts into a C scalar, and the expression
    caches what it read. Its other attributes then saw that C scalar: with e = v[i], e.gb_obj
    after e.value was an int8_t *, which a C call taking a GrB_Scalar refused with
    TypeError, while e.is_cscalar said False. They now get the kind of Scalar the expression
    reports, holding the same value. A value read is still a snapshot, as on main. Found by
    review (r599 R1).
  • Coverage: tests for the row-and-column case of in on a Matrix, and
    # pragma: no cover on test helpers that run only on failure.

Stack created with GitHub Stacks CLI • Give Feedback 💬

@eriknw eriknw changed the title 19 scalar fast paths Add fast paths for scalar element access Aug 4, 2026
@eriknw
eriknw marked this pull request as ready for review August 4, 2026 16:07
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch 3 times, most recently from d7cb7c6 to 51646e7 Compare August 5, 2026 03:18
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch from 51646e7 to b69beb4 Compare August 5, 2026 17:44
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch from b69beb4 to 94e3f47 Compare August 5, 2026 18:03
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch 2 times, most recently from 424bc8d to c2e3c77 Compare August 6, 2026 07:59
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch 2 times, most recently from d92d27f to 705c990 Compare August 6, 2026 15:41
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch 2 times, most recently from 3794f68 to ac640b1 Compare August 6, 2026 20:41
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch from ac640b1 to 599c70b Compare August 7, 2026 02:48
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch from 599c70b to fe14199 Compare August 7, 2026 05:09
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch from fe14199 to e1b5cc4 Compare August 26, 2026 17:30
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch from e1b5cc4 to 96c2e11 Compare September 18, 2026 03:09
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch from 96c2e11 to 190e1b6 Compare September 18, 2026 03:46
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch from 190e1b6 to 7de471f Compare September 18, 2026 17:34
@eriknw
eriknw added this pull request to stack #635 October 8, 2026 21:46
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch from 54608f0 to 5531ece Compare October 9, 2026 03:06
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch 2 times, most recently from 97ef353 to 9fbf7fc Compare October 14, 2026 18:45
@eriknw
eriknw removed this pull request from stack #635 October 19, 2026 07:45
@eriknw
eriknw changed the base branch from 18-ss-namespace-refcycle to main October 19, 2026 07:46
@eriknw
eriknw force-pushed the 19-scalar-fast-paths branch from 9fbf7fc to a0b601a Compare October 19, 2026 07:47
@eriknw
eriknw changed the base branch from main to 35-ssconfig-attr-guard October 19, 2026 07:48
@codecov

codecov Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.86%. Comparing base (4cd73f1) to head (36a8610).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #599      +/-   ##
==========================================
+ Coverage   98.81%   98.86%   +0.04%     
==========================================
  Files         105      106       +1     
  Lines       31735    32601     +866     
  Branches     3702     3788      +86     
==========================================
+ Hits        31360    32230     +870     
- Misses        241      247       +6     
+ Partials      134      124      -10     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

For a plain integer index, with a non-UDT dtype and no active Recorder, call
GrB_*_extractElement directly instead of building an extract expression, a
Scalar, and converting through Scalar.value.

Semantics are unchanged: the same IndexError messages and negative-index
handling as parse_index, bool indices still rejected, UDTs and active
Recorders falling back to the expression path, and TransposedMatrix (which
reuses Matrix.get) extracting the mirrored element.

Measured on an Apple M5 Pro with timeit, best-of-7, warm, while other test
suites were running (load average about 4.5), so indicative rather than
clean-room:

    v.get(i)      5.05us -> 0.38us   (13x)
    A.get(i, j)   6.67us -> 0.49us   (14x)

A bare GrB_Vector_extractElement_FP64 call through cffi measures about
0.06us on the same machine, so most of the remaining 0.38us is Python call
and argument handling rather than GraphBLAS work.
…xpr.new()

Applies the Vector/Matrix.get recipe from the previous commit to three more
scalar access paths, calling GrB_*_extractElement or GrB_*_setElement
directly instead of building an extract expression, an Updater, and a Scalar.

Only exact-fit Python int, float, bool and complex values take the setitem
fast path, so dtype inference and cffi coercion match the Updater path
exactly.  Masks, accum, opts, UDTs and an active Recorder all fall back to
the full path.  TransposedMatrix has no __setitem__, so the setitem fast path
cannot be reached through a transposed view.

An out-of-range index falls through from the fast lane to the expression
path, so membership raises the same IndexError, with the same message, from
either lane.  Assignment likewise raises IndexError, in the fast lane as
before.

Measured on an Apple M5 Pro with timeit, best-of-7, warm, while other test
suites were running (load average about 5), so these are indicative rather
than clean-room:

    0 in v         3.95us -> 0.40us   (9.9x)
    (0, 0) in A    5.57us -> 0.53us   (10.4x)
    v[i] = 5       3.69us -> 0.50us   (7.3x)
    A[i, j] = 5    5.12us -> 0.62us   (8.2x)

The ScalarIndexExpr.new() path gains only the skipped per-argument
marshalling in the `call` wrapper.  That difference did not clear measurement
noise on a loaded machine, so no figure is quoted for it; the change stands
on doing strictly less work, not on a measured win.
A `typ is int` branch ahead of the np.issubdtype machinery skips two type
checks costing about 125ns each for the overwhelmingly common case of a plain
Python int index.  output_type maps only the exact `int` type to `int`, so
bool and numpy integer indices keep their existing handling.  Error messages,
negative wrapping, and the returned AxisIndex are unchanged, and a plain int
is always signed, so the negative branch matches the signedinteger branch it
bypasses.

Measured on an Apple M5 Pro with timeit, min of 15 runs of 20000 calls, warm,
while other test suites were running (load average about 5.7).  Before and
after were taken back to back with the same protocol:

    v[i] construction    1.85us -> 1.50us   (-19%)
    v[i].new()           3.48us -> 3.13us   (-10%)
    A[i, j].new()        5.04us -> 4.34us   (-14%)

These are small percentages, so the measurement is worth qualifying: min and
median differed by under 2% on every case, and each saving (0.35us, 0.35us,
0.70us) is many times that spread, in the direction the diff predicts and of
the size two skipped 125ns checks predict.  List, slice, and array extracts
are unchanged, since they never took the int branch.
The fast lanes for get, __contains__, integer __setitem__, scalar extract,
and plain-int index parsing were checked only by hand-run equivalence
scripts that CI never executes.  A fast lane that drifts from the
expression path it stands in for returns a wrong answer rather than a slow
one, so the comparison belongs in the suite where a refactor will trip over
it.

211 tests compare each fast path against the expression path across the
builtin dtypes (13 of them where complex is supported), hits and misses,
negative and out-of-range indices, exception type and message parity,
coercion and overflow edges, UDT and Recorder fallback, and
TransposedMatrix.  They run in the fast tier, in well under a second.

The TransposedMatrix case for Matrix.get was absent from this file and is
added here.  It is not the only guard: test_matrix.py::test_get already
asserts A.T.get(1, 0), and deleting the mirroring swap from the get fast
path fails that test as well as this one.  What the new case adds is shape
and breadth.  It uses a non-square 3x4 matrix, where equal dimensions cannot
hide a row/column swap, and it sweeps every cell against the expression path
rather than checking two positions.
The .value, __float__, __int__ and related accessors on an index-extract
expression (v[i], A[i, j]) resolved through automethods._get_value, which
called .new() to build a GrB_Scalar (extract number one) and then read
Scalar.value off it (extract number two, plus nvals).  For the nine
read-only scalar attrs that only need the raw element, resolve with a single
extractElement straight into a cscalar through a new private
ScalarIndexExpr._extract_fast hook.

_get_value consults the hook only for _fast_scalar_attrs and only when the
expression defines it, so every other expression type is unchanged and falls
to .new() as before.  UDT values, which need numpy conversion in
Scalar.value, and active Recorders fall back to .new().

Measured on an Apple M5 Pro with timeit, min of 15 runs of 20000 calls,
warm, before and after taken back to back while other test suites were
running (load average about 5):

    v[i].value      4.72us -> 3.14us   (1.50x)
    float(v[i])     4.88us -> 3.18us   (1.53x)
    A[i, j].value   5.93us -> 4.29us   (1.38x)

v[i].new() was measured alongside as a control and did not move, which is
what this diff predicts since it does not touch that path.  .get(i), at
about 0.38us, remains the fast API; this only helps the older v[i].value and
float(v[i]) idiom, whose floor is the cost of building the v[i] expression.

The hook region sits above the autogenerate markers, so regenerating
automethods.py leaves it in place.  Private surface only (_extract_fast,
_fast_scalar_attrs).
The fast lanes for Vector/Matrix get, __contains__, and integer-key
__setitem__ accepted any object implementing __index__, while the
expression path (IndexerResolver.parse_index) accepts only exact int
and np.integer scalars.  Anything else implementing __index__, such as
a 0-d ndarray, answered on the fast lane but raised on the slow path.
Reproduced at 5581edc8 (fast vs slow), where the slow-path error is
TypeError: Invalid number of dimensions for index: 0:

  Vector.get(np.array(5))         -> 2.5   vs TypeError
  np.array(5) in v                -> True  vs TypeError
  v[np.array(5)] = 1.0            -> sets  vs TypeError
  Matrix.get(np.array(1), np.array(2))  -> 20    vs TypeError
  (np.array(1), np.array(2)) in A       -> True  vs TypeError
  A[np.array(1), np.array(2)] = 1.0     -> sets  vs TypeError

A custom class implementing __index__ diverged the same way in all six
lanes; for it the slow path raises TypeError: Invalid type for index:
...; unable to convert to list.

Replace the duck-typed __index__ probe in all six lanes with the type
check parse_index applies: exact int or np.integer (bool has its own
exact type; np.bool_ is not an np.integer).  Anything else now falls
through to the expression path and gets its canonical error.  Bounds
checks and negative wrapping are unchanged for accepted types.

The remaining fast lanes on this branch cannot see such inputs: the
parse_index int lane already dispatches on exact type, and
ScalarIndexExpr.new / _extract_fast run only on indices that
parse_index has already validated.

Tests: add a 0-d ndarray and an __index__-implementing class to the
bad-input parity cases for get, __contains__, and __setitem__ on both
Vector and Matrix (17 new cases).  With the gate reverted, exactly
these 17 fail; with it, test_fastpath_parity.py goes 230 -> 247 passed
and the pinned suite 1034 -> 1051 passed, 141 skipped.
The fast path for v[i] = x and A[i, j] = x typed a Python int by itself,
so 2**63 was INT64 and raised OverflowError, while the full assign path
now types an int beyond INT64 as the destination's dtype and stores it
into UINT64 (#591). Such an int now takes the full path, as numpy values
and Scalars do.
test_fastpath_parity.py names a pair of UDT Vectors au and bu, and
codespell reads bu as a misspelling of by, but, bug and so on.
test_record_failed_call made a Recorder (gb.Recorder() starts one) and
test_record_repr_html started one (and called start() too), and neither
stopped it, so it kept running for the rest of the pytest session. Both
are on main: test_record_failed_call since #114 (2021), and
test_record_repr_html since #263 (2022). There that only means every
later call was recorded.

Here it matters: the scalar fast paths (get, in, integer-key setitem,
v[i].new() and value reads) step aside while a Recorder runs, so every
test that ran after test_recorder.py took the full path instead. In file
order that is test_scalar.py onward; under CI's random order it can be
any test, the fast-path parity tests included, which then compared the
full path with itself.

Both tests now record inside a with block. A new autouse fixture in
conftest.py fails a test that leaves a Recorder running (other than the
session's own one under --record) and stops it, so one leak cannot
change what the tests after it test. No other test trips it.
Behavior fix. When GrB_*_extractElement_<T> failed for a reason other
than a missing value, check_status built the error from the call's first
argument, the fresh empty C-scalar output, and asking it for its C value
raised EmptyObject ("Empty C scalar is invalid when passed as value
..."), which hid GraphBLAS's error. Extracting from an object whose data
was exported with give_ownership=True did this whenever the element went
to a C scalar.

The two cases differ in age. v[0].new(is_cscalar=True), and v[0].new()
under C-scalar defaults, raised EmptyObject on main too. v[0].value
raised NullPointer on main, which read the value through a GrB_Scalar;
this PR's single-extract fast path for value reads (_extract_fast) reads
every value into a C scalar, and so made .value raise EmptyObject. That
part is a regression from this PR, fixed here before it ships.

_extract_element now names the input as the object to report against,
through an optional exc_arg on _Pointer that check_status already honors
(as for _MatrixArray), so the error is GraphBLAS's, as with a GrB_Scalar
output and as in the fast paths. A TransposedMatrix reports against its
Matrix, since there is no GrB_TransposedMatrix_error. check_status is
unchanged. The new test extracts from a given-away Matrix, its transpose
and a Vector into both kinds of Scalar, and checks the same NullPointer
text for both; it fails before this with EmptyObject, and with the
"Unable to get the error string" text if a TransposedMatrix reports
against itself.
Behavior fix, the same as the previous commit's for the other calls that
write into a C scalar. Matrix.nrows, ncols and nvals, Vector.size and
nvals, Scalar.nvals and the GrB_Scalar read in Scalar.value pass their
count or value to GraphBLAS in a C scalar, and when the call failed,
check_status asked that empty C scalar for the error text, which raised
EmptyObject ("Empty C scalar is invalid when passed as value ...") and
hid GraphBLAS's error. Each now names its own object for the error,
through _Pointer's exc_arg; check_status is unchanged. A TransposedMatrix,
which shares Matrix's nrows, ncols and nvals, names its Matrix, as in the
previous commit, since there is no GrB_TransposedMatrix_error.

main has all of these. Of them, only the Matrix and Vector ones can be
reached through a public call: an object whose data was exported with
give_ownership=True has no GraphBLAS object left, and A.nvals, A.T.nvals
or v.size on it raised EmptyObject instead of NullPointer. Nothing leaves
a Scalar without its GraphBLAS object, and Scalar.value checks for
emptiness first, which already reported the error correctly, so the read
itself cannot fail; it changes for consistency only. The tests check that
a given-away Matrix, its transpose and a Vector, and a GrB_Scalar set to
NULL the way export leaves the others, raise NullPointer with the same
text as checking the object itself; they fail before this with
EmptyObject, and A.T with the "Unable to get the error string" text if it
reports against itself.
…th does

get, in, integer-key setitem and v[i].new() call GraphBLAS directly and
check its status themselves; get and in must also tell "no value" apart
from an error. Value reads extract through the full path's C-scalar
branch instead, which reports GraphBLAS's error since "Report
GraphBLAS's error when extracting an element into a C scalar". Nothing
reached those checks: Python validates every index and value first. An
object whose data was exported with give_ownership=True is left with no
GraphBLAS object, and every call on it fails, so the parity tests now use
one to check that each fast path, and each value read, raises the same
NullPointer as its reference, for a Vector and a Matrix, and for
v[i].new() into C scalars too. A get or in that read any error as a
missing value would return the default or False instead.
(0, slice(None)) in A and ([0, 1], 2) in A extract a row or a column,
not an element, so both the full path and _contains_matrix_slow raise
the TypeError that asks for a 2-tuple of ints; no case gave one.
_IndexLike.__index__ runs only if a fast lane accepts the object, which
the tests exist to catch, and __repr__ only in a failure message, so
both are marked "only on failure".
…a value read

A value read of v[i] (e.value, float(e), ...) extracts into a C scalar,
and the expression caches what it reads through. Its other attributes
then saw that C scalar: e.gb_obj was an int8_t * where a GrB_Scalar *
was, so a C call taking a GrB_Scalar refused it with TypeError, while
e.is_cscalar still said False. Main and the Recorder path gave a
GrB_Scalar (r599 R1).

Now an attribute other than a value read converts the cached C scalar
once into the kind of Scalar the expression reports, holding the same
value, so a value read is still a snapshot, as on main.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant