Memory layout and strides

NumPy · Advanced · 15 min

Strides explain why views are free, why some reshapes copy, and why loop order changes speed by 10x.

Every ndarray is a flat buffer of bytes plus metadata. The strides tuple says how many bytes to jump to move one step along each axis. For a C-ordered (3, 4) array of float64, strides are (32, 8): the next column is 8 bytes away, the next row 32 bytes (4 elements x 8).

The address of a[i, j] is simply offset + i*strides[0] + j*strides[1]. That one formula explains a lot:

  • Slicing with a step just multiplies a stride: a[:, ::2] has strides (32, 16) over the same buffer.
  • Transposing swaps the strides, (8, 32), again without moving data.
  • Broadcasting uses a stride of 0, so one value is reused along an axis.
  • Reshape can only return a view when the new shape can be expressed with strides over the existing layout; otherwise it must copy.

C order (row-major, NumPy's default) keeps the last axis contiguous; Fortran order (order="F") keeps the first axis contiguous. a.flags shows C_CONTIGUOUS and F_CONTIGUOUS. np.ascontiguousarray returns a C-contiguous copy if needed, which some C libraries and GPU transfers require.

Layout matters for speed because CPUs fetch memory in 64-byte cache lines. Reductions or loops that walk along the contiguous axis read sequential memory; walking across it jumps by large strides and wastes most of each cache line. Summing a large C-ordered matrix along axis=1 versus over its transpose can differ noticeably for that reason.

np.lib.stride_tricks.sliding_window_view(x, w) uses overlapping strides to give all windows of length w without copying. Its low-level cousin as_strided can build any view, including ones that read outside the buffer, so avoid it unless you have checked the arithmetic.

Example

import numpy as np

a = np.arange(12, dtype=np.float64).reshape(3, 4)
print(a.strides)                 # (32, 8)
print(a[:, ::2].strides)         # (32, 16)  same buffer
print(a.T.strides)               # (8, 32)   axes swapped
print(a.T.flags["C_CONTIGUOUS"], a.T.flags["F_CONTIGUOUS"])   # False True

f = np.asfortranarray(a)
print(f.strides)                 # (8, 24)

b = np.broadcast_to(np.array([1.0, 2.0]), (1000, 2))
print(b.strides)                 # (0, 8)  no copy, stride 0

from numpy.lib.stride_tricks import sliding_window_view
x = np.arange(6)
w = sliding_window_view(x, 3)    # all length-3 windows, a view
print(w)                         # [[0 1 2] [1 2 3] [2 3 4] [3 4 5]]
print(w.strides, np.shares_memory(w, x))   # (8, 8) True
print(w.mean(axis=1))            # moving average [1. 2. 3. 4.]

Remember

  • Strides are bytes per step along each axis; indexing is offset + sum(i*stride)
  • Slices, transposes and broadcasts are new strides over the same buffer
  • C order: last axis contiguous; F order: first axis contiguous
  • Iterate and reduce along the contiguous axis for cache efficiency

Watch out for

  • Writing through a sliding_window_view or as_strided view: overlapping elements alias the same memory (the former is read-only by default for that reason).

Check yourself

  1. An int32 array has shape (5, 6) in C order. What are its strides?
  2. Why does `a.T.reshape(-1)` copy, while `a.reshape(-1)` doesn't, for a C-ordered 2-D a?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: numpy.org/doc/stable/reference/arrays.ndarray.html#internal-memory-layout-of-an-ndarray