两周前,我们发布了 jina-embeddings-v4 的 GGUF 格式及其多种动态量化版本。jina-embeddings-v4 原模型有 37.5 亿参数,在我们的 GCP G2 GPU 实例上直接运行时效率不高。因此,我们希望通过更小、更快的 GGUF 格式来加速推理。
在转换和运行这些 GGUF 模型的过程中,我们踩坑很多也积累了一些小技巧。而目前 llama.cpp 开发者社区主要聚焦于大型语言模型,因此我们想从一个向量模型供应商的角度,简单来聊聊我们的经验。
很多人可能没意识到,现在的向量模型和大型语言模型(LLM)在架构上基本一致。例如,jina-embeddings-v4 基于 Qwen2.5-VL-3B-instruct,而 jina-reranker-m0 是基于 Qwen2-VL-2B。唯一的本质区别在于输出:LLM 的输出是生成式的,而向量模型和重排器的输出是判别式的。
这种一致性有好有坏:好的是,我们可以直接利用 llama.cpp 的高效实现(如 ubatch_size和kv cache)来运行模型;现实是:llama.cpp 中现有的向量功能实现,大多是围绕着旧式的 BERT/RoBERTa 的纯编码器 encoder-only 架构开发的,尚未适配现代纯解码器的 decoder-only 向量模型。
本文将分享我们如何把纯解码器的向量模型适配到 GGUF 格式以及在 llama.cpp 工具链(如 llama-embedding 和 llama-serving)的优化实操经验。
基础版与量化版 GGUF
jina-embeddings-v4 基于 Qwen2.5-VL-3B-instruct 构建,并集成了三个 LoRA 适配器,分别针对:
- retrieval:文档检索任务
- text-matching:文本匹配任务
- code:代码检索任务
模型本身也为视觉文档检索和多向量输出进行了深度训练。我们的思路是,复用 llama.cpp 中已有的 Qwen2.5-VL-3B 计算图,再通过 llama-embedding 执行推理。
但我们一上来就发现,llama.cpp 的 mmproj(视觉 Transformer)的实现存在 bug。给定相同的图像输入,它生成的向量结果与 Qwen2.5-VL-3B 的 Torch 实现不一致。我们正在自己的分支(fork) 中修复这个问题。但在此期间,我们决定在目前发布的 GGUF 版本中移除视觉模块。
我们向上游提交的bug报告:https://github.com/ggml-org/llama.cpp/discussions/14851
多向量输出功能同样也没有原生支持,但这个问题大。多向量输出来自最后一个 Transformer 模块中的一个 MLP。因此,最差情况下,我们可以先导出这个 MLP,等 llama.cpp 输出 token 级向量后,再手动应用它。jina-reranker-m0-GGUF 就是采用这种方式实现的。这个方法虽然计算效率不高,但好在MLP比较小,且不用修改和重新编译 llama.cpp 就能工作。
所以,为了完全兼容 llama.cpp 现有的 Qwen2.5-VL-3B 计算图,我们剥离了视觉 Transformer 和多向量投射器,然后将所有 LoRA 适配器合并回基础语言模型。
最终,我们得到了三个针对特定任务的 v4 模型,每个模型的参数量从 37.5 亿减少到了 30.9 亿。
我们针对不同任务,创建了三个 GGUF 仓库:
接着,我们使用 Unsloth 推荐的 calibration_data_v5_rc.txt 校准文件,为上述三个基础 GGUF 模型分别生成了 imatrix 重要性矩阵文件。然后,我们结合 imatrix 文件,调用 llama-quantize 工具将 float16 模型执行量化。
具体命令如下:
# 构建 imatrix 文件
llama-imatrix -m jina-embeddings-v4-text-retrieval-F16.gguf -f calibration_data_v5_rc.txt -ngl 99 --no-ppl -o imatrix-retrieval-512.dat
# 执行量化
./quantize.sh jina-embeddings-v4-text-retrieval-F16.gguf retrieval-i3 imatrix-retrieval-512.dat jinaai/jina-embeddings-v4-text-retrieval-GGUF
这里的 quantize.sh 脚本负责批量处理不同的量化类型:
#!/bin/bash
F16_MODEL_FILE="$1"
OUTPUT_DIR="$2"
IMATRIX="$3"
HF_REPO="$4"
FILENAME="$(basename "$F16_MODEL_FILE")"
BASE_NAME="${FILENAME%-F16.gguf}"
BASE_NAME="${BASE_NAME%.gguf}"
mkdir -p "$OUTPUT_DIR"
# 定义量化类型数组
QUANT_TYPES=("IQ1_S""IQ1_M""IQ2_XXS""IQ2_M""Q2_K""IQ4_NL""IQ4_XS""IQ3_XXS""IQ3_S""IQ3_M""IQ3_XS""Q3_K_M""Q4_K_M""Q5_K_S""Q5_K_M""Q6_K""Q8_0")
# 循环执行量化
for quant_type in"${QUANT_TYPES[@]}"; do
llama-quantize --imatrix "${IMATRIX}""$F16_MODEL_FILE""${OUTPUT_DIR}/${BASE_NAME}-${quant_type}.gguf"$quant_type 8
done
最终,我们将所有量化模型上传至 HuggingFace。
用法与注意事项
现在,我们可以用 llama-server 和 llama-embedding 来部署 GGUF 向量模型。
Transformer 库允许我们灵活编写输入预处理代码。但在 llama.cpp 中,我们必须手动完成这一步,除非你打算重新编译 llama-server。为了确保 GGUF 模型的输出结果与原始 jina-embeddings-v4 模型完全一致,你 必须非常小心地 为输入内容手动添加前缀。
参考以下表格:
有些用户可能会对 ⚠️ 标记处感到奇怪:在 text-matching 任务中,即使指定 prompt_name=’passage’,输入前缀依然会被强制改为 “Query: “。这个设计其实很合理。因为 text-matching 是一个句子相似度任务,输入内容没有主次之分,两者是对称的。
通过 llama-server 部署
安装 llama.cpp 后,运行 llama-server 命令,即可将向量模型部署为一个兼容 OpenAI API 的 HTTP 服务。例如,要启动 text-matching 的 F16 模型,可以执行:
llama-server -hf jinaai/jina-embeddings-v4-text-matching-GGUF:F16 --embedding --pooling mean -ub 8192
必须添加 –pooling mean 参数,因为 v4 模型采用均值池化生成向量。
然后,通过 curl 发送请求:
curl -X POST "http://127.0.0.1:8080/v1/embeddings" \
-H "Content-Type: application/json" \
-d '{
"input": [
"Query: A beautiful sunset over the beach",
"Query: Un beau coucher de soleil sur la plage",
"Query: 海滩上美丽的日落",
"Query: 浜辺に沈む美しい夕日"
]
}'
如果使用 retrieval 和 code 模型,则需要根据输入类型,手动添加 Query: 或 Passage: 前缀:
curl -X POST "http://127.0.0.1:8080/v1/embeddings" \
-H "Content-Type: application/json" \
-d '{
"input": [
"Query: A beautiful sunset over the beach",
"Query: Un beau coucher de soleil sur la plage",
"Passage: 海滩上美丽的日落",
"Passage: 浜辺に沈む美しい夕日"
]
}'
通过 llama-embedding 执行
要快速验证,你也可以使用预编译的 llama-embedding 工具进行单次向量编码。但我们 不推荐 用它来批量处理,因为它存在性能问题,我们会在下文详细讨论。
llama-embedding -hf jinaai/jina-embeddings-v4-text-matching-GGUF:F16 --pooling mean -p "Query: jina is awesome" --embd-output-format json 2>/dev/null
注意事项小结
在开始之前,需要了解使用 GGUF 模型时要注意的几个要点:
- 必须手动添加前缀:你必须在输入文本前手动添加 Query: 或 Passage:。
- 暂不支持图像:当前版本无法处理图像输入。因为 llama.cpp 在实现 Qwen2.5-vl-3b 的视觉模块 (mmproj) 时存在错误,我们在 GGUF 模型中移除了该功能。目前,我们正与上游社区合作解决此问题。
- 暂不支持多向量输出:llama.cpp 的 Qwen2.5-vl-3b 计算图并未实现多向量输出。最简单的绕过方法是,设置 –pooling none 从 llama.cpp 获取 token 级向量,然后单独导出并运行 MLP。这样做无需重新编译代码。
- Matryoshka 嵌套表示特性:v4 模型使用了 Matryoshka 嵌套表示学习进行训练,GGUF 格式也保留了这一特性。因此,当你获得一个 NxD 形状的向量时,可以通过 embeddings[:, :truncate_dim] 直接截断,得到更小的向量。但我们只针对特定维度 [128, 256, 512, 1024, 2048] 进行了训练。如果你截断到 131 维,其质量不会介于 128 维和 256 维之间,而是会比两者差很多,因为 131 维没有经过训练。
- 迟分(Late Chunking)的局限:你依然可以将“迟分”作为一个后处理步骤。但 v4 是一个因果模型,迟分不再是双向的:前面的文本块向量,将无法包含后续文本块的上下文信息。而在 v3 中,因为我们使用了双向注意力,每个文本块都包含了全局上下文。关于因果性是否让迟分技术过时,我们团队内部对此也有争议。一方认为,阅读本身就是从前到后的因果过程,上下文自然来自前方。另一方则认为,单向注意力机制限制了信息的充分流动。因此,“迟分”技术在 v4 模型中的实际效果,仍需我们进一步研究和验证。
使用 llama-embedding 实现高效向量
llama-embedding 是 llama.cpp 的一个 C++ 封装库,它通过标准输入输出(stdin, stdout)处理文本,接口设计得非常简洁。我们选择重点优化llama-embedding,而非 llama-server,因为后者涉及的网络排队、负载均衡等问题超出了我们当前的范围。我们关注的核心问题更纯粹:一块 24GB 显存的 L4 GPU,其性能极限在哪里?处理长文档时,它的显存占用峰值会达到多少?
为什么选择 L4?因为 GCP 基于 L4 提供了便捷的 Cloud Run 服务,而且 L4 是目前最普及、最经济的无服务器推理 GPU。虽然 GCP 也能提供 A100/H100,但我们CEO的思维很直接:如果一个 3B 参数的模型就动辄喊着用 A100/H100 来部署,那不是炫,是我们还得练(skill issue)。
我们先介绍一下几个影响性能的关键参数。在 llama.cpp 中:
- 逻辑批次大小 (-b) :代表单次评估调用中提交给模型的最大 token 数。
- 物理批次大小 (-ub) :代表硬件一次前向传播中同时处理的实际 token 数,受显存限制。
- 上下文窗口 (-c) :是模型一次能“看到”的 token 总数上限。v4 模型是 32,000。
下图描绘了三者之间的关系。
关系图
我们做出的改进
在我们的代码分支 https://github.com/hanxiao/llama.cpp 中,我们做了几项优化来提升 llama-embedding 的效率:
- 简化批次处理:我们淘汰了 -b 参数,让数值自动与上下文长度 -c 保持一致。这样一来,用户不用手动设置它,因为我们总是利用模型的全部上下文长度进行逻辑批处理。
- 灵活控制显存:原版 llama.cpp 实现里,强制物理批次大小 -ub 等于逻辑批次大小 -b,但我们解除了这一绑定,允许用户独立设置 -ub。这让用户在编码长文本时能精确控制显存峰值。例如,你可以用一个很小的 512-token 物理批次来处理 32K 的长上下文。请注意,此项修复仅适用于像 jina-embeddings-v4 这样的因果向量模型。
- 修正均值池化:我们修复了当物理批次 (ub) 小于逻辑批次 (b) 时,程序无法准确计算均值池化的 bug。
这些改动极大地简化了长文本、仅解码器向量模型的使用,同时能有效管理显存。用户现在只需配置两个参数:
- -c:最大上下文长度。
- -ub:物理批次大小。
以下是在 L4 GPU 上运行我们代码分支的完整命令:
# 编译
git clone https://github.com/hanxiao/llama.cpp.git
cd llama.cpp
cmake -B build -DGGML_CUDA=ON
cmake --build build --config Release -j 8
# 运行
INPUT_PREFIX="Query: "# 或 "Passage: "
cat big_input.txt | sed "s/^/${INPUT_PREFIX}/" | \
./llama.cpp/build/bin/llama-embedding -f /dev/stdin \
-hf "jinaai/jina-embeddings-v4-text-retrieval-GGUF:FP16" \
--pooling mean \
--no-escape \
--embd-output-format array \
--ubatch-size 512 \
--ctx-size 8192 \
--flash-attn \
-ngl 99 \
> "embeddings.txt" 2> "error.log"
big_input.txt 文件中的每一行都是一个待向量的句子。–no-escape 确保句子中的换行符不被误判为分隔符。–flash-attn 和 -ngl 99 则用于在 L4 GPU 上获得最佳性能。
基准测试
我们做基准测试,旨在回答以下几个问题:
- 对比原始的 v4 Float16 模型,我们的量化模型表现如何?性能在哪个节点会下降到不如直接使用 v3 模型?
- 在 L4 GPU 上,每种量化版本的运行速度有多快?峰值显存占用是多少?
- 物理批次大小 -ub 和上下文长度 -c 如何影响速度与峰值显存?
我们使用了以下数据集进行基准测试:
我们使用自构建的 llama-embedding 工具执行所有测试。
量化质量
测试表明,表现最好的量化版本是 IQ3_M (3.84 BPW)。低于 2 bits 的量化版本,其性能甚至不如 v3,因此基本没有使用价值。
不同量化等级在各数据集上的性能分布,得分越高越好。
速度与显存
我们固定使用 NanoHotpotQA 数据集,测试所有量化版本的速度和显存。我们发现,FP16 精度的 GGUF 版本(2023 tok/s)甚至比原生版本(1865 tok/s)略快。大多数量化版本的速度集中在 2000-2100 tok/s。启用 Flash Attention 后,所有量化版本的速度普遍提升约 77%,达到 3000 tok/s 以上。然而,即便速度最快的 Q8_0 版本(约 3700 tok/s),仍远不及原生 v3 模型(16000 tok/s)。不过,量化版本显著节省了显存,IQ3 等级的版本其显存占用已接近 v3 FP16 模型。
不同量化等级在速度和显存占用上的表现。理想的模型位于图表的右上角,即低显存、高速度。
最佳物理批次与上下文大小
现在,我们固定使用 IQ3_S 量化版本,来研究物理批次大小 (-ub) 和上下文大小 (-c) 如何影响速度与显存。在 L4 GPU 上的测试结果显示,当 -ub=512 且 -c=2048 时,配置达到最优,速度为 4,143 tok/s,显存占用 2,025MB。
由此得出的结论很直观:当你知道输入文档的最大长度时,应设置一个刚好能覆盖它的最小上下文。至于物理批次大小,512 在 L4 GPU 上似乎是最佳选择。
性能热力图展示了不同 -c 和 -ub 组合下的速度(左)和显存占用(右)。颜色越亮代表速度越快或显存占用越高。
每秒处理 Token 数性能
峰值显存占用 (MB)
结论
对于希望在低成本 GPU 上高效运行 v4 量化模型 GGUF 的用户,我们推荐选择 IQ3_S 或 IQ3_M 版本,并配合我们定制的 llama-embedding 工具。在常规数据集(句子长度小于 2048 token)上,这套方案能达到 4000 tok/s 的处理速度。若要处理更长的文档,只需增大上下文 -c,并适当控制物理批次大小 -ub,就能有效降低显存占用。借助我们的定制工具,你可以将 -ub 设为 1024 这样的小值,仅用 3GB 显存就能编码超长文档(>32K token)——这在原版实现或原生 Transformers 中是无法做到的。
对速度优化的追求永无止境。我们总希望找到更快、更精简、吞吐量更高的实现方法。4000 tok/s 远非我们的上限,未来还有大量工作要做。除了修复 llama.cpp 中 qwen2.5-vl-3b 的视觉模块实现,我们还在探索更深层次的 llama.graph 和 KV 缓存优化,改进 llama-serving 的批处理逻辑,并为向量 API 增加流式处理选项。
我们的最终目标,是让 llama.cpp 原生支持现代的纯解码器的多模态向量模型,为我们当前及未来的重排器版本提供坚实基础。
文章来自于微信公众号“Jina AI”。