Making PyTorch skip the part that didn’t change
HS‑002 2026‑08‑07 PyTorch extension
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.
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.
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.
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 corpus | 21 SuiteSparse matrices, 1,723–170,998 columns, 6,511–2,600,295 nonzeros |
| Sweep | 21 operators × 5 churn rates × 5 dirty-set seeds × 2 motion models × 400 steps |
| Changed set | 256 columns per step (138 where the operator is smaller than that) |
| Batch width | 8, tile 8, double precision |
| Baseline | Threaded C++ compressed-row column delta, compiled for the exact changed set |
| Toolchain | gcc 13.3, -O3 -march=native, PyTorch 2.13.0+cpu |
| Machine | One pinned four-core x86 box, turbo off, governor fixed, one job at a time |
| Aggregation | Geometric mean over operators within a seed, then median across seeds; brackets are the seed range |
| Correctness | Every 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.
| Step | B=1 | B=8 | B=16 |
|---|---|---|---|
| Slicing the columns first, inside PyTorch | 6.74× | 3.21× | 2.52× |
| Then: a hand-written C++ column delta | 10.48× | 6.80× | 7.76× |
| Then: HST's scheduled delta | 0.91× | 2.46× | 3.22× |
| Cumulative, against recomputing in PyTorch | 64× | 54× | 64× |
| Against the best delta a PyTorch user can write | 9.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 drift | uniform jump | operators where HST is the better arm |
|---|---|---|---|
| 0 — frozen | 0.48× [0.47–0.49] | 0.47× [0.46–0.48] | 1/21 drift · 1/21 jump |
| 0.002 | 6.94× [6.59–6.97] | 2.93× [2.63–3.17] | 21/21 drift · 20/21 jump |
| 0.01 | 5.99× [5.90–6.18] | 2.09× [2.06–2.26] | 21/21 drift · 16/21 jump |
| 0.05 | 4.96× [4.83–5.03] | 1.62× [1.62–1.67] | 21/21 drift · 14/21 jump |
| 0.25 | 4.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.
| Operator | n | nnz | frozen | drift ρ=0.002 | drift ρ=0.25 | jump ρ=0.002 | jump ρ=0.25 |
|---|---|---|---|---|---|---|---|
| TSOPF_RS_b39_c30 | 60,098 | 1,079,986 | 1.00 | 33.89 | 33.44 | 16.72 | 1.60 |
| TSOPF_RS_b162_c3 | 15,374 | 610,299 | 0.87 | 31.94 | 29.04 | 14.33 | 2.57 |
| TSOPF_RS_b300_c1 | 14,538 | 1,474,325 | 0.99 | 24.01 | 29.13 | 16.33 | 3.82 |
| nasa2910 | 2,910 | 174,296 | 0.71 | 14.19 | 8.70 | 5.37 | 3.71 |
| raefsky3 | 21,200 | 1,488,768 | 0.73 | 13.60 | 9.10 | 4.91 | 2.75 |
| case9 | 14,454 | 147,972 | 0.62 | 12.40 | 12.11 | 4.31 | 1.15 |
| msc10848 | 10,848 | 1,229,776 | 0.54 | 10.49 | 8.42 | 5.28 | 3.50 |
| ct20stif | 52,329 | 2,600,295 | 0.59 | 9.73 | 5.90 | 3.11 | 1.74 |
| s3rmt3m3 | 5,357 | 207,123 | 0.64 | 9.57 | 5.08 | 3.95 | 2.65 |
| memplus | 17,758 | 99,147 | 0.60 | 9.49 | 6.64 | 4.41 | 0.84 |
| olafu | 16,146 | 1,015,156 | 0.47 | 9.13 | 7.66 | 3.64 | 2.48 |
| bcsstk25 | 15,439 | 252,241 | 0.38 | 5.95 | 3.07 | 1.81 | 1.05 |
| circuit_3 | 12,127 | 48,137 | 0.53 | 5.46 | 4.31 | 1.59 | 0.62 |
| bcsstk18 | 11,948 | 149,090 | 0.35 | 4.99 | 2.62 | 1.66 | 0.95 |
| add32 | 4,960 | 19,848 | 0.36 | 3.91 | 1.79 | 1.30 | 0.85 |
| epb2 | 25,228 | 175,027 | 0.38 | 3.59 | 1.98 | 1.21 | 0.70 |
| rajat03 | 7,602 | 32,653 | 0.32 | 2.88 | 1.28 | 1.10 | 0.76 |
| wang3 | 26,064 | 177,168 | 0.27 | 2.32 | 1.16 | 1.08 | 0.66 |
| scircuit | 170,998 | 958,936 | 0.36 | 2.24 | 1.43 | 0.85 | 0.45 |
| bcspwr09 | 1,723 | 6,511 | 0.24 | 1.96 | 1.52 | 1.73 | 1.19 |
| bcspwr10 | 5,300 | 21,842 | 0.18 | 1.32 | 1.15 | 1.20 | 0.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.
| Regime | n | ceiling | steady | with calibration | picks the oracle’s arm |
|---|---|---|---|---|---|
| drift, churning | 418 | 5.56× | 5.08× | 3.97× | 418/418 |
| jump, churning | 419 | 2.06× | 1.89× | 1.54× | 364/419 |
| drift, frozen | 105 | 1.00× | 0.96× | 0.90× | 102/105 |
| jump, frozen | 105 | 1.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
| Mistake | worst cell | where |
|---|---|---|
| Using the baseline where ours wins | 35.6× | TSOPF_RS_b39_c30, drift, ρ=0.01 |
| Using ours where the baseline wins | 2.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.
The same measurement, on your workload.
An assessment runs your system on your own hardware and reports the difference. It starts with a call.