1. 命名张量
命名张量(Named Tensors)允许用户将显式名称与Tensor的维度关联起来,便于对Tensor进行其他操作。笔者推荐使用维度的名称进行维度操作,这样可以避免重复计算Tensor每个维度的位置。支持命名张量的工厂函数(factory functions)有tensor、empty、ones、zeros、randn等。
下面举例说明命名张量的使用,其中N代表batch_size,C代表通道数,H代表高度,W代表宽度。
In: # 命名张量API在后续还有可能还有变化,系统会提示warning,在此忽略
import warnings
warnings.filterwarnings("ignore")
# 直接使用names参数创建命名张量
imgs = t.randn(1, 2, 2, 3, names=('N', 'C', 'H', 'W'))
imgs.names
Out:('N', 'C', 'H', 'W')
In: # 查看旋转操作造成的维度变换
imgs_rotate = imgs.transpose(2, 3)
imgs_rotate.names
Out:('N', 'C', 'W', 'H')
In: # 通过refine_names对未命名的张量命名,不需要名字的维度可以用None表示
another_imgs = t.rand(1, 3, 2, 2)
another_imgs = another_imgs.refine_names('N', None, 'H', 'W')
another_imgs.names
Out:('N', None, 'H', 'W')
In: # 修改部分维度的名称
renamed_imgs = imgs.rename(H='height', W='width')
renamed_imgs.names
Out:('N', 'C', 'height', 'width')
In: # 通过维度的名称做维度转换
convert_imgs = renamed_imgs.align_to('N', 'height', 'width','C')
convert_imgs.names
Out:('N', 'height', 'width', 'C')
在进行张量的运算时,命名张量可以提供更高的安全性。例如,在进行Tensor的加法时,如果两个Tensor的维度名称没有对齐,那么即使它们的维度相同也无法进行计算。
In: a = t.randn(1, 2, 2, 3, names=('N', 'C', 'H', 'W'))
b = t.randn(1, 2, 2, 3, names=('N', 'H', 'C', 'W'))
# a + b
# 报错,RuntimeError: Error when attempting to broadcast dims ['N', 'C', 'H', 'W'] and dims ['N', 'H', 'C', 'W']: dim 'H' and dim 'C' are at the same position from the right but do not match.
2. Tensor与NumPy
Tensor在底层设计上参考了NumPy数组,它们彼此之间的相互转换非常简单高效。因为NumPy中已经封装了常用操作,同时与Tensor之间在某些情况下共享内存,所以当遇到CPU Tensor不支持的操作时,可以先将其转成NumPy数组,完成相应处理后再转回Tensor。由于这样的操作开销很小,所以在实际应用中经常进行二者的相互转换,下面举例说明:
In: import numpy as np
a = np.ones([2, 3], dtype=np.float32)
aOut:array([[1., 1., 1.],
[1., 1., 1.]], dtype=float32)In: # 从NumPy数组转化为Tensor,由于dtype为float32,所以a和b共享内存
b = t.from_numpy(a)
# 该种情况下,使用t.Tensor创建的Tensor与NumPy数组仍然共享内存
# b = t.Tensor(a)
bOut:tensor([[1., 1., 1.],
[1., 1., 1.]])In: # 此时,NumPy数组和Tensor是共享内存的
a[0, 1] = -1
b # 修改a的值,b的值也会被修改Out:tensor([[ 1., -1., 1.],
[ 1., 1., 1.]])
注意:使用torch.Tensor()创建的张量默认dtype为float32,如果NumPy的数据类型与默认类型不一致,那么数据仅会被复制,不会共享内存。
In: a = np.ones([2, 3])
# 注意和上面的a的区别(dtype不是float32)
a.dtypeOut:dtype('float64')In: b = t.Tensor(a) # 此处进行拷贝,不共享内存
b.dtypeOut:torch.float32In: c = t.from_numpy(a) # 注意c的类型(DoubleTensor)
cOut:tensor([[1., 1., 1.],
[1., 1., 1.]], dtype=torch.float64)In: a[0, 1] = -1
print(b) # b与a不共享内存,所以即使a改变了,b也不变
print(c) # c与a共享内存Out:tensor([[1., 1., 1.],
[1., 1., 1.]])
tensor([[ 1., -1., 1.],
[ 1., 1., 1.]], dtype=torch.float64)
注意:无论输入类型是什么,torch.tensor()都只进行进行数据拷贝,不会共享内存。读者需要注意torch.Tensor(),torch.from_numpy()与torch.tensor()在内存共享方面的区别。
In: a_tensor = t.tensor(a)
a_tensor[0, 1] = 1
a # a和a_tensor不共享内存Out:array([[ 1., -1., 1.],
[ 1., 1., 1.]])
除了使用上述操作完成NumPy和Tensor之间的数据转换,PyTorch还构建了torch.utils.dlpack模块。该模块可以实现PyTorch张量和DLPack内存张量结构之间的相互转换,因此,用户可以轻松实现不同深度学习框架的张量数据的交换。注意:转换后的DLPack张量与原PyTorch张量仍然是共享内存的。
3. Tensor的基本结构
Tensor的数据结构如图3-1所示。Tensor分为头信息区(Tensor)和存储区(Storage),头信息区主要保存Tensor的形状(size)、步长(stride)、数据类型(type)等信息,真正的数据在存储区保存成连续数组。头信息区元素占用内存较少,主要内存占用取决于Tensor中元素的数目,即存储区的大小。
一般来说,一个Tensor有与之对应的Storage,Storage是在data之上封装的接口。Tensor的内存地址指向Tensor的头(head),不同Tensor的头信息一般不同,但可能使用相同的Storage。关于Tensor的很多操作虽然创建了一个新的head,但是它们仍共享同一个Storage,下面举例说明。
In: a = t.arange(0, 6).float()
b = a.view(2, 3)
# Storage的内存地址一样,即它们是同一个Storage
a.storage().data_ptr() == b.storage().data_ptr()
Out:True
In: # a改变,b也随之改变,因为它们共享Storage
a[1] = 100
b
Out:tensor([[ 0., 100., 2.],
[ 3., 4., 5.]])
In: # 对a进行索引操作,只改变了head信息,Storage相同
c = a[2:]
a.storage().data_ptr() == c.storage().data_ptr()
Out:True
In: c.data_ptr(), a.data_ptr() # data_ptr返回Tensor首元素的内存地址
# 可以看出两个内存地址相差8,这是因为2×4=8:相差两个元素,每个元素占4个字节(float)
# 如果差值不是8,如16,那么可以用a.type()查看一下数据类型是不是torch.FloatTensor
Out:(94880397551496, 94880397551488)
In: c[0] = -100 # c[0]的内存地址对应a[2]的内存地址
a
Out:tensor([ 0., 100., -100., 3., 4., 5.])
In: d = t.Tensor(c.storage()) # d和c仍然共享内存
d[0] = 6666
b
Out:tensor([[ 6.6660e+03, 1.0000e+02, -1.0000e+02],
[ 3.0000e+00, 4.0000e+00, 5.0000e+00]])
In: # 下面四个Tensor共享Storage
a.storage().data_ptr() == b.storage().data_ptr() == c.storage().data_ptr() == d.storage().data_ptr()
Out:True
In: # c取得a的部分索引,改变了偏移量
a.storage_offset(), c.storage_offset(), d.storage_offset()
Out:(0, 2, 0)
In: e = b[::2, ::2] # 隔2行/列取一个元素
print(a.storage().data_ptr() == e.storage().data_ptr()) # 共享内存
print(e.is_contiguous()) # e的存储空间是不连续的
Out:True
False
由此可见,绝大多数操作不是修改Tensor的Storage,而是修改了Tensor的头信息。这种做法更节省内存,同时提升了处理速度。此外,有些操作会导致Tensor不连续,这时需要调用tensor.contiguous()方法将它们变成连续的数据。该方法会复制数据到新的内存,不再与原来的数据共享Storage。
读者可以思考一个问题,高级索引一般不共享Storage,而基本索引共享Storage,这是为什么呢?(提示:基本索引可以通过修改Tensor的offset、stride和size实现,不用修改Storage的数据,高级索引则不行。)