thepragmaticquant.com

Ten thousand series, one pass

Contents
  1. 01 Ten thousand series is a different problem
  2. 02 Three ways to run it
  3. 03 Two races, two opponents
  4. 04 One series, once
  5. 05 The reduce is the panel API
  6. 06 Only the answer grows

TL;DR: Bootstrapping 10,000 related series at once, the fused panel pass finishes in 0.821 seconds where the per-series loop needs minutes. Call it roughly 220x on the time axis, and that multiplier belongs to the loop alone. On the memory axis it holds peak use to about a tenth of a gigabyte, roughly 141x lighter than building every resampled copy at once, and that multiplier belongs to the materializing path alone. Two wins, two different opponents, and they never share a podium.

Ten thousand related time series, a thousand bootstrap replicates, resampled copies, of each, two hundred observations per series, eight bytes per number. Run the multiplication before anything else gets to happen: 10,000 x 1,000 x 200 x 8 bytes is 16 GB. That is scratch space, the resampled copies alone, before the first statistic is computed on any of them. It is more memory than most of the machines the job actually runs on have to spare, and it is the bill for the most natural implementation anyone would write. And that is the polite version of the problem, where every series has the same length; the data that refuses to be a rectangle is waiting a few sections down.

The workload gives you two baselines to lose to and two axes to lose on. There is a loop that wastes time, and there is a tensor, the full set of resampled copies held in memory at once, that wastes memory. They are different opponents on different scoreboards, which is why a multiplier that names neither its baseline nor its axis has not told you what it beat.

So we run the honest version: ten thousand series, three implementations, two axes, and every multiplier chained to the baseline it actually beat. The skill is portable far beyond one library: it is how to read any benchmark table, yours or anyone’s, by asking which baseline and which axis each multiplier is chained to. There are two races here; we run both, and we score them separately.

Ten thousand series is a different problem

A panel is many related time series measured side by side. Daily sales for ten thousand stores, or returns for ten thousand tickers. The single-series tools from earlier in this track assume one series at a time; a panel brings failure modes of its own. The most stubborn of those is that panels are often ragged: the series do not all share a length, because store 4,412 opened last spring and sensor 209 died in March; raggedness returns below as an API question.

The statistical machinery is unchanged from the rest of this series. A bootstrap resamples your data many times and watches the statistic wobble from resample to resample; that wobble is the uncertainty estimate. Each resampled copy’s statistic is a replicate, and I fix B = 1000 replicates per series throughout. The resampling itself is the block scheme carried over from the first piece, contiguous chunks resampled whole so the dependence survives. The fixed configuration here is MovingBlock(block_length=20), not a choice I am relitigating.

The previous piece in this track rebuilt the single-series hot path around two refusals: never materialize (build in full, in memory) an array whose only consumer is a reduction (a collapse to one summary number), and never carry state you can derive. Here we push both refusals to panel scale, where every cost from that story gets multiplied by ten thousand.

The output, in plain words, before any of the machinery: a thousand numbers for each of ten thousand series, one bootstrap distribution of the mean per series, sitting in a grid, and everything below is about what it costs to fill it.

Three ways to run it

The first way is the loop: take the single-series tool and call it ten thousand times, once per series. Nothing about it is wrong, and its memory behavior turns out to be perfectly respectable; the waste is time. Every call pays a fixed setup toll, dispatch, validation, per-call state, before any resampling happens. The previous article’s lesson about carried state applies here multiplied by the series count: ten thousand calls means ten thousand tolls, and the tolls add up to most of the clock.

The second way is to materialize: build the whole resampled block first, then reduce it. The block in question is the tensor from the second paragraph, every resampled copy of every series at once. Walk its size in words: ten thousand series, times a thousand replicates of each, times two hundred observations per replicate, times eight bytes per observation, and that is the 16 GB from the opening paragraph. Materializing is the correct tool when your workflow consumes the resampled paths themselves, path-dependent statistics such as the drawdown of each resampled series, and its memory bill is the honest price of wanting the paths. The measured bill agrees with the napkin: the materializing path’s peak on our benchmark, 16.08 GB, sits right beside the 16 GB the multiplication predicts, so there is no mystery in the number: it is the tensor. The twist is that materializing is not even fast; it pays the memory and still loses the race.

