跳转至

wenet对onnx的支持

Pytorch转ONNX

Pytorch转ONNX实际上是将模型的每一个op转化为ONNX定义的某一个算子,比如对于Pytorch中的nn.Upsample()F.interpolate(),在转换为ONNX后最终都会成为ONNX的Resize算子。通过修改继承自torch.autograd.Function算子的symbolic方法,可以改变该算子映射到ONNX算子的行为。

模型部署入门教程(二):解决模型部署中的难题 - 知乎 torch.onnx — PyTorch master documentation

Pytorch转ONNX格式的torch.onnx.export()函数需要torch.jit.ScriptModule,而不是torch.nn.Module,如果传入的模型不是ScriptModule形式,该函数会利用tracing方式,追踪流入tensor的流向,来记录模型运算时的所有操作并转为ScriptModule:

跟踪法只能通过实际运行一遍模型的方法导出模型的静态图,无法识别出模型中的控制流(如循环)和运行时的动态变化;记录法则能通过解析模型来正确记录所有的控制流。

模型部署入门教程(三):PyTorch 转 ONNX 详解 - 知乎

tracing方式的转换会导致模型无法对动态的操作进行捕获,比如对torch.tensor的动态切片操作会被当做固定的长度切片,一旦切片的长度发生变化就会触发错误。为了对这些动态操作进行保存,可以使用scripting的方式,直接将动态操作流改写为ScriptModule。

导出ONNX

torch.onnx.export

# wenet/wenet/bin/export_onnx_cpu.py
encoder = asr_model.encoder
...
inputs = (chunk, offset, required_cache_size,
              att_cache, cnn_cache, att_mask)
...
encoder_outpath = os.path.join(args['output_dir'], 'encoder.onnx')
...
dynamic_axes = {
    'chunk': {1: 'T'}, # chunk张量在axis=1上是可变的,该axis=1维度名为T
    'att_cache': {2: 'T_CACHE'},
    'att_mask': {2: 'T_ADD_T_CACHE'},
    'output': {1: 'T'},
    'r_att_cache': {2: 'T_CACHE'},
}
...
torch.onnx.export(
    encoder, # model (torch.nn.Module, torch.jit.ScriptModule or torch.jit.ScriptFunction)
    inputs,# 模型输入参数。导出时可以构建随机等大的张量作为输入参数,用于模型的张量跟踪
    encoder_outpath,  # 导出onnx文件的路径
    opset_version=13,  # 转换时参考哪个ONNX算子集版本
    export_params=True, # 是否导出模型的参数
    do_constant_folding=True, # 常数折叠优化,对仅输出常数的op直接用常数代替
    input_names=[
        'chunk', 'offset', 'required_cache_size',
        'att_cache', 'cnn_cache', 'att_mask'
    ], # 分配给计算图输入节点的名称,需要和`inputs`顺序一致
    output_names=['output', 'r_att_cache', 'r_cnn_cache'], # 分配给计算图输出节点的名称,有序
    dynamic_axes=dynamic_axes, # 指定张量的可变维度
    verbose=False) # 打印输出模型的描述

torch.onnx.export()dynamic_axes参数可以指定一些张量的可变维度,形式为dict<string, dict<python:int, string>> or dict<string, list(int)>, default empty dict。默认情况下,导出的模型将所有输入输出张量的大小均设置为给定张量的大小,为了指定张量的一些维度是动态可变的,可以设置dynamic_axes,其中:

  • KEY(str):大小可变的输入/输出张量名,张量名需要在input_namesoutput_names中。
  • VALUE (dict or list):如果是dictkey是可变大小对应的维度,value是对应维度名;如果是list,每个元素表示可变大小对应的维度。

参数opset_version指定的ONNX算子集版本可参考onnx/Operators.md at main · onnx/onnx · GitHub。在Pytorch中,和ONNX有关的定义存放在pytorch/torch/onnx at master · pytorch/pytorch · GitHub

其中,symbolic_opset{n}.py(符号表文件)表示 Pytorch 在支持第n版 ONNX 算子集时新加入的内容,可以在该目录下查找Pytorch到ONNX算子的映射。

在实际应用时可以在torch.onnx.export()opset_version中先预设一个版本号,碰到问题就去对应的Pytorch符号表文件里去查。如果某算子确实不存在,或者算子的映射关系不满足要求,就可能需要利用其它算子绕过去,或者自定义算子。

