Making PyTorch skip the part that didn’t change

Which build these numbers come from, before you read any of them. Every speedup on this page was measured on Profile 3, a scheduling variant that lives in our research tree. It is not in the build we distribute, and per tiers.json it is marked spec only, not buildable today. Read them as a research result about how the delta idea behaves under motion, not as what you get after an install. What the community build actually delivers on the same axis is a much weaker, honestly narrower range: roughly 0.95–8.53× under churn, and about parity when the changed set is frozen — a floor, not a ceiling. We would rather tell you that than let a research number do the selling.

A lot of simulation and streaming code has the same shape. There is a big sparse matrix that describes how things are wired together — a grid, a mesh, a circuit, a graph — and it does not change. There is a state vector that does change, but only a little: a few hundred entries out of a hundred thousand, every step. And then there is a line that multiplies the two together, over and over, for as long as the simulation runs.

In PyTorch that line is torch.sparse.mm(A, x), and it does the whole multiply every time. It has no way to know that almost nothing moved. We built a PyTorch extension that does know, and this note reports what it is worth — including the cases where it is worth nothing and the ones where it costs you.

The idea in one paragraph

If A is fixed, then multiplying it by a slightly different vector gives a slightly different answer, and you can compute the difference directly: A(x + dx) = Ax + A·dx. You already have Ax from last step. So the only work left is A·dx, and if dx is zero almost everywhere, that touches only the columns of A where it isn’t. Everyone in this area knows that much. What we add is the layout: we cut A into tiles ahead of time and record which tiles each column falls in, so at run time we can walk only the tiles the changed columns pass through and never look at the rest. The answer is the same one PyTorch would have given — agreement to 1.6e‑15 relative across the whole sweep below, which is floating-point noise, not an approximation we chose.

What you actually install

An out-of-tree extension. You do not build PyTorch from source and you do not patch it: the ops register through TORCH_LIBRARY like any other custom op, so they are traceable and they compose with the rest of your model. There is a thin Python wrapper over the top for the common case. The apply writes into a tensor you own, in place, which is a small thing that turns out to matter a great deal — see the ladder below.

op   = HSTOperator(A, tile=8)          # compile the tile index once
sess = op.session(cols, B=8)           # schedule for the columns you expect

for step in range(n):
    cols, dx = advance(...)            # your solver, unchanged
    sess.apply_cols(cols, dx, y)       # y += A[:, cols] @ dx, in place

The schedule is compiled once for a set of column tiles and then reused: apply_cols takes a different column set each step, which is legal as long as the columns stay inside tiles the schedule already covers. That is what lets one schedule survive a changed set that keeps moving, and it is the mechanism behind everything below. That is also the whole bargain: you pay a preparation cost up front so that every later step can be cheap, which means the method is worth having when the loop is long and worth nothing when it is short.

How much faster — and than what

This is the part that needs care, because you can get almost any number you like out of this comparison depending on what you put on the other side. So here is the whole ladder, each rung measured in the same run on the same five operators, at a batch width of 8.

10× 20× 50× torch.sparse.mm, every step the starting point 1.0× Slice the columns, then torch.sparse.mm stay in PyTorch 3.2× A hand-written C++ column delta leave PyTorch 21.9× HST scheduled delta our contribution 53.8×
Four ways to do the same update. Each bar is measured against recomputing the product in PyTorch every step. Log scale.

Read that from the top. Recomputing in PyTorch is the starting point. Slicing the changed columns out once and multiplying only the slice — which is the best you can do without leaving PyTorch — is 3.2× faster than that. Writing the same delta by hand in C++ is another 6.8× on top. And our scheduled version is another 2.5× on top of that, for 54× end to end.

The honest reading of that chart is that the big number is mostly not ours. Anyone who swaps torch.sparse.mm for our extension will measure 16.7×, and that is a real thing that will happen to them — but it factors as 6.8× for leaving PyTorch and 2.5× for the schedule, and only the second number is ours. torch.sparse.mm has no out=, so every call allocates a fresh output block, and its cost tracks the number of rows rather than the size of the slice you asked for. Those are structural facts about the API, not benchmark artefacts, and any competent C++ delta collects most of that speedup without us.

So the number we care about internally is the last rung only: what is the schedule worth against a hand-written delta that is already doing the right thing? That comparison is the rest of this note.

