Skip to content

perf: default integer arrays to int32 for ~25% memory reduction#566

Open
FBumann wants to merge 9 commits intoPyPSA:masterfrom
FBumann:perf/int32
Open

perf: default integer arrays to int32 for ~25% memory reduction#566
FBumann wants to merge 9 commits intoPyPSA:masterfrom
FBumann:perf/int32

Conversation

@FBumann
Copy link
Collaborator

@FBumann FBumann commented Feb 2, 2026

Changes proposed in this Pull Request

Cut memory for internal integer arrays (labels, vars indices, _term coords) by ~25% and improve build speed by ~10-35% by defaulting to int32 instead of int64.

What changed

  • linopy/constants.py: Added DEFAULT_LABEL_DTYPE = np.int32
  • linopy/model.py: Variable and constraint label assignment uses np.arange(..., dtype=DEFAULT_LABEL_DTYPE) with overflow guard that raises ValueError if labels exceed int32 max (~2.1 billion)
  • linopy/expressions.py: _term coord assignment and .astype(int) for vars arrays now use DEFAULT_LABEL_DTYPE
  • linopy/variables.py: ffill, bfill, sanitize use DEFAULT_LABEL_DTYPE instead of astype(int) (which widened labels back to int64); Variables.to_dataframe arange uses int32
  • linopy/constraints.py: Constraints.to_dataframe arange uses DEFAULT_LABEL_DTYPE
  • linopy/common.py: fill_missing_coords uses int32 arange; save_join outer-join fallback uses DEFAULT_LABEL_DTYPE instead of astype(int); polars schema infers Int32/Int64 based on actual array dtype
  • test/test_constraints.py: Updated dtype assertions to use np.issubdtype (compatible with both int32 and int64)
  • test/test_dtypes.py (new): Tests for int32 labels, expression vars, solve correctness, and overflow guard
  • dev-scripts/benchmark_lp_writer.py (new): Benchmark script supporting --phase memory|build|lp_write with --plot comparison mode

Benchmark results

Reproduce with:

# Run on master
python dev-scripts/benchmark_lp_writer.py --phase memory --model basic -o bench_master_memory.json --label "master_org"
python dev-scripts/benchmark_lp_writer.py --phase build --model basic -o bench_master_build.json --label "master_org"

# Run on this branch
python dev-scripts/benchmark_lp_writer.py --phase memory --model basic -o bench_int32_memory.json --label "int32"
python dev-scripts/benchmark_lp_writer.py --phase build --model basic -o bench_int32_build.json --label "int32"

# Plot comparisons
python dev-scripts/benchmark_lp_writer.py --plot bench_master_memory.json bench_int32_memory.json
python dev-scripts/benchmark_lp_writer.py --plot bench_master_build.json bench_int32_build.json

Memory (dataset .nbytes)

Consistent 1.25x reduction across all problem sizes (e.g. 640 MB → 512 MB at 8M vars). The labels and vars arrays shrink 50% (int64 → int32) while lower/upper/coeffs/rhs stay float64.

benchmark_memory_comparison

Build speed

Consistently ~1.1-1.35x faster across all sizes (30 iterations with GC, tight error bars). 10-20% for large models (170ms → 153ms at 8M vars), and up to 35% for small/medium models where the fixed overhead of array allocation matters more relative to total time.

benchmark_build_comparison

Similar results on real PyPSA model.

No influence on lp-write

Checklist

  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in doc.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes doc/release_notes.rst of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

  linopy/constants.py — Added DEFAULT_LABEL_DTYPE = np.int32

  linopy/model.py — Variable and constraint label assignment now uses np.arange(..., dtype=DEFAULT_LABEL_DTYPE) with overflow guards that raise ValueError if labels exceed
  int32 max.

  linopy/expressions.py — _term coord assignment and all .astype(int) for vars arrays now use DEFAULT_LABEL_DTYPE (int32).

  linopy/common.py — fill_missing_coords uses np.arange(..., dtype=DEFAULT_LABEL_DTYPE). Polars schema inference now checks array.dtype.itemsize instead of the old
  OS/numpy-version hack.

  test/test_constraints.py — Updated 2 dtype assertions to use np.issubdtype instead of == int.

  test/test_dtypes.py (new) — 7 tests covering int32 labels, expression vars, solve correctness, and overflow guards.
…k to int64 via astype(int), now use DEFAULT_LABEL_DTYPE. Also Variables.to_dataframe arange for

  map_labels.
  - linopy/constraints.py: Constraints.to_dataframe arange for map_labels.
  - linopy/common.py: save_join outer-join fallback was casting to int64.
