Skip to main content

What the numbers measure

Every figure here is given against a declared baseline. A number without its denominator is not interpretable, and the choice of denominator changes the size of a ratio.

Which package allocates the bytes​

Every surviving allocation of a 200x100 transport build, attributed to the package that allocated it. Both rows are the same model over the same sets. Rows and columns are fixed at 200 and 20 000, and the density of the coefficient determines the nonzeros.

nonzerosnimblendnimopt
20 0000.490 MB0.406 MB
2 2090.204 MB0.406 MB

nimblend allocates 16.0 bytes per added nonzero, an int32 column and a float64 value. The matrix of a model is stored in a nimblend.EntryBuffer, and the CSR conversion returns views of that buffer. nimopt moves by 48 bytes across a ninefold change in the matrix. It allocates the per-column vectors, the bounds and the integrality, and the row bounds. The sets fix those counts.

What it does not claim. It does not claim that nimblend allocates more bytes than nimopt. The totals follow from the nonzeros per column of the model. A model with one entry per column puts nimopt above nimblend with no boundary violation. The measure is the gradient, not the totals.

Two models​

benchmarks/bench_transport.py ships from plants to warehouses over a network. Each plant serves a band of nearby warehouses, and the flow variable spans the arcs, not the full product.

plantswarehousesarcs/plantrowscolumnsnonzerosmatrixpeakratiobuild
200100103002 0004 0000.05 MB0.39 MB8.09x8 ms
2 000500202 50040 00080 0000.96 MB6.95 MB7.24x45 ms
10 0002 0004012 000400 000800 0009.60 MB69.71 MB7.26x314 ms

benchmarks/bench_storage.py dispatches a thermal fleet, a solar fleet and a set of batteries against an hourly demand. The batteries couple adjacent hours through a cyclic state_of_charge row, and the generators through an upward ramp limit.

generatorsbatterieshoursrowscolumnsnonzerosmatrixpeakratiobuild
1021684 8622 6889 7240.12 MB0.95 MB8.10x26 ms
40872081 32046 080166 9602.00 MB14.76 MB7.37x69 ms
80208 7602 111 0801 226 4004 379 84052.56 MB370.45 MB7.05x1 552 ms

Both models solve through HiGHS. The two lag rules are visible in the row counts. Over 168 hours the ramp block has 10 x 167 rows: the first hour has no predecessor, and its row is not produced. The cyclic state_of_charge row wraps to the last hour and keeps all 2 x 168 of its rows.

Peak against the matrix​

The peak occurs while the model is built, not while it is assembled. On the 400 000-column transport model, the build peaks at 69.72 MB and the assembly that follows peaks at 68.23 MB. The expression of every constraint is built once to measure its shape, and the model that produces the matrix remains live while the matrix is written.

The denominator determines the size of the ratio. The CSR matrix is 9.60 MB; the data a solver takes — matrix, row pointer, row and column bounds, cost, integrality — is 21.04 MB, and the model that produced it is 21.74 MB more. Peak is 7.26x the matrix and 3.31x the full LP data, and 45.99 MB of the 69.72 MB is still live when the build returns.

The ratio is close to constant across the rungs, 8.09x at 4 000 nonzeros and 7.26x at 800 000. The figure of the smallest rung is stable and is not a first-call artifact: three consecutive measurements in one process give 0.39, 0.38 and 0.38 MB. The excess of the small rung over the large one is the fixed structures of the model. Those structures do not shrink with the matrix.

Where the transient bytes go, attributed by the frame that allocated them at the moment the 400 000-column build peaks:

bytesallocated by
16.26 MBthe caller's own arc labels and costs
14.40 MBthe broadcast product of a parameter against a variable
12.80 MBthe sorted copies the two unordered blocks need
8.00 MBthe variable's own index, values and column coordinate
6.40 MBthe subset's codes and its index in code order
3.20 MBthe positions a probe resolves to
3.20 MBa ravelled key set

