作者:SkyXZ
CSDN:SkyXZ~-CSDN博客
博客园:SkyXZ - 博客园
- LLM工具链工具包:wget https://d-robotics-aitoolchain.oss-cn-beijing.aliyuncs.com/llm_s100/1.0.0/D-Robotics_LLM_S100_1.0.0_SDK.tar.gz
- LLM工具链开发文档:wget https://d-robotics-aitoolchain.oss-cn-beijing.aliyuncs.com/llm_s100/1.0.0/D-Robotics_LLM_S100_1.0.0_Doc.zip
- 所有代码已传至:https://github.com/xiongqi123123/RDK_OE_LLM_ZOO
随着多模态大模型、VLM 乃至 VLA 的快速发展,越来越多的模型在视觉编码器部分采用了 SigLip 这一类结构。相比传统 CNN,SigLip 这类以 Transformer 为核心的视觉骨干在跨模态任务中表现更强,但也正因为其结构更复杂、对数值分布更敏感,直接沿用 RDK 传统 PTQ 量化流程时,往往会出现较明显的精度掉点。此前我也写过一篇基于传统工具链量化 ViT 的教程,虽然整体流程能够跑通,但从最终效果来看,精度损失仍然偏大,并不算是一个足够理想的部署方案。而超哥之前写过一个文档,但是并没有给出量化的参考仅给出了可下载的量化后的权重。
而本文则基于地瓜机器人推出的 OE-LLM 大模型工具链,以 siglip-so400m-patch14-384 为例,完整记录我是如何从模型结构分析出发,一步步完成 SigLip 在 Leap 框架下的网络重构、模型注册、校准编译以及PC 与板端验证的。相较于传统 PTQ 方法,OE-LLM 更适合处理这类面向大模型时代的 Transformer 视觉编码器,也更有机会在保证可部署性的同时,取得更稳定的量化效果。希望这篇文章不仅能够帮助大家在 RDK 平台上跑通 SigLip 的量化部署流程,也能为后续适配更多视觉编码器或多模态模型提供一个可复用的参考。
一、环境配置(按照开发手册配置即可)
开发机配置(PC电脑)
Bash
# Step1:下载D-Robotics_LLM_{version}.tar.gz安装包并正确解压。
wget https://d-robotics-aitoolchain.oss-cn-beijing.aliyuncs.com/llm_s100/1.0.0/D-Robotics_LLM_S100_1.0.0_SDK.tar.gz
# Step2:安装Conda环境
wget -c https://mirrors.bfsu.edu.cn/github-release/conda-forge/miniforge/LatestRelease/Miniforge3-Linux-x86_64.sh
chmod 777 Miniforge3-Linux-x86_64.sh
sh Miniforge3-Linux-x86_64.sh
conda create -n oellm python=3.10
conda activate oellm
# (oellm) xxx@xxx:~$
# Step3:安装必要的python环境
# D-Robotics_LLM_{version}路径下
pip install -r ./oellm_build/requirements.txt
pip install ./oellm_build/hbdk4_compiler-{version}-cp310-cp310-manylinux_2_17_x86_64.whl
pip install ./oellm_build/hbdk4_runtime_aarch64_unknown_linux_gnu_nash-{version}-py3-none-any.whl
pip install ./oellm_build/leap_llm-{version}-py310-none-any.whl
# Step4:将leapllm的model链接进本地开发目录
pip3 show leap-llm
ln -s /path/you/oellm/lib/python3.10/site-packages/leap_llm .
权重下载
我们以siglip-so400m-patch14-384为示例来体验oe_llm
Bash
export HF_ENDPOINT=https://hf-mirror.com
huggingface-cli download google/siglip-so400m-patch14-384 --local-dir siglip-so400m-patch14-384
二、使用OE-LLM量化编译
Leap工具链分析(仅个人分析,不代表地瓜官方)
我个人理解,oe-llm与传统的量化流程(如离线 PTQ 或 QAT)有明显区别。它更像是一套"模型重构 + 校准 + 编译导出"的工具链:开发者需要先基于leap_llm提供的模块,将原始 PyTorch 网络改写为 Leap 可识别的实现;随后使用校准数据运行浮点forward()收集量化统计信息;最后切换到build()路径,将模型导出为 Leap/HBDK 计算图并编译成可部署到板端的 HBM 模型。
从源码来看,leap_llm/nn/modules中常用的量化相关组件主要包括以下几类。需要注意的是,并不是所有模型都只依赖FakeQuant模块,部分模型也会混用DynamicQuant、RMSNorm、LayerNormSplit等其他封装组件。
| 量化相关组件 | 所在文件 | 功能描述 |
|---|---|---|
| FakeQuantEmbedding | embedding.py | 量化嵌入层 |
| FakeQuantLinear | linear.py | 量化线性层 |
| FakeQuantRMSNorm | rms_norm.py | 量化 RMS 归一化 |
| ConstFakeQuant | const_fake_quant.py | 常量假量化/定点化 |
| FakeQuantAdd | ops.py | 量化加法 |
| FakeQuantMul | ops.py | 量化乘法 |
| FakeQuantRsqrt | ops.py | 量化平方根倒数 |
| FakeQuantReduceMean | ops.py | 量化均值归约 |
| FakeQuantPow | ops.py | 量化幂运算 |
| FakeQuantMatmul | matmul.py | 量化矩阵乘法 |
| DynamicQuantMatmul | matmul.py | 动态量化矩阵乘法 |
| FakeQuantSoftmax | activation.py | 量化 Softmax |
| FakeQuantSwish | activation.py | 量化 Swish 激活 |
| FakeQuantGELU | activation.py | 量化 GELU 激活 |
| FakeQuantPatchEmbedding | vision_embedding.py | 量化视觉 Patch 嵌入 |
整体流程可以概括为三步。第一步是完成模型重构。通常需要将原始的nn.Module改写为继承Model或Module的 Leap 版本,并将其中的关键算子替换为对应的量化组件。每个重构后的模块通常都需要同时实现build()和forward()两套逻辑:其中build()负责使用leap.*算子描述最终要编译到 BPU 上的计算图,forward()则保留 PyTorch 浮点路径,用于校准和精度对齐。
Python
# 原始模型
class StandardLLM(nn.Module):
def __init__(self):
self.embedding = nn.Embedding(vocab_size, hidden_size)
self.layers = nn.ModuleList([DecoderLayer() for _ in range(num_layers)])
self.norm = nn.RMSNorm(hidden_size)
self.lm_head = nn.Linear(hidden_size, vocab_size)
# OE-LLM重构后的模型(示意)
class QuantLLM(Model):
def __init__(self):
self.embedding = FakeQuantEmbedding(vocab_size, hidden_size)
self.layers = nn.ModuleList([QuantDecoderLayer() for _ in range(num_layers)])
self.norm = FakeQuantRMSNorm(hidden_size)
self.lm_head = FakeQuantLinear(hidden_size, vocab_size)
def build(self, inputs):
pass
def forward(self, inputs):
pass
第二步是校准。校准阶段的核心目标是让各个量化模块在浮点前向过程中收集统计信息,例如absmax、缩放因子或归一化相关的范围信息。因此,校准数据的预处理流程必须与目标模型的真实输入分布尽可能一致。对于视觉模型,往往需要单独实现适合该模型的图像预处理与 patch 化逻辑,而不能直接套用默认的 LLM 文本预处理流程。
Python
# 收集量化统计信息
model.set_compile_mode(False)
for batch in calibration_data:
model.forward(batch) # 量化模块在forward过程中收集统计信息
最后一步是编译导出。此时模型会切换到build()路径,由leap_llm将计算图导出、转换并进一步编译为板端可执行的模型文件。这里的dtype和march并不是固定不变的,需要根据具体模型实现和目标平台来确定。以本文的 SigLip Vision 为例,最终编译阶段使用的是leap.float16。
Python
# 生成量化编译模型
model.set_compile_mode(True)
model.compile(
dtype=leap.float16,
march="nash-e",
output_model_path="model.hbm"
)
SigLip结构分析与工具链适配
- SigLip网络结构源码开源:transformers/src/transformers/models/siglip/modeling_siglip.py at main · huggingface/transformers
我们根据包里的标准格式创建好SigLip的文件结构便可以结合SigLip的Pytorch网络结构使用FakeQuant类对应执行重构,接下来我们结合Transformer库中SigLip的PyTorch实现来对比分析实现
Bash
(xq) qi.xiong@instance-ujccspas:~/RDK_OE_LLM/D-Robotics_LLM_S100_1.0.0_SDK/leap_llm/models/siglip$ tree -L 2
.
├── __init__.py
├── blocks
│ ├── __init__.py
│ ├── attention.py
│ ├── encoder_layer.py
│ └── mlp.py
└── model.py
4 directories, 8 files
(1)MLP部分
SigLip 的 MLP 部分非常标准,本质上就是 Vision Transformer 中最常见的前馈网络结构:先将通道维从hidden_size升维到intermediate_size,经过激活函数后再投影回原维度。由于 SigLip 使用的是普通的 GELU MLP,而不是 SwiGLU 或 MoE 这类更复杂的结构,因此这一部分也是整个工具链适配中最容易完成一一映射的部分。
官方 SigLip 实现
Python
class SiglipMLP(nn.Module):
def __init__(self, config):
super().__init__()
self.activation_fn = ACT2FN[config.hidden_act]
self.fc1 = nn.Linear(config.hidden_size, config.intermediate_size)
self.fc2 = nn.Linear(config.intermediate_size, config.hidden_size)
def forward(self, hidden_states):
hidden_states = self.fc1(hidden_states)
hidden_states = self.activation_fn(hidden_states)
hidden_states = self.fc2(hidden_states)
return hidden_states
从结构上看,这一块没有额外的残差、没有门控、也没有特殊的数据排布变化,所以我在 Leap 中直接按照"线性层 -> 激活函数 -> 线性层"的顺序进行了重写。其核心思想就是把 PyTorch 的nn.Linear和 GELU 激活替换成 Leap 工具链里对应的量化模块,并保持张量流向完全一致。
Leap中的实现
Python
class SiglipMLP(Module):
def __init__(self, hidden_size, intermediate_size):
super().__init__()
self.fc1 = FakeQuantLinear(hidden_size, intermediate_size, bias=True)
self.fc2 = FakeQuantLinear(intermediate_size, hidden_size, bias=True)
self.activation_fn = FakeQuantGELU(quantized=True, quant_bits=16)
def build(self, hidden_states):
hidden_states = self.fc1(hidden_states)
hidden_states = self.activation_fn(hidden_states)
hidden_states = self.fc2(hidden_states)
return hidden_states
def forward(self, hidden_states):
hidden_states = self.fc1(hidden_states)
hidden_states = self.activation_fn(hidden_states)
hidden_states = self.fc2(hidden_states)
return hidden_states
这一部分的适配思路可以总结为三点。第一,nn.Linear直接替换为FakeQuantLinear,权重和激活量化都交给工具链内部处理。第二,官方实现里的 GELU 对应替换为FakeQuantGELU,并保持原有的计算顺序不变。第三,由于这部分没有控制流和动态图操作,因此build()和forward()几乎可以保持完全同构,这也是 SigLip 适配相对顺手的原因之一。
(2)Attention部分
SigLip 的视觉注意力同样属于比较"干净"的标准多头自注意力。输入的hidden_states先分别经过q_proj、k_proj、v_proj线性映射,然后 reshape 成多头格式,接着执行QK^T、缩放、Softmax,再和V做乘法,最后经过out_proj输出。这一路径与经典 ViT 几乎一致。
官方 SigLip 实现
Python
class SiglipAttention(nn.Module):
def __init__(self, config):
super().__init__()
self.embed_dim = config.hidden_size
self.num_heads = config.num_attention_heads
self.head_dim = self.embed_dim // self.num_heads
self.scale = self.head_dim ** -0.5
self.k_proj = nn.Linear(self.embed_dim, self.embed_dim)
self.v_proj = nn.Linear(self.embed_dim, self.embed_dim)
self.q_proj = nn.Linear(self.embed_dim, self.embed_dim)
self.out_proj = nn.Linear(self.embed_dim, self.embed_dim)
def forward(self, hidden_states):
batch_size, seq_length, embed_dim = hidden_states.shape
queries = self.q_proj(hidden_states)
keys = self.k_proj(hidden_states)
values = self.v_proj(hidden_states)
queries = queries.view(batch_size, seq_length, self.num_heads, self.head_dim).transpose(1, 2)
keys = keys.view(batch_size, seq_length, self.num_heads, self.head_dim).transpose(1, 2)
values = values.view(batch_size, seq_length, self.num_heads, self.head_dim).transpose(1, 2)
attn_weights = torch.matmul(queries, keys.transpose(-1, -2)) * self.scale
attn_weights = nn.functional.softmax(attn_weights, dim=-1)
attn_output = torch.matmul(attn_weights, values)
attn_output = attn_output.transpose(1, 2).reshape(batch_size, seq_length, embed_dim)
return self.out_proj(attn_output)
我在 Leap 中实现这部分时,核心并不是"改结构",而是把同样的结构用工具链可识别的算子显式地写出来。尤其是在build()里,张量的reshape、transpose和matmul都需要明确描述,不能依赖 PyTorch 在运行时做隐式推导。同时,由于这里适配的是视觉编码器推理路径,我也不需要保留官方实现中attention_mask、output_attentions以及 dropout 等训练或调试相关分支。
Leap中的实现
Python
class SiglipAttention(Module):
def __init__(self, hidden_size, num_attention_heads):
super().__init__()
self.embed_dim = hidden_size
self.num_heads = num_attention_heads
self.head_dim = self.embed_dim // self.num_heads
self.scale = self.head_dim ** -0.5
self.q_proj = FakeQuantLinear(self.embed_dim, self.embed_dim, bias=True)
self.k_proj = FakeQuantLinear(self.embed_dim, self.embed_dim, bias=True)
self.v_proj = FakeQuantLinear(self.embed_dim, self.embed_dim, bias=True)
self.out_proj = FakeQuantLinear(self.embed_dim, self.embed_dim, bias=True)
self.qk = FakeQuantMatmul(8, 16)
self.sv = FakeQuantMatmul(None, 8)
self.softmax = FakeQuantSoftmax(quant_bits=16, quantized=True)
def build(self, hidden_states):
batch_size = hidden_states.type.shape[0]
q_len = hidden_states.type.shape[1]
query_states = self.q_proj(hidden_states)
key_states = self.k_proj(hidden_states)
value_states = self.v_proj(hidden_states)
query_states = leap.reshape(query_states, [batch_size, q_len, self.num_heads, self.head_dim])
query_states = leap.transpose(query_states, [0, 2, 1, 3])
key_states = leap.reshape(key_states, [batch_size, q_len, self.num_heads, self.head_dim])
key_states = leap.transpose(key_states, [0, 2, 3, 1])
value_states = leap.reshape(value_states, [batch_size, q_len, self.num_heads, self.head_dim])
value_states = leap.transpose(value_states, [0, 2, 1, 3])
attn_weights = self.qk(query_states, key_states)
attn_weights = leap.mul(attn_weights, self.scale)
attn_weights = self.softmax(attn_weights)
attn_output = self.sv(attn_weights, value_states)
attn_output = leap.transpose(attn_output, [0, 2, 1, 3])
attn_output = leap.reshape(attn_output, [batch_size, q_len, self.embed_dim])
return self.out_proj(attn_output)
这一部分的适配重点主要有三点。第一,QKV 和输出投影全部替换为FakeQuantLinear。第二,两个矩阵乘法分别使用FakeQuantMatmul来完成,其中QK^T和Attn @ V采用了不同的量化配置。第三,Softmax 则对应为FakeQuantSoftmax。整体来看,这一层仍然严格保持了官方 SigLip 的注意力结构,只是把原本由 PyTorch 隐式完成的步骤展开成了 Leap 可以直接编译的显式图。
(3)EncoderLayer部分
如果说 MLP 和 Attention 是 SigLip 的基本算子单元,那么 Encoder Layer 则是它们在 Vision Transformer 中真正的组合方式。官方 SigLip 的单层结构是典型的 Pre-Norm 形式:先做LayerNorm -> Self-Attention -> Residual,再做LayerNorm -> MLP -> Residual。这一层的逻辑实际上决定了整个视觉主干网络的主体拓扑。
官方 SigLip 实现
Python
class SiglipEncoderLayer(nn.Module):
def __init__(self, config):
super().__init__()
self.layer_norm1 = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
self.self_attn = SiglipAttention(config)
self.layer_norm2 = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
self.mlp = SiglipMLP(config)
def forward(self, hidden_states):
residual = hidden_states
hidden_states = self.layer_norm1(hidden_states)
hidden_states = self.self_attn(hidden_states)
hidden_states = residual + hidden_states
residual = hidden_states
hidden_states = self.layer_norm2(hidden_states)
hidden_states = self.mlp(hidden_states)
hidden_states = residual + hidden_states
return hidden_states
在我做 Leap 适配时,这一层真正需要思考的地方并不是残差连接本身,而是LayerNorm应该怎么落地。相比线性层和矩阵乘法,归一化算子通常对数值范围更敏感,因此我这里没有直接使用普通LayerNorm封装,而是使用了工具链中更稳定的LayerNormSplit实现。它会在校准阶段统计输入范围,并在build()阶段显式拆成均值、方差、rsqrt、缩放和偏置等原子操作,更适合当前这条 Vision PTQ 路径。
Leap中的实现
Python
class SiglipEncoderLayer(Module):
def __init__(self, layer_id, hidden_size, num_attention_heads, intermediate_size, layer_norm_eps):
super().__init__()
self.self_attn = SiglipAttention(hidden_size, num_attention_heads)
self.layer_norm1 = LayerNormSplit(hidden_size, eps=layer_norm_eps)
self.mlp = SiglipMLP(hidden_size, intermediate_size)
self.layer_norm2 = LayerNormSplit(hidden_size, eps=layer_norm_eps)
def build(self, hidden_states):
residual = hidden_states
hidden_states = self.layer_norm1(hidden_states)
hidden_states = self.self_attn(hidden_states)
hidden_states = leap.add(residual, hidden_states)
residual = hidden_states
hidden_states = self.layer_norm2(hidden_states)
hidden_states = self.mlp(hidden_states)
hidden_states = leap.add(residual, hidden_states)
return hidden_states
从结果上看,这一层的重构仍然保持了和官方实现相同的前后顺序,因此整网的语义不会发生变化。真正的变化只发生在算子层:nn.LayerNorm被替换为LayerNormSplit,残差加法在build()中写成leap.add,而注意力和 MLP 则复用前面已经量化好的子模块。这种分层替换的方式有一个明显好处,就是每一层都能独立对齐、独立调试。
(4)Embedding与整体VisionModel部分
SigLip 的视觉输入部分和 CLIP 有一个很明显的区别:它的 Vision Embedding 不引入 class token,而是直接将图像切成 patch 后映射到 token 序列,再叠加绝对位置编码。对于固定输入分辨率的部署场景来说,这一设计非常适合做静态图编译。
官方 SigLip Embedding 与 Vision 主干实现
Python
class SiglipVisionEmbeddings(nn.Module):
def __init__(self, config):
super().__init__()
self.patch_embedding = nn.Conv2d(
in_channels=config.num_channels,
out_channels=config.hidden_size,
kernel_size=config.patch_size,
stride=config.patch_size,
)
self.num_patches = (config.image_size // config.patch_size) ** 2
self.position_embedding = nn.Embedding(self.num_patches, config.hidden_size)
def forward(self, pixel_values):
patch_embeds = self.patch_embedding(pixel_values)
embeddings = patch_embeds.flatten(2).transpose(1, 2)
embeddings = embeddings + self.position_embedding(self.position_ids)
return embeddings
class SiglipVisionTransformer(nn.Module):
def __init__(self, config):
super().__init__()
self.embeddings = SiglipVisionEmbeddings(config)
self.encoder = SiglipEncoder(config)
self.post_layernorm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
def forward(self, pixel_values):
hidden_states = self.embeddings(pixel_values)
hidden_states = self.encoder(hidden_states)
hidden_states = self.post_layernorm(hidden_states)
return hidden_states
我在 Leap 里并没有把整个 HuggingFace 的SiglipVisionTransformer原封不动搬过来,而是做了面向编译部署的拆解。具体来说,输入部分被拆成了PatchEmbedding和PositionEmbedding两个更小的模块,主干网络则由SiglipVisionEmbeddings + 多层 SiglipEncoderLayer + post_layernorm组成。这样拆开以后,既方便权重映射,也方便单独处理 patch 卷积量化和位置编码常量化。
Leap中的实现
Python
class SiglipVisionEmbeddings(Module):
def __init__(self, hidden_size, num_channels, patch_size, image_size):
super().__init__()
self.num_patches = (image_size // patch_size) ** 2
self.embed_dim = hidden_size
self.patch_embedding = PatchEmbedding(hidden_size, num_channels, patch_size)
self.position_embedding = PositionEmbedding(self.num_patches, hidden_size)
self.position_ids = torch.arange(self.num_patches).unsqueeze(0).contiguous()
def build(self, pixel_values):
self.patch_embedding.to("cpu", dtype=torch.float32)
patch_embeds = self.patch_embedding(pixel_values)
batch = pixel_values.type.shape[0]
embeddings = leap.reshape(patch_embeds, [batch, self.num_patches, self.embed_dim])
embeddings = leap.add(embeddings, self.position_embedding(self.position_ids))
return embeddings
class SiglipVisionModel(Model):
def __init__(self, config):
super().__init__()
self.embeddings = SiglipVisionEmbeddings(...)
self.layers = nn.ModuleList([...])
self.post_layernorm = LayerNormSplit(config.hidden_size, eps=config.layer_norm_eps)
def build(self, pixel_values):
pixel_values = leap.transpose(pixel_values, dims=[0, 2, 3, 1])
hidden_states = self.embeddings(pixel_values)
for layer in self.layers:
hidden_states = layer(hidden_states)
hidden_states = self.post_layernorm(hidden_states)
return hidden_states
这里有几处是我在适配 SigLip 时必须明确处理的。第一,Leap 的conv2d路径使用的是 NHWC 数据格式,因此在SiglipVisionModel.build()里需要先对输入图像做一次NCHW -> NHWC转换。第二,PatchEmbedding内部会对输入和卷积权重做假量化处理,因此它不是简单地把nn.Conv2d换个名字,而是真正承担了 patch 投影的量化职责。第三,由于本文的目标是完成视觉主干网络的量化编译,而不是复刻 HuggingFace 的完整多头池化头,所以这里仅保留 Vision Transformer Backbone 和最后的post_layernorm输出,不再实现SiglipMultiheadAttentionPoolingHead。
此外,为了让我这套 Leap 版 SigLip 可以真正接上工具链流程,我还需要补齐权重映射和编译包装。SiglipVision.load_model()负责把 HuggingFace checkpoint 中的权重名映射到 Leap 实现对应的模块名;SiglipApi则负责读取safetensors、执行图像预处理、跑 calibration,并最终调用compile()生成 HBM 文件。也就是说,完成模块重构之后,真正让模型"跑起来"的关键在于:网络结构、权重映射、校准数据预处理和编译入口,这四部分必须同时闭环。
模型注册
完成models/siglip下的网络重构之后,还不能直接通过oellm_build调用这套模型。想要让工具链真正识别我新增的 SigLip,需要把它注册到leap_llm/apis/model/model_factory.py中。这里的model_factory本质上就是整个oe-llm工具链的模型分发中心,命令行传入的--model_name最终都会在这里被解析成对应的 API 实例。
(1)SigLip 的注册实现
Python
@register_model("siglip-so400m", ["nash-e", "nash-m", "nash-p"])
def _build_siglip_so400m(args):
from leap_llm.apis.model.siglip import SiglipApi
return SiglipApi(
input_model_path=args.input_model_path,
output_model_path=args.output_model_path,
calib_image_path=args.calib_image_path,
device=args.device,
model_type="siglip-so400m",
core_num=args.vit_core_num,
)
这里的注册逻辑并不复杂,但它决定了 SigLip 是否能真正接入整个工具链。@register_model("siglip-so400m", ["nash-e", "nash-m", "nash-p"])这一行完成了两件事:第一,把命令行模型名siglip-so400m和构建函数绑定起来;第二,声明这个模型支持哪些march目标平台。这样当我执行oellm_build --model_name siglip-so400m时,工具链就能自动构造出SiglipApi实例,而不是把它当成一个未知模型。
接着在oellm_build.py里,命令行参数会先被统一解析,然后通过create_model_api(args.model_name, args)分发到刚才注册好的构建函数,最后调用model.compile(**compile_kwargs)进入实际的量化编译流程。
Python
model = create_model_api(args.model_name, args)
if not model:
return
model.compile(**compile_kwargs)
从设计上看,这种写法的好处很明显:网络结构实现、校准逻辑、编译逻辑都收敛在SiglipApi内部,而oellm_build只负责调度,不需要为 SigLip 额外写一套特判逻辑。这样后续如果我要继续加别的视觉模型,只需要重复"模型实现 + API包装 + model_factory注册"这一套流程即可。
完成模型注册后,SigLip 的实际量化入口就落在leap_llm/apis/model/siglip.py中。这个文件负责三件事:加载原始权重、准备校准图片、以及执行校准和编译。也就是说,siglip.py是把网络实现和工具链流程真正连接起来的那一层。
(2) 校准数据预处理
SigLip 的输入预处理和普通 LLM 显然不同,所以我在 API 中单独实现了_load_siglip_image_data()。它会对输入图片执行Resize -> ToTensor -> Normalize(mean=0.5, std=0.5),把数据变换到 SigLip Vision 预训练时使用的[-1, 1]分布,再组织成(1, 3, H, W)格式供校准和验证使用。
Python
def _load_siglip_image_data(calib_image_path=None, image_size=384):
transform = transforms.Compose([
transforms.Resize((image_size, image_size), interpolation=transforms.InterpolationMode.BICUBIC),
transforms.ToTensor(),
transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]),
])
for img_path in image_files:
image = Image.open(img_path).convert("RGB")
pixel_values = transform(image).unsqueeze(0)
yield pixel_values
(3) API 初始化
在SiglipApi.__init__()中,我先从safetensors或pytorch_model.bin读取权重,再调用前面实现好的SiglipVision.load_model()完成权重映射与模型构造。之后再把校准图片一次性预加载到self.calib_image_data中,供后面的 calibration 阶段使用。
Python
class SiglipApi:
def __init__(self, input_model_path, output_model_path, calib_image_path=None, ...):
checkpoint = {}
if os.path.exists(safetensors_path):
with safe_open(safetensors_path, framework="pt") as f:
for key in f.keys():
checkpoint[key] = f.get_tensor(key)
self.vit_model = SiglipVision.load_model(input_model_path, checkpoint)
self.calib_image_data = list(
_load_siglip_image_data(
calib_image_path,
image_size=self.vit_model.config.image_size,
)
)
(4) 校准与编译
真正的量化流程集中在SiglipApi.compile()里。这里先把模型切到compile_mode(False),用浮点前向跑完整个校准集,让各个量化模块收集统计信息;然后再切回compile_mode(True),走build()路径导出并编译 HBM。
Python
def compile(self, **kwargs):
device = self.device if torch.cuda.is_available() else "cpu"
dtype = torch.float32
vit_module = self.vit_model
vit_module.set_model_device(device, dtype=dtype)
vit_module.set_compile_mode(False)
for image_pixel in self.calib_image_data:
vit_module.forward(image_pixel.to(device))
vit_module.set_model_device("cpu", dtype=torch.float16)
vit_module.set_compile_mode(True)
vit_module.compile(
dtype=leap.float16,
output_model_path=self.vit_file_name,
core_num=self.core_num,
**kwargs,
)
这一段基本就是 SigLip 量化编译的主流程。其中最关键的是两次模式切换:
set_compile_mode(False)阶段走的是 PyTorchforward(),目的是收集量化统计信息。set_compile_mode(True)阶段走的是 Leapbuild(),目的是导出计算图并生成最终部署模型。
开始量化
完成了上述所有流程之后,对应到实际使用时,我们只需要通过oellm_build调用即可,例如:
Bash
oellm_build \
--model_name siglip-so400m \
--march nash-e \
--input_model_path /path/to/siglip-so400m-patch14-384 \
--output_model_path ./output/siglip
执行这条命令后,工具链会自动完成模型创建、校准、导出、MLIR转换、HBO编译和 HBM 链接,最终得到板端可部署的 SigLip Vision 模型。

