Universal functions (ufunc) basics
A universal function (ufunc) operates on an NDArray element by element, with broadcasting, type casting, and a small set of standard options. np.add, np.sqrt, np.exp, np.less, and the arithmetic/comparison operators are all ufuncs: each takes a fixed number of array inputs and produces a fixed number of array outputs, looping in fast C#/SIMD code rather than a C# for loop.
NumSharp implements the ufunc model - elementwise semantics, broadcasting, NEP 50 casting, out=/where=/dtype= - but exposes each ufunc as a direct np.* function (and operator), not as a NumPy-style ufunc object with .reduce/.accumulate/.at methods. This page covers the model and the porting differences.
Elementwise operation
The simplest ufunc is addition:
np.array([0, 2, 3, 4]) + np.array([1, 1, -1, 2]); // [1 3 2 6]
np.add(np.array([0, 2, 3, 4]), np.array([1, 1, -1, 2])); // same
Every arithmetic (+ - * / %, unary -), comparison (== != < > <= >=), and logical (& | !) operator is a ufunc, and each has a named function form:
| Category | Operators | Named ufuncs |
|---|---|---|
| Arithmetic | + - * / % |
add, subtract, multiply, divide/true_divide, mod/remainder, power, floor_divide |
| Comparison | == != < > <= >= |
equal, not_equal, less, greater, less_equal, greater_equal |
| Logical / bitwise | & \| ! |
logical_and/or/not/xor, bitwise_and/or/xor, invert |
| Unary math | - | sqrt, exp, log, sin, cos, abs, negative, square, sign, floor, ceil, … |
Broadcasting
Ufuncs apply broadcasting so inputs of different shapes still combine - a size-1 dimension is stepped with stride 0 (the same stored element feeds every position along that axis):
var a = np.ones((3, 4));
var b = np.array([1, 2, 3, 4]); // (4,)
a + b; // (3, 4) - b broadcast across rows
See Broadcasting for the full shape rules.
Type casting
When a ufunc's inputs have different dtypes, NumSharp picks a result dtype with NumPy 2.x's promotion rules (NEP 50): the inputs are cast to a common type, the loop runs there, and the result carries that dtype.
var i = np.array([1, 2, 3]); // int32
var f = np.array([1.5, 2.5, 3.5]); // float64
(i + f).dtype; // float64 - int32 promoted
A key NEP 50 subtlety: weak scalars (C# primitive literals) adopt the array's dtype rather than upcasting it, while strong operands (arrays, 0-d NDArray) promote normally:
var x = np.array([1, 2, 3], np.int8);
(x + 1).dtype; // int8 - weak scalar 1 does not upcast
(x + np.array([1])).dtype; // int32 - the int32 array (strong) forces promotion
Full promotion rules and the 15×15 table are in Data types → Type promotion and NumPy Compliance.
out=, where=, and dtype=
The elementwise ufuncs take NumPy's three keyword-style options, exposed as one overload shaped like NumPy's signature - f(x[, x2], NDArray out = null, NDArray where = null, DType dtype = null):
var a = np.array([1.0, 2.0, 3.0, 4.0]);
var dst = np.zeros(4);
np.sqrt(a, @out: dst); // write into dst, return it
np.add(a, 10, @out: dst); // dst = a + 10
np.add(a, 10, @out: dst, where: a > 2); // only where the mask is true; other slots keep prior contents
np.add(0.1, 0.2, dtype: np.float32); // run the loop at float32 precision
out- write the result into an existing array (any compatible layout); it is returned.outmay alias an input (overlap-safe). The result is cast toout's dtype under NumPy'ssame_kindrule.where- a boolean mask that broadcasts to the output; positions where it isfalsekeep their prior contents (so pairwherewith a meaningfulout).dtype- selects the loop dtype, i.e. the precision the computation runs at (np.add(0.1, 0.2, dtype: np.float32)computes in float32 even into a float64out). It also gates which loops exist - a float-only ufunc rejects an integerdtype.
@out is spelled with the @ because out is a C# keyword. Pass where/dtype by name.
Reductions
A reduction collapses an axis with a binary ufunc (sum, product, min, max, …). NumSharp exposes these as direct functions with axis, dtype, and keepdims:
var x = np.arange(9).reshape(3, 3);
np.sum(x, axis: 1); // [3 12 21] - reduce along axis 1
np.sum(x); // 36 - reduce all axes (no axis)
np.prod(x.astype(np.float64), axis: 0);
np.max(x, axis: 0, keepdims: true); // shape (1, 3)
One divergence from NumPy: the reductions take a single
int?axis (or none - all axes). NumPy's multi-axis tuple form (axis=(0, 1)) is not supported; pass no axis to reduce everything, or reduce one axis at a time.
The reduce upcast rule
For sum/prod/cumsum/cumprod with no explicit dtype, an integer or boolean input smaller than the default integer is upcast to int64 - uint64 for unsigned inputs - to avoid overflow, matching NumPy:
var x = np.array([1, 2, 3], np.int32);
np.sum(x).dtype; // int64 - accumulating reductions widen (uint64 for unsigned)
np.mean(x).dtype; // float64
np.max(x).dtype; // int32 - min/max/amax preserve the input dtype
abs, sign, min, max, and the comparisons preserve the dtype; only the accumulating reductions widen. This is NEP 50 alignment - see the DirectILKernelGenerator NEP50 table and Data types.
Pass an explicit dtype to control it: np.sum(x, dtype: np.float64).
NumSharp does not expose ufunc-object methods
NumPy attaches reduce, accumulate, reduceat, outer, and at to the ufunc object (np.add.reduce(x), np.multiply.accumulate(x), np.add.at(x, idx, v)). NumSharp has no ufunc-object surface - use the direct functions instead:
| NumPy ufunc method | NumSharp equivalent |
|---|---|
np.add.reduce(x, axis) |
np.sum(x, axis) |
np.multiply.reduce(x) |
np.prod(x) |
np.maximum.reduce(x) / np.minimum.reduce(x) |
np.max(x) / np.min(x) |
np.add.accumulate(x) |
np.cumsum(x) |
np.multiply.accumulate(x) |
np.cumprod(x) |
np.add.at(x, idx, v) (unbuffered scatter-add) |
np.add(x, v, @out: ...) / np.put/np.place (note: no accumulating scatter) |
np.add.outer(a, b) |
a[Slice.All, np.newaxis] + b (broadcast; newaxis is an index object, not a slice-string token) |
np.maximum/np.minimum (the elementwise pairwise ufuncs) exist as direct functions and are distinct from np.max/np.min (the reductions), exactly as in NumPy.
Fused expressions - np.evaluate (NumSharp extension)
Chaining ufuncs (a * b + c) normally allocates an intermediate per operation. NumSharp adds np.evaluate, which compiles an expression tree into one pass - every elementwise node runs inside a single inner loop, reading each operand once and allocating no intermediates:
NDArray r = np.evaluate((NDExpr)a * b + 2); // fused a*b+2, one pass
NDArray s = np.evaluate(NDExpr.Sum((NDExpr)a * b)); // fused sum(a*b), no temp
This is the NumSharp analog of numexpr.evaluate - measured 3–6× faster than the equivalent NumPy chain on large arrays. Per-node dtypes follow the same NEP 50 rules as the individual ufuncs. See NDIter and the API reference.
Common patterns
In-place elementwise transform
np.multiply(a, 2.0, @out: a); // double a in place (out aliases input)
Masked update
np.add(a, 100, @out: a, where: a < 0); // add 100 only to negative elements
Reduce with overflow safety
np.sum(np.array(new byte[] { 200, 200, 200 })); // uint64 result (600) - no uint8 overflow
Troubleshooting
"np.add.reduce doesn't exist"
NumSharp has no ufunc-object methods. Use np.sum (see the table above).
"My int32 sum came back int64"
That's the reduce upcast rule (NEP 50) - accumulating reductions widen small integer inputs. min/max/abs preserve the dtype. Pass dtype: to override.
"out didn't change / masked positions had garbage"
With where, the false positions keep whatever out held before - initialize out (e.g. from a copy) so masked-off slots are meaningful.
"A weak scalar didn't upcast my array"
By design (NEP 50): int8_array + 1 stays int8. Use a strong operand (+ np.array([1])) or an explicit dtype to promote.
API reference
| Feature | Form |
|---|---|
| Elementwise ufunc | np.add(a, b), np.sqrt(a), operators + - * /, == < >, & \| ! |
out= |
np.sqrt(a, @out: dst) - write into dst, returned; may alias an input |
where= |
np.add(a, b, @out: dst, where: mask) - masked; false slots keep prior contents |
dtype= |
np.add(a, b, dtype: np.float32) - selects the loop precision/loop |
| Reductions | np.sum, np.prod, np.min, np.max, np.mean, np.std, np.var, np.cumsum, np.cumprod (with axis, dtype, keepdims) |
| Pairwise | np.maximum, np.minimum, np.fmax, np.fmin |
| Fused | np.evaluate((NDExpr)…), NDExpr.Sum/Prod/Min/Max/Mean |
Related reading
- Broadcasting - the shape rules ufuncs apply.
- Data types - casting and promotion (NEP 50).
- Indexing on NDArray - where
out/wherewrite. - IL Generation - how the elementwise loops are compiled (SIMD) and the NEP 50 reduce table.
- NumPy ufunc basics guide - the upstream article.