📝

Draft article

This draft is visible to admins and superusers only. Sign in with an authorized account.

← Макалаларга кайтуу
August 14, 2026
5 мүн окуу

Model Pruning for Low-Latency Trading Inference

#model-compression
#pruning
#lottery-ticket
#latency
#deployment

Our DeepLOB article ends its deployment section with three bullets — ONNX plus TensorRT, INT8 quantization, FPGA — and no treatment of any of them. This article is the missing treatment of the first problem underneath all three: the model is bigger than it needs to be. Neural network pruning removes redundant parameters, and the interesting claim in the literature is not that this saves memory but that a subnetwork holding 10-20% of the weights can match the dense model's accuracy.

That the latency matters at all is a case the blog has already made — ZigBolt on the messaging path and the IPC tax with the break-even arithmetic — and spread modeling already owns the fast-but-slightly-worse versus slow-but-better trade-off, complete with a gradient-boosting-versus-deep-learning table that has an inference-latency row. What none of them cover is how you make a given model smaller. Of the stages in a quoting loop, model inference is the one fully under our control; the transport legs are covered, with reproducible p50/p95/p99 numbers, in data communication for algotrading.

What this article is: the math and working code for magnitude pruning, structured pruning, Iterative Magnitude Pruning, movement pruning, knowledge distillation, and NVIDIA 2:4 semi-structured sparsity, applied to a trading MLP.

What it is not: a measured result. Every empirical article on this blog carries a provenance line or a companion repo, and this one carries neither yet. The sparsity-versus-accuracy-versus-latency curve is stated below as an experiment to run, not as a table to quote. Treat everything here as the method, and the numbers as pending.

What Pruning Buys You

The constraint is a size one. Consider a mid-frequency model — a 4-layer MLP with 2048 hidden units over order book features:

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

For din=100d_{\text{in}} = 100, h=2048h = 2048, L=4L = 4, dout=3d_{\text{out}} = 3, that is roughly 12.6 million parameters — about 48 MB in float32. L2 is typically 1-4 MB, so the weights do not fit; they are streamed from further out on every forward pass. Prune 95% of them and you are at roughly 630K effective parameters and 2.4 MB, which does fit.

Whether that translates into wall-clock time depends on whether the kernel is memory-bound, and that is an arithmetic-intensity question rather than a size question. The backtest engine speed ladder works the roofline model (Williams, Waterman & Patterson) through a measured example rather than asserting a penalty factor; the same framing applies here, and the same discipline should: measure the bytes moved before claiming the speedup.

Pruning Fundamentals

Unstructured Pruning

The simplest approach: set individual weights to zero based on their magnitude. Given a weight matrix WRm×n\mathbf{W} \in \mathbb{R}^{m \times n}, create a binary mask M{0,1}m×n\mathbf{M} \in \{0, 1\}^{m \times n} such that:

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

where τ\tau is a threshold chosen to achieve the desired sparsity level ss:

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

The pruned matrix is W=WM\mathbf{W}' = \mathbf{W} \odot \mathbf{M}, with \odot the Hadamard product. The intuition is that weights near zero contribute little to the layer's output.

The problem, stated plainly because it is easy to misread the sparsity number: unstructured sparsity does not translate into a speedup on standard hardware. A matrix with 90% zeros still issues the same number of multiply-accumulates unless you switch to sparse kernels or hardware with sparsity support. When the code below prints Sparsity: 90.0%, that is a count of zeros — it is not a 10x anything, and on a dense CPU GEMM it is not a 1.01x either. The paths that do buy time are structured pruning (smaller matrices) and 2:4 semi-structured sparsity (hardware support), both below.

Structured Pruning

Structured pruning removes entire neurons, channels, or attention heads. For a linear layer y=Wx+b\mathbf{y} = \mathbf{W}\mathbf{x} + \mathbf{b} with WRm×n\mathbf{W} \in \mathbb{R}^{m \times n}, removing neuron jj zeroes the jj-th row of W\mathbf{W} and the jj-th element of b\mathbf{b}:

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

Neurons with the smallest 2\ell_2-norm go first. This is the variant that produces genuinely smaller matrices — but only if you actually rebuild the layers. Zeroing rows and leaving the tensor at its original shape changes nothing about the FLOP count; the rebuild step in the implementation section is what converts the mask into a 512×n512 \times n matrix.

For convolutional layers the analog is filter pruning. Given WRCout×Cin×k×k\mathbf{W} \in \mathbb{R}^{C_{\text{out}} \times C_{\text{in}} \times k \times k}, the importance of output filter ii is:

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}

