Oct 13, 2025
8 min read
candle,
Rust,

yolov10 in candle

yolov10 in candle

移植 yolov10 到 candle 不见得有多复杂,主要难度来源于 ultralytics 这个项目本身的代码可读性太差。ultralytics 为了适配 yolov5-v12,还有 yoloeyolo-world 这些不同的变种,ultralytics代码库中充斥着大量的 if 判断,再加上训练/推理模式混合在一起,使得代码看起来非常混乱。

我个人不是太认可这种代码组织方式,实在是太乱了。牺牲一些抽象换来更好的可读性不好吗?毕竟,模型不同版本之间本身没有什么联系。

不过,在经过一番努力之后,我成功地将 yolov10 移植到了 rust ai 框架 candle 中。

分析

ultralytics 通过配置文件定义模型结构,再通过yaml_model_load(cfg) 加载配置文件,解析模型。这里以 yolov10s 为例,因为 yolov10s 尺寸较小,准确率也能接受。 yolov10s 尺寸的结构定义如下:

backbone:
  # [from, repeats, module, args]
  - [-1, 1, Conv, [64, 3, 2]] # 0-P1/2
  - [-1, 1, Conv, [128, 3, 2]] # 1-P2/4
  - [-1, 3, C2f, [128, True]]
  - [-1, 1, Conv, [256, 3, 2]] # 3-P3/8
  - [-1, 6, C2f, [256, True]]
  - [-1, 1, SCDown, [512, 3, 2]] # 5-P4/16
  - [-1, 6, C2f, [512, True]]
  - [-1, 1, SCDown, [1024, 3, 2]] # 7-P5/32
  - [-1, 3, C2fCIB, [1024, True, True]]
  - [-1, 1, SPPF, [1024, 5]] # 9
  - [-1, 1, PSA, [1024]] # 10

# YOLOv10.0n head
head:
  - [-1, 1, nn.Upsample, [None, 2, "nearest"]]
  - [[-1, 6], 1, Concat, [1]] # cat backbone P4
  - [-1, 3, C2f, [512]] # 13

  - [-1, 1, nn.Upsample, [None, 2, "nearest"]]
  - [[-1, 4], 1, Concat, [1]] # cat backbone P3
  - [-1, 3, C2f, [256]] # 16 (P3/8-small)

  - [-1, 1, Conv, [256, 3, 2]]
  - [[-1, 13], 1, Concat, [1]] # cat head P4
  - [-1, 3, C2f, [512]] # 19 (P4/16-medium)

  - [-1, 1, SCDown, [512, 3, 2]]
  - [[-1, 10], 1, Concat, [1]] # cat head P5
  - [-1, 3, C2fCIB, [1024, True, True]] # 22 (P5/32-large)

  - [[16, 19, 22], 1, v10Detect, [nc]] # Detect(P3, P4, P5)

这是一个 23 层的模型结构定义。

yolov10 是一个NMS-Free 模型,具体来说,就是训练期间结合了一对多(one2many)和一对一(one2one)的策略,对于训练,走的一对多(one2many),对于推理,走的一对一(one2one)。因此我们不需要理会(one2many) 的内容。

实现思路

首先, yolov8 有纯 rust 语言实现,代码在这里https://github.com/huggingface/candle。比较遗憾的是,candle 官方项目里的 yolov8 并没有基于ultralytics 这个项目实现,原因大概是许可证问题。因此我们会发现,candle 里的 yolov8 的模型节点名和ultralytics完全对不上,我们也无法直接导出ultralytics的模型再使用 candle 进行推理 (当然,修改节点就能满足要求了)。

但是一些模型我们可以直接复用。因此我们移植工作大概内容如下:

  1. 实现从ultralytics 导出 safetensors 权重
  2. 实现 yolov8 中没有的模块,如SCDownC2fCIBPSAv10Detect 等。
  3. 适配 ultralytics 的权重节点名称。
  4. 实现 v10 版本的后处理。

导出权重

candle 使用的权重格式是 safetensors,因此我们首先需要将ultralytics导出的权重转换成 safetensors。

核心代码如下:

    model = YOLO(model_path)
    print(model.model)

    tensors = model.model.state_dict() # type: ignore

    for k, v in tensors.items():
        print(str(k), v.shape)

    # 保存为safetensors格式
    save_model(model.model, output_path) # type: ignore

我们需要保存 2 个 print 的输出,一个是权重的形状,一个是权重的 key。主要用于后面适配权重的节点名称。这个很重要

实现模块

SCDownC2fCIB 是比较正常的卷积模块,不展开。