Against a delta that is already good

The baseline here is deliberately strong — a compressed row list compiled for the exact set of changed columns, threaded, with the integer widths and restrict qualifiers you would use if you cared. It is what a good engineer produces after a week on the problem. Against that baseline the answer depends almost entirely on how the changed set moves, and much less on how fast it changes.

0.5× 1× parity frozen 0.002 0.01 0.05 0.25 ρ — fraction of the changed set replaced each step 0.47–0.48× both models 6.94× 5.99× 4.96× 4.75× local drift 2.93× 2.09× 1.62× 1.36× uniform jump
local drift — the changed set walks along the operator uniform jump — it teleports somewhere unrelated
Ratio of baseline time to our time per step; above 1× we are faster. 21 sparse operators, five random starting sets each, 400 steps per cell, batch width 8. The frozen point on the left is a separate case, drawn detached because a line through it would imply a trend that isn’t there. Log scale.

When the changed set drifts — a wavefront moving across a mesh, a load moving along a network, the ordinary behaviour of a time-stepping simulation — we run 6.9× the baseline at the lowest churn rate and still 4.8× at the highest rate we have measured. All 21 operators are faster, at every churn rate in that range. The reason is the tiling: when the set moves a little, most of the tiles it needs are the ones it needed last step, so we pay only for the new ones. The baseline is compiled for an exact column set, so any movement at all forces it to recompile.

When the changed set jumps — a genuinely random new set every step, nothing reused — the advantage collapses from 2.9× to 1.4× and the number of operators we win on falls from 20 of 21 to 12 of 21. That is the mechanism working exactly as described: there is no locality left to exploit, so there is nothing for the schedule to save.

And when nothing moves at all — a fixed set of changed columns, step after step — we lose, at 0.48×. This is not a defect and it is not going to be fixed. Tiles are a fixed grid, so a tile containing one changed column still gets scanned as a tile; the baseline touches exactly the entries it needs. Our best case on a frozen set is a tie, and padding cannot be negative. If your changed set never moves, use the baseline.

Conditions. Baseline: a threaded C++ compressed-row column delta compiled for the exact changed set. 21 SuiteSparse operators (1,723 to 170,998 columns; 6,511 to 2,600,295 nonzeros), five random starting sets, five churn rates, two motion models, 400 steps per cell, batch width 8, tile 8, double precision. Geometric mean over operators within a seed, then the median across seeds; the range in the appendix tables is across seeds. Both arms pay the same per-call overhead and both hoist the same setup. Measured on a pinned four-core x86 bench machine — turbo disabled, governor fixed, one job on the box at a time — under gcc 13.3. ρ = 0.25 is the highest churn rate in the sweep; we have not measured above it. Every cell was checked against a from-scratch recompute, worst relative error 1.6e‑15.

Neither arm wins everywhere, so we ship both

Put the two motion models side by side per operator and the picture stops being a single number. Under drift every operator is on our side, by between 1.3× and 34×. Under jump at the fastest churn rate measured, nine of the same 21 operators are on the baseline’s.

0.5× 10× 20× 40× ← baseline wins   1×   HST wins → TSOPF_RS_b39_c30 TSOPF_RS_b162_c3 TSOPF_RS_b300_c1 nasa2910 raefsky3 case9 msc10848 ct20stif s3rmt3m3 memplus olafu bcsstk25 circuit_3 bcsstk18 add32 epb2 rajat03 wang3 scircuit bcspwr09 bcspwr10
local drift, ρ=0.002 uniform jump, ρ=0.25
Every operator in the sweep, sorted by its drift result. Bars run right from parity where we are faster and left where the baseline is. Log scale.

You cannot pick between the two by looking at the matrix, and the clearest evidence is that all nine operators the baseline wins under jump are ones we win under drift at the same churn rate, by between 1.15× and 6.6×. Same matrices, same rate, opposite answers. We also tried the density statistic that ought to separate the two classes: it overlaps badly, with our wins running as low as 0.18 and the baseline’s as high as 0.80. What decides is how the workload moves, and that is not a property of the matrix you can read off in advance.