@FBumann FBumann changed the title Perf/int32 perf: default integer arrays to int32 for ~25% memory reduction Feb 2, 2026
@FBumann FBumann mentioned this pull request Feb 2, 2026
3 tasks
FBumann added 2 commits March 14, 2026 18:45
…ords. Here's what changed:

  - test_linear_expression_sum / test_linear_expression_sum_with_const: v.loc[:9].add(v.loc[10:], join="override") → v.loc[:9] + v.loc[10:].assign_coords(dim_2=v.loc[:9].coords["dim_2"])
  - test_add_join_override → test_add_positional_assign_coords: uses v + disjoint.assign_coords(...)
  - test_add_constant_join_override → test_add_constant_positional: now uses different coords [5,6,7] + assign_coords to make the test meaningful
  - test_same_shape_add_join_override → test_same_shape_add_assign_coords: uses + c.to_linexpr().assign_coords(...)
  - test_add_constant_override_positional → test_add_constant_positional_different_coords: expr + other.assign_coords(...)
  - test_sub_constant_override → test_sub_constant_positional: expr - other.assign_coords(...)
  - test_mul_constant_override_positional → test_mul_constant_positional: expr * other.assign_coords(...)
  - test_div_constant_override_positional → test_div_constant_positional: expr / other.assign_coords(...)
  - test_variable_mul_override → test_variable_mul_positional: a * other.assign_coords(...)
  - test_variable_div_override → test_variable_div_positional: a / other.assign_coords(...)
  - test_add_same_coords_all_joins: removed "override" from loop, added assign_coords variant
  - test_add_scalar_with_explicit_join → test_add_scalar: simplified to expr + 10
Copy link
Collaborator Author

@FBumann FBumann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

The guards and logical checks all look correct (see discussion in prior review). Two things to add before merge:

1. Release notes

Please add to doc/release_notes.rst under "Upcoming Version", something like:

* Default internal integer arrays (labels, variable indices, ``_term`` coordinates) to ``int32`` instead of ``int64``, reducing memory usage by ~25% and improving model build speed by 10-35%. The dtype is controlled by ``linopy.constants.DEFAULT_LABEL_DTYPE`` and can be changed back to ``np.int64`` before model construction if needed. An overflow guard raises ``ValueError`` if labels exceed the int32 maximum (~2.1 billion).

2. Document how to override DEFAULT_LABEL_DTYPE

Since every module imports DEFAULT_LABEL_DTYPE by name at import time, simply assigning linopy.constants.DEFAULT_LABEL_DTYPE = np.int64 after import won't propagate. The constant should either:

(a) Be documented as a compile-time constant (users edit constants.py or monkey-patch before importing linopy), or

(b) Be read indirectly so runtime changes work. For example, change all usages to read from the module rather than a local binding:

# In constants.py — no change needed
DEFAULT_LABEL_DTYPE = np.int32

# In model.py, expressions.py, etc. — instead of:
from linopy.constants import DEFAULT_LABEL_DTYPE

# Use:
from linopy import constants
# ... then reference constants.DEFAULT_LABEL_DTYPE everywhere

This way linopy.constants.DEFAULT_LABEL_DTYPE = np.int64 at runtime would work. Option (b) is a small change and much more user-friendly. Up to you whether this is worth doing now or in a follow-up.

Minor items from prior review (still applicable)

  • Add -> None return type annotations to all test functions in test_dtypes.py (CI blocker)
  • Guard test_solve_with_int32_labels with pytest.importorskip("highspy")
  • Remove trailing spaces in the overflow error message strings in model.py

@FBumann
Copy link
Collaborator Author

FBumann commented Mar 14, 2026

Note on scipy compatibility: scipy sparse matrices (CSC/CSR) already use int32 for their indices and indptr arrays internally, regardless of input dtype. So every solver receiving matrices through scipy (HiGHS, MOSEK, Gurobi, cuPDLPx) is already getting int32 indices today on master. This change just aligns linopy's internal arrays with what scipy already produces — no new risk to solver interfaces.

FBumann and others added 2 commits March 14, 2026 19:57
- Move DEFAULT_LABEL_DTYPE from constants.py into options["label_dtype"]
- Widen OptionSettings types from int to Any
- Add validation: label_dtype only accepts np.int32 or np.int64
- Fix matrices.py empty clabels fallback to use configured dtype
- Fix f-string quoting and trailing spaces in overflow error messages
- Add -> None annotations and importorskip guard in test_dtypes.py
- Add tests for int64 override and invalid dtype rejection
- Add release notes entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dimension coordinates (fill_missing_coords, _term coord) are small
index arrays, not the large label/vars arrays that benefit from int32.
xarray's index creation is slower with int32 than the default int64,
causing a 13-38% build regression. Revert these to default int while
keeping int32 for labels and vars where the memory savings matter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@FBumann
Copy link
Collaborator Author

FBumann commented Mar 14, 2026

Benchmark Results: perf/int32 vs master

Summary

Matrix generation is the big win: up to 57% faster at scale (basic n=1000), with 15-20% memory savings on large models.

Build is now neutral — small models unchanged, large basic models even faster (-10 to -19%).

LP write is mixed — big wins at large sizes (basic n=500/1000: -41 to -46%), but some small-size regressions (+15-25%) that look like noise.

Knapsack build still has overhead (+8-38% on small/medium), likely from binary variable handling with int32 conversion.

Bottom line: Clear net positive for any non-trivial model. The bigger the model, the bigger the gains.

Benchmarks from PR #567. Run on macOS, Python 3.11, CPython 64-bit. Full problem sizes (no --quick).

int32_benchmark_results.csv

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant