Junkor

GGUF, 漫漫长路

原文:@vboykis GGUF, the long way around

原文中包含很多依赖内容的引用,是以链接的形式在内容中体现的,翻译和整理的过程中遗漏了很多,欢迎大家阅读原文进行深度探索。

从着手在Mac本地运行一些6b以内的模型开始,就对这五花八门的模型存储格式充满了好奇(深恶痛绝),不同的模型提供方或者不同的模型架构(GPT、Llama、GLM)会给出不同的格式来,你会怀疑为什么不用统一的方案,简单的方式存储这些必要的配置信息?读完这篇文章能解开你所有的这些困惑。

原作者从一个简单的基于Pytorch的线性回归模型的构建、保存、恢复开始,从pickle方式,到safetensors,到 GGML,到 GGUF,文件格式的完善过程也透露出这波AI热潮的走向与趋势;以简单的叙述方式一步步讲清楚了整个演化过程,解答了我很多疑问,原来想自己整理一番的,倒省去了很多工作,对本地运行一些量化模型、各种模型加载以及做性能对比的同学,帮助很大!

接下来开始正文吧~


How We Use LLM Artifacts

如今大型语言模型的使用方式有以下几种:

  1. 作为 OpenAI、Anthropic 或主要云提供商托管的专有模型的 API 端点
  2. 作为从 HuggingFace 模型中心下载和/或使用 HuggingFace 库进行训练/微调并托管在本地存储上的模型产物
  3. 作为模型产物,以针对本地推理优化的格式(通常是 GGUF)提供,并通过 llama.cpp 或 ollama 等应用程序访问
  4. 作为 ONNX,一种优化后端 ML 框架之间共享的格式

对于一个业余项目,我使用 llama.cpp ,这是一个基于 C/C++ 的 LLM 推理引擎,针对 Apple Silicon 上的 M 系列 GPU。

运行 llama.cpp 时,您会得到一个很长的日志,其中主要包含有关模型架构的元数据键值对,然后是其性能(并且没有任何问题)。

make -j && ./main -m /Users/vicki/llama.cpp/models/mistral-7b-instruct-v0.2.Q8_0.gguf -p "What is Sanremo? no yapping"

Sanremo Music Festival (Festival di Sanremo) is an annual Italian music competition held in the city of Sanremo since 1951. It's considered one of the most prestigious and influential events in the Italian music scene. The festival features both newcomers and established artists competing for various awards, including the Big Award (Gran Premio), which grants the winner the right to represent Italy in the Eurovision Song Contest. The event consists of several live shows where artists perform their original songs, and a jury composed of musicians, critics, and the public determines the winners through a combination of points. [end of text]

llama_print_timings:        load time =   11059.32 ms
llama_print_timings:      sample time =      11.62 ms /   140 runs   (    0.08 ms per token, 12043.01 tokens per second)
llama_print_timings: prompt eval time =      87.81 ms /    10 tokens (    8.78 ms per token,   113.88 tokens per second)
llama_print_timings:        eval time =    3605.10 ms /   139 runs   (   25.94 ms per token,    38.56 tokens per second)
llama_print_timings:       total time =    3730.78 ms /   149 tokens
ggml_metal_free: deallocating
Log end
I ccache not found. Consider installing it for faster compilation.
I llama.cpp build info: 
I UNAME_S:   Darwin
I UNAME_P:   arm
I UNAME_M:   arm64
I CFLAGS:    -I. -Icommon -D_XOPEN_SOURCE=600 -D_DARWIN_C_SOURCE -DNDEBUG -DGGML_USE_ACCELERATE -DACCELERATE_NEW_LAPACK -DACCELERATE_LAPACK_ILP64 -DGGML_USE_METAL  -std=c11   -fPIC -O3 -Wall -Wextra -Wpedantic -Wcast-qual -Wno-unused-function -Wshadow -Wstrict-prototypes -Wpointer-arith -Wmissing-prototypes -Werror=implicit-int -Werror=implicit-function-declaration -pthread -Wunreachable-code-break -Wunreachable-code-return -Wdouble-promotion 
I CXXFLAGS:  -I. -Icommon -D_XOPEN_SOURCE=600 -D_DARWIN_C_SOURCE -DNDEBUG -DGGML_USE_ACCELERATE -DACCELERATE_NEW_LAPACK -DACCELERATE_LAPACK_ILP64 -DGGML_USE_METAL  -std=c++11 -fPIC -O3 -Wall -Wextra -Wpedantic -Wcast-qual -Wno-unused-function -Wmissing-declarations -Wmissing-noreturn -pthread   -Wunreachable-code-break -Wunreachable-code-return -Wmissing-prototypes -Wextra-semi
I NVCCFLAGS: -O3 
I LDFLAGS:   -framework Accelerate -framework Foundation -framework Metal -framework MetalKit 
I CC:        Apple clang version 14.0.3 (clang-1403.0.22.14.1)
I CXX:       Apple clang version 14.0.3 (clang-1403.0.22.14.1)

