Ascend-SACT/chronos-forecasting-310p
模型介绍文件和版本Pull Requests讨论分析
下载使用量0

模型概述及场景

Chronos 是 Amazon Science 推出的预训练时间序列预测模型家族,支持零样本(zero-shot)时间序列预测任务。Chronos 模型将时间序列视为一种"语言",通过缩放和量化将时间序列转换为 token 序列,利用语言模型架构进行训练和推理。当前支持三种模型类型:

  • Chronos-2:最新模型,支持单变量、多变量和协变量预测,在多个基准测试上取得零样本最佳性能
  • Chronos-Bolt:基于 patch 的变体,比原始 Chronos 快 250 倍、内存效率提升 20 倍
  • Chronos (T5):原始基于语言模型架构的模型,通过交叉熵损失训练

该模型在客户侧有广泛的时间序列预测需求,主要使用场景包含电力负荷预测、商品销量预测、金融指标预测、运维指标监控等时序预测任务,需要在昇腾 310P 上进行推理部署。本文档在昇腾 310P 上基于 torch_npu 完成模型的适配和推理工作。

准备运行环境

配套版本环境准备指导
Python3.10+-
torch2.7.1-
torch_npu2.7.1.post2-
CANN8.2.RC2-
transformers4.41+-
accelerate0.34+-

1.1 环境准备

设备支持

Atlas 300I/500 智能加速卡(310P 芯片)

部署卡类型信息:310P3

部署方式:单卡推理

操作系统:ARM / x86_64

1.2 Docker 环境配置

使用支持昇腾 NPU 的 Docker 镜像,启动命令如下:

docker run -it -u root -d --net=host \
  --privileged \
  --ipc=host \
  --device=/dev/davinci_manager \
  --device=/dev/devmm_svm \
  --device=/dev/hisi_hdc \
  -v /usr/local/Ascend/driver:/usr/local/Ascend/driver \
  -v /usr/local/dcmi:/usr/local/dcmi \
  -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \
  -v /usr/local/sbin:/usr/local/sbin \
  -v /etc/hccn.conf:/etc/hccn.conf:ro \
  --name chronos_npu \
  quay.io/ascend/vllm-ascend:v0.18.0-310p-openeuler \
  /bin/bash

运行指导

2.1 获取 Chronos 源代码

从 GitHub 克隆 Chronos 仓库:

git clone https://github.com/amazon-science/chronos-forecasting.git

注意:本文档基于已适配 310P 的代码版本,确保获取的代码包含 310P 适配修改。如使用原始上游代码,需手动应用 310P 适配补丁(参见 2.3 节)。

2.2 安装依赖包

cd chronos-forecasting

# 安装 Chronos 及其依赖
pip install -e .

# 安装额外依赖(用于 DataFrame 预测接口)
pip install 'pandas[pyarrow]>=2.0,<2.4'
pip install matplotlib

核心依赖说明:

依赖包版本要求作用
torch>=2.2,<3PyTorch 深度学习框架
torch_npu2.7.1.post2昇腾 NPU 适配层
transformers>=4.41,<5HuggingFace 模型加载
accelerate>=0.34,<2模型设备管理和分布式支持
einops>=0.7.0,<1张量操作工具
numpy>=1.21,<3数值计算
scikit-learn>=1.6.0,<2机器学习工具
pandas>=2.0,<2.4DataFrame 数据处理

下载预训练模型

Chronos 模型托管在 HuggingFace,推理时会自动下载。也可手动下载到本地:

模型 ID参数量适用场景
amazon/chronos-2120M综合预测(推荐)

手动下载示例:

# 使用 魔塔社区 下载模型
modelscope download --model amazon/chronos-2

在运行推理之前,确认 NPU 环境正常:

# 检查 NPU 设备状态
npu-smi info

# 验证 torch_npu 安装
python -c "import torch; import torch_npu; print('NPU available:', torch.npu.is_available()); print('Device:', torch.npu.get_device_name(0))"

预期输出:

NPU available: True
Device: Ascend310P3

2.3 应用 NPU 适配补丁

Chronos 原生不支持昇腾 310P NPU,需要应用适配补丁。适配修改涉及 4 个文件,核心改动量为 3 个文件,16 行插入,6 行删除(git diff 实测),外加新增 is_ascend_310p() 设备检测函数(22 行)。

# 应用 310P 适配修改
cd chronos-forecasting
git apply 310p-ascend-compat.patch

2.3.1 适配背景:310P 与 910B 的关键硬件差异

310P 是昇腾推理卡,910B 是昇腾训练卡。两者的硬件架构差异导致了适配需求:

特性Ascend 910B3(训练卡)Ascend 310P(推理卡)影响
bfloat16 (bf16)✅ 硬件原生支持❌ 硬件不支持所有 bf16 算子触发 CPU fallback
float16 (fp16)✅ 支持✅ 支持310P 可用 fp16 替代 bf16
float32 (fp32)✅ 支持✅ 支持无影响
FlashAttention✅ 有 aclnnFlashAttention 内核❌ 缺少硬件支持SDPA 会回退或报错
nanmean 算子✅ 有 NPU 内核❌ 无 NPU 内核CPU fallback
nan_to_num 算子✅ 有 NPU 内核⚠️ 可能缺内核(取决于 CANN 版本)可能 CPU fallback

