Status: implemented (Phase 2). Supersedes decision 4 of plan-ndarray.md ("
float64first").
Array holds float64 only. Three things the library is meant to do cannot be
done on that alone:
- Spectra.
go-fftreturns[]complex128. Without a complex dtype an FFT result cannot be an array, so the two libraries do not compose. - Masks. Comparisons return
0/1floats. They work forMaskSelectandWhere, but a mask cannot be told apart from data, and it costs 8 bytes per element instead of 1. - Memory-bound work.
float32halves the bytes moved, which is the whole cost of the elementwise ops; images arrive asuint8; counts and indices are integers.
The type at the centre of the library is the one thing that gets more expensive to change every release, and the module is pre-1.0. That is why it goes first.
type DType uint8 // Bool, Int8…Int64, Uint8…Uint64, Float32, Float64, Complex64, Complex128
a, _ := ndarray.Zeros(3, 4) // Float64, as before
b := a.AsType(ndarray.Complex128)
c, _ := a.Add(b) // Complex128, by NumPy promotion
m, _ := a.Greater(ndarray.Scalar(0)) // BoolThe alternative was a generic Dense[T]. It was rejected for four reasons:
- Promotion cannot be written as a generic method.
int32 / int32isfloat64andint8 + float32isfloat32: the result type depends on both operands. A method onDense[T]cannot returnDense[U]for aUchosen from its argument, and methods cannot have their own type parameters. The arithmetic would have to move to package functions, one per pair of types in the general case, and NumPy's semantics would be lost or re-encoded. - The consumers are dynamic. The notebook evaluates cells in an
interpreter, and the Ruby binding (
go-embedded-ruby) is a dynamic language. Both hold "an array" and ask it what it is; neither can carry a type parameter. - Nothing that exists breaks.
*Array, its methods and thefloat64hot path keep their names, signatures and speed. The Ruby binding uses method expressions such as(*nd.Array).Sqrt; those would stop compiling on an alias of an instantiated generic type. - Static typing is still available where it pays: generic accessors
Data[T],FromSlice[T]andItem[T]check the dtype once at the boundary and hand back a[]T.
type Array struct {
data []float64 // Float64 storage — the existing hot path, unchanged
ext any // []T for every other dtype, nil for Float64
dtype DType
shape []int
strides []int
offset int
}Keeping data []float64 as a concrete field means every existing float64
kernel, SIMD path and benchmark is untouched. The other dtypes are reached
through one type assertion per operation, not per element. Shape, strides and
views do not depend on the dtype, so slicing, reshape, transpose, broadcast and
the manipulation functions are written once over a generic helper.
Inner loops for the other dtypes are generic Go (func add[T number](dst, x, y []T)). The gc compiler stencils one copy per GC shape, and every numeric type
has its own shape, so these are fully specialised loops. SIMD kernels come
dtype by dtype after correctness, float32 first.
- Array–array promotion follows
numpy.result_type. The full 13×13 table is generated from NumPy 2.5.3 and committed as a test fixture (testdata/promotion.json, embedded so the emulated CI arches can read it); the implementation is tested against every cell. - Go scalars are weak, like Python scalars under NEP 50:
AddScalar(1.5)on afloat32array staysfloat32, on anint8array becomesfloat64.ScalarOf(v, dtype)makes a strong one. - True division of integers or bools gives
float64. - Reductions:
Sum/Prod/CumSumof bool and signed ints accumulate inint64, of unsigned ints inuint64;Meanof integers isfloat64;Max/Minkeep the dtype; arg-reductions areint64. - Float ufuncs on integers (
Sqrt,Exp,Sin…) givefloat64for 32- and 64-bit ints,float32for 16-bit ints. - Comparisons return
Bool.MaskSelectandWhereaccept aBoolmask and, as NumPy does, any other dtype by truthiness, so existing0/1masks keep working. - Integer overflow wraps, as it does in both Go and NumPy.
| NumPy | here | why |
|---|---|---|
float16 exists; sqrt(int8) is float16 |
no float16; sqrt(int8), sqrt(uint8) and sqrt(bool) give float32 |
Go has no half-precision arithmetic; float32 is the smallest type that holds the result exactly |
round is half-to-even |
half-away-from-zero (unchanged) | documented since v0.1.0 |
datetime64, str_, object, structured dtypes |
absent | out of scope for a numeric library |
At(idx...) float64keeps working for every real dtype, converting the element. On a complex array it panics with a message that namesAtComplex. NumPy instead discards the imaginary part with aComplexWarning; Go has no warnings, and a silent loss is the defect that warning exists to flag.AtComplex(idx...) complex128, andItem[T](a, idx...) Tfor exact typed access.Set(v float64, idx...)converts into the array's dtype.
| before | after | migration |
|---|---|---|
comparisons return a Float64 0/1 array |
they return Bool |
.AsType(Float64) to get the old values; MaskSelect and Where need no change |
Nonzero returns Float64 indices |
Int64, as np.flatnonzero |
.AsType(Float64), or Data[int64] |
ArgMaxAxis/ArgMinAxis return Float64 indices |
Int64, as NumPy |
same |
MaskSelect takes a 0/1 float mask |
it takes any dtype (truthiness) | none |
The only consumer in the fleet, go-embedded-ruby/ruby (internal/vm/ndarray.go),
does not use comparisons; it is pinned to a June pseudo-version and will be
moved forward in its own pull request.
Scalar(v)makes the weak operand from any Go number; the*Scalarmethods'float64arguments are weak floats.SumAll/ProdAllreturn a 0-d array in the accumulation dtype, exact for every dtype;Sum/Prod/Mean/Max/Minkeep returningfloat64and panic on complex arrays (naming the method to use instead).LogicalNot/LogicalAnd/LogicalOr/LogicalXor,Any,All, andReal/Imag/Conj/Angle.- Float-to-integer casts saturate on every platform. NumPy leaves them
undefined: on arm64 it saturates to 64 bits and then wraps (
+Inftoint8is-1), on x86NaNgives the minimum. - A
WorkspaceservesFloat64results only; the other dtypes allocate from the heap for now. - Only
Float64has SIMD kernels and multicore fan-out; the other dtypes run tight generic loops (one per element type, generated bygen_dtypes.go).MatMulon them is a plain triple loop.
testdata/promotion.json: every one of the 169result_typecells, the weak-scalar results, and the result dtype of the unary ufuncs and the reductions, from NumPy 2.5.3. All match, withfloat16read asfloat32.testdata/values.json: 47 operations on every dtype with edge values (integer limits, NaN, ±Inf, −0) — 3,147 values. Correctly rounded operations and every integer or bool result must match to the bit; the others within 8 machine epsilons of NumPy's result dtype. A deliberately broken integermaxfails 48 of them.
- Core —
DType, storage,AsType, creation with a dtype (ZerosOf/OnesOf/FullOf/FromSlice[T]), element access, and every shape-only operation (reshape, transpose, slice, copy, flatten, expand, squeeze, concatenate, stack, string form) for every dtype. - Arithmetic — promotion, the four operators and the scalar forms,
comparisons returning
Bool,Where/MaskSelect, reductions with their accumulator types, unary ufuncs. - Complex —
Real/Imag/Conj/Angle,Absof complex to float, and anfftsubpackage bridginggo-ffton arrays (fft.FFTN(a, axes...)). - Speed —
float32SIMD kernels, thenMatMulforfloat32and complex.
Each step keeps the 100% coverage gate and the per-arch CI, and is checked against NumPy before it ships.