make: Nothing to be done for `default'.
Log start
main: build = 2125 (c88c74f)
main: built with Apple clang version 14.0.3 (clang-1403.0.22.14.1) for arm64-apple-darwin22.4.0
main: seed  = 1707673859
llama_model_loader: loaded meta data with 24 key-value pairs and 291 tensors from /Users/vicki/llama.cpp/models/mistral-7b-instruct-v0.2.Q8_0.gguf (version GGUF V3 (latest))
llama_model_loader: Dumping metadata keys/values. Note: KV overrides do not apply in this output.
llama_model_loader: - kv   0:                       general.architecture str              = llama
llama_model_loader: - kv   1:                               general.name str              = mistralai_mistral-7b-instruct-v0.2
llama_model_loader: - kv   2:                       llama.context_length u32              = 32768
llama_model_loader: - kv   3:                     llama.embedding_length u32              = 4096
llama_model_loader: - kv   4:                          llama.block_count u32              = 32
llama_model_loader: - kv   5:                  llama.feed_forward_length u32              = 14336
llama_model_loader: - kv   6:                 llama.rope.dimension_count u32              = 128
llama_model_loader: - kv   7:                 llama.attention.head_count u32              = 32
llama_model_loader: - kv   8:              llama.attention.head_count_kv u32              = 8
llama_model_loader: - kv   9:     llama.attention.layer_norm_rms_epsilon f32              = 0.000010
llama_model_loader: - kv  10:                       llama.rope.freq_base f32              = 1000000.000000
llama_model_loader: - kv  11:                          general.file_type u32              = 7
llama_model_loader: - kv  12:                       tokenizer.ggml.model str              = llama
llama_model_loader: - kv  13:                      tokenizer.ggml.tokens arr[str,32000]   = ["<unk>", "<s>", "</s>", "<0x00>", "<...
llama_model_loader: - kv  14:                      tokenizer.ggml.scores arr[f32,32000]   = [0.000000, 0.000000, 0.000000, 0.0000...
llama_model_loader: - kv  15:                  tokenizer.ggml.token_type arr[i32,32000]   = [2, 3, 3, 6, 6, 6, 6, 6, 6, 6, 6, 6, ...
llama_model_loader: - kv  16:                tokenizer.ggml.bos_token_id u32              = 1
llama_model_loader: - kv  17:                tokenizer.ggml.eos_token_id u32              = 2
llama_model_loader: - kv  18:            tokenizer.ggml.unknown_token_id u32              = 0
llama_model_loader: - kv  19:            tokenizer.ggml.padding_token_id u32              = 0
llama_model_loader: - kv  20:               tokenizer.ggml.add_bos_token bool             = true
llama_model_loader: - kv  21:               tokenizer.ggml.add_eos_token bool             = false
llama_model_loader: - kv  22:                    tokenizer.chat_template str              = {{ bos_token }}{% for message in mess...
llama_model_loader: - kv  23:               general.quantization_version u32              = 2
llama_model_loader: - type  f32:   65 tensors
llama_model_loader: - type q8_0:  226 tensors
llm_load_vocab: special tokens definition check successful ( 259/32000 ).
llm_load_print_meta: format           = GGUF V3 (latest)
llm_load_print_meta: arch             = llama
llm_load_print_meta: vocab type       = SPM
llm_load_print_meta: n_vocab          = 32000
llm_load_print_meta: n_merges         = 0
llm_load_print_meta: n_ctx_train      = 32768
llm_load_print_meta: n_embd           = 4096
llm_load_print_meta: n_head           = 32
llm_load_print_meta: n_head_kv        = 8
llm_load_print_meta: n_layer          = 32
llm_load_print_meta: n_rot            = 128
llm_load_print_meta: n_embd_head_k    = 128
llm_load_print_meta: n_embd_head_v    = 128
llm_load_print_meta: n_gqa            = 4
llm_load_print_meta: n_embd_k_gqa     = 1024
llm_load_print_meta: n_embd_v_gqa     = 1024
llm_load_print_meta: f_norm_eps       = 0.0e+00
llm_load_print_meta: f_norm_rms_eps   = 1.0e-05
llm_load_print_meta: f_clamp_kqv      = 0.0e+00
llm_load_print_meta: f_max_alibi_bias = 0.0e+00
llm_load_print_meta: n_ff             = 14336
llm_load_print_meta: n_expert         = 0
llm_load_print_meta: n_expert_used    = 0
llm_load_print_meta: rope scaling     = linear
llm_load_print_meta: freq_base_train  = 1000000.0
llm_load_print_meta: freq_scale_train = 1
llm_load_print_meta: n_yarn_orig_ctx  = 32768
llm_load_print_meta: rope_finetuned   = unknown
llm_load_print_meta: model type       = 7B
llm_load_print_meta: model ftype      = Q8_0
llm_load_print_meta: model params     = 7.24 B
llm_load_print_meta: model size       = 7.17 GiB (8.50 BPW) 
llm_load_print_meta: general.name     = mistralai_mistral-7b-instruct-v0.2
llm_load_print_meta: BOS token        = 1 '<s>'
llm_load_print_meta: EOS token        = 2 '</s>'
llm_load_print_meta: UNK token        = 0 '<unk>'
llm_load_print_meta: PAD token        = 0 '<unk>'
llm_load_print_meta: LF token         = 13 '<0x0A>'
llm_load_tensors: ggml ctx size =    0.22 MiB
ggml_backend_metal_buffer_from_ptr: allocated buffer, size =  7205.84 MiB, ( 7205.91 / 49152.00)
llm_load_tensors: offloading 32 repeating layers to GPU
llm_load_tensors: offloading non-repeating layers to GPU
llm_load_tensors: offloaded 33/33 layers to GPU
llm_load_tensors:      Metal buffer size =  7205.84 MiB
llm_load_tensors:        CPU buffer size =   132.81 MiB
..................................................................................................
llama_new_context_with_model: n_ctx      = 512
llama_new_context_with_model: freq_base  = 1000000.0
llama_new_context_with_model: freq_scale = 1
ggml_metal_init: allocating
ggml_metal_init: found device: Apple M2 Max
ggml_metal_init: picking default device: Apple M2 Max
ggml_metal_init: default.metallib not found, loading from source
ggml_metal_init: GGML_METAL_PATH_RESOURCES = nil
ggml_metal_init: loading '/Users/vicki/llama.cpp/ggml-metal.metal'
ggml_metal_init: GPU name:   Apple M2 Max
ggml_metal_init: GPU family: MTLGPUFamilyApple8  (1008)
ggml_metal_init: GPU family: MTLGPUFamilyCommon3 (3003)
ggml_metal_init: GPU family: MTLGPUFamilyMetal3  (5001)
ggml_metal_init: simdgroup reduction support   = true
ggml_metal_init: simdgroup matrix mul. support = true
ggml_metal_init: hasUnifiedMemory              = true
ggml_metal_init: recommendedMaxWorkingSetSize  = 51539.61 MB
ggml_backend_metal_buffer_type_alloc_buffer: allocated buffer, size =    64.00 MiB, ( 7270.59 / 49152.00)
llama_kv_cache_init:      Metal KV buffer size =    64.00 MiB
llama_new_context_with_model: KV self size  =   64.00 MiB, K (f16):   32.00 MiB, V (f16):   32.00 MiB
llama_new_context_with_model:        CPU input buffer size   =     9.01 MiB
ggml_backend_metal_buffer_type_alloc_buffer: allocated buffer, size =     0.02 MiB, ( 7270.61 / 49152.00)
ggml_backend_metal_buffer_type_alloc_buffer: allocated buffer, size =    80.31 MiB, ( 7350.91 / 49152.00)
llama_new_context_with_model:      Metal compute buffer size =    80.30 MiB
llama_new_context_with_model:        CPU compute buffer size =     8.80 MiB
llama_new_context_with_model: graph splits (measure): 3

system_info: n_threads = 8 / 12 | AVX = 0 | AVX_VNNI = 0 | AVX2 = 0 | AVX512 = 0 | AVX512_VBMI = 0 | AVX512_VNNI = 0 | FMA = 0 | NEON = 1 | ARM_FMA = 1 | F16C = 0 | FP16_VA = 1 | WASM_SIMD = 0 | BLAS = 1 | SSE3 = 0 | SSSE3 = 0 | VSX = 0 | MATMUL_INT8 = 0 | 
sampling: 
        repeat_last_n = 64, repeat_penalty = 1.100, frequency_penalty = 0.000, presence_penalty = 0.000
        top_k = 40, tfs_z = 1.000, top_p = 0.950, min_p = 0.050, typical_p = 1.000, temp = 0.800
        mirostat = 0, mirostat_lr = 0.100, mirostat_ent = 5.000
sampling order: 
CFG -> Penalties -> top_k -> tfs_z -> typical_p -> top_p -> min_p -> temp 
generate: n_ctx = 512, n_batch = 512, n_predict = -1, n_keep = 0

这些日志可以在 Llama.cpp 代码库中找到。在那里,您还会找到 GGUF。 GGUF(GPT 生成的统一格式)是用于在 Llama.cpp 和其他本地运行器(例如 Llamafile、Ollama 和 GPT4All)上提供模型服务的文件格式。

要了解 GGUF 的工作原理,我们需要首先深入研究机器学习模型及其产生的产物的类型。

What is a machine learning model

让我们首先描述一个机器学习模型。最简单的说,模型是一个文件或文件集合,其中包含模型架构以及训练循环生成的模型的权重和偏差。

在LLM领域,我们通常对 Transformer 风格的模型和架构感兴趣。

在Transformer中,我们有许多部分组成:

  • 对于输入,我们使用从人类生成的自然语言内容聚合的训练数据语料库

  • 对于算法,我们

    • 将这些数据转换为嵌入信息
    • 对嵌入进行位置编码,以提供有关单词在序列中彼此相对位置的信息
    • 基于初始化的权重组合,为序列中每个单词相对于其他单词创建多头自注意力
    • 通过 softmax 标准化层
    • 通过前馈神经网络运行结果矩阵
    • 将输出投影到所需任务的正确向量空间中
    • 计算损失然后更新模型参数
  • 输出:通常对于聊天完成任务,模型返回任何给定单词完成短语的统计可能性。由于其自回归性质,它会对短语中的每个单词一次又一次地执行此操作。

    Untitled

来源参考 link.

如果该模型作为消费者最终产品,它仅返回基于最高概率的实际文本输出,并具有多种选择文本的策略。

Untitled

简而言之,我们使用方程将输入转换为输出。除了模型的输出之外,我们还拥有用于承载模型处理过程的产物,也就是模型本身。

Starting with a simple model

让我们从 Transformer 的复杂性中退一步,在 PyTorch 中构建一个小型线性回归模型。对我们来说幸运的是,线性回归也是一个(浅层)神经网络,因此我们可以在 PyTorch 中使用它,并使用相同的框架将我们的简单模型映射到更复杂的模型。

线性回归采用一组数值输入并生成一组数值输出。 (与transformer相反,transformer接受一组文本输入并生成一组文本输入及其相关的数字概率。)

例如,假设我们为统计学家生产手工榛子酱,并希望预测我们在任何一天将生产多少罐 Nulltella。假设我们有一些可用的数据,即我们每天有多少小时的阳光,以及我们每天能够生产多少罐 Nulltella。

事实证明,当阳光明媚的时候,我们更有灵感去生产榛子酱,并且我们可以在数据中清楚地看到输入和输出之间的这种关系(周五至周日我们不生产 Nulltella,因为我们更喜欢在那些日子里写数据、序列化格式):

| day_id | hours   | jars |
|--------|---------|------|
| mon    | 1       | 2    |
| tues   | 2       | 4    |
| wed    | 3       | 6    |
| thu    | 4       | 8    |

这是我们用来训练模型的数据。我们需要将这些数据分为三个部分:

  1. 用于训练我们的模型(训练数据)
  2. 用于测试我们模型的准确性(测试数据)
  3. 用于在模型训练阶段调整模型的超参数、meta-aspects,例如学习率(验证集)。

在线性回归的具体情况下,技术上不存在超参数,尽管我们可以合理地认为我们在 PyTorch 中设置的学习率为 1。假设我们有 100 个这样的数据点值。

我们将数据分为训练、测试和验证。通常接受的分割是使用 80% 的数据进行训练/验证,20% 的数据用于测试。我们希望我们的模型能够访问尽可能多的数据,以便它学习更准确的表示,因此我们将大部分数据留给训练。

现在我们有了数据,我们需要编写算法。从线性回归的输入 $X$ 获取输出$Y$ 的方程为:

$$ y = \beta_0 + \beta_1x_1 + \varepsilon $$

这告诉我们,输出 $y$(Nulltella 的罐子数量)可以通过以下方式预测:

  • $x_1$ :一个输入变量(或特征),(日照小时数)
  • $\beta_1$ :具有给定的权重,也称为参数(该特征的重要性)
  • 加上一个误差项 $\varepsilon$,它是捕获模型噪声的观测值与实际值之间的差异

我们的任务是不断预测和调整权重,以最佳方式求解该方程,以获得数据呈现的实际 $Y$与基于算法的预测 $Y’$ 之间的差异,以找到每个点和线之间的最小平方差和 $\sqrt{\frac{1}{n} \sum_{i=1}^n{(y_i-y’)^2}}$ 。换句话说,我们希望最小化 $\varepsilon$ ,因为这意味着,我们得到的每个点, $Y’$都尽可能接近实际的$Y$。

我们通过梯度下降来优化这个函数,从零或随机初始化的权重开始,然后继续重新计算权重和误差项,直到达到最佳停止点。我们会知道我们正在成功,因为根据 RMSE 计算,我们的损失应该在每次训练迭代中逐渐减少。

这是端到端的整个模型学习过程(除了标识化(tokenization),我们只对特征是文本并且我们想要进行语言建模的模型进行标识化):

Untitled

Writing the model code

现在,让我们更具体地用代码来描述这些想法。当我们训练模型时,我们使用一组特征值初始化函数。

让我们通过将 $x_1$和 $Y$ 初始化为 PyTorch Tensor 对象来将数据添加到模型中。

# Hours of sunshine
X = torch.tensor([[1.0], [2.0], [3.0], [4.0]], dtype=torch.float32)

# Jars of Nulltella
y = torch.tensor([[2.0], [4.0], [6.0], [8.0]], dtype=torch.float32)

在代码中,我们的输入数据是 X ,它是一个torch的tensor对象,我们的输出数据是 y 。我们初始化一个 LinearRegression,它是 PyTorch 模块的子类,具有一个线性层,该层具有一个输入特征(sunshine)和一个输出特征(jars of Nulltella)。

我将包含整个模型的代码,然后我们将逐个讨论它。

import torch
import torch.nn as nn
import torch.optim as optim

X = torch.tensor([[1.0], [2.0], [3.0], [4.0]], dtype=torch.float32)
y = torch.tensor([[2.0], [4.0], [6.0], [8.0]], dtype=torch.float32)

# Define a linear regression model and its forward pass 
class LinearRegression(nn.Module):
    def __init__(self):
        super(LinearRegression, self).__init__()
        self.linear = nn.Linear(1, 1)  # 1 input feature, 1 output feature

    def forward(self, x):
        return self.linear(x)

# Instantiate the model
model = LinearRegression()

# Inspect the model's state dictionary
print(model.state_dict())

# Define loss function and optimizer
criterion = nn.MSELoss() 
# setting our learning rate "hyperparameter" here
optimizer = optim.SGD(model.parameters(), lr=0.01)  

# Training loop that includes forward and backward pass 
num_epochs = 100
for epoch in range(num_epochs):
    # Forward pass
    outputs = model(X)
    loss = criterion(outputs, y)
    RMSE_loss  = torch.sqrt(loss)

    # Backward pass and optimization
    optimizer.zero_grad()  # Zero out gradients
    RMSE_loss.backward()  # Compute gradients
    optimizer.step()  # Update weights

    # Print progress
    if (epoch+1) % 10 == 0:
        print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}')

# After training, let's test the model
test_input = torch.tensor([[5.0]], dtype=torch.float32)
predicted_output = model(test_input)
print(f'Prediction for input {test_input.item()}: {predicted_output.item()}')

一旦我们有了输入数据,我们就初始化我们的模型,一个 LinearRegression ,它是专门用于线性回归的 Module 基类的子类。

前向传播涉及将我们的数据输入神经网络并确保其传播通过所有层。由于我们只有一个,因此我们必须将数据传递到单个线性层。前向传递用于计算我们的预测 Y 。

class LinearRegression(nn.Module):
    def __init__(self):
        super(LinearRegression, self).__init__()
        self.linear = nn.Linear(1, 1)  # 1 input feature, 1 output feature

    def forward(self, x):
        return self.linear(x)

我们选择如何优化模型的结果,也就是模型的损失如何收敛。在本例中,我们从 mean squared error 开始,然后修改它以使用 RMSE ,即数据集中预测值与实际值之间的平均平方差的平方根。

# Define loss function and optimizer
criterion = torch.sqrl(nn.MSELoss())  # RMSE in the training loop
optimizer = optim.SGD(model.parameters(), lr=0.01)

....
for epoch in range(num_epochs):
    # Forward pass
    outputs = model(X)
    loss = criterion(outputs, y)
    RMSE_loss  = torch.sqrt(loss)

现在我们已经定义了模型的运行方式,我们可以实例化模型对象本身了。

Instantiating the model object

model = LinearRegression()
print(model.state_dict())

请注意,当我们实例化 nn.Module 时,它有一个名为“state_dict”的属性。这个很重要。状态字典保存有关每层的信息以及每层中的参数,即权重和偏差。

从本质上讲,它是一个 Python 字典。

在这种情况下,LinearRegression 的实现返回一个有序字典,其中包含网络的每一层以及这些层的值。每个值都是一个 Tensor 。

OrderedDict([('linear.weight', tensor([[0.5408]])), ('linear.bias', tensor([-0.8195]))])

for param_tensor in model.state_dict():
    print(param_tensor, "\t", model.state_dict()[param_tensor].size())

linear.weight    torch.Size([1, 1])
linear.bias      torch.Size([1])

对于我们的小模型,它是一个小的 OrderedDict 元组。您可以想象,在变压器等大型网络中,这个张量集合会变得非常大并且占用大量内存。如果每个参数(每个 Tensor 对象)占用 2 个字节的内存,那么一个 70 亿参数的模型可以占用 14GB 的 GPU 空间。

然后,我们循环运行模型的前向和后向传递。在每个步骤中,我们都会进行前向传递来执行计算,向后传递来更新模型对象的权重,然后将所有这些信息添加到模型参数中。

# Define loss function and optimizer
criterion = nn.MSELoss() 
optimizer = optim.SGD(model.parameters(), lr=0.01)  

# Training loop 
num_epochs = 100
for epoch in range(num_epochs):
    # Forward pass
    outputs = model(X)
    loss = criterion(outputs, y)
    RMSE_loss  = torch.sqrt(loss)

    # Backward pass and optimization
    optimizer.zero_grad()  # Zero out gradients
    RMSE_loss.backward()  # Compute gradients
    optimizer.step()  # Update weights

    # Print progress
    if (epoch+1) % 10 == 0:
        print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}')

完成这些循环后,我们就训练了模型工件。训练完模型后,我们现在拥有的是一个内存中对象,它表示该模型的权重、偏差和元数据,存储在 LinearRegression 模块的实例中。

当我们运行训练循环时,我们可以看到损失缩小了。也就是说,实际值越来越接近预测值:

Epoch [10/100], Loss: 33.0142
Epoch [20/100], Loss: 24.2189
Epoch [30/100], Loss: 16.8170
Epoch [40/100], Loss: 10.8076
Epoch [50/100], Loss: 6.1890
Epoch [60/100], Loss: 2.9560
Epoch [70/100], Loss: 1.0853
Epoch [80/100], Loss: 0.4145
Epoch [90/100], Loss: 0.3178
Epoch [100/100], Loss: 0.2974

我们还可以看到,当我们计算梯度并更新后向传递中的权重时,是否打印出参数已更改的 state_dict :

"""before"""
OrderedDict([('linear.weight', tensor([[-0.6216]])), ('linear.bias', tensor([0.7633]))])
linear.weight    torch.Size([1, 1])
linear.bias      torch.Size([1])
{'state': {}, 'param_groups': [{'lr': 0.01, 'momentum': 0, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'maximize': False, 'foreach': None, 'differentiable': False, 'params': [0, 1]}]}
Epoch [10/100], Loss: 33.0142
Epoch [20/100], Loss: 24.2189
Epoch [30/100], Loss: 16.8170
Epoch [40/100], Loss: 10.8076
Epoch [50/100], Loss: 6.1890
Epoch [60/100], Loss: 2.9560
Epoch [70/100], Loss: 1.0853
Epoch [80/100], Loss: 0.4145
Epoch [90/100], Loss: 0.3178
Epoch [100/100], Loss: 0.2974

"""after"""
OrderedDict([('linear.weight', tensor([[1.5441]])), ('linear.bias', tensor([1.3291]))])

正如我们所看到的,优化器有自己的 state_dict ,它由我们之前讨论过的超参数组成:学习率、权重衰减等等:

print(optimizer.state_dict())
{'state': {}, 'param_groups': [{'lr': 0.01, 'momentum': 0, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'maximize': False, 'foreach': None, 'differentiable': False, 'params': [0, 1]}]}

现在我们有了经过训练的模型对象,我们可以传入新的特征值以供模型评估。例如,我们可以传入 5 小时日照时间的 X 值,看看我们期望生产多少罐 Nulltella。

我们通过将 5 传递给实例化的模型对象来实现这一点,该对象现在是用于运行线性回归方程的方法和我们的状态字典、权重、当前权重集和偏差的组合给出一个新的预测值。我们得到 9 jars,这与我们的预期非常接近。

test_input = torch.tensor([[5.0]], dtype=torch.float32)
predicted_output = model(test_input)
print(f'Prediction for input {test_input.item()}: {predicted_output.item()}')
Prediction for input 5.0: 9.049455642700195

为了清楚起见,我抽象出了大量细节,即 PyTorch 在将这些数据移入和移出 GPU 以及使用 GPU 高效数据类型进行高效计算方面所做的大量工作,这是 PyTorch 的很大一部分。为了简单起见,我们现在将跳过这些。

Serializing our objects

到目前为止,一切都很好。现在,我们在内存中拥有了有状态的 Python 对象,可以传达模型的状态。但是,当我们需要保留这个非常大的模型(我们可能花费了 24 个小时以上的训练)并再次使用它时,会发生什么?

此场景描述如下,

假设一名研究人员正在试验一种新的深度学习模型架构,或现有模型的变体。她的架构将有一大堆配置选项和超参数:层数、每层的类型、各种向量的维数、在何处以及如何标准化激活、使用哪些非线性等等。许多模型组件将是机器学习框架提供的标准层,但研究人员也将插入一些新颖的逻辑。

我们的研究人员需要一种方法来描述特定的具体模型——这些设置的特定组合——可以序列化,然后重新加载。她需要这个有几个相关原因:

她可能有权访问包含 GPU 或可用于运行作业的其他加速器的计算集群。她需要一种方法将模型描述提交给在该集群上运行的代码,以便代码可以在集群上运行她的模型。

当这些模型正在训练时,她需要以这样的方式保存它们的进度快照,以便在硬件出现故障或作业被抢占的情况下可以重新加载和恢复它们。一旦模型经过训练,研究人员将希望再次加载它们(可能是最终快照和一些部分训练的检查点),以便对它们进行评估和实验。

我们所说的序列化是什么意思?这是将对象和类从编程运行时写入文件的过程。反序列化是将磁盘上的数据转换为内存中的编程语言对象的过程。现在我们需要将数据序列化为可以写入文件的字节流。

Untitled

为什么叫“序列化”?因为在过去,数据通常存储在磁带上,这需要bits在磁带上按顺序排列。

由于现在许多 Transformer 风格的模型都是使用 PyTorch 进行训练的,因此训练产物使用 PyTorch 的 save 实现将对象序列化到磁盘。

Untitled

What is a file

再次,为了简单起见,让我们抽象出 GPU,并假设我们在 CPU 中执行所有这些计算。 Python 对象存在于内存中。该内存在其生命周期开始时被分配在一个特殊的私有堆中,由Python内存管理器管理的私有堆中,具有针对不同对象类型的专用堆。

当我们初始化 PyTorch 模型对象时,操作系统通过默认内存分配器通过较低级别的 C 函数(即 malloc )分配内存。

当我们使用tracemalloc运行代码时,我们可以看到PyTorch的内存实际上是如何在CPU上分配的(再次记住,GPU操作是完全不同的)。

import tracemalloc

tracemalloc.start()

.....
pytorch
...

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')

print("[ Top 10 ]")
for stat in top_stats[:10]:
    print(stat)

[ Top 10 ]
<frozen importlib._bootstrap_external>:672: size=21.1 MiB, count=170937, average=130 B
/Users/vicki/.pyenv/versions/3.10.0/lib/python3.10/inspect.py:2156: size=577 KiB, count=16, average=36.0 KiB
/Users/vicki/.pyenv/versions/3.10.0/lib/python3.10/site-packages/torch/_dynamo/allowed_functions.py:71: size=512 KiB, count=3, average=171 KiB
/Users/vicki/.pyenv/versions/3.10.0/lib/python3.10/dataclasses.py:434: size=410 KiB, count=4691, average=90 B
/Users/vicki/.pyenv/versions/3.10.0/lib/python3.10/site-packages/torch/_dynamo/allowed_functions.py:368: size=391 KiB, count=7122, average=56 B
/Users/vicki/.pyenv/versions/3.10.0/lib/python3.10/site-packages/torch/_dynamo/allowed_functions.py:397: size=349 KiB, count=1237, average=289 B
<frozen importlib._bootstrap_external>:128: size=213 KiB, count=1390, average=157 B
/Users/vicki/.pyenv/versions/3.10.0/lib/python3.10/functools.py:58: size=194 KiB, count=2554, average=78 B
/Users/vicki/.pyenv/versions/3.10.0/lib/python3.10/site-packages/torch/_dynamo/allowed_functions.py:373: size=136 KiB, count=2540, average=55 B
<frozen importlib._bootstrap_external>:1607: size=127 KiB, count=1133, average=115 B

在这里,我们可以看到我们从 import 中导入了 170k 对象,其余的分配来自 torch 中的 allowed_functions 。

How does PyTorch write objects to files?

我们还可以更明确地看到内存中这些对象的类型。在 PyTorch 和 Python 系统库创建的所有其他对象中,我们可以在这里看到我们的 Linear 对象,它具有 state_dict 作为属性。我们需要将该对象序列化为字节流,以便将其写入磁盘。

import gc
# Get all live objects
all_objects = gc.get_objects()

# Extract distinct object types
distinct_types = set(type(obj) for obj in all_objects)

# Print distinct object types
for obj_type in distinct_types:
    print(obj_type.__name__)

InputKind
KeyedRef
ReLU
Manager
_Call
UUID
Pow
Softmax
Options 
_Environ
**Linear**
CFunctionType
SafeUUID
_Real
JSONDecoder
StmtBuilder
OutDtypeOperator
MatMult
attrge

PyTorch 使用 Python 的 pickle 框架并包装 pickle load 和 dump 方法将对象序列化到磁盘。

Pickle 遍历对象的继承层次结构,并将遇到的每个对象转换为可流式产物。它对嵌套表示递归地执行此操作(例如,理解 nn.Module 和 Linear 继承自 nn.Module )并将这些表示转换为字节表示,以便它们可以被写入文件。

作为示例,让我们采用一个简单的函数并将其写入 pickle 文件。

import torch.nn as nn
import torch.optim as optim
import pickle

X = torch.tensor([[1.0], [2.0], [3.0], [4.0]], dtype=torch.float32)

with open('tensors.pkl', 'wb') as f: 
    pickle.dump(X, f) 

当我们使用 pickletools 检查 pickle 对象时,我们了解数据是如何组织的。

我们导入一些函数,将数据加载为张量,然后加载该数据的实际存储,然后加载其类型。当从 pickle 文件转换为 Python 对象时,该模块执行相反的操作。

python -m pickletools tensors.pkl
    0: \x80 PROTO      4
    2: \x95 FRAME      398
   11: \x8c SHORT_BINUNICODE 'torch._utils'
   25: \x94 MEMOIZE    (as 0)
   26: \x8c SHORT_BINUNICODE '_rebuild_tensor_v2'
   46: \x94 MEMOIZE    (as 1)
   47: \x93 STACK_GLOBAL
   48: \x94 MEMOIZE    (as 2)
   49: (    MARK
   50: \x8c     SHORT_BINUNICODE 'torch.storage'
   65: \x94     MEMOIZE    (as 3)
   66: \x8c     SHORT_BINUNICODE '_load_from_bytes'
   84: \x94     MEMOIZE    (as 4)
   85: \x93     STACK_GLOBAL
   86: \x94     MEMOIZE    (as 5)
   87: B        BINBYTES   b'\x80\x02\x8a\nl\xfc\x9cF\xf9 j\xa8P\x19.\x80\x02M\xe9\x03.\x80\x02}q\x00(X\x10\x00\x00\x00protocol_versionq\x01M\xe9\x03X\r\x00\x00\x00little_endianq\x02\x88X\n\x00\x00\x00type_sizesq\x03}q\x04(X\x05\x00\x00\x00shortq\x05K\x02X\x03\x00\x00\x00intq\x06K\x04X\x04\x00\x00\x00longq\x07K\x04uu.\x80\x02(X\x07\x00\x00\x00storageq\x00ctorch\nFloatStorage\nq\x01X\n\x00\x00\x006061074080q\x02X\x03\x00\x00\x00cpuq\x03K\x04Ntq\x04Q.\x80\x02]q\x00X\n\x00\x00\x006061074080q\x01a.\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80?\x00\x00\x00@\x00\x00@@\x00\x00\x80@'
  351: \x94     MEMOIZE    (as 6)
  352: \x85     TUPLE1
  353: \x94     MEMOIZE    (as 7)
  354: R        REDUCE
  355: \x94     MEMOIZE    (as 8)
  356: K        BININT1    0
  358: K        BININT1    4
  360: K        BININT1    1
  362: \x86     TUPLE2
  363: \x94     MEMOIZE    (as 9)
  364: K        BININT1    1
  366: K        BININT1    1
  368: \x86     TUPLE2
  369: \x94     MEMOIZE    (as 10)
  370: \x89     NEWFALSE
  371: \x8c     SHORT_BINUNICODE 'collections'
  384: \x94     MEMOIZE    (as 11)
  385: \x8c     SHORT_BINUNICODE 'OrderedDict'
  398: \x94     MEMOIZE    (as 12)
  399: \x93     STACK_GLOBAL
  400: \x94     MEMOIZE    (as 13)
  401: )        EMPTY_TUPLE
  402: R        REDUCE
  403: \x94     MEMOIZE    (as 14)
  404: t        TUPLE      (MARK at 49)
  405: \x94 MEMOIZE    (as 15)
  406: R    REDUCE
  407: \x94 MEMOIZE    (as 16)
  408: .    STOP
highest protocol among opcodes = 4

pickle 作为文件格式的主要问题是,它不仅捆绑了可执行代码,而且没有对正在读取的代码进行检查,并且没有模式保证,您可以将恶意的东西传递给 pickle,

不安全性并不是因为 pickle 包含代码,而是因为它们通过调用 pickle 中命名的构造函数来创建对象。任何可调用对象都可以用来代替类名来构造对象。恶意 pickles 将使用其他 Python 可调用对象作为“构造函数”。例如,危险的pickle可能不执行“models.MyObject(17)”,而是执行“os.system(‘rm -rf /’)”。 unpickler 无法区分“models.MyObject”和“os.system”之间的区别。两者都是它可以解析的名称,产生它可以调用的东西。 unpickler 按照 pickle 的指示执行其中任何一个。

How Pickle works

Pickle 最初适用于基于 Pytorch 的模型,因为它也与 Python 生态系统紧密耦合,并且最初的 ML 库工件不是深度学习系统的关键输出。

研究的主要输出是知识,而不是软件工件。研究团队编写软件是为了回答研究问题并提高他们/他们的团队/他们的领域对某个领域的理解,而不是为了拥有软件工具或解决方案而编写软件。

然而,随着 2017 年 Transformer 论文发布后基于 Transformer 的模型的使用增多, transformers 库的使用也随之增加,该库将加载调用委托给 PyTorch 的 load 方法,使用pickle。

一旦从业者开始创建基于Pickle的模型产物并将其上传到 HuggingFace 等模型中心,机器学习模型供应链安全就成为一个问题。

From pickle to safetensors

随着使用 PyTorch 训练的深度学习模型的机器学习爆炸式增长,这些安全问题达到了紧要关头,2021 年,Trail of Bits 发布了一篇关于 pickle 文件不安全的帖子。

HuggingFace 的工程师开始开发一个名为 safetensors 的库,作为 pickle 的替代品。 Safetensors 的开发是为了高效,但也比 pickle 更安全、更符合人体工程学。

首先, safetensors 与Python的绑定不像Pickle那样紧密:使用pickle,你只能在Python中读取或写入文件。 Safetensors 跨语言兼容。其次,安全张量还限制了语言执行、序列化和反序列化可用的功能。第三,由于 safetensors 的后端是用 Rust 编写的,因此它更严格地强制执行类型安全。最后,safetensors 专门针对张量作为数据类型的工作进行了优化,而 Pickle 则没有。再加上它是用 Rust 编写的,使得读写速度非常快。

在 Trail of Bits 和 EleutherAI 的共同推动下,对 safetensors 进行了安全审计,结果令人满意,这导致 HuggingFace 将其调整为 Hub 上模型的默认格式。向前跨了一大步。 (非常感谢 Stella 和 Suha 讲述了这段历史和背景,也感谢所有为 Twitter 帖子做出贡献的人。)

How safetensors works

safetensors 格式如何工作?与 LLMs 中处于前沿的大多数内容一样,代码和提交历史记录将说明大部分内容。让我们看一下文件规范。

  • 8字节:N,无符号小端64位整数,包含头部的大小
  • N 字节:表示标头的 JSON UTF-8 字符串。标头数据必须以 { 字符 (0x7B) 开头。标头数据尾部可以用空格 (0x20) 填充。标头是一个字典,例如 {“TENSOR_NAME”: {“dtype”: “F16”, “shape”: [1, 16, 256], “data_offsets”: [BEGIN, END]}, “NEXT_TENSOR_NAME”: {…} , …}, data_offsets 指向相对于字节缓冲区开头的张量数据(即不是文件中的绝对位置),以 BEGIN 作为起始偏移量,以 END 作为前一偏移量(因此总张量字节大小 =结束 - 开始)。一个特殊的键metadata允许包含自由格式的string-to-string map。不允许任意 JSON,所有值都必须是字符串。
  • 文件的其余部分:字节缓冲区。

这与 state_dict 和 pickle 文件规范不同,但 safetensors 的出现遵循了从 Python 对象到成熟文件格式的自然演变。

文件是一种在磁盘上以字节为单位存储从编程语言对象生成的数据的方式。在查看不同的文件格式规范(Arrow、Parquet、protobuf)时,我们将开始注意到它们的布局方式的一些模式。

  1. 在文件中,我们需要一些指示符来表明这是一种文件“X”类型。通常表示为**magic byte**.
  2. 然后,有一个标头(header),表示文件的元数据(在机器学习的情况下,我们有多少层、学习率和其他方面。)
  3. 实际数据。 (对于机器学习文件,张量)
  4. 然后,我们需要一个规范来告诉我们在读取文件时会看到什么内容,文件中有哪些类型的数据类型以及它们如何表示为字节。本质上是文件布局和 API 的文档,以便我们可以针对它编写文件读取器。
  5. 文件规范通常告诉我们的一个特征是数据是小端还是大端,也就是说,我们是否将最大的数字存储在前面或最后。这变得很重要,因为我们希望在具有不同默认字节布局的系统上读取文件。
  6. 然后,我们实现专门读取和写入该文件规范的代码。

通过之前查看 statedicts 和 pickle 文件,我们开始注意到的一件事是,机器学习数据存储遵循一种模式,我们需要存储:

  1. 大量向量集合
  2. 关于这些向量的元数据
  3. 超参数(hyperparameters)

然后,我们需要能够实例化模型对象,我们可以用该数据填充并运行模型操作。

作为文档中 safetensors 的示例:我们从 Python 字典(又名状态字典)开始,保存并加载文件。

import torch
from safetensors import safe_open
from safetensors.torch import save_file

tensors = {
   "weight1": torch.zeros((1024, 1024)),
   "weight2": torch.zeros((1024, 1024))
}
save_file(tensors, "model.safetensors")

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

我们使用 save_file(model.state_dict(), ‘my_model.st’) 方法将文件渲染到 safetensors,在从pickle到safetensor的转换过程中,我们也是从状态字典开始。

Safetensors 很快成为共享模型权重和架构的领先格式,用于进一步微调,在某些情况下还可以用于推理。

Checkpoint files

到目前为止,我们已经了解了简单的 state_dict 文件和单个 safetensors 文件。但是,如果您正在训练一个长期运行的模型,您可能需要保存的不仅仅是权重和偏差,并且您希望经常保存您的状态,以便在您开始在训练运行中发现问题时可以恢复。 PyTorch 有检查点。检查点是一个具有模型 state_dict 的文件,但也包含

优化器的 state_dict,因为它包含随着模型训练而更新的缓冲区和参数。您可能想要保存的其他项目包括您停止的epoch、最新记录的训练损失、外部 torch.nn.Embedding 层等。这也被保存为字典或pickled,然后在需要时unpickled。所有这些也保存到字典中,即 optimizer_state_dict ,与 model_state_dict 不同。

# Additional information
EPOCH = 5
PATH = "model.pt"
LOSS = 0.4

torch.save({
            'epoch': EPOCH,
            'model_state_dict': net.state_dict(),
            'optimizer_state_dict': optimizer.state_dict(),
            'loss': LOSS,
            }, PATH)

此外,大多数大型语言模型现在还包括标记生成器等附带文件,以及 HuggingFace、元数据等。因此,如果您使用 PyTorch 模型作为通过 Transformers 库生成产物的工具,您将获得如下所示的repo结构:link

GGML

随着从pickle迁移到safetensors的工作正在进行,以进行广义模型微调和推理,Apple Silicon继续变得更好。因此,人们开始将建模工作和推理从基于GPU的大型计算集群带到本地和边缘设备。

Georgi Gerganov 的项目旨在使 OpenAI 的 Whisper 通过 Whisper.cpp 在本地运行。是成功的,也是后来项目的催化剂。 Llama-2 作为主要开源模型的发布,再加上 LoRA、大型语言模型等模型压缩技术的兴起,这些模型通常只能在实验室或工业级 GPU 硬件上访问(例如小型 GPU 硬件)。我们在这里运行的基于 CPU 的示例)也成为了思考在本地使用和运行个性化模型的催化剂。

基于 whisper.cpp 的兴趣和成功,Gerganov 创建了 llama.cpp,一个用于处理 Llama 模型权重的包,最初采用 pickle 格式,采用 GGML 格式,用于本地推理。

GGML 最初既是一个库,也是一种补充格式,专门为耳语的边缘推理而创建。您还可以使用它进行微调,但通常它用于读取在基于 GPU Linux 的环境中在 PyTorch 上训练的模型,并转换为 GGML 以在 Apple Silicon 上运行。

作为示例,以下是 GGML 脚本,它将 PyTorch GPT-2 检查点转换为正确的格式,读取为 .bin 文件。这些文件是从 OpenAI 下载的。

生成的 GGML 文件将所有这些压缩为一个并包含:

  • 带有可选版本号的magic number
  • 特定于模型的超参数,包括有关模型的元数据,例如层数、头数等。 描述大多数张量类型的 ftype,对于 GGML 文件,量化版本编码在 ftype 中除以 1000
  • 嵌入词汇表,它是预先添加长度的字符串列表。
  • 最后,张量列表及其长度前置的名称、类型和张量数据

有几个元素使 GGML 比检查点文件更有效地进行本地推理。首先,它使用模型权重的 16 位浮点表示。一般来说, torch 默认以32位浮点数初始化浮点数据类型。 16 位或半精度意味着模型权重在计算和推理时使用的内存减少了 50%,而不会显着降低模型精度。其他架构选择包括使用 C,它提供比 Python 更高效的内存分配。最后,GGML 是针对Silicon做过优化的

不幸的是,在提高效率的过程中,GGML 包含了许多重大更改,这给用户带来了一些问题。

最大的一个问题是,由于所有内容(数据、元数据和超参数)都写入同一个文件中,因此如果模型添加超参数,则会破坏新文件无法获取的向后兼容性。此外,文件中不存在模型架构元数据,并且每个架构都需要自己的转换脚本。所有这些导致了性能的脆弱和 GGUF 的创建。

Finally, GGUF

GGUF 具有与 GGML 相同类型的布局,元数据和张量数据位于单个文件中,但此外还被设计为向后兼容。主要区别在于,新文件格式不再使用以前的超参数值列表,而是使用可容纳移位值的键值查找表。

我们围绕机器学习模型如何工作和文件格式的布局建立的直觉现在使我们能够理解 GGUF 格式。

首先,我们知道 GGUF 模型对于特定架构默认是小端字节序,我们记得是最低有效字节在前,并且针对不同的计算机硬件架构进行了优化。

然后,我们通过文件头 gguf_header_t ,包含magic byte,告诉我们这是一个GGUF文件:

Must be `GGUF` at the byte level: `0x47` `0x47` `0x55` `0x46`. 

以及键值对:

// The metadata key-value pairs.
gguf_metadata_kv_t metadata_kv[metadata_kv_count];

此文件格式还提供版本控制,在本例中我们看到这是该文件格式的版本 3。

// Must be `3` for version described in this spec, which introduces big-endian support.
    //
    // This version should only be increased for structural changes to the format.

然后,是张量

gguf_tensor_info_t

整个文件看起来像这样,当我们与像 llama.cpp 和 ollama 这样的模型文件加载库工作时,他们会采用这个规范并编写代码来打开这些文件并读取它们。

Untitled

Conclusion

我们经历了一场旋风般的冒险,以建立对机器学习模型如何工作、它们产生什么产物、过去几年机器学习产物存储的故事如何岁月变迁的,并最终出现在 GGUF 的文档中,以便更好地理解当我们对 GGUF 中的工件执行本地推理时呈现给我们的日志。希望这对您有所帮助,祝您好运!