The third way is the one we defend, and it needs two words glossed first. A reduce is the collapse from a resampled series to one number, here a mean: two hundred resampled observations go in, one statistic comes out. The single-series version of the idea already appeared in the previous piece, where it deleted a 160 MB intermediate. A fused pass does the resample and the reduce in one motion, inside one compiled loop body, so the resampled copy never exists anywhere. The fused panel pass returns the full bootstrap distribution of the statistic, a B x num_series matrix, every replicate’s mean for every series, and the whole design leans on that return type. VaR and CVaR, the quantile and tail-average risk measures finance reads off a distribution, come straight off that matrix with no giant tensor anywhere in the story. Only workflows that consume the resampled paths themselves need to materialize.

The methodology, pinned once, starts with the fixed configuration: B = 1000, n = 200 observations per series, MovingBlock(block_length=20), the mean as the statistic, random_state=0. Box: an ephemeral 8-vCPU dedicated cloud instance with 30 GB of RAM, created and destroyed for the run. Software and date: tsbootstrap 0.7.0, measured 2026-07-14, every cell the settled median of repeated timed runs after an explicit warm up. The spread is pinned in the committed ledger. Peak memory means the most the process ever held at once, above its resting floor, and it prints in binary megabytes throughout this page: the 15,337 MB in the table below is 16.08 GB in the decimal units disk vendors use. The ledger names the three paths reduce_panel, materialize_all, and per_series_loop, in the order I introduced them above. Here is the whole ledger; everything after the table is derived from it.

seriespathwall time (s)peak memory delta (MB)
100reduce_panel0.01250.9
100materialize_all1.846157.1
100per_series_loop1.7943.9
1,000reduce_panel0.08329.4
1,000materialize_all16.371,534.3
1,000per_series_loop17.9611.4
10,000reduce_panel0.821108.4
10,000materialize_all185.715,337
10,000per_series_loop180.686.4

B = 1000, n = 200, MovingBlock(block_length=20), statistic mean, random_state=0. Settled median of repeated timed runs; per-cell spreads are pinned in the committed ledger. Measured with tsbootstrap 0.7.0 on an 8-vCPU dedicated cloud instance, 30 GB RAM, on 2026-07-14. The reduce_panel row is the only one that stays small in both columns at every scale.

Two races, two opponents

The fused pass, one clause of recap: resample and reduce in a single motion, so the intermediate copy never exists; the table above already contains its scoreline.

At ten thousand series, the fused pass finishes in 0.821 seconds where the per-series loop takes 180.6 seconds, roughly 220x faster, and that is a time-axis win over the loop, no one else. On the memory axis, the fused pass peaks at 108.4 MB where the materializing path peaks at 15,337 MB, roughly 141x lighter, and that is a memory-axis win over the tensor, no one else.

Two wins, two different opponents, and any sentence that combines them must say so. The same discipline applies to most published benchmark tables, ours among them: for every multiplier, ask which baseline it was measured against and which axis it lives on. A headline that combines the two races into one number is claiming a comparison nobody ran. (The fused pass is also about 226x faster than the materializing path itself, a third ratio that names its own baseline just as the others do.)

The reading discipline applies to our own numbers first. I measured tsbootstrap 0.4.0 and 0.7.0 back to back on the same box, and the 10,000-series headline ratios agree across the two versions within noise. The one version-sensitive spot is the fused pass at small scale: it runs about 2.8x faster at 100 series on 0.7.0, which lifts the 1,000-series loop ratio from about 200x to 216x; I print 0.7.0 throughout. Against the July 3 measurement of the same workload, the memory headline moved from about 163x to about 141x, because a newer numba and a different box raised the fused pass’s resident overhead identically on both library versions, while the time headline held; I print the current, lower claim.

The 10,000-series row of the ledger as a map, on logarithmic axes. The fused point sits far left, cheap on time and nearly as low on memory as the loop; the loop and the tensor sit far to the right, and that distance is the story. The loop was already cheap on memory; the tensor path is cheap on neither axis, which is the point of plotting it. The fused pass is the first point cheap on both, and the two brackets deliberately refuse to meet: one names the loop, the other names the tensor.
data table
path (10,000 series)wall time (s)peak memory delta (MB)
reduce_panel (fused)0.821108.4
materialize_all185.715,337
per_series_loop180.686.4
time ratio, fused vs the loop only~220x
memory ratio, fused vs the tensor only~141x

