2. Tensors
Sections
Reading settings
Natural voice recorded for this course, the same in every browser.
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
A tensor is an array with any number of dimensions whose elements all have one type. In C# you’d reach for a float[,] or a Span<float> with a width; in Java, a float[] and an index formula. A tensor keeps the flat buffer and the formula together, and every operation of a model, from a matrix product to a softmax, is an operation on tensors. This lesson is the vocabulary of the rest of the course.
| PyTorch | Candle | |
|---|---|---|
| Create from values | torch.tensor([[1., 2.], [3., 4.]]) |
Tensor::new(&[[1f32, 2.], [3., 4.]], &Device::Cpu)? |
| Element type | t.dtype, t.to(torch.float16) |
t.dtype(), t.to_dtype(DType::F16)? |
| Shape | t.shape, t.view(4, -1) |
t.dims(), t.reshape((4, ()))? |
| Broadcasting | implicit in a + b |
explicit: a.broadcast_add(&b)? |
| Indexing | t[:, 1:] |
t.i((.., 1..))? |
| An error | an exception | a candle_core::Error value |
TorchSharp keeps PyTorch’s names and behaviour in C#, so the PyTorch column is also a good guide to it. Sources: PyTorch tensors, Candle’s cheat sheet and Tensor on docs.rs.
The code is examples/l02_tensors.rs and examples/l02_errors.rs. Values are printed by a small helper, show, which flattens a tensor and rounds every value, so that the three operating systems print the same digits.
What a Tensor is
Section titled “What a Tensor is”tensor.rs, lines 23-68, shortened:
pub struct Tensor_ { id: TensorId, storage: Arc<RwLock<Storage>>, layout: Layout, op: BackpropOp, is_variable: bool, dtype: DType, device: Device,}
pub struct Tensor(Arc<Tensor_>);A Tensor is a reference-counted pointer, like a C# or Java object reference: clone() copies the pointer, not the numbers. The numbers live in a Storage, a flat buffer on one device, shared between tensors behind its own Arc. The Layout says how to read that buffer: the shape, the step between elements along each dimension (the strides), and where the tensor starts. op remembers the operation that produced the tensor, for gradients (lesson 4). Lesson 3 shows which operations share a storage and which copy it.
Nothing in Tensor is mutable through the public API, except a Var’s content (lesson 4): every operation returns a new tensor.
Creating tensors
Section titled “Creating tensors”let from_array = Tensor::new(&[[1f32, 2., 3.], [4., 5., 6.]], &dev)?;let from_vec = Tensor::from_vec(vec![1u32, 2, 3, 4, 5, 6], (3, 2), &dev)?;let hole = Tensor::from_vec((0..12).collect::<Vec<i64>>(), (2, (), 3), &dev)?;Tensor::zeros((2, 2), DType::F64, &dev)?;Tensor::full(7u8, 3, &dev)?;Tensor::arange_step(0f32, 1., 0.25, &dev)?;let scalar = Tensor::new(2.5f64, &dev)?;== creatingnew(&[[f32; 3]; 2]): shape [2, 3], F32, [1, 2, 3, 4, 5, 6]from_vec(Vec<u32>, (3, 2)): shape [3, 2], U32, [1, 2, 3, 4, 5, 6]from_vec(0..12, (2, (), 3)): shape [2, 2, 3], I64, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]zeros((2, 2), F64): shape [2, 2], F64, [0, 0, 0, 0]full(7u8, 3): shape [3], U8, [7, 7, 7]arange_step(0.0, 1.0, 0.25): shape [4], F32, [0.00, 0.25, 0.50, 0.75]scalar: rank 0, dims [], value 2.5Tensor::newtakes a Rust array, nested arrays, a slice or a single number, and reads the shape from its type:&[[f32; 3]; 2]is a(2, 3)tensor. The element type comes from the literal,1f32here.Tensor::from_vectakes a flatVecand a shape. A shape is a tuple of dimensions, a singleusizefor one dimension, or()for a scalar.- One dimension of the shape can be
(), a hole Candle fills from the number of elements, like-1in PyTorch: 12 values in(2, (), 3)make(2, 2, 3). - A single number makes a tensor of rank 0, with no dimensions.
to_scalarreads it back into a Rust value.
Rows of different lengths don’t compile, because [f32; 2] and [f32; 3] are different types. This is one of the few shape errors Rust catches for you:
let ragged = Tensor::new(&[[1f32, 2.], [3., 4., 5.]], &Device::Cpu)?;error[E0308]: mismatched types --> course\examples\x_ragged.rs:4:44 |4 | let ragged = Tensor::new(&[[1f32, 2.], [3., 4., 5.]], &Device::Cpu)?; | ^^^^^^^^^^^^ expected an array with a size of 2, found one with a size of 3Element types
Section titled “Element types”DType lists U8, U32, I16, I32, I64 for integers; F16, BF16, F32, F64 for floats; and 8-, 6- and 4-bit float formats used to store quantized models. Weights are floats; indexes, token ids and masks are integers.
Lines 37-54 convert four floats:
let x = Tensor::new(&[1.4f32, 2.5, -2.5, 300.7], &dev)?;show("to_dtype(U8)", &x.to_dtype(DType::U8)?, 0)?;show("to_dtype(I64)", &x.to_dtype(DType::I64)?, 0)?;show("to_dtype(F16)", &x.to_dtype(DType::F16)?, 2)?;== dtypesf32: shape [4], F32, [1.40, 2.50, -2.50, 300.70]to_dtype(U8): shape [4], U8, [1, 2, 0, 255]to_dtype(I64): shape [4], I64, [1, 2, -2, 300]to_dtype(F16): shape [4], F16, [1.40, 2.50, -2.50, 300.75]1/3 as f64 0.33333333333333331, as f32 0.33333334326744080size in bytes: F16 2, F32 4, F64 8, U8 1A float becomes an integer the way Rust’s as cast does it: the fraction is dropped (2.5 becomes 2, not 3), and a value outside the target’s range is clamped (−2.5 becomes 0 in U8, 300.7 becomes 255). Java drops the fraction too, but (byte) 300.7f gives 44 there: the float becomes the int 300, which then wraps around to a byte (JLS 5.1.3). C# clamps like Rust since .NET 9. A half-precision float has only 65,536 bit patterns, so 300.7 becomes 300.75, its nearest one. A model stored in F16 takes half the memory of F32, for that loss of precision.
Mixing types is an error, not a silent conversion: the error list at the end of this lesson has row + ints.
Shapes and dimensions
Section titled “Shapes and dimensions”== shapesdims [2, 3, 4], rank 3, elem_count 24dim(0) 2, dim(D::Minus1) 4dims3: b 2, r 3, c 4reshape((4, ())) -> [4, 6]flatten_from(1) -> [2, 12]unsqueeze(0) -> [1, 2, 3, 4], then squeeze(0) -> [2, 3, 4]transpose(1, 2) -> [2, 4, 3], permute((2, 0, 1)) -> [4, 2, 3]- Dimensions are numbered from 0.
D::Minus1counts from the end, like-1in Python: the last dimension, whatever the rank. dims3()checks that the rank is 3 and returns the three sizes as a tuple, which reads better than indexingdims()in model code.reshapechanges the shape and keeps the elements in the same order;flatten_from(1)merges every dimension from 1 on.unsqueeze(0)adds a dimension of size 1, often a batch of one;squeeze(0)removes it.transposeswaps two dimensions,permutereorders all of them. Neither moves a number: lesson 3 shows they only change the strides.
Broadcasting is explicit
Section titled “Broadcasting is explicit”Adding a bias row to every row of a matrix is broadcasting: the smaller tensor is repeated along the dimensions where it has size 1, or where it has no dimension at all. PyTorch and TorchSharp do it inside +. Candle’s + requires two identical shapes, and broadcasting has its own methods: lines 81-88.
let m = Tensor::new(&[[1f32, 2., 3.], [4., 5., 6.]], &dev)?;let row = Tensor::new(&[10f32, 20., 30.], &dev)?;let col = Tensor::new(&[[100f32], [200.]], &dev)?;show("m.broadcast_add(row)", &m.broadcast_add(&row)?, 0)?;show("m.broadcast_add(col)", &m.broadcast_add(&col)?, 0)?;show("row.broadcast_mul(col)", &row.broadcast_mul(&col)?, 0)?;show("(&m * 2.0)? - 1.0", &((&m * 2.0)? - 1.0)?, 0)?;show("m.broadcast_as((2, 2, 3))", &m.broadcast_as((2, 2, 3))?, 0)?;== broadcastingm.broadcast_add(row): shape [2, 3], F32, [11, 22, 33, 14, 25, 36]m.broadcast_add(col): shape [2, 3], F32, [101, 102, 103, 204, 205, 206]row.broadcast_mul(col): shape [2, 3], F32, [1000, 2000, 3000, 2000, 4000, 6000](&m * 2.0)? - 1.0: shape [2, 3], F32, [1, 3, 5, 7, 9, 11]m.broadcast_as((2, 2, 3)): shape [2, 2, 3], F32, [1, 2, 3, 4, 5, 6, 1, 2, 3, 4, 5, 6]The rule is NumPy’s, implemented in shape.rs, lines 191-227: align the shapes on the right, and along each dimension the sizes must be equal or one of them must be 1. (2, 3) and (3) give (2, 3); (3) and (2, 1) give (2, 3), an outer product. broadcast_binary_op computes the common shape, calls broadcast_as on the operands that need it, then the plain operation.
A number is different: &m * 2.0 works with any shape, because bin_trait! implements Mul<f64> as affine(2.0, 0.0), an element-wise x · 2 + 0.
Operators on tensors return a Result, which has two consequences. The ? goes on the parenthesized expression, as in (&m * 2.0)? - 1.0, and forgetting it gives one of two compile errors, depending on what comes next:
error[E0277]: cannot subtract `{float}` from `Result<candle_core::Tensor, candle_core::Error>` --> course\examples\x_minus.rs:5:30 | 5 | let shifted = (&m * 2.0) - 1.0; | ^ no implementation for `Result<candle_core::Tensor, candle_core::Error> - {float}`error[E0599]: no method named `sum_all` found for enum `Result<T, E>` in the current scope --> course\examples\x_methods.rs:5:27 | 5 | let total = (&a + &a).sum_all()?; | ^^^^^^^ method not found in `Result<candle_core::Tensor, candle_core::Error>`Result<Tensor> - Tensor does compile, because Candle implements the operators on Result when the right side is a tensor, so (&a + &b) - &c works without ? in the middle. Only the version with a number on the right, Result<Tensor> - f64, is missing.
Indexing
Section titled “Indexing”The IndexOp trait adds i, which takes an index, a range, or a tuple of them, one per dimension: lines 91-109.
== indexingm.i(1): shape [3], F32, [4, 5, 6]m.i((.., 1)): shape [2], F32, [2, 5]m.i((.., 1..)): shape [2, 2], F32, [2, 3, 5, 6]m.i((0, ..=1)): shape [2], F32, [1, 2]m.narrow(1, 0, 2): shape [2, 2], F32, [1, 2, 4, 5]m.index_select(ids [2, 0, 2], 1): shape [2, 3], F32, [3, 1, 3, 6, 4, 6]m.i((.., &ids)): shape [2, 3], F32, [3, 1, 3, 6, 4, 6]m.gather(picks [[2], [0]], 1): shape [2, 1], F32, [3, 4]m.i((1, 2)) as a Rust value: 6m.to_vec2(): [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]- A number removes its dimension:
m.i(1)is row 1, of shape(3). A range keeps it:m.i((.., 1..))is still two-dimensional.indexer.rs, lines 27-61 turns both intonarrowcalls. index_selectpicks whole rows or columns by aU32orI64tensor of indexes, repeats included. An embedding table is exactly that (lesson 5).gatherpicks one element per row: for row 0 the element in column 2, for row 1 the element in column 0. A classification loss uses it to pick the score of the right class.to_scalar,to_vec1,to_vec2andto_vec3copy the values out into Rust numbers and vectors, and check both the rank and the type.
There is no [] indexing, because Rust’s Index trait must return a reference to something that already exists, and a slice of a tensor is a new tensor:
error[E0608]: cannot index into a value of type `candle_core::Tensor` --> course\examples\x_index.rs:5:18 |5 | let first = m[0]; | ^^^Reductions and other operations
Section titled “Reductions and other operations”== operationsm.sum_all(): shape [], F32, [21]m.sum(0): shape [3], F32, [5, 7, 9]m.sum_keepdim(0): shape [1, 3], F32, [5, 7, 9]m.mean(D::Minus1): shape [2], F32, [2.0, 5.0]m.max(1): shape [2], F32, [3, 6]m.argmax(1): shape [2], U32, [2, 2]m.t()?.matmul(&m): shape [3, 3], F32, [17, 22, 27, 22, 29, 36, 27, 36, 45]m.sqr()?.sqrt(): shape [2, 3], F32, [1, 2, 3, 4, 5, 6]m.ge(3.0): shape [2, 3], U8, [0, 0, 1, 1, 1, 1]row.exp(): shape [3], F32, [2.7183, 7.3891, 20.0855]softmax by hand: shape [3], F32, [0.0900, 0.2447, 0.6652]Tensor::cat(&[&m, &m], 0): shape [4, 3], F32, [1, 2, 3, 4, 5, 6, 1, 2, 3, 4, 5, 6]Tensor::stack(&[&row, &row], 0): shape [2, 3], F32, [10, 20, 30, 10, 20, 30][[2., 3.], [5., 6.]]Tensor[[2, 2], f32]- A reduction along a dimension removes it; the
_keepdimversion leaves it with size 1, which is the shape you need to broadcast the result back against the original.sum_allandmean_allreduce to a scalar. argmaxreturnsU32indexes, and comparisons such asgereturn aU8mask of 0 and 1.catjoins tensors along an existing dimension,stackalong a new one.- The softmax, which turns scores into probabilities, isn’t a method of
Tensor(candle-nnhas one, lesson 5). Written by hand, it shows the pattern every model uses: reduce with_keepdim, then broadcast back.
fn softmax(x: &Tensor) -> candle::Result<Tensor> { let shifted = x.broadcast_sub(&x.max_keepdim(D::Minus1)?)?; let e = shifted.exp()?; e.broadcast_div(&e.sum_keepdim(D::Minus1)?)}Subtracting the maximum first changes nothing in the result, since the factor exp(-max) cancels out, and keeps exp from overflowing on large scores.
Errors at run time
Section titled “Errors at run time”Shapes and types are run-time values, so most mistakes come back as an Err. examples/l02_errors.rs makes one of each and prints its message:
== shapesm + row (no implicit broadcasting): error: shape mismatch in add, lhs: [2, 3], rhs: [3]m.broadcast_add(row): okm.broadcast_add([1, 2]): error: shape mismatch in broadcast_add, lhs: [2, 3], rhs: [2]m.matmul(m): error: shape mismatch in matmul, lhs: [2, 3], rhs: [2, 3]m.matmul(row): error: shape mismatch in matmul, lhs: [2, 3], rhs: [3]m.reshape((4, 2)): error: shape mismatch in reshape, lhs: [2, 3], rhs: [4, 2]m.reshape(((), 4)): error: cannot reshape tensor with 6 elements to ((), 4)col.broadcast_as((2, 3)): okrow.broadcast_as((3, 2)): error: cannot broadcast [3] to [3, 2]Tensor::cat(&[&m, &col], 0): error: shape mismatch in cat for dim 1, shape for arg 1: [2, 3] shape for arg 2: [2, 1]m.squeeze(0): ok
== dimensions and indexesm.sum(2): error: sum: dimension index 2 out of range for shape [2, 3]row.dim(D::Minus2): error: dim: dimension index -2 out of range for shape [3]m.i(2): error: narrow invalid args start + len > dim_len: [2, 3], dim: 0, start: 2, len:1m.i((.., ..5)): error: narrow invalid args start + len > dim_len: [2, 3], dim: 1, start: 0, len:5m.narrow(1, 2, 2): error: narrow invalid args start + len > dim_len: [2, 3], dim: 1, start: 2, len:2m.index_select([0, 3], 1): error: index-select invalid index 3 with dim size 3m.i((0, 0, 0)): error: narrow: dimension index 0 out of range for shape []
== dtypesrow.to_vec1::<f64>(): error: unexpected dtype, expected: F64, got: F32row + ints: error: dtype mismatch in add, lhs: F32, rhs: U32ints.matmul(ints): error: unsupported dtype U32 for op matmulints.sqrt(): panic: not yet implemented: no unary function for u32m.index_select(f32 ids, 0): error: unsupported dtype F32 for op index-selectm.to_scalar::<f32>(): error: unexpected rank, expected: 0, got: 2 ([2, 3])Most messages name the operation and both shapes, which is what you need. A few need translating:
m.squeeze(0)succeeds and returnsmunchanged, because dimension 0 has size 2, not 1. PyTorch does the same (tensor.rs, line 2567); a shape check of your own is the way to catch it.- An index past the end,
m.i(2), is reported as thenarrowit becomes. m.i((0, 0, 0))has one index too many. The first two indexes select a scalar, and the third one fails on it: “dimension index 0 out of range for shape []” describes that last step, not your mistake.row.to_vec1::<f64>()doesn’t convert: reading values out requires the exact type, so callto_dtypefirst.
check.sh runs this example with the same environment as your shell. With RUST_BACKTRACE=1 set, every one of these errors also carries a backtrace, because Candle captures one when it creates an error (error.rs, lines 263-273). That helps find where a shape error came from in a large model.
Two panics
Section titled “Two panics”Two operations in that list don’t return an error; they panic, and would stop a server’s thread. The example catches the panic with a helper, caught, to print its message.
sqrt on an integer tensor. Unary operations are generated by a macro whose integer branches are todo!("no unary function for u32") (op.rs, lines 490-493). todo! panics with “not yet implemented”. The same code is on main today. Convert to a float type before calling sqrt, exp, log or the other float functions.
A buffer shorter than its shape. Lines 55-62:
let short = Tensor::from_vec(vec![1f32, 2., 3., 4., 5.], (2, 3), &dev)?;println!("from_vec(5 values, (2, 3)): ok, dims {:?}, elem_count {}", short.dims(), short.elem_count());caught("short.sum_all()", || short.sum_all());== a shape that doesn't match the data (issue #3812, fixed after 0.11.0)from_vec(5 values, (2, 3)): ok, dims [2, 3], elem_count 6short.sum_all(): panic: range end index 6 out of range for slice of length 5Five values with a shape of six elements are accepted, and the error only appears later, as a panic, in the first operation that reads the sixth element. The documentation of from_vec says the counts must match, but in 0.11.0 a shape without a hole skips the check: shape.rs, lines 474-478 ignores the element count. It is issue #3812, which also traces a family of wrong results in batched attention masks to it, fixed on main by pull request #3813 on August 13, 2026, after 0.11.0 was released. Until the next release, check data.len() against the shape yourself when the data comes from outside. The CPU backend panics on a slice index; on a GPU, the issue reports reads past the buffer.
== seedsDevice::Cpu.set_seed(42): error: cannot seed the CPU rng with set_seedThe CPU backend draws rand and randn from the thread’s random generator and refuses to seed it (cpu_backend/mod.rs, lines 3096-3108). A reproducible run on the CPU has to generate its random numbers itself, with a seeded generator from the rand crate, and pass them to Tensor::from_vec. That matters for lesson 5, where a layer’s weights start random.
Key takeaways
Section titled “Key takeaways”- A
Tensoris a cheap-to-clone pointer to a shared buffer plus a layout; operations return new tensors. - Shapes are tuples, with at most one
()hole.D::Minus1counts from the end. Rust catches ragged array literals, and nothing else about shapes. - Element types never convert silently. Float to integer truncates and clamps;
F16halves memory and rounds. +,-,*,/between tensors need equal shapes. Broadcasting isbroadcast_addand its siblings; a number on the right usesaffine.iindexes with numbers and ranges;index_selectandgatherindex with tensors.- Errors are values with useful messages. In 0.11.0, float functions on integer tensors panic, and
from_vecdoesn’t check the element count; the CPU generator can’t be seeded.
Exercises
Section titled “Exercises”The solutions are in examples/l02_exercises.rs, and their output in expected/l02_exercises.txt.
- Given three points
pof shape(3, 2)and two pointsqof shape(2, 2), compute the(3, 2)matrix of squared distances between each point ofpand each point ofqwith broadcasting, then the index of the nearest point ofqfor each point ofp. Compute the same matrix a second way, without a three-dimensional tensor.
Solution
let p = Tensor::new(&[[0f32, 0.], [3., 4.], [1., 1.]], &dev)?; // (3, 2)let q = Tensor::new(&[[0f32, 0.], [6., 8.]], &dev)?; // (2, 2)// (3, 1, 2) - (1, 2, 2) broadcasts to (3, 2, 2): one difference vector per pairlet diff = p.unsqueeze(1)?.broadcast_sub(&q.unsqueeze(0)?)?;let d2 = diff.sqr()?.sum(D::Minus1)?;// Nearest point of q for each point of pshow("nearest", &d2.argmin(1)?, 0)?;// The same without the 3-d tensor: |p|^2 + |q|^2 - 2 p.qlet pp = p.sqr()?.sum_keepdim(1)?; // (3, 1)let qq = q.sqr()?.sum_keepdim(1)?.t()?; // (1, 2)let d2_bis = pp.broadcast_add(&qq)?.sub(&(p.matmul(&q.t()?)? * 2.0)?)?;== exercise 1: squared distances between every point of p and every point of qd2: shape [3, 2], F32, [0, 100, 25, 25, 2, 74]dims [3, 2]nearest: shape [3], U32, [0, 0, 0]d2 by |p|^2 + |q|^2 - 2 p.q: shape [3, 2], F32, [0, 100, 25, 25, 2, 74]The point (3, 4) is at the same distance, 25, from both points of q; argmin returns the first index on a tie. The first version builds a (3, 2, 2) tensor, which for a thousand points of each and 768 dimensions would be three gigabytes of floats; the second only builds (3, 2) matrices, which is how nearest-neighbour search on embeddings is written (lesson 7, and the GA AI course).
- A model gives scores for 3 classes to 4 examples, in a
(4, 3)tensor, and the right classes are[1, 0, 1, 1]. Compute the accuracy, the share of examples whose highest score is the right class, as anf32.
Solution
let labels = Tensor::new(&[1u32, 0, 1, 1], &dev)?;let predicted = scores.argmax(D::Minus1)?;let correct = predicted.eq(&labels)?; // U8: 1 where equallet accuracy = correct.to_dtype(DType::F32)?.mean_all()?.to_scalar::<f32>()?;== exercise 2: accuracy of scores against labelspredicted: shape [4], U32, [1, 0, 2, 0]correct: shape [4], U8, [1, 1, 0, 0]accuracy 0.5argmax gives U32, so the labels must be U32 too, or eq fails with a dtype mismatch. The mean of a U8 mask would be an integer mean; converting to F32 first gives 0.5. The last example’s scores, 3.0 and 2.9, are close: accuracy ignores by how much a prediction is right or wrong, which is why models are trained on a loss instead (IX course, lesson 3).
Sources
Section titled “Sources”- Candle at
31f35b1:tensor.rs,shape.rs,indexer.rs,dtype.rs,error.rs candle_core::Tensorandcandle_core::Erroron docs.rs- Candle issue #3812 and pull request #3813
- NumPy broadcasting, the rule Candle follows
- The Rust Reference: numeric casts