Diese Seite gibt es noch nicht auf Deutsch, deshalb steht sie hier auf Englisch. Zur englischen Seite
Tensors on the CPU: std/tensor#
Status: designed, not built (2026-10-23) — this record decides what std/tensor is on the CPU: the type and its
layout, the scalars it accepts, whether a slice is a copy or a view, broadcasting and how a shape mismatch is reported,
the operations and operators of the first release, how a result is written in place, what the first parallel loop waits
for, and the hooks Dual, std/gpu and reverse mode will need. Nothing in std/, runtime/ or compiler/ changed
with it; slice 1 of section 11 starts the building. It is step 1 of epic #82, and it is the full design that
COMPUTE.md promises "before the first of these packages is built".
std/tensor is pure TorbScript on Buffer<Item>: no new native, no syntax, no foreign function. The shape is a
run-time value, Matrix<Scalar, Rows, Columns> stays in std/linear with its size in the type, and a tensor is a value
like every other, so a change to a copy never shows in the original.
The questions are decided here, with the reason next to each decision. Only a public commitment, a cost, a product direction or the break of a published contract may stay open, and section 12 says that none does.
1. Where it stands#
What the package is built from, checked against the tree on 2026-10-23.
| Ingredient | State | What it means for a tensor |
|---|---|---|
Buffer<Item> (std/core/src/buffer.trb, #54, #300) | built | withCapacity, append, get, set, [index], for; a write is in place while the storage has one owner and copies once when the value was copied; slice copies. There is no filled(count, value) and no resize: a buffer of n items is withCapacity n and n appends |
Numeric, Signed | built (std/number) | + - * / %, ==, <, Show, Scalar.zero, Scalar.one through a type parameter (LINEAR gap 10, closed) |
Real | built for Float64 and Fixed | squareRoot, exponential, naturalLogarithm, sine, ..., Power; Float32 has none of it (LINEAR gap 8) |
Plain | built | granted by the compiler to a type with nothing counted inside: Int, Float, Fixed, a record of them |
windows and Window (std/parallel, #4) | built | over var ArrayList<Item>, not Buffer (CONCURRENCY section 16, "not built here: the buffer half") |
Random (std/random) | built | a value; var fn float(): Float64 draws in [0, 1); split() |
Array<Item, const Size: Int> | runs nowhere | LINEAR gap 9: why a tensor cannot be an array, and why the shape is a run-time value |
| Operators on a generic type | built (LINEAR gap 12, item 1) | a + b is Add.add; probed for tensors in section 7 |
ArrayList and Buffer are one runtime object today (COLLECTION-STORAGE.md section 1), which
is why the windows question of section 8 is a signature and not a runtime change.
2. The rules every decision answers to#
- Pure TorbScript on
Buffer. A slice adds no native and no syntax. A slice that finds it needs one stops, files the need as its own issue, and falls under the two-commit rule of CLAUDE.md; the seed refresh stays with the owner. - A tensor is a value. Copying one and writing the copy never touches the original, whatever the layout under it.
- Shapes are run-time
Ints. Nothing here needs arithmetic on size parameters (COMPUTE section 3). - A panic is a broken promise of the program (PANICS.md section 5.1): every panic of this package has a precondition the program can state as an ordinary expression, and the record names that expression.
- A decision that costs speed names the benchmark that bounds it (section 10).
- Reproducible by construction. The same program on the same inputs makes the same bits in both back ends and on any number of workers; section 4 fixes the order of every reduction for that reason.
3. The type and its layout#
Decision 1: one generic type, Tensor<Scalar: Numeric = Float>, with four private fields.
public type Tensor<Scalar: Numeric = Float> {
/** The extent of every axis, outermost first. Empty for a tensor of rank 0. */
private shape: List<Int>
/** Items to step over to move one along an axis; as many as `shape`. */
private strides: List<Int>
/** The position of the item `[0, 0, ...]` in `storage`. */
private offset: Int
/** Every item of the tensor, and for a view possibly more. Held by value: a copy is a count, a write copies once. */
private storage: Buffer<Scalar>
}Reason: Vector2 is one type over a scalar with a bound and a default (LINEAR section 1), and the rank is data, not a
type: Tensor<Float> is a vector, a matrix and a batch of images by the length of shape. Float is the default
scalar, so Tensor alone is a Float64 tensor as Vector2 alone is. shape and strides are List<Int>: they are
short, they are shared by every view of one tensor in O(1), and they are the values a kernel signature will be built from
(section 9). The fields are private, so no program can build a tensor that breaks the invariants of decision 3.
Decision 2: row-major order, strides counted in items, never negative. The dense strides of a shape [2, 3, 4] are
[12, 4, 1]: the last axis moves fastest. Reason: row-major is what iteration over a Buffer, safetensors, ONNX and
every model format the layer above reads already is, and the order of for item in tensor is then the order of the
storage. std/linear stores matrix columns (LINEAR section 7: a column is where a basis vector lands); the
conversion between a Matrix3 and a Tensor is an explicit function of a later slice and is a transpose, not a cast.
A negative stride (a reversed view) is out: reversing is a copy, and a stride that is never negative keeps the index
arithmetic of every kernel an addition.
Decision 3: the invariants a value keeps.
- I1.
shape.length() == strides.length(): the rank. - I2. Every extent is at least 0; the number of items is the product of the extents (
count()), and1for a rank 0 tensor. - I3. Every stride is at least 0, and a stride is 0 only on an axis of extent 1 or on a view made by
broadcast(section 6). - I4. When
count() > 0, the last item has a position inside the storage:offset + sum((extent - 1) * stride) < storage.length(). - I5. The dense normal form:
offset == 0, the strides are the row-major ones of the shape, andstorage.length() == count(). Every constructor and every result of an operation is dense; only a view (section 5) is anything else, andisDense()says which.
Equality, Show, iteration and every reduction look at the items in row-major order of the shape, never at the
fields, so two tensors with the same items and different layouts are equal. Equals is therefore written by hand and
not generated from the fields, which would compare strides and storage.
Decision 4: a zero-sized axis is an ordinary extent, and a rank 0 tensor holds one item.
- Shape
[0, 3]hascount() == 0, an empty storage is allowed, and it iterates nothing. Shape[]hascount() == 1: it is a scalar, the product of no extents.Tensor.scalar(3.0)makes one,tensor[[]]reads it. - A zero extent broadcasts with 1 and with 0 and with nothing else (section 6).
[0, 3] + [3]is[0, 3]. sum()andproduct()of an empty tensor areScalar.zeroandScalar.one.minimum(),maximum()andmean()answerNone(section 7): the emptiness may come from data, and anOptionis the total form.matmulof[0, 3]and[3, 2]is[0, 2], and of[2, 0]and[0, 3]is[2, 3]of zeros: the sum over no terms.
Decision 5: the construction functions of the first slice.
extend<Scalar: Numeric> Tensor<Scalar> {
/** A tensor of rank 0 holding `value`. */
static fn scalar(value: Scalar): Self
/** Every item `Scalar.zero`, every item `Scalar.one`, every item `value`. */
static fn zeros(shape: List<Int>): Self
static fn ones(shape: List<Int>): Self
static fn filled(shape: List<Int>, value: Scalar): Self
/** The square matrix with `Scalar.one` on the diagonal. */
static fn identity(size: Int): Self
/** The items in row-major order. Panics when `values` holds a number of items other than `count(shape)`. */
static fn of(values: Iterate<Scalar>, shape: List<Int>): Self
/** Takes `storage` as the dense storage of the tensor, without a copy. Panics when its length is not `count(shape)`. */
static fn over(storage: Buffer<Scalar>, shape: List<Int>): Self
}
extend Tensor<Float64> {
/** Every item drawn by `source.float()`, in row-major order: the same seed makes the same tensor. */
static fn uniform(shape: List<Int>, var source: Random): Self
/** Normally distributed items, `mean` and `deviation` as written, from pairs of draws (Box-Muller). */
static fn normal(shape: List<Int>, var source: Random, mean: Float64 = 0.0, deviation: Float64 = 1.0): Self
}Reason: these are the constructors every later package needs and nothing more. A negative extent panics with
the extent -1 of axis 0 is negative. over is the one construction that does not copy: it is how a decoded model file
or a GPU read-back becomes a tensor (section 9), and it is why storage is a Buffer the tensor holds by value.
uniform and normal live in the Float64 extension because std/random draws Float64 and the narrowing to
Float32 is a later, separate decision; they take the generator as a var parameter like Random.shuffle, so a
caller who passes a split() of its generator per layer gets the numbers of JAX's keys (RANDOM section 1). There is no
empty that leaves items unset: a value has no uninitialised state. A tensor of n items is Buffer.withCapacity n and
n appends because Buffer has no filled; whether that is fast enough is the first row of section 10, and the
answer if it is not is a native Buffer.filled asked for under rule 1, not a change to this design.
Decision 6: element access takes the index as a list. tensor[[1, 0]] and tensor[[1, 0]] = 3.0 are Index<List<Int>, Scalar> and MutableIndex<List<Int>, Scalar>; tensor.get([1, 0]) is the Option twin. A multi-axis index in the
brackets (tensor[1, 0]) is not valid TorbScript ("name [...] is always indexing"), and a tuple key is a fixed arity,
which a rank-generic type cannot use; probed 2026-10-23, section 7. A list key costs one allocation per access unless the
compiler drops it, so element access is for tests and small code, and every kernel of the package walks the storage
with strides instead; the tensor-index row of section 10 measures it. Panics: index [2, 0] is out of bounds for a shape of [2, 2], and index [1] has 1 axis and the tensor has 2. Members that read a tensor without indexing:
shape(): List<Int>, rank(): Int, count(): Int, isDense(): Bool, and Iterate<Scalar> in row-major order.
Length is not implemented: its length() could mean the first extent or the item count, and a name that can mean
both is not given to either.
4. The scalars#
Decision 7: the bound of each operation, and nothing is bounded higher than it needs.
| Bound | Operations |
|---|---|
Numeric | construction, add, subtract, multiply/divide by a scalar, scaled(by:), divided(by:), shifted(by:), sum, product, minimum, maximum, mean, dot, matmul, every shape operation, map |
Signed | unary minus (a conditional extend<Scalar: Signed> Tensor<Scalar> with Negate, the pattern of LINEAR section 6), absolute() |
Real | squareRoot(), exponential(), naturalLogarithm(), the trigonometric members, normalized-like operations of later slices; uniform and normal are Float64 |
Plain | only the parallel loop of section 8: Item: Plain is what windows asks, and the compiler grants it to Float, Int and Fixed |
Reason: the line of LINEAR section 1: arithmetic alone lives under Numeric, so a tensor of Int (an index map, a
histogram, a quantized weight matrix) and of Fixed (a deterministic simulation) has everything that is arithmetic and
cannot reach a square root by accident. mean needs the count as a Scalar: Scalar.from(count) is a static member of
a trait through a type parameter, which LINEAR gap 7 reports as unreachable in the back end, so mean builds the count
by doubling and adding Scalar.one (O(log n) additions, exact while the count is representable in the scalar). It is
therefore Numeric; for an integer scalar it truncates, as / does, and the docblock says so.
Decision 8: Float32 is not a prerequisite of any slice. Float32 has its arithmetic, ==, compare, Show and
the narrowing from Float64 (#35), so it is a Numeric and a tensor of Float32 supports every operation of the
Numeric row. What it lacks is Real (LINEAR gap 8: extend Float32 with Real over sqrtf, sinf, powf and the
rest, as natives of their own). That is its own slice (section 11, slice 8) and it is a seed matter, because it adds
natives; it gates only the Real row on Float32, and so std/learning on Float32 models, not std/tensor.
Float16 and BFloat16 do not exist and are out of scope: the layout makes no assumption about the width of a scalar,
so they join as scalars when they are built.
Decision 9: the scalars the first slices test on, and the order of every reduction. Tests run on Float64 (the
reference, with isCloseTo where a result is rounded), on Int (exact; overflow panics, as everywhere) and on Fixed
(bit-identical on every platform, so the expected output is compared byte for byte; its products overflow near 46341,
which the tests choose their numbers around, LINEAR section 8). Float32 gets one smoke test of construction and
arithmetic. Reductions are reproducible by definition, not by luck:
sum()adds the items in row-major order in blocks of 1024, each block left to right fromScalar.zero, and then the block sums left to right. A reduction along an axis adds each output item's terms left to right. Amatmulitem is the sum of its products over the inner axis in increasing order.product(),minimum(),maximum()follow the same association.- Reason: a parallel sum splits the work into pieces whose borders depend on the length alone (CONCURRENCY section 6), and
a float sum is not associative. If the serial
sumwere one left-to-right pass, the parallel slice would change the bits of every program that sums a float tensor. With the block fixed now, the serial and the parallelsumare the same function of the data, on one worker or sixteen. The cost is nil: the same additions in a different bracketing.
5. Slices and views#
Decision 10: tensor[a..b], sliced(axis:, range), selected(axis:, index), transposed(), permuted(axes),
expanded(axis), squeezed(axis) and reshaped(to:) do not copy items. They build a new Tensor value with other
shape, strides and offset over the same storage value. Copying a Buffer value is a count (#54: "a copy is
alive" is exactly what makes the next write copy), so a view is O(rank) in time and allocation, and the tensor it came
from is untouched. reshaped(to:) is a view when the strides allow it (the tensor is dense, or the new axes only split
or merge axes that are contiguous in memory) and one copy of count() items otherwise; it never changes the order of
the items.
Decision 11: a view is a value, and the rule for writing is one sentence. A write to a tensor is in place when the
tensor is dense and its storage has one owner; otherwise it first makes the tensor dense, one copy of count() items,
and writes there. A view is not dense unless it happens to cover its storage whole, so writing a view copies its
items, never its storage; writing the original while a view is alive copies the storage once (the Buffer promise) and
leaves the view on the old one.
var weights = Tensor.of([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], shape: [3, 2])
const firstTwoRows = weights[0..2] // a view: shape [2, 2], the storage of `weights` shared
weights[[0, 0]] = 9.0 // `weights` is dense, but its storage has a second owner: copied once
print firstTwoRows[[0, 0]] // 1.0, the value it was made with
print weights[[0, 0]] // 9.0
var copy = firstTwoRows
copy[[1, 1]] = 0.0 // a view is not dense: its 4 items are copied, then written
print firstTwoRows[[1, 1]] // 4.0This is the same value semantics Buffer documents ("a copy never sees a later write to the original"), carried through
the layout. Why not a copy, as Buffer.slice does. Buffer.slice copies because a Buffer is the type for code
that cannot afford a shared write, and a slice that copied in a loop is the O(n) the type exists to avoid. A tensor
differs: its operations read far more than they write, a batch of a training set is cut and read once, and a copy of
every tensor[a..b] would make the most common operation of a model the most expensive. Why not a view that writes
through, as NumPy does: b = a[0..2]; b[[0, 0]] = 9 would change a, which is the aliasing the language removed from
every other type. A deliberate write through a part is spelled tensor[a..b] = values (MutableSlice.replace, along
the first axis, the shapes must match) or var fn replace(axis:, range, values) for another axis, and the parallel loop
of section 8 writes through Window, the one borrow the language allows, for the length of one call.
The cost this accepts, named. A view keeps the whole storage alive: a view of one row of a million-row tensor holds
a million rows until it is dropped, and compacted() (one copy, dense) is the way to let go. And a program that keeps
a view alive while it writes the original in a loop pays one storage copy, at the first write only, because after it the
original has one owner again. Both are # Pitfalls of the docblock, and tensor-write of section 10 measures the second.
6. Broadcasting and errors#
Decision 12: broadcasting is NumPy's rule, with the arithmetic written out. Two shapes a of rank p and b of
rank q are aligned at their last axes; the shorter one is read as if it had leading axes of extent 1. With
r = max(p, q), a' and b' those padded shapes, the result has, for each axis k in 0..r:
result[k] = a'[k] when a'[k] == b'[k]
= b'[k] when a'[k] == 1
= a'[k] when b'[k] == 1
= a mismatch otherwiseso [2, 3] with [3] is [2, 3], [2, 1] with [1, 3] is [2, 3], [] with anything is that shape (a scalar is a
rank 0 tensor, which is how a scalar operand is the same rule), [0, 3] with [1, 3] is [0, 3], and [2, 3] with
[2] is a mismatch at axis 1 (3 against 2). An operand's item for a result index is found by giving every axis whose
extent is 1 (or missing) the stride 0 (broadcast(to:) builds exactly that view, and it is the one place I3 permits a
stride 0 on a longer axis). No operand is materialised. Operations that broadcast: add, subtract, scaled(by:),
divided(by:), combine, accumulate (the second operand into the first, whose shape must not grow). matmul
broadcasts nothing in the first release (section 7).
Decision 13: a shape mismatch panics, with a predicate beside it. a + b of shapes that do not broadcast panics with
shapes [2, 3] and [2] do not broadcast: the extents 3 and 2 of axis 1 differ and neither is 1and the other shape errors say, in the same form: cannot reshape a tensor of shape [2, 3] (6 items) into [4, 2] (8 items), matmul needs the inner extents to agree: [2, 3] times [4, 2], 3 against 4, the axis 2 is out of range for a tensor of rank 2, the extent -1 of axis 0 is negative, a shape of [2, 3] holds 6 items and 5 were given. Reason, by
PANICS.md section 5.1 clause 1: the precondition is an ordinary expression of the program, so it may panic,
and the expression has a name: a.canBroadcastWith(b), a.canReshape(to: shape), a.canMultiply(b), and the total
form Tensor.broadcastShape(first, second): List<Int>? for the code that wants the answer and not the stop. The alternative,
a Result from every +, is not possible for an operator (Add answers its Output) and would put an expect on every
line of a model; std/linear made the same choice (inverse(): Self? is the one partial operation with a total form;
the rest panic on a broken promise). A model file whose shapes disagree with its layers is data, and that check lives in
the loader of std/learning, which answers a Result of its own before any tensor operation runs.
7. The first release#
Decision 14: the operators, decided by a probe. COMPUTE section 3 asks whether the checker accepts "operator traits
with a foreign Other and Output (a tensor times a scalar)". Probed on the nightly of 2026-10-23 with scratch
programs (torb check and torb run, in a temporary directory, nothing in the repository):
type Tensor<Scalar: Numeric = Float> with Multiply<Scalar> {
shape: List<Int>
data: Buffer<Scalar>
fn multiply(factor: Scalar): Self {
var result: Buffer<Scalar> = Buffer.withCapacity data.length()
for item in data {
result.append item * factor
}
Tensor(shape, result)
}
}
const a: Tensor<Float> = Tensor([2], Buffer.from([1.0, 2.0]))
const b = a * 3.0| Probe | torb check | torb run |
|---|---|---|
Tensor<Float> * Float and Tensor<Int> * Int with with Multiply<Scalar> on the generic type | ok | [3.0, 6.0], [3, 6] |
the same type also with Multiply (tensor times tensor) on the type, plus extend Tensor<Float64> with Multiply<Float64> | ok | a * 3.0 and a * b both run |
the same, with extend<Scalar: Numeric> Tensor<Scalar> with Multiply<Tensor<Scalar>, Tensor<Scalar>> beside with Multiply<Scalar> | was refused (Tensor<Scalar>` already implements `Multiply<Tensor<Scalar>, Tensor<Scalar>>` ... Two implementations of one trait may not overlap), fixed by #441: ok | a * 3.0 and a * b both run |
3.0 * tensor with extend Float64 with Multiply<Tensor<Float64>, Tensor<Float64>> in the package that owns Tensor | ok | (before #440) internal error: multiply.f64` works on `Float64`, and %0 is `Record(T_probe2_Tensor__Float64) |
tensor[1, 0] and a Slice<(Range<Int>, Int)> for tensor[a..b, c] | name [...] is always indexing; (Range<Int64>, Int64)` does not implement `Compare` | |
Index<(Int, Int), Float> and grid[(1, 0)] | ok | 3.0 |
The findings, and what each decides:
tensor * scalarandtensor / scalarwork today, with a foreignOtherand the type itself asOutput, for every scalar, in the VM. They areMultiply<Scalar>andDivide<Scalar>on the type, asVector2has them.tensor * tensorcan share the generic type withtensor * scalar: the checker used to treat the two implementations as overlapping because it unifiedScalarwithTensor<Scalar>, which has no finite solution. That false positive is fixed by #441 (the overlap check unifies over one substitution with an occurs check); the first release did not wait for it.scalar * tensorchecked and then miscompiled: the lowering took the primitive product of the left operand. Closed by #440, which lowers the operator as the implementation the checker recorded; the first release still has noscalar * tensor(a library choice, LINEAR section 6: writetensor * scalar).- There is no multi-axis index and no multi-axis slice syntax: decision 6 and decision 10 follow from it, and no checker gap is claimed, because the grammar is the language's.
So the first release spells the operators as follows, and none of them is a slice's own work-around:
| Written | Trait | Meaning |
|---|---|---|
a + b, a - b | Add, Subtract | elementwise, broadcasting (decision 12) |
a * s, a / s | Multiply<Scalar>, Divide<Scalar> | every item times / divided by the scalar |
-a | Negate, Signed only | every item negated |
a == b | Equals, by hand | same shape and equal items in row-major order |
a[[i, j]], a[i..j] | Index<List<Int>, Scalar>, Slice | an item; a view along the first axis |
a * b of two tensors does not exist: it would be the elementwise product to a NumPy reader and the matrix product to a
Matrix3 reader (LINEAR section 6 gives the same reason for vector * vector), so the two are named: scaled(by:) and
matmul. The trait methods are the same calls (a.add(b), a.multiply(2.0)). Since #441 the checker no longer stands in
the way of * between two tensors; whether to add it is a decision to revisit with the numbers of the first release,
and it is not promised here.
Decision 15: the signatures of the first release. Every member is documented with its # Panics; the shape errors are
those of decision 13.
extend<Scalar: Numeric> Tensor<Scalar> {
// layout and access
fn shape(): List<Int>
fn rank(): Int
fn count(): Int
fn isDense(): Bool
fn get(index: List<Int>): Scalar?
fn compacted(): Self // dense, one copy if it is not
// elementwise, broadcasting; the operators are `add`, `subtract`, `multiply(Scalar)`, `divide(Scalar)`
fn add(other: Self): Self
fn subtract(other: Self): Self
fn multiply(factor: Scalar): Self
fn divide(divisor: Scalar): Self
fn scaled(by other: Self): Self // the elementwise product
fn divided(by other: Self): Self // the elementwise quotient
fn shifted(by offset: Scalar): Self // every item plus the scalar
fn mapped<Output: Numeric>(transform: (item: Scalar) => Output): Tensor<Output>
// shape, all views where decision 10 says so
fn reshaped(to shape: List<Int>): Self
fn flattened(): Self // reshaped(to: [count()])
fn transposed(): Self // swaps the last two axes; itself below rank 2
fn permuted(axes: List<Int>): Self
fn expanded(axis: Int): Self // inserts an axis of extent 1
fn squeezed(axis: Int): Self // removes an axis of extent 1
fn sliced(axis: Int, range: Bounds<Int>): Self
fn selected(axis: Int, index: Int): Self // drops the axis
fn broadcast(to shape: List<Int>): Self
// reductions over every item, in the order of decision 9
fn sum(): Scalar
fn product(): Scalar
fn minimum(): Scalar? // None when there are no items
fn maximum(): Scalar?
fn mean(): Scalar? // None when there are no items; truncates for an integer scalar
// reductions along one axis; keepDimension keeps it with extent 1 (what a broadcast back needs)
fn summed(axis: Int, keepDimension: Bool = false): Self
fn minimized(axis: Int, keepDimension: Bool = false): Self // panics when the axis has extent 0
fn maximized(axis: Int, keepDimension: Bool = false): Self
fn averaged(axis: Int, keepDimension: Bool = false): Self
// products
fn dot(other: Self): Scalar // two rank 1 tensors of one length
fn matmul(other: Self): Self
}matmul takes two rank 2 tensors [m, k] and [k, n] and answers [m, n]. A rank 1 first operand is a row and a rank 1
second operand a column, and the axis that exists only for that is dropped from the result (so a matrix times a vector is
a vector, as in NumPy). Operands of rank 3 and more, a batch, are not in the first release: they broadcast leading
axes, they are what attention needs, and they are slice 7 of section 11. transposed() below rank 2 is the tensor
itself, as in NumPy. There is no inferred extent (-1) in reshaped(to:): a magic number in a list of extents is a
run-time convention where flattened() and an explicit shape say what is meant. minimized, maximized and averaged
along an axis of extent 0 panic (cannot take the minimum along axis 1 of extent 0), because the result has no value to
hold; summed of such an axis is zeros.
8. In place, and the first parallel loop#
Decision 16: the in-place surface is five var fn members and one rule. The rule is decision 11's: a write is in
place when the tensor is dense and its storage has one owner. The pure operations of section 7 allocate their result as
Buffer.withCapacity count() and append, once, with no regrowth. The members that write without a copy, where a program
holds the one owner in a var (a weight updated by a gradient step, a running total):
extend<Scalar: Numeric> Tensor<Scalar> {
var fn fill(value: Scalar)
var fn map(transform: (item: Scalar) => Scalar)
var fn combine(other: Self, using combiner: (own: Scalar, other: Scalar) => Scalar) // other broadcast to this shape
var fn accumulate(other: Self) // this = this + other, in place
var fn assignProduct(first: Self, second: Self) // this = first.matmul(second), no new result
}plus set and replace of the indexing traits. combine and accumulate keep the shape of the tensor they change: a
second operand that would make it grow panics (decision 13). assignProduct needs a result of the right shape already,
which is the "pre-sized result Buffer" of the Buffer promise: the loop of a model allocates its activations once
and assignProducts into them every step. Naming follows the language: a past participle for the pure form (scaled,
mapped), an imperative for the var fn (map, fill), as reverse() is beside a hypothetical reversed() on
Buffer.
Decision 17: the first slices are serial, and the parallel loop is slice 9. The data-parallel loop is
windows(var result.storage, count) { var window => ... } over the dense result of an elementwise operation or a
matmul, inside the package (where the private storage is reachable): each window is a disjoint run of the result,
the borders depend on the length alone (CONCURRENCY section 16), and the work per window reads the inputs and writes
only its window. It needs two things that do not exist yet, and the slice does not start until both do:
windowsoverBuffer. Todaywindowstakes avar ArrayList<Item>; the buffer half of CONCURRENCY gap 6 is "not built here".ArrayListandBufferare one runtime object, so this is a signature ofstd/parallel, and it belongs to the first slice of COLLECTION-STORAGE.md (epic #55) or to a small issue of its own, whichever lands first. This record does not file it; the loop files the slice when it starts.- Reading the inputs without a copy per worker.
spawncopies a closure's captures into the target heap (CONCURRENCY section 1), and a helper of the region is such a task, so a body that captures an inputBuffermay copy it once per helper, which is the O(n) the loop is meant to remove. The gate is the onetests/conformance/parallel-windows.trbalready uses:Workers.copies()after the region is 0 for an elementwise add of two million-item tensors. If it is not, the missing piece is read-only borrowed windows of the inputs beside the writable window of the result (the "alongside the input windows" of CONCURRENCY section 6), a CONCURRENCY slice of its own that this one then waits for.
Reductions parallelise over the blocks of decision 9, so the bits do not change; matmul parallelises over rows of the
result, never over the inner axis, for the same reason. A tensor of a scalar that is not Plain (Decimal, if its
layout is counted) runs its windows on the caller's worker, the same answer without the speed-up (CONCURRENCY section 16).
9. What comes next needs from a tensor#
Constraints only; the packages are designed in their own records.
Dual(#83). ATensor<Dual<Scalar>>must be a tensor:Dual<Scalar>implementsNumeric,SignedandRealwheneverScalardoes (that is already COMPUTE's promise that every function generic overRealcan be differentiated), andCompareon a dual compares its primal part, whichminimumandmaximumrely on. The tensor needs nothing else, because no member of section 7 is specialised to a built-in scalar.std/gpu(#85). To upload, a kernel needs the items contiguous, the shape, and nothing hidden:compacted()followed by a read of its denseBuffer(a memberdenseStorage(): Buffer<Scalar>, added by slice 3, a count and no copy for a dense tensor) andshape(). To download,Tensor.over(storage, shape)takes the read-backBufferwithout a copy. A tensor has no field for a device handle, because a value cannot carry an identity: a device-resident tensor is a type ofstd/gputhat owns a handle and converts through these two members. A kernel receives a tensor as(storage, shape, strides, offset), which are the four fields, so a quoted expression over a tensor can be lowered without the package changing its layout; that is why decision 1 fixes them as the whole of the type.- Reverse mode (#86).
gradient { ... }receives the expression tree, so every operation must be a named member and not a loop inside an operator, and every broadcast must have an inverse the tree can name: the gradient of a broadcast is a sum along the broadcast axes, which issummed(axis:, keepDimension: true); the gradient ofreshaped,transposedandpermutedis the inverse shape operation, which exist; the gradient ofmatmulis twomatmuls oftransposed()operands. Section 7 has all of them, and none needs a member that is not in the first release. - Layers above.
std/learningloads weights withTensor.over, updates them withaccumulate, and checks shapes itself (decision 13). Quantized inference overIntandFixedtensors needs nothing but decision 7.
10. Cost#
Every benchmark is a pair of programs in benchmarks/, built with torb build --release and measured by the pattern of
tools/buffer-cost.sh: the fastest of nine runs on the forge CI runner, a measurement and not a gate (it exits 0 whatever
it finds and says where it could not measure), run at the end of tier B. The first slice adds tools/tensor-cost.sh in
that pattern; the first slice that implements an operation records the number in its PR, and a ratio over the bound
is not landed until the decision it implements is revisited with the number, as in COLLECTION-STORAGE section 6. The
baseline of every row is the program a careful author writes by hand over a Buffer (a counted loop with get and
set, the hot loop of benchmarks/buffer-write.trb), and the bounds are provisional: they are the first guess, and a
measurement may move one with its reason written in the PR.
| Benchmark | Measures | Baseline | Bound | Decision it bounds | Recorded by |
|---|---|---|---|---|---|
tensor-zeros | Tensor.zeros([1000, 1000]) against Buffer.withCapacity plus a million append | the hand loop | 1.10 | 5 (no Buffer.filled) | slice 1 |
tensor-index | a loop of tensor[[i, j]] reads against buffer[i * columns + j] | the hand loop | recorded, no bound | 6 (list key) | slice 1 |
tensor-add | elementwise add of two dense 1 000 000 Float64 tensors | one loop appending a[i] + b[i] into a new Buffer | 1.10 | 10, 12 | slice 2 |
tensor-add-broadcast | [1000, 1000] + [1000] | the same with the index arithmetic written by hand | 1.25 | 12 | slice 2 |
tensor-write | accumulate in place into a dense tensor of one owner, then once with a live view | the in-place loop of buffer-write | 1.10; the live-view case is one storage copy | 11, 16 | slice 3 |
tensor-sum | full sum() of 1 000 000 Float64 | one left-to-right loop | 1.10 | 9 (blocks of 1024) | slice 4 |
tensor-matmul | matmul of two 256 x 256 Float64 tensors | the triple loop over Buffer, same order | 1.10 | 9, 15 | slice 5 |
tensor-parallel | tensor-add and tensor-matmul on the worker pool | the serial slice's number | recorded; Workers.copies() is 0 | 17 | slice 9 |
Two risks are named so that the slice checks them first. The index arithmetic of a strided read (an addition and a
multiplication per axis per item) is what the 1.25 of the broadcast row allows, and the dense same-shape case must take
the straight loop without it, which is why isDense() is a member and the dense branch is the first one of every
kernel. The second is the allocation count: a view allocates two small lists, so a loop of a million tensor[a..b] is a
row of its own if the slice 3 profile shows it.
11. The slices#
Each slice is one PR of one or two Conventional Commits and is filed as its own issue only when it starts (the loop files it; this record does not). Seed is whether the slice changes what the seed compiler has to read, by the rule of CLAUDE.md: a syntax change, a new native, a new driver flag, a std name the compiler looks up by string. The seed refresh itself stays with the owner and is never part of a slice.
| # | Slice | Commits | Seed | Waits for |
|---|---|---|---|---|
| 1 | Package std/tensor (manifest, workspace, docs page): Tensor with its fields and invariants, scalar, zeros, ones, filled, identity, of, over, shape/rank/count/isDense, get, Index and MutableIndex, Iterate, Show, Equals; tests on Float64, Int, Fixed; tensor-zeros, tensor-index, tools/tensor-cost.sh | 1: feat(tensor): Tensor<Scalar> over a Buffer | none expected: ordinary TorbScript | - |
| 2 | Elementwise with broadcasting: Add, Subtract, Multiply<Scalar>, Divide<Scalar>, Negate, scaled, divided, shifted, mapped, broadcast, the shape errors and canBroadcastWith; tensor-add, tensor-add-broadcast | 1 | none expected | 1 |
| 3 | Views and writes: reshaped, flattened, transposed, permuted, expanded, squeezed, sliced, selected, Slice, MutableSlice, compacted, denseStorage, fill, map, combine, accumulate; the value-semantics tests of decision 11; tensor-write | 1 or 2 (shape operations, then the in-place members) | none expected | 2 |
| 4 | Reductions: sum, product, minimum, maximum, mean, summed, minimized, maximized, averaged; the block order of decision 9; tensor-sum | 1 | none expected | 3 |
| 5 | dot, matmul, assignProduct; tensor-matmul | 1 | none expected | 3 |
| 6 | uniform, normal for Float64 over std/random; the reproducibility test (same seed, same tensor; a split() per layer) | 1 | none expected | 1 |
| 7 | Batched matmul (rank 3 and more, leading axes broadcast) | 1 | none expected | 5 |
| 8 | extend Float32 with Real and the Real members on a Float32 tensor (LINEAR gap 8) | 2: teach the natives (sqrtf and the rest), then std/ | yes: new natives, two-commit rule; the refresh is the owner's | - (not on the critical path) |
| 9 | The data-parallel loop of decision 17 over windows; tensor-parallel | 1 | none expected; the windows signature change is std/parallel | windows over Buffer (epic #55), and the Workers.copies() gate of decision 17 |
The checker findings of section 7 (#440, #441) block none of these. Slices 2 to 6 can be built in the order of their dependencies by separate workers once slice 1 has landed; slice 8 can be built at any time.
12. What stays open#
Nothing is left for the owner. Every question of the issue is a technical choice and is decided above: the
representation (1), the order and the strides (2), the invariants (3), the empty and the rank 0 tensor (4), the
constructors (5) and element access (6), the bounds (7), Float32 (8), the test scalars and the order of a reduction (9),
the view and its write rule (10, 11), broadcasting and the shape errors (12, 13), the operators by probe (14), the
signatures (15), in place and the parallel loop (16, 17).
Two things are deliberately not promised, and are named so that nobody reads a promise into them. The stream of
Tensor.normal (Box-Muller from consecutive draws) is documented but not fixed across versions: whether it is a public
promise in the way Random's SplitMix64 is, is a commitment the owner makes when std/learning needs reproducible
initialisation, and it is asked then, with that package, not now. And * between two tensors is a revisit with the numbers of the first release, not a plan. Neither blocks a slice.
Related#
- COMPUTE.md — the four layers, the order, and what these packages test in the language (section 3, answered in section 7 here).
- COLLECTIONS.md — gap 6 (
Buffer), gap 7 (WindowandPlain). - COLLECTION-STORAGE.md —
ArrayListonBuffer, the record whose cost pattern this one follows, and the epic (#55) thatwindowsoverBufferwaits for. - CONCURRENCY.md — section 6 (
windows, "the tensor loop"), section 16 (the region as built). - LINEAR.md — the scalar tower, the
Numeric/Signed/Realline, gaps 7 to 9 and 12. - RANDOM.md —
Random, splittable keys. - PANICS.md — section 5.1, the principle the shape errors follow.
- ECS.md — "What the design needs from
Buffer<Item>": the in-place promise this package shares. - PERFORMANCE.md — how a cost is measured.