And what is the fused pass’s 108.4 MB, mostly? The answer itself: a thousand means for each of ten thousand series at eight bytes each is 1,000 x 10,000 x 8 bytes, about 80 MB, and that grid is the product being bought, with the scaffolding around it costing little more than a rounding error.

One series, once

To see why the fused pass wins both races, follow one series through the machine, with two words from the previous article back in hand. The cache is the small fast memory next to the processor, the desk at arm’s reach, and work that stays there is cheap. Everything else lives in DRAM, main memory, the big slow pool at the far end of the room, and work that keeps going back to it is what the two losing paths are made of.

So take series 7,204 of 10,000, a store with 200 days of sales. The panel sits in one flat array, and series 7,204 is found by its bookmark: an offset that says where its 200 observations begin. Its whole life is 200 x 8 bytes, 1.6 kB, loaded over the cache boundary once. It is still sitting in that fast memory when the kernel derives all 1,000 of its replicate index rows on the spot, gathers them, and reduces them. Its thousand means go out to the output grid, and then the kernel evicts it to make room for series 7,205 and never touches it again.

That is the entire mechanism, once more with no vocabulary at all. One small series arrives in fast memory, everything that will ever be asked of it happens right there, the answers leave, the series leaves, next series, ten thousand times over.

The part of that story deferred from the previous article is where the 1,000 index rows come from. Each series has a slot, its position in the panel counted from zero, and the slot is about to matter because it is an ingredient of every random draw. The compiled backend draws its randomness from a counter-based generator, a random number generator whose state is computed from who is asking rather than carried from call to call. The previous piece used it to delete per-replicate seed objects on one series, and the panel version extends the same idea along a second axis. The key for any draw is a pure function of three things: the root key, the series slot, and the replicate. Nothing is stored, so nothing has to be handed between ten thousand series in the right order. One consequence is a guarantee users can test: a panel’s first series, series slot 0, gets exactly the stream it would get standalone, with no special case anywhere in the fold.

The generator is Philox, the counter-based design from the Random123 paper, the same constants cuRAND ships; the previous article’s derive-dont-carry idea, now folded across two axes instead of one.
The fused pass never builds the tensor because its unit of residency is one series: load it once, derive its thousand replicate streams on the spot, reduce while it is still in fast memory, write the means, evict, next; unequal lengths ride the same flat-array-plus-offsets layout, so the ragged case runs the same loop. Everything else on screen is bookkeeping for that one choice.
data table
on-canvas quantityvalue
one series crossing the cache boundary (n = 200 x 8 bytes)1.6 kB, once
output grid (1,000 x 10,000 means x 8 bytes, analytical)80 MB
the tensor never built (10,000 x 1,000 x 200 x 8 bytes, analytical)16 GB, never allocated
measured peak while running (tsbootstrap 0.7.0, 2026-07-14)108.4 MB

Now the contrast that explains both losses at once. The materializing path in effect follows one replicate instead of one series, and one replicate touches all ten thousand series: a working set, the bytes a pass must keep hot to make progress, that fits nowhere, so everything reloads from main memory on every pass. The fused pass wins because the thing it keeps close is one series, small enough to live in cache for its whole turn.

The fine print comes in two pieces. First, the streams are keyed to the seed plus the slot order, so reordering the panel reorders every downstream stream. Reproducibility is a contract about the seed and the order together, and shuffling your series is a new experiment. Second, the compiled path is equal in distribution to the NumPy reference path, and it is not bit-identical to it; inside the compiled path, results are bitwise reproducible for a given seed regardless of thread count.

backend="numpy" remains the reproducible per-series reference path, byte-stable across releases; the compiled panel kernels are opt-in, and when the compiled extra is missing they raise an error, with no silent fallback.

Everything in the napkin sum below is transparent arithmetic on the fixed configuration except the two measured rows.

