Here are six integers:

std::vector<int> values{1, 2, 3, 4, 5, 6};

The vector owns one contiguous run of storage, a fact established when a retained pointer watched a vector grow in the address investigation. Nothing about those six integers says matrix. C++ sees a vector with six elements.

This article is about the extra agreement that makes the same storage mean this:

1  2  3
4  5  6

It is a two-row, three-column matrix. Its shape is 2x3. Shape is not stored inside std::vector<int>; the program has to carry it separately.

That sounds like bookkeeping. Then the matrix is turned sideways without moving a byte, multiplied by the original, and produces the wrong answer:

correct:
14  32
32  77

wrong:
22  28
49  64

Both answers came from the same six integers. The multiplication loop was the same too. Two stride values caused the split.

First, a row has to begin somewhere

Rows and columns are positions, not C++ types. For the 2x3 arrangement, element (0, 0) is 1, (0, 2) is 3, and (1, 0) is 4. The first coordinate selects a row; the second selects a column.

The storage is flat:

flat index:  0  1  2  3  4  5
value:       1  2  3  4  5  6

Three columns fit in each row. Moving down one row therefore skips three elements. Moving right one column skips one. The flat index is:

row * 3 + column

For (1, 2), that is 1 * 3 + 2, or flat index 5, whose value is 6.

This arrangement is called row-major because one complete row appears before the next. A small accessor is enough to encode it:

int& at(std::size_t row, std::size_t column) {
    return data[row * 3 + column];
}

The reference return type matters. at(1, 2) is not a copy of the integer. It reaches the integer in the vector, so assignment changes the underlying storage.

Hard-coding 3 works for this matrix and immediately becomes a nuisance for the next one. Carrying the shape gives a reusable version:

struct Matrix {
    std::vector<int> data;
    std::size_t rows;
    std::size_t columns;

    int& at(std::size_t row, std::size_t column) {
        return data[row * columns + column];
    }
};

The number of elements must be rows * columns. If it is not, the shape is lying about the storage. Production tensor libraries check some version of this contract because an incorrect shape can turn an ordinary subscript into an out-of-bounds access.

A dot product is a loop you already know

Take two equal-length rows:

a = [1, 2, 3]
b = [4, 5, 6]

Their dot product multiplies matching positions and adds the products:

1*4 + 2*5 + 3*6 = 32

In C++:

int total = 0;
for (std::size_t i = 0; i < 3; ++i) {
    total += a[i] * b[i];
}

No new machine operation appeared. “Dot product” names this particular use of a multiply-and-accumulate loop.

The two inputs must have the same length. That condition is part of the operation, just as the bounds are part of an array subscript. With floating-point inputs, addition order and fused multiply-add can change the last bits; sixteen million and one developed that numerical problem. The integers here keep the storage problem visible without rounding noise.

Matrix multiplication repeats the dot product. To produce output (row, column), take one row from the left matrix and one column from the right:

for (std::size_t row = 0; row < left.rows; ++row) {
    for (std::size_t column = 0; column < right.columns; ++column) {
        int total = 0;
        for (std::size_t inner = 0; inner < left.columns; ++inner) {
            total += left.at(row, inner) * right.at(inner, column);
        }
        result.at(row, column) = total;
    }
}

The inner loop uses left.columns values. The right matrix therefore needs the same number of rows:

left.columns == right.rows

A 2x3 matrix can multiply a 3x4 matrix. The result has two rows from the left and four columns from the right, so its shape is 2x4:

(2x3) * (3x4) -> (2x4)

The two inner threes disappear into the dot product. If they disagree, there is no pair of equal-length vectors to multiply.

Turning the matrix without moving it

The transpose exchanges rows and columns:

original (2x3)        transpose (3x2)

1  2  3               1  4
4  5  6               2  5
                       3  6

One implementation allocates six new integers and writes them in the transposed order. That creates a copy:

copied storage: [1, 4, 2, 5, 3, 6]

There is another option. Keep the original [1, 2, 3, 4, 5, 6] and change the indexing rule. The transposed (row, column) points back to original (column, row).