Removing filter ii eliminates an entire output channel, reducing FLOPs proportionally.

The Lottery Ticket Hypothesis

In 2019, Frankle and Carbin introduced the Lottery Ticket Hypothesis (LTH): within a randomly initialized dense network there exists a sparse subnetwork — a "winning ticket" — that, trained from its original initialization, matches the accuracy of the full network in a comparable number of iterations.

Formally, consider f(x;θ0)f(\mathbf{x}; \boldsymbol{\theta}_0) initialized with θ0Dθ\boldsymbol{\theta}_0 \sim \mathcal{D}_\theta. After training to convergence we obtain θ\boldsymbol{\theta}^* and derive a pruning mask m\mathbf{m}. The LTH states there exists m\mathbf{m} such that:

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}^*)

with m0θ0\|\mathbf{m}\|_0 \ll |\boldsymbol{\theta}_0|. The original experiments were on MNIST and CIFAR-10, where winning tickets retained 10-20% of parameters. Nothing about that transfers to order book data by assumption — LOB features are non-stationary and the label is nearly noise, which is a different regime from image classification in exactly the ways that might matter.

Iterative Magnitude Pruning (IMP)

The ticket is found by IMP:

  1. Initialize the network with θ0\boldsymbol{\theta}_0.
  2. Train to convergence, obtaining θ\boldsymbol{\theta}^*.
  3. Prune the p%p\% of weights with smallest magnitude, creating mask m\mathbf{m}.
  4. Reset surviving weights to their values in θ0\boldsymbol{\theta}_0 (rewinding).
  5. Repeat from step 2 with the masked network.

Each round prunes fraction pp (typically 20%), so after nn rounds (1p)n(1-p)^n of the parameters survive. After 10 rounds at p=0.2p = 0.2, roughly 10.7% remain.

Three Hypotheses About Trading Models, None of Them Tested

It is tempting to argue that LTH should work especially well on market data. Three such arguments come up; all three are hypotheses, and stating them as facts is the failure mode this blog exists to avoid.

  1. Financial signals are sparse. Most of an order book snapshot is noise, so a sparse subnetwork might be naturally aligned with a sparse signal. Testable: compare IMP against a same-sparsity random mask; if sparsity itself is doing the work, the random mask should not be far behind.
  2. Winning tickets generalize across regimes. This one is an empirical claim about markets with no citation behind it, and it is the most interesting of the three. It is directly testable against the regime labels from regime detection with HMMs: find the ticket in regime A, retrain it in regime B, and compare against a ticket found natively in B.
  3. Sparsity regularizes. Lower effective capacity may reduce fitting to microstructure noise — which would show up as the pruned model's out-of-sample gap being smaller than the dense model's, not merely comparable.

Implementation: Pruning a Trading MLP

The Base Model

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():,}")

Unstructured Magnitude Pruning

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%}")

Structured Pruning, With the Rebuild That Makes It Real

Masking rows is half the job. The half that produces the speedup is rebuilding each layer at its reduced shape — which means propagating the removal forward: dropping row jj of layer ii also drops column jj of layer i+1i+1 and channel jj of any BatchNorm1d between them.

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

Two things to check before trusting this, in the same spirit as the equivalence gates the rest of the blog runs:

  • Shapes. rebuilt should show hidden dimensions at (1fraction)h(1 - \text{fraction}) \cdot h — 512 for fraction=0.75, h=2048h = 2048 — and a parameter count that has fallen quadratically, since both dimensions of the interior matrices shrink.
  • Outputs. In eval() mode, rebuilt(x) must match the masked model's rebuilt-free output to floating-point tolerance on the same batch. If it does not, the column propagation is wrong, and every downstream number is measuring a different model than you think.

The row-survival test assumes a masked row is exactly zero and a live row is not. That holds for ln_structured output; it would not hold if some other procedure produced a genuinely all-zero live neuron, so assert the survivor count against the requested fraction rather than trusting the norm test blindly.

Iterative Magnitude Pruning (Lottery Ticket Search)

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 is the raw material for the sparsity-versus-accuracy curve this article owes you. eval_fn has to be genuinely out-of-sample, on purged splits — an IMP run scored in-sample will report a beautiful curve that means nothing.

Measuring It

Latency is measured with the same harness convention as the rest of the blog — warmup excluded, best-of-N, p50/p95/p99 reported rather than a mean — and that protocol, with the code, is in Polars vs pandas. Three points specific to pruning:

  • Benchmark the rebuilt model, not the masked one. A masked model at batch size 1 measures the dense shape.
  • Report the batch size. Batch 1 (quoting loop) and batch 256 (research sweep) sit on different sides of the memory-bound/compute-bound line, and pruning helps them differently.
  • Report accuracy on the same split, at the same horizon, with the label definition stated. A latency table without the matching accuracy column is an argument for deleting the model entirely.