PSA 全称Position-Sensitive Attention, 是一个实现位置敏感注意力机制的神经网络模块,用于增强特征提取和处理能力。没深入之前,我以为 yolov10 是没有注意力模块的。移植的时候才发现,我的理解有错误。不过yolov10 也只有这么一个模块,不像 yolov12,注意力机制模块贯穿整个模型。

PSA 最核心的是 Attention 模块。 Attention 多头注意力机制增强表达能力,Rust 实现如下:

#[derive(Clone, Debug)]
pub struct Attention{
    qkv: ConvBlock,
    proj: ConvBlock,
    pe: ConvBlock,
    num_heads: usize,
    key_dim: usize,
    scale: f64,
    head_dim: usize,
}

impl Attention {
    /// num_heads=8, attn_ratio=0.5
    pub fn load(vb:VarBuilder,dim:usize,num_heads:usize,attn_ratio:f64)->Result<Self> {
        let head_dim = dim / num_heads;
        let key_dim = (head_dim as f64 * attn_ratio) as usize;
        let scale = (key_dim as f64).powf(-0.5);
        let nh_kd = key_dim * num_heads;
        let h = dim + nh_kd * 2;

        let qkv = ConvBlock::load(vb.pp("qkv"), dim, h, 1, 1, None, None, false)?;
        let proj = ConvBlock::load(vb.pp("proj"), dim, dim, 1, 1, None, None, false)?;
        let pe = ConvBlock::load(vb.pp("pe"), dim, dim, 3, 1, None, Some(dim), false)?;

        Ok(
            Self {
                qkv,
                proj,
                pe,
                num_heads,
                key_dim,
                scale,
                head_dim,
            }
        )
    }
}

其他模块没有特别的地方。

碰到的实现问题

candle 和 pytorch 终究是不一样的,很多操作 API 存在不一样的地方,之前我自己有记录过一些不一样的地方。这次移植的时候再次碰到了一些问题。

nn.ModuleList

这个模块属于容器,在 candle 中是不存在的。一般我喜欢使用 Vec<Box<dyn Module>> 来代替。

比如 python 版本:

self.m = nn.ModuleList(CIB(self.c, self.c, shortcut, e=1.0, lk=lk) for _ in range(n))

Rust 语言版本:

let mut cib = Vec::with_capacity(n);
for idx in 0..n {
    // CIB(self.c, self.c, shortcut, e=1.0, lk=lk)
    let b = CIB::load(vb.pp(format!("m.{idx}")), c, c, shortcut, 1f64, lk)?;
    cib.push(b)
}

torch.view

candle 不存在 view 函数,因此需要使用 reshape 函数代替。

split

同样地,candle 也不存在 split 函数。yolov10 中使用得最多的方式是指定尺寸列表分割,比如:

q,k,v = x.split([self.key_dim, self.key_dim, self.head_dim], dim=2)

在 rust 中,有类似分割张量的函数我能想到的就是 narrow

let q = rs.narrow(2, 0, self.key_dim)?;  // 从dim=2的第0个位置开始,取key_dim个元素
let k = rs.narrow(2, self.key_dim, self.key_dim)?;  // 从dim=2的第key_dim个位置开始,取key_dim个元素
let v = rs.narrow(2, self.key_dim * 2, self.head_dim)?;  // 从dim=2的第key_dim*2个位置开始,取head_dim个元素

矩阵乘法操作

python 可以实现类似的k @ v 这种矩阵乘法,在 rust 中,需要使用 matmul 函数。

let kv = k.matmul(v)?;

有 padding 的 nn.MaxPool2d