An int64 ravel key costs 8 bytes per entry and an argsort permutation another 8, against the 12 bytes a matrix entry occupies. An alignment therefore costs more than the result it produces. That cost sets the lower bound of the ratio. It is per operation and transient, and it is not retained.

A second constraint costs its own rows and little else. Over a 500 000-cell model, one constraint peaks at 65.71 MB against an 8.00 MB buffer, and two constraints peak at 75.85 MB against a 16.00 MB buffer. That is 2.14 MB beyond the rows the second constraint adds. A constraint stores its term list and no block. An expression exists while its shape is measured and again while it is written, and never between.

What it does not claim. It does not claim that peak memory is 7x the matrix in an absolute sense. It is 7.26x the CSR matrix and 3.31x the full LP data on this model. Which of the two applies depends on the comparison being made.

Against linopy​

benchmarks/bench_vs_linopy.py builds the same three models both ways. Inputs — the arc list, the hourly profiles, the costs — are prepared outside the measured region and passed to both. The measured build runs from an empty model to the matrix a solver is given: assemble() for nimopt, m.matrices for linopy. Each side runs in a process of its own: resident memory includes the import cost of the library that is loaded. A row is printed once the two agree on rows, columns, nonzeros and the solved objective.

Resident memory is sampled, not traced. tracemalloc records only what passes through the Python allocator. Two libraries that allocate by different routes would then be compared on the route and not on the memory.

A variable over a sparse subset. The flow spans the arcs in nimopt and the full plant-by-warehouse product under a mask in linopy.

arcsmatrixnimopt RSSlinopy RSSnimopt buildlinopy build
2 0000.05 MB3.4 MB27.3 MB3.8 ms177.7 ms
40 0000.96 MB10.7 MB107.5 MB27.9 ms206.5 ms
400 0009.60 MB72.9 MB1 682.9 MB292.9 ms844.5 ms

At 400 000 arcs the mask spans a 20 000 000-cell product: nimopt builds the same matrix in a twenty-third of the memory and a third of the time. The design targets this case.

Temporal coupling on a dense model. Ramp limits and a cyclic state_of_charge row over a full generator-by-hour grid.

rowsmatrixnimopt RSSlinopy RSSnimopt buildlinopy build
4 8620.12 MB4.3 MB28.8 MB8.5 ms267.5 ms
81 3202.00 MB18.9 MB41.4 MB58.6 ms280.4 ms
2 111 08052.56 MB383.4 MB409.3 MB1 511.0 ms507.9 ms

Here the advantage narrows and then reverses on build time. At 2 111 080 rows linopy builds the same matrix 3.0x faster for 7% more resident memory. Nothing is sparse in this model, and the design costs time with no corresponding saving. Every operation aligns by key, while xarray broadcasts over dense grids and aligns by position. The model is also built twice, once to measure the shape of each constraint and once to write it. Resident memory is close on both sides: both store the same dense coefficient grids.

Integrality. Making the flow an integer column changes neither build: nimopt 8.6 MB and 27.3 ms against its own 10.7 MB and 27.9 ms as an LP, linopy 108.4 MB and 204.4 ms against 107.5 MB and 206.5 ms. Integrality is a column vector, not a matrix.

Reading the solution back. Primals onto their sets and duals onto their rows: nimopt 0.7–6.9 ms across every rung, linopy 1.4–64.5 ms. The 64.5 ms is the 400 000-arc transport model, where the solution is unpacked onto the masked product the build used.

The matrices differ in width. linopy returns a scipy matrix with int64 column indices, and the same 800 000 nonzeros occupy 12.80 MB against 9.60 MB for nimopt.

What it does not claim. It does not claim that nimopt is faster than linopy. On a dense temporally coupled model at two million rows it is three times slower, and the table reports that row with the others. The numbers support a narrower claim. Where a model is sparse in its variables, not materialising the grid saves memory and time. Where it is not, the alignment work costs time and saves nothing.