关键发现:310P 不支持 bfloat16 是所有报错的根本原因。在 910B 上一切正常,但在 310P 上:

  • 所有 bf16 算子(包括 relu、isnan、softmax、matmul、layer_norm 等看似"没问题"的基础算子)都会触发 CPU fallback
  • CPU fallback 导致 tensor 反复在 NPU → CPU → NPU 之间搬运,每个算子约增加 0.5~1ms
  • 整条推理路径上的数十个算子全部 fallback,导致推理耗时暴增、精度偏差

torch_npu 的 _op_plugin_docs.py 明确记录了这一限制:

Atlas 训练系列产品: 数据类型支持float16、float32.           ← 910B
Atlas A2 训练系列产品: 数据类型支持float16、float32、bfloat16. ← 910B 新版
Atlas 推理系列产品: 数据类型仅支持float16、float32.           ← 310P,⚠️ 无 bfloat16

因此,310P 适配的首要步骤是将模型 dtype 从 torch.bfloat16 改为 torch.float16,这一步解决了约 80% 的报错。剩余的专项问题(SDPA、nanmean、nan_to_num)则需要代码层面的补丁处理。

2.3.2 关键修改详解

修改 1:is_ascend_310p() 设备检测函数(src/chronos/utils.py,新增 22 行)

修改内容:新增 is_ascend_310p() 函数,用于检测当前设备是否为昇腾 310P 芯片。

def is_ascend_310p(device: torch.device | None = None) -> bool:
    """Check if we're running on Huawei Ascend 310P chip."""
    try:
        if hasattr(torch, "npu") and torch.npu.is_available():
            if device is not None and device.type != "npu":
                return False
            device_idx = (device.index if device is not None else None) or torch.npu.current_device()
            device_name = torch.npu.get_device_name(device_idx)
            return "310P" in device_name.upper()
    except Exception:
        pass
    return False

修改 2:InstanceNorm 中 nanmean/nan_to_num 替换(src/chronos/chronos_bolt.py,+12 行 -2 行)

修改内容:在 InstanceNorm.forward() 的 loc_scale is None 分支中,310P 上使用 isnan + masked sum/divide 替代 nanmean,使用 isnan + where 替代 nan_to_num。

# ====== 修改前(原始代码,910B 上正常)======
loc = torch.nan_to_num(torch.nanmean(x, dim=-1, keepdim=True), nan=0.0)
scale = torch.nan_to_num((x - loc).square().nanmean(dim=-1, keepdim=True).sqrt(), nan=1.0)

# ====== 修改后(310P 适配)======
if is_ascend_310p(x.device):
    # 310P 专用路径
    mask = ~torch.isnan(x)
    mask_sum = mask.sum(dim=-1, keepdim=True).to(x.dtype)
    loc = torch.where(mask, x, torch.zeros_like(x)).sum(dim=-1, keepdim=True) / mask_sum.clamp(min=1.0)
    loc = torch.where(mask_sum == 0, 0.0, loc)
    scale = torch.sqrt(
        torch.where(mask, (x - loc).square(), torch.zeros_like(x)).sum(dim=-1, keepdim=True)
        / mask_sum.clamp(min=1.0)
    )
    scale = torch.where(mask_sum == 0, 1.0, torch.where(scale == 0, self.eps, scale))
else:
    # 标准路径(910B/GPU 使用)
    loc = torch.nan_to_num(torch.nanmean(x, dim=-1, keepdim=True), nan=0.0)
    scale = torch.nan_to_num((x - loc).square().nanmean(dim=-1, keepdim=True).sqrt(), nan=1.0)

修改 3:注意力实现从 SDPA 切换为 eager(src/chronos/chronos2/config.py,+3 行 -2 行)

修改内容:在 Chronos2CoreConfig.__init__() 中,310P 上将默认注意力实现从 "sdpa" 切换为 "eager"。

# ====== 修改前(原始代码)======
# Attention implementation - default to "sdpa" if not specified
attn_implementation = attn_implementation or "sdpa"

# ====== 修改后(310P 适配)======
# Attention implementation - default to "eager" on Ascend NPU (310P lacks
# FlashAttention hardware; "sdpa" may fall back or error), otherwise "sdpa"
attn_implementation = attn_implementation or ("eager" if is_ascend_310p() else "sdpa")

修改 4:nan_to_num 替换为 where(isnan)(src/chronos/chronos2/model.py,+3 行 -2 行)

修改内容:在 _prepare_patched_context() 中,310P 上使用 torch.where(torch.isnan(...)) 替代 torch.nan_to_num。

# ====== 修改前(原始代码)======
patched_mask = torch.nan_to_num(self.patch(context_mask), nan=0.0)

# ====== 修改后(310P 适配)======
patched_mask_raw = self.patch(context_mask)
if is_ascend_310p(context.device):
    patched_mask = torch.where(torch.isnan(patched_mask_raw), 0.0, patched_mask_raw)