quantitysize
one series’ data (200 x 8 bytes; its whole life in cache)1.6 kB
that series’ 1,000 replicate means (1,000 x 8 bytes)8 kB
the materialized slab for that one series (1,000 x 200 x 8 bytes, schematic)1.6 MB
the full tensor, analytical (10,000 x 1,000 x 200 x 8 bytes)16 GB
the full tensor, measured peak16.08 GB
the full answer, analytical (1,000 x 10,000 x 8 bytes)80 MB
the full answer, measured peak108.4 MB
tensor bytes per answer byten = 200

The tensor’s measured peak sits just above the 16 GB arithmetic, by well under one percent. Which side of the arithmetic the measurement lands on comes down to the binary-versus-decimal unit convention pinned in the methodology: 15,337 binary megabytes and 16.08 decimal gigabytes are the same measurement. The answer’s measured peak sits above its 80 MB arithmetic for a plainer reason: the measured delta includes index buffers and allocator slack on top of the answer itself. The last row generalizes: the waste factor of materializing equals n, the length of your series, and it grows with every observation you add. You pay the length of every series again for every byte you keep.

The reduce is the panel API

The data that refuses to be a rectangle, promised in the first section, is an API fact in tsbootstrap. A ragged panel cannot be a 2-D array: there is no rectangle when series 4,412 has 61 observations and series 209 has 214. The panel input tsbootstrap accepts is instead one flat array of all observations plus a short list of offsets saying where each series begins: one long shelf with bookmarks (CSR, compressed sparse row, is the storage convention we borrow). The mechanical reality: the compiled kernels consume that layout directly, flat values plus the offset list, indptr, so ragged access is contiguous inside each series rather than per-element pointer chasing.

That is also why tsbootstrap ships a separate panel function: a loop over the single-series call could never fix the shape problem. Unequal lengths make the materialized rectangle incoherent: there is no (num_series, B, n) tensor when there is no shared n, and no .values() to hand you. The one thing every series can produce in a common shape is a reduced statistic, so the reduced statistic is the panel API.

python
# the compiled backend needs the accel extra:
#   pip install "tsbootstrap[accel]"
import numpy as np
from tsbootstrap import MovingBlock, bootstrap_reduce_panel

rng = np.random.default_rng(0)
# a small ragged panel: five series, five different lengths
panel = [rng.standard_normal(n) for n in (180, 205, 197, 210, 200)]

out = bootstrap_reduce_panel(
    panel, method=MovingBlock(block_length=20), statistic="mean",
    n_bootstraps=1000, random_state=0, backend="compiled")
# n_bootstraps passed explicitly: the default is 999,
# and this article's numbers assume 1000

print(out.shape)
# (1000, 5): the full bootstrap distribution of the mean,
# one column per series

# the flat layout is accepted directly, too:
# bootstrap_reduce_panel((values, indptr), ...) where values is one
# flat float64 array and indptr marks where each series begins

For completeness: recursive, model-based methods raise for panels in this release, with an error that says so; a residual refit across unequal lengths would not mean anything, and the library declines to pretend otherwise. And scoped to what I actually checked: as of this writing, the multivariate and ragged-panel reduce paths have no equivalent in arch, so no speedup number versus arch exists for panels, and I imply none anywhere here.

I also measured the ragged contract, at one configuration. I drew series lengths from a normal distribution centered on 200 with standard deviation 50, floored at 20 observations, then adjusted the draw so the panel’s total observation count lands at exactly 2,000,000 observations. That total matches the uniform 10,000 x 200 run’s two million exactly, so the two runs carry the same total data. The fused pass over that ragged panel finishes in 0.875 seconds, about 6 percent above the uniform run’s 0.821 seconds, and about 208x faster than the per-series loop’s 182.2 seconds on the same ragged panel, a ratio chained to the loop like every other one here.

Only the answer grows

The ledger’s memory column across 100, 1,000, and 10,000 series says which line each path rides. The fused pass rides the output line: 0.9, 9.4, 108.4 MB, linear in the number of answers and in nothing else. The materializing path rides the tensor line: 157.1 MB, 1,534.3 MB, 15,337 MB, ten times the bill for every ten times the series. And the loop stays light the whole way, 3.9, 11.4, 86.4 MB, hugging the fused line; its cost lives entirely on the time axis. At 1,000 series the fused pass is already about 216x faster than the loop (17.96 over 0.0832), a ratio that names its baseline in the same clause, as every other one in the piece does.