Advanced Techniques

Pruning with Knowledge Distillation

Rather than pruning and fine-tuning in isolation, use the original dense model as a teacher. The pruned student minimizes a combination of task loss and the KL divergence from the teacher's output distribution:

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)

where zt\mathbf{z}_t and zs\mathbf{z}_s are teacher and student logits, TT is the temperature, and α\alpha balances the objectives. The T2T^2 factor rescales the distillation gradients, which otherwise shrink as 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

Movement Pruning

Rather than pruning by absolute magnitude, movement pruning (Sanh et al., 2020) prunes weights that are moving toward zero during training. The importance score accumulates the gradient-weight product:

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)

Weights with negative scores are pruned. The argument for it over magnitude pruning is specifically about fine-tuning: when you adapt a pre-trained model, the magnitude distribution was shaped by the pre-training task, so magnitude is a stale importance signal and direction-of-travel is a fresher one. For a trading model retrained on rolling windows, that is the more common situation than training from scratch.

NVIDIA 2:4 Structured Sparsity

Ampere-and-later NVIDIA GPUs support 2:4 structured sparsity in hardware: out of every 4 contiguous weights, exactly 2 must be zero.

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

This is the one form of fine-grained sparsity that the hardware actually rewards, which is why it matters more than the 90%-zeros number from unstructured pruning. The constraint is local rather than global — it does not care which two of each four survive — so it is a much weaker restriction than fixing a global mask, though 50% is the only sparsity level on offer.

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()

Realizing the speedup requires the inference path to use the sparse tensor cores — an ONNX export plus TensorRT build, or torch.sparse.to_sparse_semi_structured. Exporting a 2:4-masked model through a dense runtime gives you the accuracy cost and none of the benefit.

Production Deployment

Validation

A pruned model is a new model, not a compressed old one, and it goes through the same acceptance gate as any other candidate: rolling retraining and out-of-sample revalidation per walk-forward optimization, with the selection-effect correction from the deflated Sharpe ratio. That correction is not optional here — IMP generates a sequence of candidate models, so the sparsity level that looks best across ten rounds was chosen under search, and its Sharpe needs deflating by the effective number of trials. A flat rule like "reject if Sharpe drops more than 5%" does not survive that arithmetic, which is why you will not find one in this article.

Quantization Stacking

Pruning composes with quantization. A model 90% sparse and quantized to INT8 has a compression ratio of:

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

A 48 MB model becomes 1.2 MB. That is a storage claim and nothing more. Whether the 1.2 MB model produces the same decisions is a separate question with its own answer, and the GPU precision trap is the reason to ask it: on this blog, fp32 alone has been shown to produce a relative error of 211 in a backtest computation that looked entirely reasonable. INT8 is a far more aggressive reduction than that. Ship a quantized-and-pruned model only behind a quantified parity gate against the fp32 dense model — decision agreement rate and PnL delta on a held-out period, not an assurance.

Monitoring

Pruned models can be more sensitive to distribution shift. Worth watching:

  • Activation sparsity: if surviving neurons emit mostly zeros, the effective model is smaller than intended and probably degrading.
  • Gradient norms during retraining: exploding gradients suggest the surviving subnetwork is being asked to compensate too aggressively for what was removed.
  • Prediction entropy: a pruned model that becomes overconfident on noisy microstructure data is likely fitting the training regime.

Conclusion

The methods are well established and, until the sweep runs, that is all this article claims. Unstructured pruning gives you a sparsity number and no speed. Structured pruning gives you speed if — and only if — you rebuild the layers rather than mask them. The Lottery Ticket Hypothesis suggests the compact model already exists inside the overparameterized one, though that has been demonstrated on image benchmarks and not on order book data, and the three reasons it "should" work on market data given above are hypotheses with experiments attached, not findings.

The practical heuristic from the literature is to train large and prune down rather than design small from the start: the large model explores the loss landscape more effectively, and pruning preserves the pathways that mattered. Whether that holds for a trading model, at what sparsity, and at what accuracy cost, is one IMP sweep away — and this article should be read again after that sweep, with numbers in it.

blog.disclaimer

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 соода аналитикасы, рынок талдоолору жана платформа жаңылыктары үчүн биздин жаңылыктар бюллетенине жазылыңыз.

Биз сиздин купуялыгыңызды урматтайбыз. Каалаган убакта жазылымдан чыга аласыз.