精度验证
PC机精度验证
量化完成后,下一步就是验证量化模型与浮点模型之间的输出一致性。oe-llm在leap_llm/apis/verifier_cli.py和leap_llm/apis/verifier/backends.py中提供了一套通用 verifier,我这里也把 SigLip 接到了这条链路中。
首先,在verifier_cli.py中我把 SigLip 加入了支持列表:
Python
SUPPORTED_MODELS = [
...,
"siglip-so400m",
]
同时,由于 SigLip 是纯视觉模型,不走文本输入流程,所以在 verifier 中需要单独走图像加载分支。这里直接复用了前面在siglip.py中实现的_load_siglip_image_data(),保证量化和验证阶段使用完全一致的图像预处理方式。
Python
if verifier_args.model_name in SIGLIP_MODELS:
image_loader = _load_siglip_image_data(verifier_args.input_image_path)
else:
image_loader = load_image_data(verifier_args.input_image_path, max_num=1)
接着在verifier/backends.py中,我把 SigLip 接入到了 Torch 侧模型加载逻辑。当模型名属于SIGLIP_MODELS时,backend 会直接加载我实现的SiglipVision,并切到浮点推理模式:
Python
elif self.args.model_name in SIGLIP_MODELS:
siglip_ckpt = {}
with safe_open(sf_path, framework="pt") as f:
for key in f.keys():
siglip_ckpt[key] = f.get_tensor(key)
self.torch_vlm_model = SiglipVision.load_model(
self.args.model_dir, siglip_ckpt
)
self.torch_vlm_model.set_compile_mode(False)
self.torch_vlm_model.set_model_device(self.device, torch.float32)
然后 verifier 会分别运行 Torch 模型和量化模型,收集最终输出以及中间层结果。对 SigLip 这种视觉模型来说,核心流程就是:
run_vlm_torch()调用浮点 SigLip 网络,得到 PyTorch 输出run_vlm_bc()或run_vlm_hbm()调用量化后的 BC/HBM 模型,得到部署侧输出ComparisonReporter.compare_inference_results()对两侧结果做逐层或最终输出的 cosine 对比,并生成 JSON/Excel 报告
如果只想在 PC 上做量化图或编译产物的输出比对,可以直接调用 verifier 工具。例如:
Bash
python -m leap_llm.apis.verifier_cli \
--model_name siglip-so400m \
--model_dir /path/to/siglip-so400m-patch14-384 \
--compare_mode bc \
--quant_vlm_model_path ./output/siglip/siglip-so400m_vit_ptq.bc \
--input_image_path /path/to/calib/images
在这种模式下,更适合检查 BC 图与浮点模型之间的算子级或层级一致性,便于分析是哪一层开始出现精度损失

