Skip to content

Add an experimental Array API namespace over Tensor (graphblas.array_api) - #667

Draft
eriknw wants to merge 77 commits into
50-tensorfrom
58-array-api
Draft

eriknw wants to merge 77 commits into
50-tensorfrom
58-array-api

Conversation

@eriknw

@eriknw eriknw commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Experimental. This adds graphblas.array_api, a namespace that implements the Python Array API standard (2025.12) over the n-dimensional Tensor from the PR below. Arrays are logically dense, as the standard requires, but are stored sparse: each Array is a Tensor plus a fill value for the unstored positions. Arrays that are mostly zeros (or mostly any one value) stay small, and every operation runs as GraphBLAS operations on the stored entries.

It is a draft. The namespace may still change, and the Tensor changes it carries may move into the Tensor PR.

What is in it

  • Every name of the 2025.12 standard except the fft extension, plus linalg and __array_namespace_info__.
  • Construction from coordinates (from_coo) without going through a dense array. asarray takes numpy arrays, Python sequences and Tensors, and x.tensor gives the underlying Tensor back (copy on write).
  • Extensions beyond the standard:
    • keyword-only semiring= on matmul, tensordot and vecdot, and einsum(subscripts, *operands, semiring=None). For example, matmul(D, G, semiring="min_plus") is one relaxation step of a shortest-path computation.
    • prune(x) drops stored entries equal to the fill, and refill(x, fill_value) changes an Array's fill without changing its values.
    • compute(x) computes a pending (deferred) Array now.
    • x.fill_value, and x.nnz (stored entries, explicit fills included, as in pydata/sparse).
  • from_coo sums the values at a repeated coordinate, as pydata/sparse and scipy do.
  • One level of deferred contraction. matmul, tensordot, vecdot and einsum return a pending result, which other operations evaluate when they need it. multiply(S, A @ B) with a sparse S computes the product only at S's stored positions (a sampled dense-dense product, SDDMM). Indexing a pending product slices its operands first. A three-operand einsum such as "ij,jk,ik->" (triangle counting) computes the pair's product only at the third operand's stored positions.
  • graphblas.config["array_api_densify_limit"] (default 1e8): an operation that would materialize more positions than this raises DensifyError instead of allocating them.

Semantics

  • Values follow the standard, with numpy as the test oracle. NaN and infinity are exact. Transcendental functions use SuiteSparse's unary operators and are within 4 ulp of numpy (the tests saw at most 1 ulp). Where numpy itself departs from the standard's special cases, this namespace gives numpy's answer.
  • The sign of a zero at an unstored position is unspecified. With exact signed zeros, a sparse row scaling d[:, None] * A would have to store every position where d is negative.
  • No value depends on which positions happen to be stored. Two Arrays with the same values give the same results, whatever their storage. Structure-dependent operations (masks, explicit structure) stay on Tensor.
  • Contractions treat an unstored position as contributing no term, as scipy.sparse and pydata/sparse do: 0 * inf at an unstored position does not produce NaN. A stored NaN still propagates, including through min/max semirings.
  • semiring= requires each operand's fill to equal the semiring's additive identity, and that identity to annihilate the multiplication. The result's fill is that identity. Otherwise it raises ValueError. For example, min_plus needs operands filled with inf.

Tensor changes in this PR

Each change is in its own commit:

  • dup(dtype) keeps the layout of an n x 1 Tensor.
  • A broadcast ewise_mult into a shape with no positions.
  • A masked contraction passes a transpose through to SuiteSparse instead of copying the operand. One case went from 2.0 s to 0.9 ms.
  • A masked contraction asks for dot products when they cost well under a full saxpy. Example: n = 1e4, 1e6 mask entries, k = 64, one thread, from 8.9 s to 0.018 s.
  • A private Tensor._map_values.
  • concat through one GxB_Matrix_concat.

Testing

  • Over 1000 new tests, with 100% line coverage of graphblas/array_api. They pass with and without Numba; the namespace does not need Numba.
  • The official conformance suite (array-api-tests), run with pydata/sparse's CI settings (--max-examples=2 --derandomize):
passed failed
pydata/sparse 0.19.2 1278 137
this branch 1418 46

The failures are special cases where numpy differs from the standard (inherited on purpose), the 14 fft names, __dlpack__(copy=False), and a bug in the suite's can_cast test.

  • Differential fuzzing against numpy over semirings, deferred contractions, masked contractions in every layout, transcendentals, pow special cases, concat, prune and mixed indexing keys: tens of thousands of cases, with no mismatches left except the accepted unstored-term cases above.

Performance

Inputs with 1e7 stored entries, an 18-core arm64 Mac, JIT on, best of 3. Times are in seconds, at 1 thread / 8 threads:

operation this branch pydata/sparse (1 thread)
exp of the values 0.018 / 0.0044
concat along axis 0 0.085 / 0.016 0.021
concat along axis 1 0.077 / 0.014 0.271
SDDMM, n = 1e6, k = 64, 1e7 mask entries 0.76 / 0.13 (python-graphblas Matrix: 0.67 / 0.11) COO operands: out of memory already at n = 1e4
argmax along axis 0 (u2d, 1e7 entries) 0.285 / 0.107 (it was 11.9 / 11.5 when it ranked with numpy)

The same graph kernels written against the Array API in the Sparse Array Programming Suite run unchanged, apart from four functions the standard does not include. For example, SDDMM with k = 128 on ca-HepPh takes 0.015 / 0.004 s here and 0.114 s with scipy. Kernels written as a broadcast over an n x n matrix stay sparse when the fills absorb the operation (inf + x == inf) or when where's condition decides: Bellman-Ford on wiki-Vote takes 0.013 s against 0.222 s for pydata/sparse, and connected components 0.0057 s against 0.215 s. On larger graphs (soc-LiveJournal1 with 69M edges, com-Orkut with 234M stored entries), BFS, SDDMM, Bellman-Ford and triangle counting all run, and beat every rival that runs at all; SDDMM on LiveJournal takes 4.5 s at 1 thread and 0.81 s at 8 threads, while scipy, pydata/sparse and a plain python-graphblas formulation run out of memory.

Known gaps

  • No fft.
  • Indexing keys that mix None with index arrays are not supported in __setitem__.
  • When an operand of a deferred SDDMM holds inf or NaN, or its values could overflow, the whole sparse product is computed before the multiply.
  • A contraction with a non-finite nonzero fill (a 0-d inf, for example) is computed dense, under the densify limit.
  • where(x > t, x, 0) stores what a select would, but cost about 4x a raw Tensor.select at 1e7 entries when last measured (before the latest where changes, which target broadcast branches).
  • Max along an axis costs more than the raw Tensor reduction, because counting each column's stored entries is a sum, and wide column sums still run on at most 2 threads.
  • A broadcast kernel whose fills do not absorb the operation (for example D[:, None] + G with a NaN or a -inf present) still takes the union and can store close to n^2 entries.

🤖 Generated with Claude Code

@eriknw
eriknw added this pull request to stack #666 November 11, 2026 13:12
@eriknw
eriknw removed this pull request from stack #666 November 12, 2026 06:13
@eriknw
eriknw changed the base branch from 50-tensor to main November 12, 2026 06:15
@eriknw
eriknw changed the base branch from main to 50-tensor November 12, 2026 06:17
@eriknw
eriknw added this pull request to stack #670 November 12, 2026 06:17
@eriknw
eriknw force-pushed the 58-array-api branch 10 times, most recently from ac69436 to d3c884d Compare November 13, 2026 04:37
An Array is a Tensor of stored entries plus a fill value for every other
position, as in pydata/sparse. This first step has the Array class, the
dtypes, creation functions, the element-wise functions, astype and the
data type functions, __array_namespace_info__, basic indexing (ints,
slices, ..., None) and reshape, permute_dims, expand_dims and squeeze.
Element-wise results follow numpy on every position, the fill included;
numpy maps the stored values where no GraphBLAS operator gives its
answer exactly.
…umpy's clip

Every element-wise function is compared with numpy on small arrays of each
dtype, with zero and nonzero fills, NaN, infinities, signed zeros, stored
entries equal to the fill, broadcasting, 0-d arrays and Python scalars.

clip with scalar bounds is numpy's clip, which keeps -0.0 against a bound
of 0.0; with array bounds it is minimum(maximum(x, min), max), as numpy's
clip with array bounds is.
sum, prod, min, max, any and all reduce the stored entries in GraphBLAS
and then account for each fiber's unstored positions from a count of its
entries: k * fill for sum, fill ** k for prod, the fill once for the
others. No count is needed when the fill is the monoid's identity or every
position is stored. min and max propagate NaN, as numpy's do.

mean divides the sum; var and std take two passes (the stored entries'
squared deviations from their fiber's mean in GraphBLAS, plus the fill's
times its count). cumulative_sum and cumulative_prod go through numpy
under the densify limit, since their results are dense. diff subtracts
shifted slices, with prepend and append joined along the axis.
…asic indexing

isdtype takes only this namespace's dtypes (or numpy scalar types), as
numpy's does, not strings.
With both fills 0.0, the product at a position only one operand stores is
value * 0.0, which is 0.0 (the fill) only for a finite value with its sign
bit clear: -1.0 * 0.0 is -0.0 and inf * 0.0 is NaN. Each Array caches
whether its stored values are all such, and multiply takes the
intersection (ewise_mult) when both are, the union otherwise.
…-0.0 is present

GraphBLAS's max and min of floats are C's fmax and fmin, which skip NaN
and may return either of 0.0 and -0.0; numpy's maximum and minimum
propagate NaN and order -0.0 below 0.0. Without NaN or -0.0 in either
operand they agree, so an Array now caches a summary of its stored values
(finite, nonnegative, NaN, -0.0), computed once, and maximum and minimum
use GraphBLAS when the summaries and fills allow. min and max reductions
and multiply read the same summary.
Reuse the GraphBLAS Scalars that carry fills and Python scalars (a new
one costs about as much as a small union), skip the broadcast checks for
operands of one shape, and reduce over every axis to a Scalar, whose
count of unstored positions is known without a count reduction.
A 10x10 x + y now costs about 24 us (pydata/sparse: about 110 us; a bare
Tensor union: about 20 us).
An Array that stores every position never shows its fill. Where the
fills of an operation raise (an integer to a negative power) and an
operand is such an Array, the result's fill is never seen either: 0
stands in for it instead of raising an error numpy would not raise.
…ared Matrix once

map_values unloaded each operand's Matrix through a GxB_Container. Two
Tensors that share one Matrix (copy-on-write) made it unload the same
Container twice, and the second read found no values. It now unloads
each Matrix once.

Also drop branches no input reaches: numpy's type resolution already
refuses what they checked.
…every mean

The means of a reduction store exactly the fibers that hold entries, and
every entry lies in one of them, so the deviations are one broadcast
ewise_mult of the entries with the means as they are. Before, the mean of
every fiber was stored densely first (and refused past the densify
limit): var(x, axis=0) of a 1e9 x 1e9 array with a few entries now takes
about a millisecond.
where takes the union of the entries of its three operands (broadcast)
and numpy's where over their aligned values. concat joins the stored
entries in O(entries); the result's fill is the one held by the most
positions, and a part with another fill stores its fill explicitly. stack
is concat of expand_dims.
All cost O(entries of the result): flip slices, moveaxis permutes (O(1)),
and repeat, roll and tile compute the new coordinates of the stored
entries. broadcast_to replicates the entries over the broadcast axes, as
a union does, under the densify limit.
A mostly-True bool result that stores every position took fill True, and the
contractions cannot skip a True fill: a mostly-True mask times a sparse bool
matrix (m @ A, einsum, lxor_land, max_times) raised DensifyError. A fully
stored bool result now always takes fill False (an O(1) relabel: no position
shows the fill), and drops its False entries only when at most a quarter of
the entries are True. Found by review.
…se product

When an operand of a pending product held inf or NaN, or its products could
overflow, multiply(S, P) checked P's logical size against the densify limit
before computing P whole, so any large sparse product raised DensifyError
where computing without deferral succeeds. It now computes the product (a
sparse product, as without deferral) and multiplies, with no densify check.
Found by review.
…ries

X * y or X / y, where X's fill is 0 and y broadcasts and does not store every
position, stored y in full (O(size of y), within the densify limit) and past
the limit replicated it over its broadcast axes. It now reads y's values at
X's stored coordinates (projected onto y's axes, with y's fill where y
stores nothing): O(entries of X). X of shape (1e4, 1e4, 2) with 4 entries
times exp(W)[:, :, None] (W 1e4 x 1e4 with 1e5 entries): peak RSS 1634 MB to
150 MB, for a result of 4 entries. Found by review.
…nd drop the fill split

Three changes to the contractions, and two small ones, all found by review:
- Explicit fills no longer change values: when an operand holds inf or NaN,
  the operands' stored entries equal to their fill are pruned first, so such a
  value meets an explicit fill as it meets an unstored one, with no term
  ([explicit 0] @ [inf] was NaN, the pruned form 0).
- A stored NaN term propagates through a float min or max monoid, as numpy's
  min and max do; SuiteSparse's fmin and fmax skipped it, so min_plus of
  [[nan, 5]] and [[1], [3]] was 8. The result is computed at once and NaN set
  where a NaN term is: one operand's NaN entries times the others' patterns.
- A nonzero fill no longer splits A into A0 + fill (which turned inf into NaN
  and lost every digit when the fill dwarfs the values): an operand that
  stores every position takes fill 0, any other nonzero fill goes dense.
- The dense path checks the result's size against the densify limit, not
  only the operands'.
- tensordot refuses an axis out of range instead of wrapping it.
|z| of a finite complex z may overflow to inf (|1.5e308 + 1.5e308j|), so abs
of a complex array no longer passes a cached "finite" (or "nan") fact on to
its result: a multiply after it took the intersection and read 0 where numpy
has inf * 0, NaN. Found by review.
…computed once

Four small fixes, found by review:
- The soundness check of a semiring is cached per typed semiring object, not
  by name: two user semirings of one name shared the first one's verdict.
- The SDDMM's dot-product hint divides its saxpy estimate by the number of
  batches (a batched product pairs entries within a batch only). 2000 batches
  of 64 x 64 at density 0.01 under a mask of density 0.5, 8 threads: the
  deferred SDDMM went from 20.4 ms to 7.5 ms (7.8 ms without deferral).
- multiply(P, P) of a pending product computes it once.
- vecdot's docstring says the default semiring, given or not, conjugates x1.
x[0, None] = v with v shaped like x[0, None] raised: the value was sized
without the new axes. For a key of ints, slices, "..." and None, the value's
axes that line up with the new axes (of length 1) are now dropped first. Keys
that mix None with index arrays keep their former behavior. Found by review.
A contraction with an operand whose nonzero fill shows went dense, so
A @ xp.ones(n), einsum("ij,j->i", A, ones) and einsum("ij,->ij", A, 2.0) of a
large sparse A raised DensifyError. An operand with a finite nonzero fill that
is no larger than the largest operand's entries is now stored in full first
(O(its size); an unstored zero of another operand times its finite values is
0 either way), and einsum takes a 0-d operand with a nonzero fill out as a
scalar factor. A non-finite fill still goes dense, as numpy computes it.
Found by review.
An Array could not be pickled: its Tensor holds weak references. Array now
pickles as its stored coordinates and values, shape, dtype and fill, and is
rebuilt by from_coo (explicit fills stay stored; a pending product is
computed first). Found by review.
einsum took a 0-d operand out as a scalar factor of the whole sum; for an
infinite scalar that differs from numpy, which multiplies each term (the
unstored zeros times inf are NaN). A non-finite 0-d operand now takes the
dense path, as before the factoring. The SDDMM's dot hint takes the result's
shape as optional (one batch without it).
…sample

Counting the True values of a fully stored bool result, to decide whether to
drop its False entries, cost as much as the comparison that made it (2e7
values at 1 thread: greater 3.4 ms, with the count 6.4 ms), on every result.
A sample of the leading 2^16 entries now decides: over a third True, the
result is kept as it is (3.3 ms); otherwise one select of the True values
decides, and is the result when at most a quarter of the entries are True.
Dropping still costs the select (2e7 values, 1% True: 22 ms at 1 thread, 4.2 ms
at 8, against 3.2 and 1.4 for the comparison alone). Values never change; a
sample unlike the rest only changes what is stored. Found by review.
…st y's size

Two review findings in the paths that keep products sparse:
- A contraction stored an operand with a finite nonzero fill in full only
  when it was no larger than the other operands' stored entries, so
  A @ xp.ones((n, 8)) of a large sparse A raised DensifyError, and the answer
  could depend on the other operand's explicit zeros (stored in full: no term
  where an unstored zero meets inf; dense: NaN). Such an operand is now stored
  in full whenever its size fits under the densify limit, whatever the others
  store; a non-finite fill still goes dense.
- The one-sided product gathered a broadcast operand's values at every entry
  of the other even when that operand is smaller: A (1e6 x 1e6, 1e7 entries)
  times y[:, None] with y 10% stored took 147 ms and 480 MB more RSS at 8
  threads. It now gathers only when the other has fewer entries than y has
  positions, and otherwise stores y in full for the broadcast product: 6 ms,
  36 MB.
… pattern

einsum("ij,jk,ik->", U, U, U) (triangle counting) built all of U @ U before
the third operand met it. When one operand's indices are exactly what the
other two leave after contracting their shared ones (batch indices
included), the pair is now computed at that operand's stored positions only
(a masked product, with the SDDMM dot hint), times the operand, then summed
into the output. Only for semirings whose multiply distributes over the
monoid (plus_times, lor_land, min_plus, max_plus, min_max, max_min).
Operands holding inf or NaN prune their explicit fills first, as the two-operand path does; a NaN
under a float min or max monoid, a complex operand holding inf, and a
non-finite masking operand under plus_times (inf * (x + y) is not
inf * x + inf * y) take the full einsum. Values do not depend on the path.
…fill selects

Where the condition stores nothing it reads its fill, so the result there is
the operand that fill selects. When that operand needs no broadcast (or is a
scalar, or stores nothing), where now starts from it and writes the
condition's stored positions over it, reading the other operand only at
those positions. SAPS's connected components,
min(where(graph, labels, int_max), axis=1), replicated labels over every
row of graph (n^2 entries; a DensifyError past about 10k nodes); it now
stores graph's entries. Otherwise the union as before.
…ersect

inf + y is inf for every y but -inf and NaN, so when both operands' fills
are inf the result can differ from inf only where both store an entry. Add
(inf or -inf), maximum (inf, or an integer's maximum, or True), minimum (the
opposites) and logical_or (True) now take the intersection when both fills
absorb the op, which never replicates a broadcast operand. An operand that
stores every position counts as holding that fill. Floats need no NaN in
either operand and, for add, no opposite infinity: new cached facts "posinf"
and "neginf" beside "finite", "nan" and "zero". SAPS's Bellman-Ford step,
expand_dims(D, 1) + G with inf fills, wrote D across every column of G.
Such an operand never shows its fill, so matmul, tensordot, vecdot and
einsum with a semiring now relabel its fill as the monoid's identity (O(1), and no
value can tell the fills apart) instead of refusing it, when its dtype holds the identity (an
integer cannot hold inf). A dense distance matrix with no zero, given as an
ndarray, now runs min_plus.
refill(x, fill_value) (an extension) stores the positions that held the old
fill (O(size), under the densify limit; O(1) when x stores every position)
and drops the entries equal to the new fill. A dense distance matrix with
inf for "no edge" and zeros on the diagonal becomes fill inf with its zeros
stored, which a min_plus contraction accepts. The semiring error's hint,
which suggested asarray(x.tensor, fill_value=...) (that turns the positions
holding the old fill into the new one), and asarray's refusal to change an
Array's fill now point at refill.
…s to fill False