Peak memory at three scales on one honest linear axis, locked across all three charts. The first two charts establish the scale; the third breaks it, and the break is the point: the page physically cannot contain the tensor's growth. The gap is set by what each path must hold at once, a transient peak for the tensor path against an accrued output for the fused path.
data table
seriespathwall time (s)peak memory delta (MB)
100reduce_panel0.01250.9
100materialize_all1.846157.1
100per_series_loop1.7943.9
1,000reduce_panel0.08329.4
1,000materialize_all16.371,534.3
1,000per_series_loop17.9611.4
10,000reduce_panel0.821108.4
10,000materialize_all185.715,337
10,000per_series_loop180.686.4

Provenance, stated as scope: every number in this piece is one box, one library version, one date, tsbootstrap 0.7.0 on one dedicated 8-vCPU cloud instance, measured 2026-07-14. Ratios are more portable than absolute times, and even the ratios are claims about one configuration. What I did not measure, so you do not have to wonder: other block lengths, other statistics, other dtypes, and multivariate panels. I measured the ragged leg at exactly one configuration, the length-varied panel from the previous section, and everything I say about ragged performance is scoped to it.

Frequently asked questions

What is a panel bootstrap, and when do I need one?
A panel is many related time series measured side by side, like daily sales for 10,000 stores. A panel bootstrap resamples every series (with blocks, so each series' dependence survives) and computes the bootstrap distribution of a statistic for each one: here, 1,000 replicate means per series across all 10,000 series in one call. You need one whenever you want per-series uncertainty at fleet scale: confidence intervals on every store's mean, every sensor's drift, every ticker's average return, without writing the loop yourself.
Why is there a separate panel function instead of looping the single-series call, and what happens when the series have unequal lengths?
Two reasons. Performance: a loop pays a fixed per-call setup toll 10,000 times, and on the measured 10,000-series benchmark the fused panel pass ran roughly 220x faster than that per-series loop (0.821 s against 180.6 s), a time-axis comparison against the loop specifically. Shape: panels are often ragged, with unequal series lengths, so there is no rectangular array a generic API could return; the panel input is one flat array plus per-series offsets, and the reduced statistic is the one output every series can produce in a common shape.
If I shuffle the order of my series, do I get the same answers?
No. Each series' random streams are keyed to the seed plus its slot, its position in the panel, so reordering the panel reorders the streams downstream: same seed, different assignment of streams to series. The contract is exact in the other direction: series slot 0 gets precisely the stream a standalone single-series run would get, with no special case. For what the compiled backend does and does not promise relative to the default backend, see the reproducibility question in the previous article's FAQ.
I need the resampled paths themselves, not just a statistic. What do I do?
Materialize, and budget for it: that is the correct tool for path-level workflows, path-dependent statistics such as the drawdown of each resampled path, and this article's memory column is its honest bill. But check first that you actually need paths. The fused pass returns the full bootstrap distribution of the statistic, a matrix of B x num_series replicate values, so quantile and tail workflows on an estimator, such as VaR or CVaR over the statistic's distribution, are served directly with no giant tensor. Only path-level consumption requires materializing.
Why is there no arch comparison number in this article?
Because no measured number exists to print. As of this writing, arch has no equivalent multivariate or ragged-panel reduce path to run this workload against, so there is nothing to race: any panel multiplier versus arch would be a comparison against an implementation someone would first have to write. This article prints nothing it did not measure; the single-series head-to-head, where an equivalent path does exist, is the previous article's scoreboard.

As the piece ships, the panel section of the public benchmark page carries the full three-way table above: axes labeled, baselines named, provenance pinned, and a link back here. Underneath every row of it sits a contract no table can show: ten million derived random streams that have to land exactly where the key arithmetic says, on every machine, in any order. The page is built to be argued with: anyone who wants to can see what each multiplier was chained to, which is all we have been asking of any benchmark table, anyone’s, ours included. And the scoreboard closes where the piece opened, on 10,000 x 1,000 x 200 x 8 bytes. When the last of the ten thousand series is evicted, the grid of answers is sitting in memory, and the 16 GB tensor is still what it was at the start: never allocated.