So the product does not choose by a rule. It runs both arms briefly on the caller’s own data, keeps the faster one, and re-probes when the workload shifts underneath it. That is worth 5.1× in steady state under drift, or 4.0× once the cost of the probing itself is charged against it, and 1.9× / 1.5× under jump. The reason it is worth doing at all is the size of a wrong answer: the worst single case we have measured costs 35.6× if you guess wrong in one direction, and 2.4× in the other.

Whether it will help you

Six things have to be true, and they fail independently. The cheapest ones to check are first.

  • The sparse operator is fixed. Same nonzeros, same values, across many steps. If you rebuild it every iteration because it depends on the current estimate, there is nothing to amortise.
  • The step is linear. A softmax, a ratio, a percentile or a top-k anywhere in the loop and the difference of the outputs is no longer the output of the difference.
  • The change is sparse in the operator’s own dimension. A few dirty columns, not a few dirty rows of a batch.
  • The dirty set is localised. It may move — drift is the best case we have — but it should not teleport.
  • It runs in your process. The saving is microseconds per step, so a network hop or a serialisation boundary erases it before you see it.
  • The hot loop is a multiply, not a solve. A fixed operator whose bottleneck is a factorisation or a triangular substitution does not decompose this way, even with the other five perfect. This one catches people, because everything else looks right.

A useful shortcut, if you want to know in one question: is the loop you want to speed up the time-stepping loop, or the Newton loop? Traced against real solves, transient propagation and moving loads run 145 to 190 steps with a dirty set that barely turns over, which is the drift case above. Newton iterations re-linearise, so the operator is not fixed across them, and they converge in a median of ten steps — too few to amortise anything. The boundary runs straight through the middle of a single simulation.

Appendix — numbers and conditions

Every figure below was recomputed from the run’s output files for this page rather than copied from an earlier document. The per-operator table is published as a CSV so the charts above can be checked against it.

A1. Setup

Operator corpus21 SuiteSparse matrices, 1,723–170,998 columns, 6,511–2,600,295 nonzeros
Sweep21 operators × 5 churn rates × 5 dirty-set seeds × 2 motion models × 400 steps
Changed set256 columns per step (138 where the operator is smaller than that)
Batch width8, tile 8, double precision
BaselineThreaded C++ compressed-row column delta, compiled for the exact changed set
Toolchaingcc 13.3, -O3 -march=native, PyTorch 2.13.0+cpu
MachineOne pinned four-core x86 box, turbo off, governor fixed, one job at a time
AggregationGeometric mean over operators within a seed, then median across seeds; brackets are the seed range
CorrectnessEvery cell asserted against a from-scratch recompute; worst relative error 1.6e‑15 across 1,050 cells

A2. The PyTorch ladder

Five operators, localised changed set of 256 columns, reverse Cuthill–McKee ordering, tile 8. Each row is that step’s own gain over the row above it; the two totals are cumulative. Worst relative error across these cells, 7.1e‑16.

StepB=1B=8B=16
Slicing the columns first, inside PyTorch6.74×3.21×2.52×
Then: a hand-written C++ column delta10.48×6.80×7.76×
Then: HST's scheduled delta0.91×2.46×3.22×
Cumulative, against recomputing in PyTorch64×54×64×
Against the best delta a PyTorch user can write9.5×16.7×25.5×

At a batch width of 1 our rung reads 0.91× — the schedule is behind the hand-written delta there, and the 64× in that column is entirely the two rungs above it. The schedule needs batch width to pay for its own padding. Under the other column ordering in the same run the three cumulative totals read 52×, 44× and 53×.

A3. Speedup against churn

Ratio of baseline time to our time per step, over all 21 operators. Above 1.00 we are the faster arm. Median across the five seeds, with the seed range in brackets.

ρlocal driftuniform jumpoperators where HST is the better arm
0 — frozen0.48× [0.47–0.49]0.47× [0.46–0.48]1/21 drift · 1/21 jump
0.0026.94× [6.59–6.97]2.93× [2.63–3.17]21/21 drift · 20/21 jump
0.015.99× [5.90–6.18]2.09× [2.06–2.26]21/21 drift · 16/21 jump
0.054.96× [4.83–5.03]1.62× [1.62–1.67]21/21 drift · 14/21 jump
0.254.75× [4.72–4.78]1.36× [1.34–1.37]21/21 drift · 12/21 jump