onnx/Operators.md at main · onnx/onnx · GitHub 模型部署入门教程(三):PyTorch 转 ONNX 详解 - 知乎

NoneType类型参数

在流式解码时,刚开始的若干chunk中编码器的cache为空。ONNX转写的模型不支持NoneType输入,但Torch和ONNX均可以接受维度中存在0的tensor,且可以对这种tensor进行常规的切片和拼接操作,比如:

1
2
3
4
5
6
a = torch.ones((1, 2, 0, 4))  # 维度中存在0的tensor
b = torch.ones((1, 2, 3, 4))
c = torch.cat((a, b), dim=2)
torch.equal(b, c)        # True
d = torch.split(a, 2, dim=-1)
torch.equal(d[0], d[1])  # True

因此可以引入长度为0、元素值为0的dummy张量代替NoneType。

cache中的动态变化

torch.onnx.export()利用tracing方式追踪tensor流向时,无法捕获动态操作。在编码器更新缓存时,需要利用next_cache_start对上一时刻的计算产物进行切片:

# wenet/wenet/transformer/encoder.py
# required_cache_size < 0(16 chunksize / -1 leftchunks)
if required_cache_size < 0:
    # 该分支下,next_cache_start始终为0
    next_cache_start = 0
# required_cache_size == 0(16 chunksize / 0 leftchunks)
elif required_cache_size == 0:
    # 该分支下,next_cache_start始终为attention_key_size
    # 而attention_key_size是超参数计算出来的定值
    next_cache_start = attention_key_size
# required_cache_size > 0(16 chunksize / 4 leftchunks)
else:
    # 该分支下,next_cache_start动态变化
    next_cache_start = max(attention_key_size - required_cache_size, 0)

在16/-1和16/0的解码配置下,不会产生动态操作。但是在16/4的解码配置下,如果对第一个chunk送入长度为0的cache,那么前4个chunk的next_cache_start均为0,而对第5个及其之后的chunk,由于next_cache_start将变为attention_key_size - required_cache_size,计算得到的next_cache_start不再是0,这就是所谓的动态变化。

att_cache缓存多头注意力的key和value,next_cache_start表示下一个att_cache在时间维度上起始点:

# wenet/wenet/transformer/encoder.py

# new_att_cache是计算完成多头注意力但尚未利用new_cache_start进行切片并更新的注意力缓存
# 所谓的注意力缓存`att_cache`实际上就是缓存上一个chunk中的多头注意力的key和value
# new_att_cache是对key和value进行concat: new_att_cache=torch.cat((k, v),dim=-1)
# 因此new_att_cache的最后一个维度需要乘以2
# shape(new_att_cache) is (1, head, attention_key_size, d_k * 2)
# attention_key_size = cache_t1 + chunk_size
# cache_t1 = required_cache_size = chunk_size * num_decoding_left_chunks

# So, shape(new_att_cache[:, :, next_cache_start:, :]) in 16/4
# always be (1, head, chunk_size * num_decoding_left_chunks, d_k * 2)
r_att_cache.append(new_att_cache[:, :, next_cache_start:, :])

为了保证在16/4配置下next_cache_start的值在推理的全过程中维持不变,对第一个chunk 送入长度为required_cache_size而非长度为0的cache。换句话说,从第一个 chunk 开始就送入“真实”的 cache,只是该cache的元素值均为0,并利用att_mask指示该部分的cache为False,此时next_cache_start == attention_key_size - required_cache_size恒成立,也即对于第一个及之后的chunk,next_cache_start == chunk_size恒成立。

动态切片

如果使用tracing方式转写成ONNX,对torch.tensor的切片只能是静态切片,比如data[:3] = new_data,这里的3只能是固定值3,不能是传入的tensor,比如data[:data.shape[0]]在ONNX的opset<13时是不支持的。

可以依靠传入的torch.tensor作为index,实现tracing方式下对张量的动态切片,比如data[torch.tensor([1,2])]。WeNet流式解码时,每个时刻都需要编码器对输入的cache进行切片,每次均传入切片index会将模型变得复杂。此时将需要动态切片的操作通过scripting方式直接改写为ScriptModule是更优策略,比如:

@torch.jit.script
def slice_helper(x, offset):
    return x[:, -offset: , : ]

chunk = x.size(1) - output_cache.size(1)

# x_q = x[:, -chunk:, :]
# residual = residual[:, -chunk:, :]
# mask = mask[:, -chunk:, :]
# 更改为:
x_q = slice_helper(x, chunk)
residual = slice_helper(residual, chunk)
mask = slice_helper(mask, chunk)

需要注意的是,如果将torch.nn.Module转为torch.jit.ScriptModule,模型无法进行训练,此时可以将训练代码和转写代码分为两部分,实际上也可以简单地在使用到scripting的模块中,添加bool属性onnx_mode,在训练时设置为False,转写时设置为True即可:

@torch.jit.script
def slice_helper(x, offset):
    return x[:, -offset: , : ]

chunk = x.size(1) - output_cache.size(1)

if onnx_mode:
    x_q = slice_helper(x, chunk)
    residual = slice_helper(residual, chunk)
    mask = slice_helper(mask, chunk)
else:
    x_q = x[:, -chunk:, :]
    residual = residual[:, -chunk:, :]
    mask = mask[:, -chunk:, :]

当然,opset>=13 时,ONNX已经直接支持上述的动态切片操作:

# 在导出时,将opset设置为13,即可直接支持动态切片,无需任何代码层面的改动
torch.onnx.export(
    encoder, inputs, encoder_outpath, opset_version=13,
    export_params=True, do_constant_folding=True,
    input_names=[
        'chunk', 'offset', 'required_cache_size',
        'att_cache', 'cnn_cache', 'att_mask'
    ],
    output_names=['output', 'r_att_cache', 'r_cnn_cache'],
    dynamic_axes=dynamic_axes, verbose=False)

tracing只追踪tensor

tracing方式只能通过追踪tensor流向来定位参与的运算,而无法追踪其它类型比如List[tensor]。因此encoder模块中的forward_chunk()函数各个层的cache不能使用list来保存,而必须通过torch.cat()函数合并成tensor,否则在调用ONNX模型时,对模型输出的索引将会出错。比如:

r_conformer_cnn_cache.append(new_cnn_cache)

输出对应索引位置的值,不是r_conformer_cnn_cache,而是r_conformer_cnn_cache[0]。因此应改为:

r_conformer_cnn_cache = torch.cat((r_conformer_cnn_cache, new_cnn_cache.unsqueeze(0)), 0)

ONNX不支持pad_sequence

重新设计了一个与pad_sequence()等价且能被ONNX感知到shape变化的函数。

# https://github.com/wenet-e2e/wenet/blob/main/wenet/transformer/asr_model.py#L683-L721
# `pad_sequence` is not supported by ONNX, it is used
#   in `reverse_pad_list` thus we have to refine the below code.
#   Issue: https://github.com/wenet-e2e/wenet/issues/1113
# Equal to:
#   >>> r_hyps = reverse_pad_list(r_hyps, r_hyps_lens, float(self.ignore_id))
#   >>> r_hyps, _ = add_sos_eos(r_hyps, self.sos, self.eos, self.ignore_id)
max_len = torch.max(r_hyps_lens)
index_range = torch.arange(0, max_len, 1).to(encoder_out.device)
seq_len_expand = r_hyps_lens.unsqueeze(1)
seq_mask = seq_len_expand > index_range  # (beam, max_len)
#   >>> seq_mask
#   >>> tensor([[ True,  True,  True],
#   >>>         [ True,  True,  True],
#   >>>         [ True, False, False]])
index = (seq_len_expand - 1) - index_range  # (beam, max_len)
#   >>> index
#   >>> tensor([[ 2,  1,  0],
#   >>>         [ 2,  1,  0],
#   >>>         [ 0, -1, -2]])
index = index * seq_mask
#   >>> index
#   >>> tensor([[2, 1, 0],
#   >>>         [2, 1, 0],
#   >>>         [0, 0, 0]])
r_hyps = torch.gather(r_hyps, 1, index)
#   >>> r_hyps
#   >>> tensor([[3, 2, 1],
#   >>>         [4, 8, 9],
#   >>>         [2, 2, 2]])
r_hyps = torch.where(seq_mask, r_hyps, self.eos)
#   >>> r_hyps
#   >>> tensor([[3, 2, 1],
#   >>>         [4, 8, 9],
#   >>>         [2, eos, eos]])
r_hyps = torch.cat([hyps[:, 0:1], r_hyps], dim=1)
#   >>> r_hyps
#   >>> tensor([[sos, 3, 2, 1],
#   >>>         [sos, 4, 8, 9],
#   >>>         [sos, 2, eos, eos]])