That object is a view: shape and indexing metadata that refer to storage owned somewhere else. A view avoids copying, but it inherits the lifetime of the storage it views.

Hard-coded indexing rules multiply quickly, so matrix and tensor libraries describe movement with strides. A stride is the number of elements skipped when one coordinate increases by one.

The original matrix has:

row stride    = 3
column stride = 1

Its general index rule is:

row * row_stride + column * column_stride

The transpose swaps the shape and the strides:

shape         = 3x2
row stride    = 1
column stride = 3

Now (2, 1) maps to 2*1 + 1*3, flat index 5, whose value is 6.

The complete view used by the probe is small:

struct MatrixView {
    int* data;
    std::size_t rows;
    std::size_t columns;
    std::ptrdiff_t row_stride;
    std::ptrdiff_t column_stride;

    int& at(std::size_t row, std::size_t column) const {
        return data[row * row_stride + column * column_stride];
    }
};

MatrixView does not own the pointer. The vector must remain alive and must not reallocate while the view is used. That is the same lifetime problem as any other stored pointer, now wearing matrix vocabulary.

The plausible wrong matrix

The broken view gets the transposed shape right and silently assumes row-major contiguous storage:

MatrixView wrong_transpose{
    storage.data(),
    3, 2,  // transposed shape
    2, 1,  // ordinary contiguous strides for a 3x2 matrix
};

Those strides describe:

1  2
3  4
5  6

Every access stays inside the vector. Sanitizers see no invalid address. There is no crash, and the values look orderly. The metadata simply describes a different matrix from the intended transpose.

Multiplying the original by the correct transpose computes:

[1 2 3] dot [1 2 3] = 14
[1 2 3] dot [4 5 6] = 32
[4 5 6] dot [1 2 3] = 32
[4 5 6] dot [4 5 6] = 77

The wrong strides select different columns and produce:

22  28
49  64

The bug lives entirely in the interpretation. This is why checking only a pointer and element count is insufficient at a native boundary. Shape, strides, element type, ownership, and permitted access are all part of the buffer contract.

A view can change the thing it views

The transpose and original matrix share storage. They are aliases: two access paths that can reach the same object.

The probe changes the original bottom-right element:

matrix.at(1, 2) = 60;

The transposed view observes it at the exchanged coordinates:

after matrix[1,2] = 60, transpose[2,1] = 60

That can be useful. A slice or transpose need not allocate, and an update can be visible through every view. It can also surprise an algorithm that assumed its input would remain unchanged.

A copy has separate storage. Changing the original after copying leaves the copy alone. The choice is not “view good, copy bad.” A view saves allocation and movement; a copy buys an independent lifetime, a layout selected for the next operation, and freedom from aliasing with the source.

The distinction will return in the GPU article. A transposed view may avoid a copy on the host, yet its stride pattern can make adjacent GPU threads fetch distant addresses. Copying once into the desired layout can be cheaper than running many later operations through an awkward view.

The three loops are the next problem

The multiplication code is correct for arbitrary positive strides. It is also the slowest interesting version of the algorithm: three loops, one scalar result at a time, with no attention to which addresses arrive in cache together.

For square matrices with N rows and columns, it performs N*N*N multiplications and nearly as many additions. Doubling N multiplies that arithmetic by eight. The result contains only N*N values, so the expensive part is repeatedly combining the inputs.

The loop order decides how that repeated work walks through memory. The strides decide whether the next logical value is physically adjacent. The compiler may turn several scalar operations into SIMD instructions. Multiple CPU cores may divide the output. A GPU will divide it among thousands of threads, and then the same questions about adjacency and reuse become more severe.

Those are performance questions. The storage contract comes first:

shape says which coordinates exist
strides map coordinates to flat storage
the pointer identifies that storage
ownership keeps it alive
aliasing tells which other access paths can change it

With those pieces, the next investigation can change loop order, tile the work, transpose an operand, and move the computation to a GPU without using the word “matrix” as unexplained magic.

The complete program and its assertions live in writing-notes/probes/matrices/matrix_bridge.cpp.