板端精度验证
如果需要验证最终板端部署模型的精度,则可以直接在oellm_build阶段加上--verifier参数。oellm_build.py在编译完成后会自动构造VerifierArgs,并根据get_hbm_path()返回的路径把 SigLip 识别为视觉模型,把生成好的 HBM 文件传给 verifier。
Python
if hasattr(model, "get_hbm_path"):
paths = model.get_hbm_path()
if len(paths) == 1:
if "siglip" in args.model_name or "gemma4" in args.model_name:
hbm_vlm_model_path = paths[0]
对于 HBM 模式,verifier 后端会走run_vlm_hbm(),把输入图像转成 HBM 侧要求的数据格式,然后通过远端板卡执行推理,再把结果拉回 PC 与 Torch 输出进行对比:
Python
def run_vlm_hbm(self, image):
outputs = self._run_hbm_vlm_inference(image)
return outputs, None
def _run_hbm_vlm_inference(self, image_input):
model_inputs = {"_input_0": image_input.cpu().type(torch.float16).numpy()}
res = self._get_hbm_infer_res(self._hbm_vlm_module, model_inputs)
return OrderedDict([(k, TensorInfo(v, name=k)) for k, v in res.items()])
实际使用时,可以直接这样执行:
Bash
oellm_build \
--model_name siglip-so400m \
--march nash-e \
--input_model_path /path/to/siglip-so400m-patch14-384 \
--output_model_path ./output/siglip \
--calib_image_path /path/to/calib/images \
--verifier \
--remote_ip <board_ip> \
--username root \
--password <password> \
--remote_path /userdata/data/hbm_infer
执行完成后,verifier 会自动生成对比报告,这样就可以同时看到最终输出的一致性,以及在 BC 模式下进一步下钻查看中间层的 cosine 变化,从而判断这次 SigLip 量化是否达到预期效果。
三、RDK板端性能测试
完成 SigLip Vision 模型的量化编译之后,我将生成的siglip-so400m_vit_ptq.hbm部署到 RDK 板端,并使用hrt_model_exec perf对模型进行单线程性能测试。板端执行命令如下:
Bash
hrt_model_exec perf \
--thread_num 1 \
--model_file siglip-so400m_vit_ptq.hbm \
--frame_count 500 \
--profile-path .
从板端终端输出结果可以看到,模型成功加载并完成了完整的 perf 测试流程,没有出现算子回退、模型无法执行或者输入输出异常的问题。根据截图中的最终统计结果,可以整理出如下性能指标:
| 指标 | 结果 |
|---|---|
| Load model to DDR | 1572.47 ms |
| Thread num | 1 |
| Frame count | 500 |
| Average latency | 265.210 ms |
| Max latency | 265.393 ms |
| Min latency | 265.054 ms |
| FPS | 3.770 FPS |

上图展示的是板端hrt_model_exec perf的实际输出结果,可以看到模型已经成功加载到 DDR 中,并完成了完整的性能测试。最终统计结果显示,当前单线程下的平均时延约为265.210 ms,吞吐约为3.770 FPS,说明这版量化后的 SigLip Vision 模型已经具备可用的板端推理能力。

另外,从我自己开发的dtop监控界面也可以进一步确认,模型运行时 BPU 占用率处于高负载状态,可以看到 BPU 基本被持续打满。这说明这版 SigLip Vision 模型确实已经成功运行在板端 BPU 上,而不是停留在 CPU 侧做回退执行。