Three cells of 1,050 carry a churn label but never actually moved — the rate is a probability and it quantises — and are excluded from the churning figures as frozen cells wearing the wrong label. Nothing else is dropped. The frozen row is identical across motion models by construction, which makes it the sweep’s own noise floor: the two independent measurements of it land 0.7% apart.

A4. Per operator

Seed median of baseline time over our time. Values below 1.00 are greyed: those are cells where the baseline is the arm to use.

Operatornnnzfrozendrift
ρ=0.002
drift
ρ=0.25
jump
ρ=0.002
jump
ρ=0.25
TSOPF_RS_b39_c3060,0981,079,9861.0033.8933.4416.721.60
TSOPF_RS_b162_c315,374610,2990.8731.9429.0414.332.57
TSOPF_RS_b300_c114,5381,474,3250.9924.0129.1316.333.82
nasa29102,910174,2960.7114.198.705.373.71
raefsky321,2001,488,7680.7313.609.104.912.75
case914,454147,9720.6212.4012.114.311.15
msc1084810,8481,229,7760.5410.498.425.283.50
ct20stif52,3292,600,2950.599.735.903.111.74
s3rmt3m35,357207,1230.649.575.083.952.65
memplus17,75899,1470.609.496.644.410.84
olafu16,1461,015,1560.479.137.663.642.48
bcsstk2515,439252,2410.385.953.071.811.05
circuit_312,12748,1370.535.464.311.590.62
bcsstk1811,948149,0900.354.992.621.660.95
add324,96019,8480.363.911.791.300.85
epb225,228175,0270.383.591.981.210.70
rajat037,60232,6530.322.881.281.100.76
wang326,064177,1680.272.321.161.080.66
scircuit170,998958,9360.362.241.430.850.45
bcspwr091,7236,5110.241.961.521.731.19
bcspwr105,30021,8420.181.321.151.200.90

A5. What choosing the arm is worth

The router runs both arms on the caller’s workload and keeps the faster one. “Ceiling” is what an oracle that always picked correctly and paid nothing to find out would get; “steady” is the router after it has settled; “with calibration” charges it for the probing.

Regimenceilingsteadywith calibrationpicks the oracle’s arm
drift, churning4185.56×5.08×3.97×418/418
jump, churning4192.06×1.89×1.54×364/419
drift, frozen1051.00×0.96×0.90×102/105
jump, frozen1051.00×0.96×0.90×100/105

The 418 of 418 is not by itself evidence that the router works: always answering “ours” ties it in that block, because under drift ours is right 99.8% of the time. The skill is switching regime, which is worth +18.9 points over always answering the same way when the changed set drifts and +28.0 points when it jumps. On the frozen rows the router costs 4% in steady state and 10% with calibration, which is the price of asking; it is right to ask, because it cannot know the set is frozen until it looks.

A6. The cost of choosing wrong

Mistakeworst cellwhere
Using the baseline where ours wins35.6×TSOPF_RS_b39_c30, drift, ρ=0.01
Using ours where the baseline wins2.4×scircuit, jump, ρ=0.25

The asymmetry is why probing is cheap insurance. The two errors are not the same size, so a rule that is wrong occasionally in the expensive direction is worse than one that is wrong often in the cheap one.

A7. What these numbers do not cover

  • One machine and one compiler. Codegen variance on this workload is a 3–7× effect, larger than any algorithmic change we have made, so a different toolchain is a different measurement and we re-run rather than extrapolate.
  • CPU, double precision, single socket. Nothing here predicts GPU behaviour, and we have not measured it.
  • ρ = 0.25 is the top of the measured range. Above it we do not have a number, in either direction.
  • Two motion models, never averaged together. A single figure covering both would be arithmetic on two different questions, and the generator matters too — two reasonable ways of building a “jump” differ by up to 1.8× on identical operators, because one of them also shrinks the slice instead of only destroying its locality.
  • The sweep predates a round of preparation-path fixes that moved our arm faster and left the baseline arm unchanged. Re-measured on 50 cells of the current build, the churning results move 1.08 to 1.41× in our favour. The table above is the older, lower one, and we are keeping it until the full sweep is re-run.

← All notes

The same measurement, on your workload.

An assessment runs your system on your own hardware and reports the difference. It starts with a call.