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.

IngredientStateWhat it means for a tensor
Buffer<Item> (std/core/src/buffer.trb, #54, #300)builtwithCapacity, 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, Signedbuilt (std/number)+ - * / %, ==, <, Show, Scalar.zero, Scalar.one through a type parameter (LINEAR gap 10, closed)
Realbuilt for Float64 and FixedsquareRoot, exponential, naturalLogarithm, sine, ..., Power; Float32 has none of it (LINEAR gap 8)
Plainbuiltgranted by the compiler to a type with nothing counted inside: Int, Float, Fixed, a record of them
windows and Window (std/parallel, #4)builtover var ArrayList<Item>, not Buffer (CONCURRENCY section 16, "not built here: the buffer half")
Random (std/random)builta value; var fn float(): Float64 draws in [0, 1); split()
Array<Item, const Size: Int>runs nowhereLINEAR gap 9: why a tensor cannot be an array, and why the shape is a run-time value
Operators on a generic typebuilt (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#

  1. 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.
  2. A tensor is a value. Copying one and writing the copy never touches the original, whatever the layout under it.
  3. Shapes are run-time Ints. Nothing here needs arithmetic on size parameters (COMPUTE section 3).
  4. 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.
  5. A decision that costs speed names the benchmark that bounds it (section 10).
  6. 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()), and 1 for 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, and storage.length() == count(). Every constructor and every result of an operation is dense; only a view (section 5) is anything else, and isDense() 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] has count() == 0, an empty storage is allowed, and it iterates nothing. Shape [] has count() == 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() and product() of an empty tensor are Scalar.zero and Scalar.one. minimum(), maximum() and mean() answer None (section 7): the emptiness may come from data, and an Option is the total form.
  • matmul of [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.

BoundOperations
Numericconstruction, add, subtract, multiply/divide by a scalar, scaled(by:), divided(by:), shifted(by:), sum, product, minimum, maximum, mean, dot, matmul, every shape operation, map
Signedunary minus (a conditional extend<Scalar: Signed> Tensor<Scalar> with Negate, the pattern of LINEAR section 6), absolute()
RealsquareRoot(), exponential(), naturalLogarithm(), the trigonometric members, normalized-like operations of later slices; uniform and normal are Float64
Plainonly 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 from Scalar.zero, and then the block sums left to right. A reduction along an axis adds each output item's terms left to right. A matmul item 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 sum were 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 parallel sum are 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.0

This 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 otherwise

so [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 1

and 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
Probetorb checktorb run
Tensor<Float> * Float and Tensor<Int> * Int with with Multiply<Scalar> on the generic typeok[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>oka * 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: oka * 3.0 and a * b both run
3.0 * tensor with extend Float64 with Multiply<Tensor<Float64>, Tensor<Float64>> in the package that owns Tensorok(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)]ok3.0

The findings, and what each decides:

  • tensor * scalar and tensor / scalar work today, with a foreign Other and the type itself as Output, for every scalar, in the VM. They are Multiply<Scalar> and Divide<Scalar> on the type, as Vector2 has them.
  • tensor * tensor can share the generic type with tensor * scalar: the checker used to treat the two implementations as overlapping because it unified Scalar with Tensor<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 * tensor checked 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 no scalar * tensor (a library choice, LINEAR section 6: write tensor * 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:

WrittenTraitMeaning
a + b, a - bAdd, Subtractelementwise, broadcasting (decision 12)
a * s, a / sMultiply<Scalar>, Divide<Scalar>every item times / divided by the scalar
-aNegate, Signed onlyevery item negated
a == bEquals, by handsame shape and equal items in row-major order
a[[i, j]], a[i..j]Index<List<Int>, Scalar>, Slicean 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:

  1. windows over Buffer. Today windows takes a var ArrayList<Item>; the buffer half of CONCURRENCY gap 6 is "not built here". ArrayList and Buffer are one runtime object, so this is a signature of std/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.
  2. Reading the inputs without a copy per worker. spawn copies 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 input Buffer may copy it once per helper, which is the O(n) the loop is meant to remove. The gate is the one tests/conformance/parallel-windows.trb already 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). A Tensor<Dual<Scalar>> must be a tensor: Dual<Scalar> implements Numeric, Signed and Real whenever Scalar does (that is already COMPUTE's promise that every function generic over Real can be differentiated), and Compare on a dual compares its primal part, which minimum and maximum rely 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 dense Buffer (a member denseStorage(): Buffer<Scalar>, added by slice 3, a count and no copy for a dense tensor) and shape(). To download, Tensor.over(storage, shape) takes the read-back Buffer without 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 of std/gpu that 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 is summed(axis:, keepDimension: true); the gradient of reshaped, transposed and permuted is the inverse shape operation, which exist; the gradient of matmul is two matmuls of transposed() operands. Section 7 has all of them, and none needs a member that is not in the first release.
  • Layers above. std/learning loads weights with Tensor.over, updates them with accumulate, and checks shapes itself (decision 13). Quantized inference over Int and Fixed tensors 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.

BenchmarkMeasuresBaselineBoundDecision it boundsRecorded by
tensor-zerosTensor.zeros([1000, 1000]) against Buffer.withCapacity plus a million appendthe hand loop1.105 (no Buffer.filled)slice 1
tensor-indexa loop of tensor[[i, j]] reads against buffer[i * columns + j]the hand looprecorded, no bound6 (list key)slice 1
tensor-addelementwise add of two dense 1 000 000 Float64 tensorsone loop appending a[i] + b[i] into a new Buffer1.1010, 12slice 2
tensor-add-broadcast[1000, 1000] + [1000]the same with the index arithmetic written by hand1.2512slice 2
tensor-writeaccumulate in place into a dense tensor of one owner, then once with a live viewthe in-place loop of buffer-write1.10; the live-view case is one storage copy11, 16slice 3
tensor-sumfull sum() of 1 000 000 Float64one left-to-right loop1.109 (blocks of 1024)slice 4
tensor-matmulmatmul of two 256 x 256 Float64 tensorsthe triple loop over Buffer, same order1.109, 15slice 5
tensor-paralleltensor-add and tensor-matmul on the worker poolthe serial slice's numberrecorded; Workers.copies() is 017slice 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.

#SliceCommitsSeedWaits for
1Package 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.sh1: feat(tensor): Tensor<Scalar> over a Buffernone expected: ordinary TorbScript-
2Elementwise with broadcasting: Add, Subtract, Multiply<Scalar>, Divide<Scalar>, Negate, scaled, divided, shifted, mapped, broadcast, the shape errors and canBroadcastWith; tensor-add, tensor-add-broadcast1none expected1
3Views 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-write1 or 2 (shape operations, then the in-place members)none expected2
4Reductions: sum, product, minimum, maximum, mean, summed, minimized, maximized, averaged; the block order of decision 9; tensor-sum1none expected3
5dot, matmul, assignProduct; tensor-matmul1none expected3
6uniform, normal for Float64 over std/random; the reproducibility test (same seed, same tensor; a split() per layer)1none expected1
7Batched matmul (rank 3 and more, leading axes broadcast)1none expected5
8extend 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)
9The data-parallel loop of decision 17 over windows; tensor-parallel1none expected; the windows signature change is std/parallelwindows 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.

  • 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 (Window and Plain).
  • COLLECTION-STORAGE.md — ArrayList on Buffer, the record whose cost pattern this one follows, and the epic (#55) that windows over Buffer waits for.
  • CONCURRENCY.md — section 6 (windows, "the tensor loop"), section 16 (the region as built).
  • LINEAR.md — the scalar tower, the Numeric/Signed/Real line, 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.
Esc