有 padding 的 nn.MaxPool2d,比如nn.MaxPool2d(kernel_size=k, stride=1, padding=k // 2),在 rust 上可以这么实现:

x
  .pad_with_zeros(2, self.k / 2, self.k / 2)?
  .pad_with_zeros(3, self.k / 2, self.k / 2)?
  .max_pool2d_with_stride(self.k, 1)?;

v10postprocess

yolov10 移植难度最大的其实是 v10postprocess 模块。

首先,candle 没有内容 topk。不过好在 deepseek 的 candle 实现里有一个 topk 函数,比较接近pytorch 的 topk 函数,可以满足需求。


pub struct TopKOutput {
    pub values: Tensor,
    pub indices: Tensor,
}
pub trait TopKLastDimOp {
    fn topk(&self, topk: usize) -> Result<TopKOutput>;
}

impl TopKLastDimOp for Tensor {
    fn topk(&self, topk: usize) -> Result<TopKOutput> {
        // Sorted descending
        let sorted_indices = self.arg_sort_last_dim(false)?;
        // 获取最后一维的大小
        let last_dim_size = sorted_indices.dim(D::Minus1)?;
        // 确保不超过最后一维的实际大小,符合PyTorch的torch.topk行为
        let actual_topk = topk.min(last_dim_size);
        let topk_indices = sorted_indices
            .narrow(D::Minus1, 0, actual_topk)?
            .contiguous()?;
        Ok(TopKOutput {
            values: self.gather(&topk_indices, D::Minus1)?,
            indices: topk_indices,
        })
    }
}

其次,candle 中没找到取模运算(求余数)。就是下面这个操作:

result = index % nc  # 计算 index ÷ nc 的余数

并且这还是一个支持广播机制的取模运算。我的实现如下:

pub trait TensorRemOps {
    fn broadcast_rem(&self, other: &Tensor) -> Result<Tensor>;
}
impl TensorRemOps for Tensor {
    fn broadcast_rem(&self, other: &Tensor) -> Result<Tensor> {
        // 获取广播后的形状
        let broadcast_shape = broadcast_shape(self.shape(), other.shape())?;

        // 对两个张量进行广播扩展
        let self_expanded = self.expand(&broadcast_shape)?;
        let other_expanded = other.expand(&broadcast_shape)?;

        // 转换为二维数组进行逐元素取模运算
        let self_data = self_expanded.to_vec2::<u32>()?;
        let other_data = other_expanded.to_vec2::<u32>()?;

        // 执行逐元素取模运算
        let result: Vec<Vec<u32>> = self_data
            .into_iter()
            .zip(other_data.into_iter())
            .map(|(row1, row2)| {
                row1.into_iter().zip(row2.into_iter())
                    .map(|(a, b)| a % b)
                    .collect()
            })
            .collect();

        // 将结果展平并重塑为原来的形状
        let flat_result: Vec<u32> = result.into_iter().flatten().collect();
        Tensor::from_vec(flat_result, &broadcast_shape, self.device())
    }
}

到这里,结构上的障碍就基本解决完了。

下面是把模块按原版的要求像砌墙一样垒起来。

通过 print(model.model) 观察 yolov10 模型结构,我们会发现,它是一个非常扁平的结构。从 0 到 23层,如果按 rust 的代码组织方式,不太友好。因此,我把 yolov10 的大模块分成了 backboneneckhead 三大部分。

  • backbone:0 - 10 层
  • neck: 11 - 22 层
  • head: 23 层
pub struct YoloV10 {
    backbone: Backbone,
    neck: YoloNeck,
    head: V10DetectionHead,
}

可以不按原版本的结构来组织代码的原因是,v.pp()支持点语法取值,比如 model.model.0.0.0.weight。 这种组织方式还有利于与 pytorch 版本做权重值对齐。

对齐节点

对齐节点主要靠三个内容:

  1. yolov10s.yaml 文件,用于查看整体结构
  2. print(model.model) 用于对齐节点层级
  3. for k, v in tensors.items(): \print(str(k), v.shape) 用于对齐节点细节。

写一个调用用例进行测试:

  let vb = unsafe {
      VarBuilder::from_mmaped_safetensors(
          &["yolov10s.safetensors"],
          DType::F32,
          &device,
      )
  }?;
  let xs = vec![1f32; 640 * 640 * 3];
  let image_t = candle_core::Tensor::from_vec(xs, (1, 3, 640, 640), &device)?;
  let output = yolo.forward(&image_t)?;

如果我们运行上面的代码不再出错,那么恭喜!模型的节点对齐了!

检测头

前面讲过,yolov10 有一对多(one2many)和一对一(one2one) 两个策略。在训练阶段,返回是这样的:

if self.training:  # Training path
    return {"one2many": x, "one2one": one2one}

但是我们只是做推理,推理的时候,处理的其实是 one2one 分支。所以,其实 one2many 的内容是不需要的。

后处理

原来我们使用 onnx 实现 yolov10 的时候,后处理非常简单,那是因为在导出 onnx 的时候,v10postprocess 模块也进入了 onnx 的计算图中。但其实如果算上 v10postprocess 模块的话,个人认为 yolov10 的后处理要比 yolov8 复杂。在上面的实现细节中,就直接碰到了 2 个在 candle 中没有的操作。

在 candle 中,我们当然不得不自己实现 v10postprocess 模块。

fn v10postprocess(preds: &Tensor, max_det: usize, nc: usize) -> Result<Tensor> {
    let preds_shape = preds.dims();
    assert!(4 + nc == preds_shape[preds_shape.len() - 1]);

    // Split boxes and scores
    let boxes = preds.i((.., .., ..4))?;
    let scores = preds.i((.., .., 4..))?;

    let amax_scores = scores.max(D::Minus1)?;

    // max_scores, index = torch.topk(max_scores, max_det, dim=-1)
    let TopKOutput {
        values: _max_scores,
        indices: topk_indices,
    } = amax_scores.topk(max_det)?;

    let index = topk_indices.unsqueeze(D::Minus1)?; // Equivalent to index.unsqueeze(-1)
    let boxes = boxes.contiguous()?.gather(&index.repeat((1, 1, 4))?, 1)?;
    let scores = scores.contiguous()?.gather(&index.repeat((1, 1, nc))?, 1)?;

    // scores, index = torch.topk(scores.flatten(1), max_det, dim=-1)
    let scores_flat = scores.flatten(1, 2)?;
    let TopKOutput {
        values: scores,
        indices: index,
    } = scores_flat.topk(max_det)?;

    let nc_tensor = Tensor::from_slice(&[nc as u32], 1, scores.device())?;
    let index_div = index.broadcast_div(&nc_tensor)?;

    // 使用 gather 代替高级索引 boxes[i, index // nc]
    let boxes_indices = index_div.unsqueeze(2)?.repeat((1, 1, 4))?; // [batch_size, max_det, 4]
    let boxes_gathered = boxes.gather(&boxes_indices, 1)?; // [batch_size, max_det, 4]
    // scores[..., None] - 添加新轴
    let scores_expanded = scores.unsqueeze(2)?; // [batch_size, max_det, 1]

    // (index % nc)[..., None].float() - 取模并添加新轴
    let index_mod = index.broadcast_rem(&nc_tensor)?; // index % nc
    let index_mod_expanded = index_mod.unsqueeze(2)?.to_dtype(DType::F32)?; // [batch_size, max_det, 1]

    // 在最后一个维度上连接所有张量
    // python: torch.cat([boxes[i, index // nc], scores[..., None], (index % nc)[..., None].float()], dim=-1)
    // 最后一维应该是 4 + 1 + 1 = 6
    let result = Tensor::cat(&[&boxes_gathered, &scores_expanded, &index_mod_expanded], 2)?;

    Ok(result)
}

需要注意的是,candle 的 gather 要求输入的必须是连续存储的张量。因此,在 v10postprocess 中,我们需要使用 contiguous() 方法来确保张量在内存中以连续的方式存储。

对齐结果

由于ultralytics 代码比较复杂,使用同一张图片来进行对比结果进行验证其实不太可行。原因是我们很难做到和ultralytics一样的预处理。为了减少预处理带来的误差,我们最好保证输入模型的张量值是一致的。

幸好,ultralytics支持纯张量输入,这样就不会进入预处理步骤。

one = torch.ones(1, 3, 640, 640)
results = model(one)

同样地, candle 中可以这样实现:

let xs = vec![1f32; 640 * 640 * 3];
let image_t = candle_core::Tensor::from_vec(xs, (1, 3, 640, 640), &device)?;

如何对比两者输出? 这里主要是 ultralytics 的问题,两个办法,第一个办法是使用钩子函数:

# 定义钩子函数
def hook(module, input, output):
    print(f"Layer: {module.__class__.__name__}")
    print(f"Input shape: {input[0].shape}")
    print(f"Output shape: {output.shape}")
    print(f"Output sample: {output}")
    return output

layer = model.model.model[0]  # type: ignore # model.model是实际的网络结构
handle = layer.register_forward_hook(hook)

其中model.model.model[0] 中的 0 为模型的第一层。yolo10s 有 24 层[0-23]。

第二个办法是找到ultralytics 执行层的主函数。这个在ultralytics/nn/tasks.py 中可以找到_predict_once

# 第 180 行
 x = m(x)  # run
 if m.i == 0:
    print("x::",x)

m.i 也是模型的层数[0-23]。

误差

对齐结果的时候不需要要求值完全一致。事实上,这是完全不可能的事。 误差主要是硬件差异(CPU、GPU)、浮点数精度差异、框架差异、算子实现差异等影响。甚至随着模型的层数加深,误差会越来越大。 比如我目前的这个 rust 版本实现,在经过后处理之后,误差已经来到了 0.1 这个数量级。

下面是 candle 实现的效果,基本和ultralytics一致。 res

如果你对这个实现感兴趣,可以到这里看源代码:https://gitcode.com/tunzei/yolov10

当然,我的实现是有一些问题的,比如最后的累计误差有一些大了,主要是有部分算子是按自己的理解实现的,并未真正参考 pytorch的实现。

又比如 ultralytics 有部分模块做了算子融合,这无疑可以加速推理。虽然我没有做速度上的对比,但是应该推理速度上会比 ultralytics 要慢一些。