← 返回文章列表
August 14, 2026
5 分钟阅读

Model Pruning for Low-Latency Trading Inference

Model Pruning for Low-Latency Trading Inference
#model-compression
#pruning
#lottery-ticket
#latency
#deployment

我们的 DeepLOB 文章在部署部分以三个要点结束——ONNX 加 TensorRT、INT8 量化、FPGA——却没有展开其中任何一个。本文补上三者背后的第一个问题:模型比实际需要的大。神经网络剪枝会移除冗余参数,而文献中有趣的主张并不是它能节省内存,而是只保留 10--20% 权重的子网络也能达到稠密模型的准确率。

博客已经说明延迟为何重要——消息传输路径上的 ZigBolt,以及带有盈亏平衡计算的 IPC 税——而价差建模已经讨论了“稍慢但更好”与“更快但稍差”的取舍,其中的梯度提升与深度学习对比表还包含推理延迟一行。它们都没有涉及如何把一个给定模型做小。在报价循环的各个阶段中,模型推理是完全由我们控制的部分;传输路径则已在算法交易的数据通信中用可复现的 p50/p95/p99 数字覆盖。

**本文内容:**将幅度剪枝、结构化剪枝、迭代幅度剪枝、移动剪枝、知识蒸馏和 NVIDIA 2:4 半结构化稀疏性应用于交易 MLP 的数学与可运行代码。

**本文不包含:**实测结果。博客中的每篇实证文章都有来源说明或配套仓库,而本文目前两者都没有。下面的稀疏度—准确率—延迟曲线是待执行的实验,不是可以引用的表格。请把本文内容视为方法,把数字视为待定结果。

剪枝能带来什么

适配快速缓存层的紧凑神经网络

约束只有一个:大小。考虑一个中频模型——一个针对订单簿特征、包含 2048 个隐藏单元的 4 层 MLP:

Parameters=dinh+h2(L1)+hdout\text{Parameters} = d_{\text{in}} \cdot h + h^2 \cdot (L - 1) + h \cdot d_{\text{out}}

din=100d_{\text{in}} = 100h=2048h = 2048L=4L = 4dout=3d_{\text{out}} = 3 时,大约有 1260 万个参数,在 float32 中约为 48 MB。L2 缓存通常只有 1--4 MB,因此权重无法装入;每次前向传播都要从更远的内存流式读取。剪掉 95% 后,约剩 63 万个有效参数和 2.4 MB,这就能装下了。

这是否能转化为实际耗时,取决于内核是否受内存带宽限制;这是算术强度问题,而非大小问题。回测引擎速度阶梯通过一个实测例子应用 Roofline 模型(Williams、Waterman 和 Patterson),而不是直接断言惩罚因子。这里同样适用这个框架,也应遵循同样的纪律:在宣称加速之前先测量移动了多少字节。

剪枝基础

神经网络格上的结构化与非结构化剪枝

非结构化剪枝

最简单的方法:依据单个权重的幅度将其设为零。给定权重矩阵 WRm×n\mathbf{W} \in \mathbb{R}^{m \times n},创建二值掩码 M{0,1}m×n\mathbf{M} \in \{0, 1\}^{m \times n},使得:

Mij={1if Wijτ0if Wij<τM_{ij} = \begin{cases} 1 & \text{if } |W_{ij}| \geq \tau \\ 0 & \text{if } |W_{ij}| < \tau \end{cases}

其中 τ\tau 是为达到目标稀疏度 ss 而选择的阈值:

s=1M0mns = 1 - \frac{\|\mathbf{M}\|_0}{m \cdot n}

剪枝后的矩阵为 W=WM\mathbf{W}' = \mathbf{W} \odot \mathbf{M},其中 \odot 是 Hadamard 乘积。直觉是,接近零的权重对层输出的贡献很小。

**问题要直说,因为稀疏度数字很容易被误读:**非结构化稀疏性不会自动转化为标准硬件上的加速。一个 90% 为零的矩阵,仍然会发出相同数量的乘加操作,除非切换到稀疏内核或支持稀疏性的硬件。当下面的代码打印 Sparsity: 90.0% 时,它只是零值的计数——不是任何意义上的 10 倍加速,在稠密 CPU GEMM 上甚至也不是 1.01 倍。真正能节省时间的路径是结构化剪枝(更小的矩阵)和 2:4 半结构化稀疏性(硬件支持),两者如下所述。

结构化剪枝

结构化剪枝会移除整个神经元、通道或注意力头。对于 WRm×n\mathbf{W} \in \mathbb{R}^{m \times n} 的线性层 y=Wx+b\mathbf{y} = \mathbf{W}\mathbf{x} + \mathbf{b},移除神经元 jj 就是将 W\mathbf{W} 的第 jj 行和 b\mathbf{b} 的第 jj 个元素置零:

importance(j)=Wj,:2=k=1nWjk2\text{importance}(j) = \|\mathbf{W}_{j,:}\|_2 = \sqrt{\sum_{k=1}^{n} W_{jk}^2}

2\ell_2 范数最小的神经元最先被移除。这是能够真正产生更小矩阵的变体——但前提是你确实重建了各层。将行置零、让张量保持原来的形状,并不会改变 FLOP 数;实现部分的重建步骤才会把掩码转换成 512×n512 \times n 矩阵。

对于卷积层,对应的做法是滤波器剪枝。给定 WRCout×Cin×k×k\mathbf{W} \in \mathbb{R}^{C_{\text{out}} \times C_{\text{in}} \times k \times k},输出滤波器 ii 的重要性为:

importance(i)=Wi,:,:,:F=c,h,wWi,c,h,w2\text{importance}(i) = \|\mathbf{W}_{i,:,:,:}\|_F = \sqrt{\sum_{c,h,w} W_{i,c,h,w}^2}

移除滤波器 ii 会消除整个输出通道,使 FLOP 按比例减少。

彩票假说

从庞大神经网络中隔离出的微型获胜子网络

2019 年,Frankle 和 Carbin 提出了彩票假说(LTH):在一个随机初始化的稠密网络中,存在一个稀疏子网络——一张“中奖彩票”——从原始初始化开始训练后,能在相近的迭代次数内达到完整网络的准确率。

形式化地说,考虑用 θ0Dθ\boldsymbol{\theta}_0 \sim \mathcal{D}_\theta 初始化的 f(x;θ0)f(\mathbf{x}; \boldsymbol{\theta}_0)。训练收敛后得到 θ\boldsymbol{\theta}^*,并推导出剪枝掩码 m\mathbf{m}。LTH 认为存在某个 m\mathbf{m},使得:

f(x;mθ0)trainθmwhereacc(θm)acc(θ)f(\mathbf{x}; \mathbf{m} \odot \boldsymbol{\theta}_0) \xrightarrow{\text{train}} \boldsymbol{\theta}_m^* \quad \text{where} \quad \text{acc}(\boldsymbol{\theta}_m^*) \geq \text{acc}(\boldsymbol{\theta}^*)

并且 m0θ0\|\mathbf{m}\|_0 \ll |\boldsymbol{\theta}_0|。原始实验在 MNIST 和 CIFAR-10 上进行,中奖彩票保留了 10--20% 的参数。不能想当然地认为这一结论能迁移到订单簿数据——LOB 特征非平稳,标签几乎是噪声;这与图像分类处于不同状态,而差异恰好可能影响结果。

迭代幅度剪枝(IMP)

通过 IMP 找到中奖彩票:

  1. θ0\boldsymbol{\theta}_0 初始化网络。
  2. 训练至收敛,得到 θ\boldsymbol{\theta}^*
  3. 剪掉幅度最小的 p%p\% 权重,创建掩码 m\mathbf{m}
  4. 将幸存权重重置为 θ0\boldsymbol{\theta}_0 中的值(回退)。
  5. 在带掩码的网络上从第 2 步重复。

每轮剪掉比例为 pp 的权重(通常为 20%),因此经过 nn 轮后仍有 (1p)n(1-p)^n 的参数幸存。在 p=0.2p = 0.2 的情况下进行 10 轮后,大约剩下 10.7%。

关于交易模型的三个假说,全部尚未检验

很容易认为 LTH 应该特别适合市场数据。常见的论据有三个;它们都是假说,把它们陈述成事实正是本博客要避免的失败模式。

  1. 金融信号是稀疏的。 订单簿快照的大部分是噪声,因此稀疏子网络可能天然与稀疏信号对齐。可检验的方法是将 IMP 与相同稀疏度的随机掩码比较;如果起作用的是稀疏性本身,随机掩码不应落后太多。
  2. 中奖彩票能跨市场状态泛化。 这是一个关于市场的实证主张,背后没有引用,也是三个论据中最有趣的一个。可以直接用基于 HMM 的市场状态检测中的市场状态标签检验:在状态 A 中找到中奖彩票,在状态 B 中重新训练,再与在 B 中原生找到的中奖彩票比较。
  3. 稀疏性具有正则化作用。 更低的有效容量可能减少对微观结构噪声的拟合——这应表现为剪枝模型的样本外差距小于稠密模型,而不只是与之相当。

实现:剪枝交易 MLP

通过精确稀疏掩码变换的神经网络层

基础模型

import torch
import torch.nn as nn
import torch.nn.utils.prune as prune
from copy import deepcopy

class TradingMLP(nn.Module):
    """MLP for mid-price direction prediction from order book features."""
    def __init__(self, input_dim=100, hidden_dim=2048,
                 num_layers=4, output_dim=3):
        super().__init__()
        layers = []
        dims = [input_dim] + [hidden_dim] * (num_layers - 1) + [output_dim]
        for i in range(len(dims) - 1):
            layers.append(nn.Linear(dims[i], dims[i + 1]))
            if i < len(dims) - 2:
                layers.append(nn.BatchNorm1d(dims[i + 1]))
                layers.append(nn.ReLU())
                layers.append(nn.Dropout(0.1))
        self.network = nn.Sequential(*layers)

    def forward(self, x):
        return self.network(x)

    def count_parameters(self):
        return sum(p.numel() for p in self.parameters())

model = TradingMLP(input_dim=100, hidden_dim=2048,
                   num_layers=4, output_dim=3)
print(f"Total parameters: {model.count_parameters():,}")

非结构化幅度剪枝

def apply_unstructured_pruning(model, sparsity=0.9):
    """Apply global unstructured L1 pruning to all Linear layers."""
    parameters_to_prune = []
    for name, module in model.named_modules():
        if isinstance(module, nn.Linear):
            parameters_to_prune.append((module, 'weight'))

    prune.global_unstructured(
        parameters_to_prune,
        pruning_method=prune.L1Unstructured,
        amount=sparsity,
    )
    return model

def compute_sparsity(model):
    """Fraction of zero weights. Note: a *count*, not a speedup."""
    total, zeros = 0, 0
    for name, param in model.named_parameters():
        if 'weight' in name:
            total += param.numel()
            zeros += (param == 0).sum().item()
    return zeros / total if total > 0 else 0

pruned_model = apply_unstructured_pruning(deepcopy(model), sparsity=0.9)
print(f"Sparsity: {compute_sparsity(pruned_model):.1%}")

结构化剪枝:通过重建让它真正生效

掩码行只是工作的一半。真正带来加速的另一半,是以缩小后的形状重建每一层——这意味着把移除操作向前传播:删除第 ii 层的第 jj 行,也要删除第 i+1i+1 层的第 jj 列,以及两层之间任意 BatchNorm1d 的第 jj 个通道。

def apply_structured_pruning(model, fraction=0.75):
    """Mask entire neurons by L2-norm of their weight rows."""
    for name, module in model.named_modules():
        if isinstance(module, nn.Linear) and module.out_features > 10:
            prune.ln_structured(
                module, name='weight', amount=fraction, n=2, dim=0
            )
    return model

def rebuild_pruned_mlp(model):
    """
    Physically shrink a structurally pruned TradingMLP.

    Walks the Sequential once. For each Linear: drop the input columns
    the previous layer no longer emits, then drop its own dead output
    rows. BatchNorm1d channels follow the preceding Linear's survivors.
    """
    new_layers = []
    keep_in = None  # surviving output indices of the previous Linear

    for layer in model.network:
        if isinstance(layer, nn.Linear):
            if prune.is_pruned(layer):
                prune.remove(layer, 'weight')

            W, b = layer.weight.data, layer.bias.data
            keep_out = (W.norm(dim=1) > 0).nonzero(as_tuple=True)[0]
            W = W[keep_out]
            if keep_in is not None:
                W = W[:, keep_in]

            new = nn.Linear(W.shape[1], W.shape[0])
            new.weight.data = W.clone()
            new.bias.data = b[keep_out].clone()
            new_layers.append(new)
            keep_in = keep_out

        elif isinstance(layer, nn.BatchNorm1d):
            new = nn.BatchNorm1d(len(keep_in))
            new.weight.data = layer.weight.data[keep_in].clone()
            new.bias.data = layer.bias.data[keep_in].clone()
            new.running_mean = layer.running_mean[keep_in].clone()
            new.running_var = layer.running_var[keep_in].clone()
            new.num_batches_tracked = layer.num_batches_tracked.clone()
            new_layers.append(new)

        else:  # ReLU, Dropout -- shape-agnostic, reuse as is
            new_layers.append(layer)

    rebuilt = deepcopy(model)
    rebuilt.network = nn.Sequential(*new_layers)
    return rebuilt

在相信这一点之前,需要按博客其余部分的等价性检查惯例验证两件事:

  • 形状。 rebuilt 应显示 (1fraction)h(1 - \text{fraction}) \cdot h 的隐藏维度——当 fraction=0.75h=2048h = 2048 时为 512——参数数量也应呈二次下降,因为内部矩阵的两个维度都缩小了。
  • 输出。eval() 模式下,同一批数据上,rebuilt(x) 必须在浮点容差内匹配掩码模型未经 rebuilt 的输出。如果不匹配,说明列传播有误,后续所有数字测量的都不是你以为的那个模型。

行存活测试假设被掩码的行恰好为零,而存活行不为零。这对 ln_structured 的输出成立;如果其他过程产生了真正全零的存活神经元,则不成立。因此应根据请求的剪枝比例断言存活者数量,不要盲目信任范数测试。

迭代幅度剪枝(彩票搜索)

def lottery_ticket_search(model_cls, model_kwargs, train_fn, eval_fn,
                          rounds=10, prune_rate=0.2, device='cpu'):
    """
    Iterative Magnitude Pruning to find a winning ticket.

    Parameters
    ----------
    model_cls : class -- model constructor
    model_kwargs : dict -- constructor arguments
    train_fn : callable -- train_fn(model) trains the model in-place
    eval_fn : callable -- eval_fn(model) returns out-of-sample accuracy
    rounds : int -- number of pruning rounds
    prune_rate : float -- fraction of surviving weights pruned per round
    """
    model_init = model_cls(**model_kwargs).to(device)
    theta_0 = deepcopy(model_init.state_dict())

    mask = {}
    for name, param in model_init.named_parameters():
        if 'weight' in name:
            mask[name] = torch.ones_like(param, dtype=torch.bool)

    results = []

    for round_idx in range(rounds):
        model = model_cls(**model_kwargs).to(device)
        state = deepcopy(theta_0)
        for name in mask:
            state[name] = state[name] * mask[name].float()
        model.load_state_dict(state)

        train_fn(model)

        acc = eval_fn(model)
        surviving = sum(m.sum().item() for m in mask.values())
        total = sum(m.numel() for m in mask.values())
        sparsity = 1.0 - surviving / total

        results.append({
            'round': round_idx,
            'accuracy': acc,
            'sparsity': sparsity,
            'surviving_params': int(surviving)
        })
        print(f"Round {round_idx}: acc={acc:.4f}, "
              f"sparsity={sparsity:.1%}")

        all_weights = []
        for name, param in model.named_parameters():
            if name in mask:
                alive = param.data.abs()[mask[name]]
                all_weights.append(alive.flatten())

        all_weights = torch.cat(all_weights)
        k = int(len(all_weights) * prune_rate)
        if k == 0:
            break
        threshold = all_weights.kthvalue(k).values.item()

        for name, param in model.named_parameters():
            if name in mask:
                mask[name] = mask[name] & (
                    param.data.abs() >= threshold
                )

    return results, mask

results 是本文所欠缺的稀疏度—准确率曲线的原始材料。eval_fn 必须真正使用样本外、经过清洗的分割;在样本内评分的 IMP 运行会给出漂亮却毫无意义的曲线。

测量

平衡稀疏度、准确率与延迟的抽象效率前沿

延迟测量遵循博客其他文章相同的基准工具约定——排除预热,取 N 次中的最佳值,报告 p50/p95/p99 而不是平均值——协议和代码见 Polars 与 pandas。剪枝有三点需要特别注意:

  • 重建后的模型做基准测试,而不是掩码模型。批大小为 1 的掩码模型测量的仍是稠密形状。
  • 报告批大小。批大小 1(报价循环)和批大小 256(研究扫描)位于内存受限/计算受限分界线的不同侧,剪枝对它们的帮助不同。
  • 在相同分割、相同预测时域上报告准确率,并说明标签定义。没有对应准确率列的延迟表,反而是在论证应该彻底删除这个模型。

高级技术

汇聚成高效模型的高级网络压缩路径

使用知识蒸馏进行剪枝

不要孤立地剪枝和微调,而应将原始稠密模型作为教师。剪枝后的学生模型最小化任务损失与教师输出分布 KL 散度的组合:

L=(1α)Ltask(y,y^s)+αT2DKL(σ(ztT)σ(zsT))\mathcal{L} = (1 - \alpha) \cdot \mathcal{L}_{\text{task}}(\mathbf{y}, \hat{\mathbf{y}}_s) + \alpha \cdot T^2 \cdot D_{\text{KL}}\left(\sigma\left(\frac{\mathbf{z}_t}{T}\right) \| \sigma\left(\frac{\mathbf{z}_s}{T}\right)\right)

其中 zt\mathbf{z}_tzs\mathbf{z}_s 是教师和学生的 logits,TT 是温度,α\alpha 平衡两个目标。T2T^2 因子会重新缩放蒸馏梯度,否则蒸馏梯度会按 1/T21/T^2 缩小。

def distillation_loss(student_logits, teacher_logits, labels,
                      temperature=3.0, alpha=0.5):
    """Combined task + distillation loss."""
    task_loss = nn.CrossEntropyLoss()(student_logits, labels)

    soft_student = nn.functional.log_softmax(
        student_logits / temperature, dim=-1
    )
    soft_teacher = nn.functional.softmax(
        teacher_logits / temperature, dim=-1
    )
    kd_loss = nn.functional.kl_div(
        soft_student, soft_teacher, reduction='batchmean'
    )

    return (1 - alpha) * task_loss + alpha * (temperature ** 2) * kd_loss

移动剪枝

移动剪枝(Sanh 等,2020)不是按照绝对幅度剪枝,而是剪掉训练过程中趋向零的权重。重要性分数累加梯度与权重的乘积:

Sij(t)=Sij(t1)+Wij(t)Wij(t1)sign(Wij(t)LWij(t))S_{ij}^{(t)} = S_{ij}^{(t-1)} + \left| W_{ij}^{(t)} - W_{ij}^{(t-1)} \right| \cdot \text{sign}\left(W_{ij}^{(t)} \cdot \frac{\partial \mathcal{L}}{\partial W_{ij}^{(t)}}\right)

分数为负的权重会被剪掉。相对于幅度剪枝,它的论据具体针对微调:适配预训练模型时,幅度分布由预训练任务塑造,因此幅度是过时的重要性信号,而运动方向是更新的信号。对于在滚动窗口上重新训练的交易模型,这比从头训练更常见。

NVIDIA 2:4 结构化稀疏性

Ampere 及更新的 NVIDIA GPU 在硬件中支持 2:4 结构化稀疏性:每 4 个连续权重中必须恰好有 2 个为零。

i,W[4i:4i+4]0=2\forall i, \quad \|\mathbf{W}_{[4i:4i+4]}\|_0 = 2

这是硬件真正会奖励的细粒度稀疏形式,因此比非结构化剪枝中的 90% 零值更重要。该约束是局部而非全局的——它不关心每四个权重中哪两个保留——所以比固定全局掩码弱得多,尽管可用的稀疏度水平只有 50%。

from torch.ao.pruning import WeightNormSparsifier

sparsifier = WeightNormSparsifier(
    sparsity_level=0.5,
    sparse_block_shape=(1, 4),
    zeros_per_block=2,
)

sparsifier.prepare(
    model, config=[{"tensor_fqn": "network.0.weight"}]
)
sparsifier.step()
sparsifier.squash_mask()

要实现加速,推理路径必须使用稀疏张量核心——导出 ONNX 并构建 TensorRT,或使用 torch.sparse.to_sparse_semi_structured。通过稠密运行时导出 2:4 掩码模型,只会付出准确率代价,得不到任何收益。

生产部署

进入低延迟硬件通道的精简推理模型

验证

剪枝模型是一个新模型,不是压缩后的旧模型,因此要像其他候选模型一样通过验收:按照滚动前向优化进行滚动重训和样本外重新验证,并使用折减 Sharpe 比率修正选择效应。这项修正不是可选项——IMP 会生成一系列候选模型,因此十轮中看起来最好的稀疏度是在搜索中选出的,其 Sharpe 必须按有效试验次数折减。像“Sharpe 下降超过 5% 就拒绝”这样的固定规则经不起这个计算,所以本文不会给出这种规则。

量化叠加

剪枝可以与量化组合。一个稀疏度 90%、再量化为 INT8 的模型,其压缩比为:

CR=1(1s)boriginalbquantized=10.1328=40×\text{CR} = \frac{1}{(1 - s)} \cdot \frac{b_{\text{original}}}{b_{\text{quantized}}} = \frac{1}{0.1} \cdot \frac{32}{8} = 40\times

一个 48 MB 的模型会变成 1.2 MB。这只是存储层面的说法,仅此而已。1.2 MB 的模型是否会做出相同决策,是另一个有独立答案的问题;GPU 精度陷阱正是我们必须提出这个问题的原因:在本博客中,仅 fp32 就曾在一个看起来完全合理的回测计算中产生 211 的相对误差。INT8 是比这激进得多的缩减。只有在相对于 fp32 稠密模型通过量化的一致性门槛后,才能部署量化加剪枝模型——应检查决策一致率和留出期 PnL 差异,而不是凭保证上线。

监控

剪枝模型可能对分布漂移更加敏感。值得监控:

  • 激活稀疏度:如果存活神经元大多输出零,则有效模型比预期更小,而且可能正在退化。
  • 重训期间的梯度范数:梯度爆炸说明存活子网络被要求过度补偿被移除的部分。
  • 预测熵:在嘈杂的微观结构数据上变得过度自信的剪枝模型,很可能是在拟合训练状态。

结论

从稠密不确定性中解析出的紧凑高效神经网络

这些方法已经相当成熟;在完成扫描之前,本文也只主张这些方法本身。非结构化剪枝给你一个稀疏度数字,却不给速度。结构化剪枝可以带来速度,但前提是——且只有在——你重建各层,而不是只给它们加掩码。彩票假说认为紧凑模型已经存在于过参数化模型内部,但这一点是在图像基准上证明的,并未在订单簿数据上证明;上文给出的三个“它应该适用于市场数据”的理由也都是带有实验的假说,而不是研究结果。

文献中的实用启发是:先训练大模型再逐步剪小,而不是一开始就设计小模型。大模型能更有效地探索损失景观,剪枝则保留真正起作用的路径。它是否适用于交易模型、适用何种稀疏度、要付出多大准确率代价,只需一次 IMP 扫描就能回答——而在扫描完成、数据写入本文之后,应当重新阅读本文。

免责声明:本文提供的信息仅用于教育和参考目的,不构成财务、投资或交易建议。加密货币交易涉及重大损失风险。

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

紧跟市场步伐

订阅我们的时事通讯,获取独家 AI 交易见解、市场分析和平台更新。

我们尊重您的隐私。您可以随时退订。