equal(deg, 0) of a deg that leaves its few zero degrees unstored has fill
True and stores a False at every other position. When those False entries
are more than half of all positions, the result now takes fill False and
stores only its True positions (its unstored positions and stored True
entries): O(size), under twice its entries. In SAPS's PageRank on a graph
with few dangling nodes, (1/N) * zero_deg then has fill 0 and M stays
sparse (n=2000, 2 dangling: 19,965 entries instead of 3,996,000). A graph
with mostly dangling nodes keeps fill True, which is already the smaller.
matmul, tensordot, vecdot and einsum return an Array that computes on first
use. compute(x) (an extension) computes it at once and returns x, for timing
and for frameworks with a compute step (SAPS's Framework.compute, which had
only the private x._T()). Any other Array is returned as it is.
from_coo raised "Duplicate indices found, must provide dup_op" for a
repeated coordinate; pydata/sparse and scipy.sparse sum such values. It now
sums them (logical or for bool; integers wrap as numpy's sum does). One
value for every coordinate stays stored once (iso) and is built again from
the repeated value only when coordinates repeat.
A 0-d Array stores nothing (its value is its fill), but each one made a new
empty Tensor: a 1 x 1 Matrix plus a per-Matrix format setting, about 6 us,
half of a 0-d multiply. They now take an O(1) copy-on-write copy of one
empty Tensor per dtype (_utils.empty_0d), which a change to any copy does not
reach. A 0-d multiply went from 11.9 to 6.3 us; SAPS's time-stepping loops
make one per operation.
A 0-d operand stores nothing (its value is its fill), so it cannot mask the
pair's product; einsum with one takes the full path. Only an identity-valued
0-d operand reached the masked path before, where the answer was the same.
…other operand

When the operand the condition's fill selects holds only the result's fill
(a scalar, or an array that stores nothing), a condition entry equal to the
fill would store that fill: it is left out, and the other operand is read
only at the remaining entries. where(x > t, x, 0.0) with x of fill 0 stored
every entry of x > t, explicit False ones included (an explicit 0.0 at each
x <= t); it now stores x's entries above t only, as a select would.
…s in GraphBLAS

The previous commit kept only the condition's entries that select the other
operand, but did it in numpy (export the coordinates, gather, rebuild),
single-threaded. When the condition is bool and that operand has the
result's shape, it is now a select of the condition's entries, holding the
operand's fill, overwritten by the operand's entries there (an intersection).
where(x > t, x, 0.0) on 1e7 entries, including the comparison: 0.32 s at 1
thread and 0.058 s at 8 (the numpy path: 0.19 and 0.13 s; before either:
0.33 and 0.068 s, storing twice the entries); the Tensor's select alone takes
0.081 and 0.012 s.
They exported every coordinate and ranked the values with np.lexsort: O(entries log
entries) on one thread, 357x a max along the same axis at 1e7 entries. Now each step is a
GraphBLAS pass over the stored entries, with the fibers as the rows (or columns) of the
stored Matrix: the extreme of each fiber, the entries equal to it (a row or column scaling
by the extremes under eq, with a value at every diagonal position so SuiteSparse runs it as
a scaling), and the smallest position among those (a positional min_firstj or min_secondi
product). The first stored NaN wins over it, as in numpy.

Where the fill beats or ties a fiber's extreme (or is NaN), the fiber's first unstored
position takes part. It is 0 unless the fiber stores position 0, which one positional
product shows; only the fibers that do are read out, to find the first position missing
from their entries. The result stays a Vector until it becomes the output Tensor, and
stores only the positions that are not 0 (its fill).
Where the condition stores nothing its fill selects one operand; when that operand holds
only the fill, the result is the condition's selecting entries holding the other operand. A
broadcast other operand (connected components' where(graph, labels, big)) was read by
exporting the condition's coordinates and gathering in numpy, on one thread. Now the
selecting entries are a select on the condition (truthy ones, or falsy ones under a truthy
fill, for any real dtype), given the other's fill, with its stored values written over by a
broadcast ewise_mult, which the Tensor runs as a diagonal scaling without replicating the
operand. A complex condition still goes through numpy.
A broadcast ewise_mult is a diagonal scaling, but SuiteSparse takes its one-pass scaling
path only when the diagonal has a value at every position; xp.arange(n) stores no 0 (its
fill), and the general product it falls back to is 17x slower at 1e7 entries (0.71 s against
0.041 at 1 thread). The branch is now stored at every position first, when it is no larger
than 4x the condition's selecting entries, and the result is that one product.
…BLAS

where(frontier, k, level), multi-source BFS's level update, has a base (level) that stores
entries. It exported the condition's coordinates, gathered both operands there in numpy,
rebuilt them as a Tensor and took the union with the base: on soc-LiveJournal1 with 8
sources the kernel's memory grew 3.9 GB over its inputs, against 0.85 GB for SAPS's own
python-graphblas framework (which assigns under a mask). Now the condition's selecting
entries are computed as for a base that holds only its fill (a select, then the other
operand's values) and written over the base by one ewise_add: 0.83 GB, and the kernel
takes 2.0 s instead of 3.2. A complex condition still goes through numpy.
The 2-thread cap on a reduce_columnwise into 1e5 or more outputs (603ca79) came from sums:
on FROSTT Uber (4392 x 1.96e6) 4.0 / 4.0 / 17 / 15 ms at 1 / 2 / 4 / 8 threads, and the
same holds now (1e4 x 1e6 with 1e7 entries: 18 / 18 / 11 / 39 ms). MAX and MIN scale
instead, at 4 and 8 threads, with no loss on any shape measured: 5e6 x 5e6 with 1e7 entries
0.122 -> 0.050 s at 8 threads, 1e7 x 1e7 with 1e8 entries 0.66 -> 0.28 s, 1e3 x 1e7
0.100 -> 0.049, Uber 4.1 -> 2.7 ms (JIT off and on alike, SuiteSparse 10.5.1, arm64). So the
cap now applies to the other monoids only.
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