2.1 步长strides如何定位每个元素


文档摘要

2.1 步长strides如何定位每个元素 本节摘要:strides 记录沿每个维度前进一格需要跳过的字节数,它是"逻辑下标到物理地址"换算的唯一依据。步长规整(后缀连乘)时访问最快;步长错乱(转置、跨步切片)时 CPU 缓存命中率下降,某些操作的耗时会翻几倍。本节用实验量化这种差别,并给出沿正确维度操作的实用建议。 承接与定位 上一章我们知道元素地址 = 起始地址 + 下标与步长的加权和。这一节专门回答:步长除了用来定位,还决定了访问数据快不快。它是第 2 章三讲的第一讲,也是理解后面"为什么按列循环慢""为什么转置后某些计算变慢"的钥匙。 步长的两种典型形态 先看规整形态。

2.1 步长strides如何定位每个元素

本节摘要:strides 记录沿每个维度前进一格需要跳过的字节数,它是"逻辑下标到物理地址"换算的唯一依据。步长规整(后缀连乘)时访问最快;步长错乱(转置、跨步切片)时 CPU 缓存命中率下降,某些操作的耗时会翻几倍。本节用实验量化这种差别,并给出沿正确维度操作的实用建议。

承接与定位

上一章我们知道元素地址 = 起始地址 + 下标与步长的加权和。这一节专门回答:步长除了用来定位,还决定了访问数据快不快。它是第 2 章三讲的第一讲,也是理解后面"为什么按列循环慢""为什么转置后某些计算变慢"的钥匙。

步长的两种典型形态

先看规整形态。一个 C 序的 3×4 int32 数组:

import numpy as np a = np.arange(12, dtype=np.int32).reshape(3, 4) print("shape:", a.shape) # (3, 4) print("strides:", a.strides) # (16, 4) # 规整形态:strides[0] = 4 * strides[1],即后缀连乘 # 行内相邻元素物理相邻,顺序扫一行就是顺序扫内存

再看错乱形态——转置与跨步切片都会造成:

at = a.T # 转置:只交换 strides print("转置后 strides:", at.strides) # (4, 16) s = a[:, ::2] # 每行隔一个取一个 print("切片后 strides:", s.strides) # (16, 8) print("切片后 shape:", s.shape) # (3, 2) # 两者都还是视图:没有数据被复制 print(np.shares_memory(at, a), np.shares_memory(s, a)) # True True

这些数组在逻辑上完全合法,但它们"跳着"访问内存。CPU 读内存不是逐字节的,而是按缓存行(通常 64 字节)成块搬进高速缓存。顺序访问时,搬进来的每一块都能用上;跳着访问时,搬进来的块只用一小部分,带宽白白浪费。

访问模式对比示意

访问模式对比示意

亲手量一次:行遍历 vs 列遍历

拿一个 8000×8000 的 float64 数组做实验,对比按行求和与按列求和:

import numpy as np import time m = np.random.rand(8000, 8000) # C 序,行连续 t0 = time.perf_counter() row_sum = m.sum(axis=1) # 沿列方向压缩:每行内部顺序访问 t1 = time.perf_counter() print("按行求和耗时:", round(t1 - t0, 3), "秒") # 输出示例:按行求和耗时: 0.109 秒 t0 = time.perf_counter() col_sum = m.sum(axis=0) # 沿行方向压缩:跨行跳跃访问 t1 = time.perf_counter() print("按列求和耗时:", round(t1 - t0, 3), "秒") # 输出示例:按列求和耗时: 0.428 秒

同一个函数、同样多的加法,仅仅因为访问方向不同,耗时差了约 4 倍。NumPy 内核对 axis=0 做了优化,实际差距已经比纯循环小很多,但缓存效应依然清晰可见。如果是自己写的 Python 循环按列访问,差距会拉大到几十倍。

再看转置视图的隐性代价:

mt = m.T # 零拷贝转置 print(np.shares_memory(mt, m)) # True t0 = time.perf_counter() x = mt.sum(axis=1) # 看似按行,实际底层跳着访问 t1 = time.perf_counter() print("转置视图按行求和:", round(t1 - t0, 3), "秒") # 输出示例:转置视图按行求和: 0.431 秒 —— 掉回慢的访问模式

转置本身免费,但转置之后的所有计算都按新的步长走。所以"转置很便宜"只对一次性操作成立;若后续要对转置结果反复计算,用 np.ascontiguousarray 落成一份连续拷贝反而更划算(2.2 节细讲)。

实用建议

  • 求和、最值等聚合,优先想清楚哪个轴的访问是连续的,能用 axis=1 的场合别硬凑 axis=0
  • 长期反复使用的中间结果,值得花一次拷贝换来连续性
  • 需要逐列处理的算法,考虑把数据存成 F 序(创建时传 order 参数),让"列"变成物理连续

💡 关键直觉:strides 是数组的"走路方式"。方式不同,同样一段路(同样多的计算)花的力气可以差几倍。优化 NumPy 代码的第一步不是换算法,是看步长。

一个容易误判的例子:行切片其实是连续的

并非所有切片都伤缓存。取整行的切片依然连续,只有"跨列跳"才伤:

import numpy as np m = np.arange(20, dtype=np.float64).reshape(4, 5) rows = m[1:3, :] # 取中间两行 print(rows.strides) # (40, 8) —— 仍是规整后缀连乘,行内连续 print(rows.flags["C_CONTIGUOUS"]) # False!整体不连续(块之间有断裂),但每行内部连续 cols = m[:, 1:3] # 取中间两列 print(cols.strides) # (40, 8) —— 行方向步长没变,但每行只取2个,行内不连续

这个例子说明"连续"有两层:整块连续(flags 检测的对象)与访问路径连续(性能真正依赖的对象)。rows 整块不连续,但逐行处理时每行内部顺序扫描,性能几乎不受影响;cols 的 flags 同为 False,但它的访问路径处处跳跃,才是真正要担心的写法。判断性能时想访问路径,判断接口兼容性时看 flags——两把尺子别混用。

本节要点回顾

  • 定位公式:元素地址由下标与 strides 加权求和得到,定位本身永远是纯算术
  • 规整形态:C 序数组 strides 呈后缀连乘,沿最后一维访问物理连续
  • 错乱形态:转置、跨步切片产生非规整 strides,视图合法但缓存不友好
  • 实测差距:8000 阶矩阵求和,按行约 0.11 秒、按列约 0.43 秒,差约 4 倍
  • 优化次序:先看步长与访问方向,再谈换算法

下一节讲连续性的判定与转换:flags 字段怎么读,ascontiguousarray 什么时候该用、什么时候是浪费。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U