一文读懂pytorch和huggingface的大模型存储格式

随着huggingface transformers的流行,越来越多的模型采用了.safetensors的文件存储格式。它有什么特点?与传统的torch.save保存的文件有何不同?本文将详细梳理相关细节。

新晋宠儿.safetensors

基本用法

这是huggingface设计的一种新格式,大致就是以更加紧凑、跨框架的方式存储Dict[str, Tensor],主要存储的内容为tensor的名字(字符串)及内容(权重)。

其官网对文件格式的详细内容进行了解释,本质上就是一个JSON文件加上若干binary形式的buffer。对于tensor而言,它只存储了数据类型、形状、对应的数据区域起点和终点。因此它只支持存储dense and contiguous tensor,也就是稠密张量(没有stride,没有view等信息)。本质上它就像存储了一大块连续的内存,甚至可以直接把文件映射到内存里(使用Python的mmap模块)。

这种方式其实有一个问题,就是没有存储数据的大小端信息。目前大部分机器都是小端(little-endian)的,如果你在用大端机器(big-endian),建议就不要跑LLM了…… 很多代码都会容易出问题的。

理论上这种数据格式是可以支持多个tensor共享存储的,只需要把多个tensor的offsets设置成一样就行了。但是huggingface为了保持对多个框架的兼容性(同时支持pytorch、TensorFlow、jax),没有实现这一特点。如果真的有两个tensor共享存储,它只存第一个tensor。

使用一份简单的Python代码就可以将.safetensors文件转成内存中的Dict[str, Tensor]类型的对象:

from safetensors import safe_open

tensors = {}
with safe_open("model.safetensors", framework="pt", device=0) as f:
    for k in f.keys():
        tensors[k] = f.get_tensor(k)

这样得到的结果基本上就可以当pytorch的state_dict用了。

跨框架用法

.safetensors支持五种框架,包括pytorch、TensorFlow、flax(jax)、paddle(paddlepaddle)、numpy。对每个框架都提供了save/save_file/load/load_file这四个函数:

  • load( data: bytes, device = 'cpu' ) → Dict[str, Tensor]

  • load_file( filename: Union[str, os.PathLike], device = 'cpu' ) → Dict[str, Tensor]

  • save( tensors: Dict[str, Tensor], metadata: Optional = None ) → bytes

  • save_file( tensors: Dict, filename: Union[str, os.PathLike], metadata: Optional = None ) → None

对于numpy,函数没有device参数;Tensor类型根据具体的框架替换成框架的具体类,例如torch.Tensor或者np.array

权重共享

为了支持pytorch里的weight sharing(例如常见的word embedding与lm_head共享权重),safetensors专门为pytorch设计了两个函数:

  • load_model(model: Module, filename: Union[str, os.PathLike], strict = True ) → (missing, unexpected)

  • save_model( model: Module, filename: str, metadata: Optional = None, force_contiguous: bool = True )

以一个简单的共享权重的模型为例:

from torch import nn

class Model(nn.Module):
    def __init__(self):
        super().__init__()
        self.a = nn.Linear(100, 100)
        self.b = self.a

    def forward(self, x):
        return self.b(self.a(x))

model = Model()

from safetensors.torch import save_model, safe_open, load_model
save_model(model, "model.safetensors")

tensors = {}
with safe_open("model.safetensors", framework="pt") as f:
    for k in f.keys():
        tensors[k] = f.get_tensor(k)

print(list(tensors.keys())) # ['a.bias', 'a.weight']

model_restored = Model()
load_model(model_restored, "model.safetensors")

其实model.safetensors文件里完全丢失了b的信息,只保留了a的数据。这就要求我们加载模型的时候,由model_restored来提供ba共享数据的信息。

我们大致也能猜到load_model的实现方式:

def load_model(model, filename):
    data = load_file(filename)
    model_data = model.state_dict()
    for k, v in data.items():
        model_data[k].copy_(v)

部分加载

.safetensors支持只加载元数据然后再加载tensor的一部分,例如:

from safetensors import safe_open

tensors = {}
with safe_open("model.safetensors", framework="pt", device=0) as f:
    tensor_slice = f.get_slice("embedding")
    vocab_size, hidden_dim = tensor_slice.get_shape()
    tensor = tensor_slice[:, :hidden_dim]

执行f.get_slice之后,tensor_slice并没有立马读取全部的数据,只有在被索引的时候才读出必要的那部分数据。这一特性在分布式训练中很有用,一张显卡上面可能只加载权重的一部分。

元数据读取

由于.safetensors的元数据与实际数据的存储是分开的,因此有可能不读取全部数据就能够看到权重的元信息。例如,当我们访问 huggingface.co/openai-c 时,点击权重旁边的展开按钮就可以看到所有权重的名字、数据类型以及形状。

这一功能还是挺实用的,不用下载模型就可以看权重的一些维度参数。

torch.save的存储格式

torch.save是我们日常使用的函数。然而,恐怕很少有人知道它的原理。

实际上,torch.save保存的文件就是一个zip文件。是的,没错,就是个压缩包。

import torch
x = torch.tensor([0, 1, 2, 3, 4])
torch.save(x, 'tensor.pt')

我们以上述代码为例,对存储的文件直接unzip tensor.pt,就可以看到以下内容:

tensor
├── byteorder
├── data
│   └── 0
├── data.pkl
└── version

torch.save可以看做通用的Python object存储方式,实际上它不仅可以存储tensor,还可以存储函数对象,甚至可以存储torchscript编译后的内容……而且在加载时它能正确保留内存共享、内存切片等信息。

torch.save的功能太复杂,这里无法详细列出。大致来说,它是把每一个Python object使用pickle进行保存,然后打包成一个zip压缩文件,同时对pytorch内部的tensor存储、内存共享等细节进行了部分优化。

两种格式之间的转换

根据以上内容,我们可以看出,torch.save保存的内容比.safetensors多很多。所以,从torch.save转到.safetensors是比较简单的,官方也给出了转换脚本

总的来说,.safetensors是针对huggingface transformers类模型的权重存储需求而设计的一种简单的跨框架文件格式,抛弃了torch.save支持的非常多大模型用不到的特性,所以才能够变得简单、高效。


相关文章