else:
    patched_mask = torch.nan_to_num(patched_mask_raw, nan=0.0)

2.3.3 适配逻辑设计原则

所有 310P 适配遵循统一的条件分支模式:

if is_ascend_310p(device):
    # 310P 专用路径:使用有 NPU 算子支持的替代操作
    result = alternative_operation(x)
else:
    # 标准路径:保留原始高性能实现(910B/GPU 使用)
    result = original_operation(x)

这种设计确保:

方面910B / GPU310P
执行路径原始最优路径NPU 兼容替代路径
算子支持全部有硬件内核替代算子有 NPU 内核
性能最优(FlashAttention等)略有代价(eager attention)
精度原始精度与原始路径等价或差异极小
代码侵入性零侵入(is_ascend_310p() 返回 False)仅在运行时切换
  • 310P 上使用兼容的替代算子,避免 CPU 回退导致的性能损失和精度偏差
  • 其他设备(GPU、910B 等)继续使用原始最优路径,不受任何影响
  • 适配逻辑仅在运行时生效,无需修改模型配置或推理脚本
  • 代码改动量极小:仅 16 行新增、6 行删除,加上 22 行新增检测函数

2.3.4 dtype 适配:bf16 → fp16

除了代码层面的补丁,310P 推理还需要将模型 dtype 从 torch.bfloat16 改为 torch.float16。

310P 硬件不支持 bfloat16 计算,所有 bf16 算子触发 CPU fallback。将 dtype 改为 fp16 后:

算子310P bf16310P fp16说明
relu❌ CPU fallback✅ NPU 原生bf16 是根因,算子本身没问题
isnan❌ CPU fallback✅ NPU 原生同上
softmax❌ CPU fallback✅ NPU 原生同上
matmul❌ CPU fallback✅ NPU 原生同上
layer_norm❌ CPU fallback✅ NPU 原生同上

推理脚本中的修改:

# 修改前(910B3 默认)
pipeline = Chronos2Pipeline.from_pretrained(model_path, device_map=str(device), dtype=torch.bfloat16)

# 修改后(310P 适配)
pipeline = Chronos2Pipeline.from_pretrained(model_path, device_map=str(device), dtype=torch.float16)

注:fp16 与 bf16 的精度差异对 Chronos 推理影响极小。bf16 有 8 位指数/7 位尾数,fp16 有 5 位指数/10 位尾数。fp16 的尾数精度更高(10 位 vs 7 位),但指数范围更小。对于时序预测中的数值范围(典型 |x| < 100),fp16 的精度完全足够,实测相对差异 < 0.05%。

2.4 精度与性能验证

2.4.1 精度对比验证

使用 Chronos-2 模型在 CPU 和 310P NPU 上进行推理,对比 10 组不同输入的输出精度:

测试编号输入形状最大绝对差异平均绝对差异平均相对差异
#0[4, 1, 1024]4.630×10⁻⁴6.686×10⁻⁵0.014%
#1[4, 1, 1024]5.860×10⁻⁴9.177×10⁻⁵0.029%
#2[4, 1, 1024]7.260×10⁻⁴1.844×10⁻⁴0.049%
#3[4, 1, 1024]6.995×10⁻⁴8.775×10⁻⁵0.031%
#4[4, 1, 1024]4.957×10⁻⁴5.806×10⁻⁵0.010%
#5[4, 1, 1024]4.416×10⁻⁴6.309×10⁻⁵0.024%
#6[4, 1, 1024]1.299×10⁻³2.994×10⁻⁴0.161%
#7[4, 1, 1024]8.414×10⁻⁴1.149×10⁻⁴0.028%
#8[4, 1, 1024]6.752×10⁻⁴1.550×10⁻⁴0.041%
#9[4, 1, 1024]4.792×10⁻⁴7.579×10⁻⁵0.062%