超参数读写

通过ONNX的metadata接口,实现将超参数全部存入ONNX模型。

# wenet/wenet/bin/export_onnx_cpu.py
# 写入(python版本)
onnx_encoder = onnx.load(encoder_outpath)
for (k, v) in args.items():
    meta = onnx_encoder.metadata_props.add()
    meta.key, meta.value = str(k), str(v)
onnx.save(onnx_encoder, encoder_outpath)

# python版本的读取
ort_session = onnxruntime.InferenceSession(encoder_outpath)
meta = ort_session.get_modelmeta()
print("\t\tcustom_metadata_map={}".format(meta.custom_metadata_map))

// cpp版本的读取
// wenet/runtime/core/decoder/onnx_asr_model.cc
auto model_metadata = encoder_session_->GetModelMetadata();
Ort::AllocatorWithDefaultOptions allocator;
encoder_output_size_ = std::move(
  atoi(model_metadata.LookupCustomMetadataMap("output_size", allocator)));

其它

  • 通过tracing追踪模型,如果模型传入的tensor没有被使用,导出的模型将会认为不会输入该参数,如果后续输入该参数将会导致报错。
  • ONNX不支持tensor转bool的操作,训练时python脚本中大量的assert将无法使用。

ONNX推理

ONNX Runtime是由微软维护的跨平台机器学习推理加速器,也即”推理引擎“,可实现Pytorch->ONNX->ONNX Runtime这条部署流水线。WeNet ONNX推理流程为:加载模型和超参数->初始化cache->encoder推理->CTC推理->attention rescoring推理。

onnx_asr_modeltorch_asr_model均继承自asr_modelasr_model中定义了Reset()ForwardEncoderFunc()AttentionRescoring()三个纯虚函数:

  • Reset()实现了offset_att_cache_等cache的初始化。
  • ForwardEncoderFunc()包含了encoder和CTC推理。
  • AttentionRescoring()对识别结果做重打分。

[C++ ONNX Runtime APIs]均定义在Ort命名空间下。

ONNX线程数的配置

ONNX默认采用多核加速解码,设置ONNX线程数的代码为:

// wenet/runtime/core/decoder/onnx_asr_model.cc
Ort::SessionOptions OnnxAsrModel::session_options_ = Ort::SessionOptions();
...
// 会话线程数的配置
session_options_.SetIntraOpNumThreads(num_threads);
session_options_.SetInterOpNumThreads(num_threads);
...
// 使用配置启动会话
encoder_session_ = std::make_shared<Ort::Session>(
    env_, encoder_onnx_path.c_str(), session_options_);

编码器的入参个数不确定

由于导出ONNX时,存在不同的chunk_size/num_decoding_left_chunks配置,此时ONNX会自动优化掉无用参数,这将导致模型encoder.onnx的入参不一样。具体来说,当使用16/-1-1/-116/0时,next_cache_start将会被ONNX硬编码为0或chunk_size,因此不再需要required_cache_sizeatt_mask,这两个参数也将会被ONNX自动移除。

由于编码器的入参会发生变化,对于encoder.onnx,会先获取输入参数名列表,在准备编码器的输入时,根据参数名列表,挑选相应变量作为输入。而对于编码器的输出、CTC和解码器的输入和输出,也全部采用从模型读取参数名列表的方式,避免手工定义参数名列表:

// wenet/runtime/core/decoder/onnx_asr_model.cc
//根据encoder_in_names_准备输入
std::vector<Ort::Value> inputs;
for (auto name : encoder_in_names_) {
if (!strcmp(name, "chunk")) {
  inputs.emplace_back(std::move(feats_ort));
} else if (!strcmp(name, "offset")) {
  inputs.emplace_back(std::move(offset_ort));
} else if (!strcmp(name, "required_cache_size")) {
  inputs.emplace_back(std::move(required_cache_size_ort));
} else if (!strcmp(name, "att_cache")) {
  inputs.emplace_back(std::move(att_cache_ort_));
} else if (!strcmp(name, "cnn_cache")) {
  inputs.emplace_back(std::move(cnn_cache_ort_));
} else if (!strcmp(name, "att_mask")) {
  inputs.emplace_back(std::move(att_mask_ort));
}
}

int类型参数

在runtime阶段,构造int类型的张量需要进行特殊处理。创建张量的CreateTensor()函数签名为:

1
2
3
4
5
static Value Ort::Value::CreateTensor(const OrtMemoryInfo * info,
                                    T * p_data,
                                    size_t  p_data_element_count,
                                    const int64_t * shape,
                                    size_t  shape_len)

其中:

  • info:用户缓冲区所在的内存描述,比如CPU或GPU。
  • p_data:指向用户提供的缓冲区指针。
  • p_data_element_count:用户缓冲区的元素个数。
  • shape:用户缓冲区的张量大小。
  • shape_len:张量大小shape的维度个数。

在构造int类型的张量时,CreateTensor()函数里shapeshape_len两个形参应分别传入空指针和0:

// wenet/runtime/core/decoder/onnx_asr_model.cc
// 一般张量的构造
// chunk
const int64_t feats_shape[3] = {1, num_frames, feature_dim};
  Ort::Value feats_ort = Ort::Value::CreateTensor<float>(
      memory_info, feats.data(), feats.size(), feats_shape, 3);
// int类型张量的构造
// offset
int64_t offset_int64 = static_cast<int64_t>(offset_);
Ort::Value offset_ort = Ort::Value::CreateTensor<int64_t>(
  memory_info, &offset_int64, 1, std::vector<int64_t>{}.data(), 0);

Ort环境变量

Ort环境变量保存着其它对象使用的日志状态,必须在使用ONNXRuntime的其它函数之前创建好环境变量,跨线程共享环境,并且应将其设置为全局变量。

1
2
3
4
5
6
7
8
// sessions
// NOTE(Mddct): The Env holds the logging state used by all other objects.
//  One Env must be created before using any other Onnxruntime functionality.
static Ort::Env env_;  // shared environment across threads.
static Ort::SessionOptions session_options_;
std::shared_ptr<Ort::Session> encoder_session_ = nullptr;
std::shared_ptr<Ort::Session> rescore_session_ = nullptr;
std::shared_ptr<Ort::Session> ctc_session_ = nullptr;

全局变量和局部变量

用于构造att_cache_ort_att_cache_应设置为全局变量,这是因为ONNXRuntime在构建张量att_cache_ort_时不会对att_cache_里面的数据进行拷贝,而只是维护了指向att_cache_的指针,如果在Reset()函数中将att_cache_声明为局部变量,并用于构造att_cache_ort_,在识别时会出现运行时突然崩溃的现象,主要原因是att_cache_作为局部变量,内存会被系统回收,cnn_cache_声明为全局变量的原因类似。

att_mask_ort需要设置成局部变量主要有三个原因: - att_mask_ort需根据offset_动态设置元素的值。 - 构造编码器的输入时会通过std::moveatt_mask_ort清空。 - Reset()函数不需对att_mask_ort进行初始化。

常用函数

  1. Ort::AllocatorWithDefaultOptions

内存分配接口,可用于用户自定义内存分配器。在销毁内存分配器之前,必须确保使用该分配器的对象已经全部被销毁。

OnnxRuntime: OrtAllocator Struct Reference OnnxRuntime: Ort::AllocatorWithDefaultOptions Struct Reference

  1. session->GetOutputName(i, allocator)

获取模型输出节点名称。

  • 第一个入参i类型为int,表示输出节点的序号。
  • 第二个入参allocator定义内存分配器,可用于用户自定义内存分配器。

  • std::make_shared<Ort::Session>(env_, encoder_onnx_path.c_str(), session_options_)

创建会话对象,和Tensorflow 1.x类似,只有会话对象才可以执行模型推理。

  • env_类型为Ort::Env,持有所有对象的日志记录状态,在使用任何ONNXRuntime之前必须先创建一个Ort::Env
  • encoder_onnx_path.c_str()类型为const char *,模型路径。
  • session_options_类型为Ort::SessionOptions,用于创建Session对象的Options对象。

OnnxRuntime: Ort::Session Struct Reference

  1. encoder_session_->Run(Ort::RunOptions{nullptr}, encoder_in_names_.data(), inputs.data(), inputs.size(), encoder_out_names_.data(), encoder_out_names_.size())

会话对象执行模型推理。

  • Ort::RunOptions{nullptr},运行配置。
  • encoder_in_names_.data()类型为const char *const *,C风格字符串数组,输入节点名称。
  • inputs.data()类型为const T *,输入数据。
  • encoder_out_names_.data()类型为const char *const *,C风格字符串数组,输出节点名称。
  • encoder_out_names_.size()类型为size_t,输出节点名称的个数。

OnnxRuntime: Ort::Session Struct Reference

  1. ctc_ort_outputs[0].GetTensorMutableData<float>();

获取张量内部原始数据的指针,用于直接读取、写入、修改张量的数据,返回的指针在张量销毁前均有效。

Attention Rescore原理

将CTC解码结果作为目标值,送入解码器中进行计算,解码器输出正向和逆向的softmax得分,作为正向和逆向解码器的AttentionScore,计算得到最终的rescoring_score

# wenet/wenet/bin/export_onnx_cpu.py::export_decoder
# 将解码器的forward()函数替换为对应torch.jit版本的forward_attention_decoder()
decoder.forward = decoder.forward_attention_decoder

# wenet/wenet/transformer/asr_model.py::forward_attention_decoder()
# 将解码器的log_softmax结果作为score输出
decoder_out, r_decoder_out, _ = self.decoder(
    encoder_out, encoder_mask, hyps, hyps_lens, r_hyps,
    reverse_weight)  # (num_hyps, max_hyps_len, vocab_size)
decoder_out = torch.nn.functional.log_softmax(decoder_out, dim=-1)
# right to left decoder may be not used during decoding process,
# which depends on reverse_weight param.
# r_dccoder_out will be 0.0, if reverse_weight is 0.0
r_decoder_out = torch.nn.functional.log_softmax(r_decoder_out, dim=-1)
return decoder_out, r_decoder_out

# wenet/wenet/transformer/asr_model.py::BiTransformerDecoder::forward()
# 第三个入参实际是已填充的目标文本序列
def forward(
    self,
    memory: torch.Tensor,
    memory_mask: torch.Tensor,
    ys_in_pad: torch.Tensor,
    ys_in_lens: torch.Tensor,
    r_ys_in_pad: torch.Tensor,
    reverse_weight: float = 0.0,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
...

runtime调用:

// wenet/runtime/core/decoder/onnx_asr_model.cc::OnnxAsrModel::AttentionRescoring()

// 送入解码器作为target的张量实际就是CTC解码结果
Ort::Value hyps_pad_tensor_ = Ort::Value::CreateTensor<int64_t>(
  memory_info, hyps_pad.data(), hyps_pad.size(), hyps_pad_shape, 2);
...
rescore_inputs.emplace_back(std::move(hyps_lens_tensor_));
...
// 启动会话,执行解码器推理出正向和逆向的attention score
std::vector<Ort::Value> rescore_outputs = rescore_session_->Run(
  Ort::RunOptions{nullptr}, rescore_in_names_.data(), rescore_inputs.data(),
  rescore_inputs.size(), rescore_out_names_.data(),
  rescore_out_names_.size());

float* decoder_outs_data = rescore_outputs[0].GetTensorMutableData<float>();
float* r_decoder_outs_data = rescore_outputs[1].GetTensorMutableData<float>();
...
// 利用CTC解码结果和Attention解码器计算出rescoring_score
// combined left-to-right and right-to-left score
(*rescoring_score)[i] =
    score * (1 - reverse_weight) + r_score * reverse_weight;

作业帮:基于 WeNet + ONNX 的端到端语音识别方案 虎牙在 WeNet 中开源 ONNX 推理支持 论如何优雅地在 WeNet 中支持 ONNX 导出 模型部署入门教程(五):ONNX 模型的修改与调试 - 知乎 torch.onnx — PyTorch master documentation 模型部署入门教程(三):PyTorch 转 ONNX 详解 - 知乎 PyTorch (可选)将模型从 PyTorch 导出到 ONNX 并使用 ONNX Runtime 运行_w3cschool

tag:: #TODO


最后更新: 2022-06-08

评论