汇总统计:

  • 最大绝对差异:1.299×10⁻³(仅 #6 一组,其余均 < 1×10⁻³)
  • 平均绝对差异:大部分在 6×10⁻⁵ ~ 2×10⁻⁴ 之间
  • 平均相对差异:大部分 < 0.05%,最大 0.161%(#6)

精度结论:

310P(eager attention + fp16)与 CPU(sdpa + fp32)的推理输出差异极小,平均相对差异 < 0.05%,完全满足时序预测的精度要求。差异来源主要为:

  1. fp16 vs fp32 的浮点截断:310P 推理使用 fp16,CPU 推理使用 fp32,fp16 的 10 位尾数在累加运算中会引入微小截断误差
  2. eager vs sdpa 的注意力实现差异:eager 显式计算完整注意力矩阵,sdpa(FlashAttention)使用分块累加,浮点累加顺序不同导致微小差异
  3. NPU 算子的实现细节:昇腾 NPU 的 fp16 算子(softmax、matmul 等)内部累加精度可能与 CPU 实现略有不同

这些差异均在浮点精度范围内,对实际预测结果无影响。

与 910B 方案的精度对比:

在 910B 上使用 sdpa + bf16 推理,与在 310P 上使用 eager + fp16 推理,两者与 CPU(fp32)的精度差异量级相当(均在 10⁻⁴ ~ 10⁻³ 级别),差异来源相同(硬件浮点实现、注意力实现路径)。310P 的精度不逊于 910B。

2.4.2 性能基准测试

单次推理延迟对比(Chronos-2 模型,输入 shape [4, 1, 1024],预测 24 步)

平台平均延迟P50 延迟P99 延迟相对 CPU 加速
CPU1380.38 ms1379.07 ms1408.91 ms1x(基准)
310P NPU41.45 ms40.99 ms43.22 ms33.30x

310P NPU 相比 CPU 推理实现了 33.3 倍加速,单次推理延迟从 1.38 秒降至 41ms。

与 910B 方案的 性能对比:

910B 使用 FlashAttention(sdpa),注意力计算部分比 310P 的 eager 实现快约 1.5~2 倍。但由于注意力计算仅占整体推理的一小部分(Chronos-2 的主要计算在 embedding + decoder 层),整体推理延迟差异约 10%~20%。310P 的 41ms 延迟对于实时时序预测场景完全可接受。

吞吐量基准(310P 单卡,Chronos-2 模型,context_length=128,预测 24 步)

配置平均延迟P50 延迟P99 延迟吞吐量
b1_h128_p2444.82 ms44.81 ms47.02 ms22.3 samples/s
b4_h128_p2448.19 ms49.47 ms51.43 ms83.0 samples/s
b8_h128_p2446.96 ms46.15 ms51.43 ms170.4 samples/s
b16_h128_p2449.61 ms50.71 ms54.14 ms322.5 samples/s
b32_h128_p2449.07 ms49.07 ms49.95 ms652.1 samples/s
b64_h128_p2460.48 ms58.68 ms66.12 ms1058.2 samples/s

关键发现:

  • batch_size 从 1 增加到 64 时,吞吐量从 22.3 提升到 1058.2 samples/s,约 47 倍提升
  • 延迟增长平缓:b1→b64 仅从 44.82ms → 60.48ms,说明 310P 的 NPU 计算单元在批量推理时利用效率高
  • b64 配置下吞吐量超过 1000 samples/s,满足大多数实时时序预测场景的需求

已在昇腾 NPU 上完成了基础适配(dtype 切换 + 缺失算子替代)。但原始 pipeline.predict 在 310P 上以 eager 模式运行时,每个算子独立调度——132 个算子各自经历一次 CPU→NPU 下发和同步,调度开销远超计算本身。本项目在此基础上通过 GE (Graph Engine) 图编译实现整图一次提交、算子融合和冗余消除,进一步压缩推理延迟。

核心挑战

挑战根因影响
NPU eager 调度开销132 个算子独立 dispatch,NPU 实际计算仅 ~5.8ms 但总延迟 44.26ms调度开销占 NPU 推理时间的 81%
GE Converter 缺失unfold/isnan/asinh/sinh 无 ATen→GE 翻译器torch.compile(backend='npu') 编译中断
Graph break(图断裂)if xxx is not None、torch.arange 等条件分支和动态操作将计算图拆为碎片图碎片间仍需独立调度,抵消整图优化效果
Python 预处理开销Chronos2Dataset + DataLoader 逐序列 Python 循环预处理耗时 2.99ms,占端到端延迟 6.3%

解决方案

1、GE 图融合:torch.compile(backend='npu', dynamic=False) 将整个 model.forward() 编译为单一 GE 计算图,132 次 CPU→NPU dispatch 变为 1 次,并自动消除冗余 Transpose(508→极少)、融合算子模式(Mul+Add、LayerNorm+Residual 等)。

2、Converter 补齐:6 个 monkey-patch 将 GE converter 缺失的算子替换为数学等价替代——isnan→x!=x、asinh→log(x+sqrt(x²+1))、sinh→(exp(x)-exp(-x))/2、nanmean→where+sum+div、nan_to_num→where(x!=x,nan,x)、unfold→reshape。

3、Graph Break 消除:去除 model.forward 中的条件分支和动态操作——创建推理专用 forward(移除 8 个可选参数)、去掉 is_ascend_310p 条件分支、去掉 torch.autocast 改为显式 .float()、将 torch.arange 预计算为 model.register_buffer。

4、Python 预处理优化:绕过 Chronos2Dataset + DataLoader,用纯 tensor 操作完成输入格式统一和形状适配,预处理开销从 ~2.99ms 降至 ~0.36ms。

优化效果

实测数据(batch=4, seq_len=128, pred_len=24, 50 次平均):

指标原始 pipeline优化 pipeline变化
端到端总延迟47.25ms10.49ms4.5x 加速
CPU 预处理2.99ms0.36ms省 2.63ms
NPU 推理44.26ms(eager)10.13ms(compiled)省 34.13ms

各根因对应的效果:

根因优化方法效果
根因 1: NPU eager 调度开销GE 图融合NPU 推理从 44.26ms 降至 10.13ms,节省 34.13ms(占总优化的 92.8%)
根因 2: Python 预处理开销绕过预处理管线预处理从 2.99ms 降至 0.36ms,节省 2.63ms(占总优化的 7.2%)

问题解决方案与分析过程

适配设计原则

# 全局 converter 补齐 — 必须在 import chronos 之前执行
import torch_npu
from compile_npu_accel import apply_compile_patches
apply_compile_patches()

# compile_pipeline — 一次性初始化,自动替换 pipeline.predict 为优化路径
from compile_npu_accel import compile_pipeline
result = compile_pipeline(pipeline, batch_size=4, seq_len=128)
pipeline = result['pipeline']

# 推理 — 完全是原生 API,一行不变
output = pipeline.predict(inputs, prediction_length=24)

所有优化对客户透明:3 步初始化后 pipeline.predict(...) 完全不变。不支持协变量等场景时自动 fallback 到 _predict_original()。

阶段一、根因定位:Eager 模式调度开销

在 310P 上以 eager 模式运行原始 pipeline.predict(batch=4, seq_len=128, pred_len=24),测得:

指标原始 pipeline.predict
端到端总延迟47.25ms
CPU 预处理2.99ms
NPU 推理(eager 模式)44.26ms

核心发现:总延迟 47.25ms 中,NPU eager 模式推理占了 93.6%(44.26ms),但从 profiling 数据看到 NPU 的实际计算量只有约 5.8ms。这意味着 38.46ms 是调度开销而非计算开销 — 每个算子需要一次独立的 CPU dispatch + NPU kernel launch。

NPU 推理的子阶段分析:

子阶段主要算子问题
InstanceNorm(normalize)isnan, nanmean, 条件分支条件分支 → graph break;isnan/nanmean 无 GE converter → CPU 回退
Patch(分块)unfoldunfold 无 GE converter → CPU 回退
时间编码torch.arange动态常量 → graph break
Embeddinglinear(129 次独立调度)本可融合
Encoder(Attention)scaled_dot_product_attention, matmul, softmax(230 次调度)24 层 attention 各自独立调度
InstanceNorm.inversesinh, 条件分支sinh 无 GE converter;条件分支 → graph break

Profiling 算子级分析:Top-10 耗时算子(场景 A,原始 pipeline):

算子CPU 时间 (ms)调用次数
aten::matmul12.06182
aten::clone9.43272
aten::contiguous8.95241
format_contiguousV28.58481
aten::linear7.94129
empty_tensor5.021235
aten::mul4.75135
cached_contiguous_d_Transpose4.62206
aten::add3.71112
aten::mm3.59129

算子总数:122 个,CPU 总时间 114.15ms。 linear 被调用 129 次、matmul 182 次——这些本应被融合为少量复合算子,但在 eager 模式下 GE 引擎无法跨算子优化。112+129+182=423 次独立调度的 overhead 累积成了瓶颈。

如上图所示,模型推理时大量的算子调度导致 NPU 的资源无法充分使用,模型的真正计算时间只有 13.2%。

瓶颈根因总结:

排名根因耗时占比机制
1NPU eager 模式调度开销38.46ms81%132 个算子独立调度,无法融合。torch.compile 将全部算子融合为 1 个 GE 图
2Python 预处理开销2.99ms6.3%Chronos2Dataset + DataLoader 逐序列 Python 处理

核心结论:瓶颈不是"算得慢",而是"调度浪费" — 132 次独立 dispatch 才完成一次推理。优化方向不是让单个算子更快,而是让它们不再需要被单独调度。

阶段二、专项适配:针对根因的优化方法

根因 1:NPU eager 模式调度开销 → GE 图融合

原理:torch.compile(backend='npu', dynamic=False) 将整个 model.forward() 编译为一个完整的 GE 计算图:

  • 算子融合:linear + reshape + add 等连续算子合并为一个复合算子,消除中间 tensor 的存储和读取开销
  • 调度消除:整个 model.forward() 被编译为一次 GE 图执行,132 次 CPU→NPU dispatch 变为 1 次
  • 内存优化:GE 引擎在融合算子间进行 UB(Unified Buffer)融合,减少 HBM 访问次数

GE 图融合的前提是 model.forward() 必须能被 torch.compile tracing 为单一完整计算图。因此,消除 graph break 和补齐 converter 是 GE 图融合的前提。

修改 1:全局 converter 补齐(Patch 1)

昇腾 310P GE 引擎缺少以下算子的 converter:

缺失 converter 的算子等价替代替代原理
torch.isnan(x != x)IEEE 754 中 NaN 是唯一不等于自身的值,本质是 aten::ne,有 GE converter
torch.asinh/arcsinhlog(x + sqrt(x² + 1))数学恒等式:arcsinh(x) = ln(x + √(x²+1))
torch.sinh(exp(x) - exp(-x)) / 2数学恒等式:sinh(x) = (eˣ - e⁻ˣ)/2
torch.nanmeanwhere(~isnan, x, 0).sum / mask_sumNaN 安全均值 = 有效元素求和 / 有效元素计数
torch.nan_to_numwhere(x != x, nan_value, x)NaN 替换 = 是 NaN 则替换,否则保留,本质是 aten::where
torch.unfoldreshape(当 stride == size)unfold 在 stride 等于 size 时与 reshape 数学等价
.square()dev * dev逐元素平方 = 逐元素乘法,mul 有 GE converter

在 import chronos 之前执行(compile_npu_accel.py):

def apply_patches():
    # isnan → (x != x)
    torch.isnan = lambda x: x != x
    torch.Tensor.isnan = lambda self: self != self

    # asinh/arcsinh → log(x + sqrt(x²+1))
    _asinh = lambda x: torch.log(x + torch.sqrt(x ** 2 + 1))
    torch.asinh = torch.arcsinh = _asinh

    # sinh → (exp(x) - exp(-x)) / 2
    _sinh = lambda x: (torch.exp(x) - torch.exp(-x)) / 2
    torch.sinh = torch.Tensor.sinh = _sinh

    # is_ascend_310p → 强制 True(消除条件分支)
    chronos.utils.is_ascend_310p = lambda device=None: True

    # nanmean → where + sum + div
    torch.nanmean = lambda x, dim=None, keepdim=False, **kw: (
        torch.where(~(x != x), x, torch.zeros_like(x)).sum(dim=dim, keepdim=keepdim)
        / (~(x != x)).sum(dim=dim, keepdim=keepdim).to(x.dtype).clamp(min=1.0))

    # nan_to_num → where(x != x, nan_value, x)
    torch.nan_to_num = lambda x, nan=0.0, **kw: torch.where(x != x, float(nan), x)

另外 Patch.forward 也在此处替换:

# unfold → reshape
import chronos.chronos2.model as model_module
model_module.Patch.forward = patched_patch_forward

修改 2:InstanceNorm 去条件分支(Patch 2)

代码位于 compile_npu_accel.py。对模型中的每个 InstanceNorm 实例,将 forward 和 inverse 替换为 patched 版本:

for name, mod in model.named_modules():
    if isinstance(mod, bolt_module.InstanceNorm):
        mod.forward = types.MethodType(patched_instance_norm_forward, mod)
        mod.inverse = types.MethodType(patched_instance_norm_inverse, mod)

patched forward 的关键变化:

  • 去掉 if is_ascend_310p: 条件分支 → 消除 graph break
  • torch.isnan(x) → (x != x) → 使用已补齐的 converter
  • .square() → dev * dev → 使用有 converter 的 mul
  • torch.arcsinh → 使用已全局 patch 的 log(x + sqrt(x²+1))

patched inverse 的关键变化:

  • torch.sinh → 使用已全局 patch 的 (exp(x) - exp(-x))/2

修改 3:时间编码预计算(Patch 3)

在编译前预计算时间编码并注册为 model.register_buffer:

def precompute_time_encodings(model, seq_len, num_output_patches, batch_size=4):
    # context 时间编码:[-seq_len, ..., -1] / time_scale,reshape 为 patches
    ctx_enc = torch.arange(-seq_len, 0, dtype=torch.float32, device=model.device)
    ctx_enc = ctx_enc.div(time_scale).to(model.dtype).reshape(seq_len // patch_size, patch_size)
    model.register_buffer('_ctx_time_enc', ctx_enc)

    # future 时间编码:[0, ..., future_len-1] / time_scale,reshape 为 patches
    future_len = num_output_patches * model.chronos_config.output_patch_size
    fut_enc = torch.arange(0, future_len, dtype=torch.float32, device=model.device)
    fut_enc = fut_enc.div(time_scale).to(model.dtype).reshape(num_output_patches, output_patch_size)
    model.register_buffer('_fut_time_enc', fut_enc)

    # 默认 group_ids(逐行独立推理)
    model.register_buffer('_default_group_ids', torch.arange(batch_size, dtype=torch.long, device=model.device))

使用时只需 expand 到 batch 维度(不再调用 torch.arange):

context_time_enc = self._ctx_time_enc.unsqueeze(0).expand(batch_size, -1, -1)

配合 patched _prepare_patched_context 和 _prepare_patched_future_no_covariates,将所有动态操作替换为 buffer 常量:

def patched_prepare_patched_context(self, context, context_mask=None):
    context_mask = (~(context != context)).to(context.dtype)
    patched_mask = torch.where(patched_mask_raw != patched_mask_raw, 0.0, patched_mask_raw)
    context_time_enc = self._ctx_time_enc.unsqueeze(0).expand(batch_size, -1, -1)
    patched_context = torch.cat([context_time_enc, patched_context, patched_mask], dim=-1)

修改 4:RoPE 和 group mask 去条件分支(Patch 4, 5)

RoPE:去掉 torch.autocast,改为显式 .float():

def patched_rope_forward(self, x, position_ids):
    inv_freq_expanded = self.inv_freq[None, :, None].float().expand(position_ids.shape[0], -1, 1)
    position_ids_expanded = position_ids[:, None, :].float()
    freqs = (inv_freq_expanded @ position_ids_expanded).transpose(1, 2)
    emb = torch.cat((freqs, freqs), dim=-1)
    return emb.cos().to(dtype=x.dtype), emb.sin().to(dtype=x.dtype)

group mask:去掉 torch.is_floating_point 条件分支,统一使用指定 dtype:

def patched_construct_and_invert_group_time_mask(group_ids, attention_mask, floating_type):
    group_mask_f = (group_ids[:, None] == group_ids[None, :]).to(dtype=floating_type)
    attention_mask_f = attention_mask.to(dtype=floating_type)
    group_time_mask = torch.einsum("qb, bt -> qbt", group_mask_f, attention_mask_f)
    return (1.0 - group_time_mask) * torch.finfo(floating_type).min

修改 5:推理专用 model.forward(Patch 6-9)

创建推理专用路径,移除所有条件分支。

推理专用 forward:

def patched_forward_inference(self, context, context_mask=None, num_output_patches=1,
                              group_ids=None, **kwargs):
    if context_mask is None:
        context_mask = (~(context != context)).to(torch.float32)
    encoder_outputs, loc_scale, _, num_context_patches = self.encode_inference(
        context=context, context_mask=context_mask, num_output_patches=num_output_patches)
    hidden_states = encoder_outputs[0]
    forecast_embeds = hidden_states[:, -num_output_patches:]
    quantile_preds = self.output_patch_embedding(forecast_embeds)
    quantile_preds = rearrange(quantile_preds, "b n (q p) -> b q (n p)",
                               n=num_output_patches, q=self.num_quantiles,
                               p=self.chronos_config.output_patch_size)
    quantile_preds = self.instance_norm.inverse(quantile_preds, loc_scale)
    return Chronos2Output(quantile_preds=quantile_preds)

推理专用 encode:

def patched_encode_inference(self, context, context_mask, num_output_patches):
    patched_context, attention_mask, loc_scale = self._prepare_patched_context(
        context=context, context_mask=context_mask)
    encoder_outputs = self.encoder(
        attention_mask=attention_mask, inputs_embeds=input_embeds,
        group_ids=self._default_group_ids, output_attentions=False)

推理专用 _prepare_patched_future_no_covariates:

def patched_prepare_patched_future_no_covariates(self, loc_scale, num_output_patches, batch_size):
    zeros = torch.zeros(batch_size, num_output_patches, output_patch_size, ...)
    future_time_enc = self._fut_time_enc.unsqueeze(0).expand(batch_size, -1, -1)
    patched_future = torch.cat([future_time_enc, zeros, torch.zeros_like(zeros)], dim=-1)

应用方式:

model.forward = types.MethodType(patched_forward_inference, model)
model.encode_inference = types.MethodType(patched_encode_inference, model)
compiled_model = torch.compile(model, backend='npu', dynamic=False)

修改 6:pipeline.predict 自动替换(根因 2)

绕过 Chronos2Dataset + DataLoader,用 inline 预处理 + compiled model 调用:

def patched_pipeline_predict(self, inputs, prediction_length=24, **kwargs):
    if any(k in kwargs for k in ('covariates', 'future_covariates', 'past_covariates')):
        return self._predict_original(inputs, prediction_length=prediction_length, **kwargs)

    series_list = _normalize_inputs(inputs)
    batches = _pad_to_compiled_shape(series_list, self._compiled_seq_len,
                                      self._compiled_batch_size, self._compiled_device)

    for context_npu, n_series in batches:
        output = self._compiled_model(
            context=context_npu,
            context_mask=(~(context_npu != context_npu)).to(torch.float32),
            num_output_patches=num_output_patches,
        )
        result_cpu = output.quantile_preds[:n_series].cpu()

compile_pipeline() 自动将 pipeline.predict monkey-patch 为优化版本,原始方法保存为 _predict_original:

def compile_pipeline(pipeline, device=DEVICE, batch_size=4, seq_len=128, num_output_patches=2):
    pipeline.model = pipeline.model.to(device).eval()
    compiled_model = apply_patches_and_compile(pipeline.model, ...)
    warmup_compiled_model(compiled_model, ...)
    pipeline._predict_original = pipeline.predict
    pipeline.predict = types.MethodType(patched_pipeline_predict, pipeline)
    return {'compiled_model': compiled_model, 'pipeline': pipeline}

阶段三、精度验证

使用 Chronos-2 模型在 CPU 和 310P NPU 上进行推理,对比各场景的输出精度:

对比场景max_abs_diffcosine_similarity判定
原始 pipeline vs CPU5.483627e-061.00000007✅ PASS
优化 pipeline vs CPU5.960464e-061.00000000✅ PASS
InstanceNorm patch vs CPU0—✅ PASS
时间编码 buffer vs CPU0—✅ PASS

通过标准:max_abs_diff < 5e-3, cosine_similarity > 0.9999

精度结论:所有优化均为数学等价替换(恒等式,非近似),精度差异仅来自 NPU fp16 与 CPU fp32 的浮点截断误差,在 FP32 精度范围内完全对齐,满足时序预测的精度要求。

阶段四、性能验证

应用所有优化方法后,Profiling 实测对比:

指标原始 pipeline(场景 A)优化 pipeline(场景 B)变化
端到端总延迟47.25ms10.49ms4.5x 加速
预处理2.99ms0.36ms省 2.63ms
NPU 推理44.26ms(eager)10.13ms(compiled)省 34.13ms

加速来源分析:

来源节省延迟占比
Eager 调度消除 (132 次 → 1 次)~30-35ms92.8%
算子融合 (减少中间数据 HBM 读写)~3-5ms—
预处理 bypass (Python → Tensor)2.63ms7.2%

运行环境与部署步骤

配套版本
Python3.11
PyTorch2.7.1+cpu
torch_npu2.7.1.post2
chronos-forecasting2.2.2
NPU 设备Ascend 310P
模型amazon/chronos-2 (base)

基本使用(零 API 改动)

只需 3 步初始化,之后 pipeline.predict(...) 完全不变:

# Step 1: 全局 patches(必须在 import chronos 之前!)
import torch_npu
from compile_npu_accel import apply_compile_patches
apply_compile_patches()

# Step 2: 加载模型 + compile_pipeline(一次性初始化)
from chronos.chronos2 import Chronos2Pipeline
from compile_npu_accel import compile_pipeline

model_path = '/root/predict/chronos2/model_weights'
pipeline = Chronos2Pipeline.from_pretrained(model_path)
result = compile_pipeline(pipeline, batch_size=4, seq_len=128)
pipeline = result['pipeline']

# Step 3: 推理 — 完全是原生 API,一行不变
output = pipeline.predict(inputs, prediction_length=24)
# output: list[Tensor], 每个序列 (1, 21, pred_len), 在 CPU 上

输入/输出格式兼容性

支持所有原始 pipeline.predict 的输入格式:

# 3D Tensor — 标准格式(最常用)
output = pipeline.predict(torch.randn(4, 1, 128), prediction_length=24)

# 2D Tensor
output = pipeline.predict(torch.randn(4, 128), prediction_length=24)

# numpy ndarray
output = pipeline.predict(np.random.randn(4, 1, 128).astype(np.float32), prediction_length=24)

# 变长序列 — 自动截断 + 左填充 NaN
output = pipeline.predict([
    torch.randn(64),    # 短序列 → 左填充 NaN 到 128
    torch.randn(200),   # 长序列 → 截断到最后 128
    torch.randn(128),   # 标准长度 → 无需填充
], prediction_length=24)

# 单条序列
output = pipeline.predict(torch.randn(128), prediction_length=24)

输出格式与原始完全一致:

output = pipeline.predict(torch.randn(4, 1, 128), prediction_length=24)
# output[0].shape = (1, 21, 24) — 与原始 pipeline.predict 完全一致
# 21 个 quantile, 预测长度 24 步

# 取中位数预测
predictions = output[0]          # (1, 21, 24)
median = predictions[0, 10, :]   # 第 10 个 quantile ≈ 中位数

不支持场景与 Fallback

场景原因替代方案
covariates(past/future)推理路径不支持协变量自动 fallback 到 _predict_original()
多变量 cross-learning(V>1)group_ids 固定为逐行独立pipeline._predict_original()
dict 输入格式同协变量限制pipeline._predict_original()

遇到不支持场景时,pipeline.predict 会自动 fallback 到原始路径:

# 有协变量 → 自动 fallback, 无需修改代码
output = pipeline.predict(inputs, prediction_length=24, covariates=...)

# 也可手动调用原始方法
original_result = pipeline._predict_original(inputs, prediction_length=24, covariates=...)

编译 Warmup 注意事项

  • 首次调用 compile_pipeline() 时,GE 图编译需要 10-30 秒(warmup 4+1 次推理)
  • Warmup 已包含在 compile_pipeline() 中,不需要手动 warmup
  • 必须在服务启动时调用 compile_pipeline(),不要在推理循环中调用
  • Warmup 后所有后续 pipeline.predict(...) 调用均为 ~10ms,无需再次编译

总结

本案例完成了 Chronos-2 预测模型在昇腾 310P 上的 GE 图优化性能调优:

  1. 根因定位:通过 profiling 分析,定位 NPU eager 模式调度开销(38.46ms,占总延迟 81%)为核心瓶颈,Python 预处理开销(2.99ms)为次要瓶颈
  2. GE 图融合:6 个算子 converter 补齐 + 5 处 graph break 消除 + torch.compile(backend='npu') 整图编译,132 次 CPU→NPU dispatch 变为 1 次
  3. 精度验证:所有 patch 均为数学等价替换,优化后与 CPU 基准的 max_abs_diff < 6×10⁻⁶,精度无损
  4. 性能验证:310P 端到端延迟从 47.25ms 降至 10.49ms(4.5x 加速),实测 pipeline.predict API 零改动

参考资料

  • Chronos 论文: https://arxiv.org/abs/2403.07815
  • Chronos-2 技术报告: https://arxiv.org/abs/2510.15821
  • 源代码仓库: https://github.com/amazon-science/chronos-forecasting
  • torch_npu 文档: https://gitcode.com/ascend/pytorch
  • 昇腾 CANN 文档: https://www.hiascend.com/document