perf: store constraint sign as int8 codes (8x smaller sign array, lossless)#843
perf: store constraint sign as int8 codes (8x smaller sign array, lossless)#843FBumann wants to merge 3 commits into
Conversation
The constraint sign was stored as a numpy `<U2` unicode array (8 bytes per
entry) for a field with only three canonical values (`=`, `<=`, `>=`). Encode
it internally as int8 category codes (1 byte) — an 8x reduction on an array as
large as the variable-labels array, mirroring the int32-labels win. The mapping
is exact/categorical, so there is no precision risk.
`constraint.sign` still returns the canonical string DataArrays: the codes are
decoded at the public `.sign` accessor. The sign is encoded once, idempotently,
in `Constraint.__init__` and `Constraint._update_data`; all raw readers of the
stored sign array were audited and routed through decoding (LP indicator writer
in io.py, `Constraint.to_polars`/`.flat`, the infinite-RHS check in
`add_constraints`, and the dtype-aware `reindex`/`reindex_like` fill).
Default is ON (`Model.dtypes["sign"] == np.int8`); opt out with
`Model(dtypes={"sign": np.str_})` to restore the legacy `<U2` storage for A/B
memory measurement. The canonical code mapping lives in `constants.py` next to
`SIGNS`; encode/decode helpers live in `common.py` next to `maybe_replace_signs`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merging this PR will degrade performance by 21.54%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | Memory | test_build[sos-n=10] |
4.5 KB | 5.7 KB | -21.54% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing fluxopt:perf/sign-int8 (ba58b0d) with master (c2607b6)
Footnotes
-
173 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
The LP writer decoded the int8 sign codes into a full-size <U2 string array at export (via .sign), on top of the resident codes — inflating to_lp peak memory ~27-38% and wiping out the storage saving (PyPSA#843 CI). Feed the stored codes straight into to_polars instead: compute all_same_sign on the codes, decode only the scalar in the uniform case, and map codes -> pl.Enum via replace_strict in the mixed case. The full <U2 array is never allocated; polars streams the strings at the CSV sink. to_file peak now matches master (basic n=250: 1.339 vs 1.338 MB; sos n=1000, storage n=250, piecewise n=1000 all neutral) while the model still stores sign as 1-byte codes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI runs mypy over test/ with disallow_untyped_defs; annotate the build/expected params of test_all_three_senses_round_trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Closing this: after profiling it, the win is too small to justify carrying the change. The real memory lever is float coefficients (O(nnz)), tracked in #839 — not the sign. Note The following analysis was generated by AI. Why the payoff doesn't hold up. The constraint
And no benchmark can see it. The saving lives in the resident model. Every current memory benchmark is peak-based — It also fights the grain. Sign must be strings at every boundary (LP/MPS writer, solver senses), so it needs decode-on-read everywhere. The first cut regressed Numbers (basic n=250, one constraint dataset)
Whole-model array bytes (n=400): 8.02 → 7.14 MB (−11%). Build peak (memray): neutral. The branch stays on the fork if anyone wants to revive it (e.g. bundled with a resident-footprint benchmark that would actually show it). For now, focusing memory effort on #839 (float32 coefficients). |
TODO (author): replace this line with your own intent/motivation before un-drafting.
Note
The following content was generated by AI.
Second follow-up to the int32-labels win (#566). The constraint
signis stored as a numpy<U2unicode array — 8 bytes/entry for a field with only three canonical values (=,<=,>=). Storing it asint8category codes (1 byte) is an 8× reduction on an array as large as thevarsarray, and — unlike the float32 change in #839 — it is exact, with no precision trade-off.What this does. The sign is stored internally as
int8codes and decoded back to canonical string DataArrays at the publicconstraint.signboundary, so existing string consumers are unaffected. Encoding happens at constraint construction; decoding at the.signaccessor and the LP/MPS + indicator export paths. Default is on (opt-out viaModel(dtypes={"sign": np.str_}), which restores the exact legacy<U2storage — kept mainly to A/B measure the saving in CI).Because it's lossless, this one is a candidate to stay default-on (no opt-in flip planned, unlike #839).
Implementation, raw-sign reader audit & verification
Files
linopy/constants.py: canonicalSIGN_TO_CODE(=→0,<=→1,>=→2) +CODE_TO_SIGN, marked a stable netcdf/pickle format (never renumber).linopy/common.py:encode_signs/decode_signs(+ numpy kernels). Encode is idempotent and passes degenerate dtypes through (soDataset.shift's int8→float+NaN upcast doesn't crash).linopy/model.py:DtypeKey = Literal["labels", "sign"],"sign" ∈ {np.int8, np.str_}(default int8), per-key validation, docstrings; decodes the infinite-RHS sign check inadd_constraints.linopy/constraints.py: encode inConstraint.__init__and_update_data; decode inConstraint.sign; dtype-awarereindex/reindex_likefill.linopy/io.py: indicator LP writer decodes viacon.sign.Raw-sign reader audit (every direct reader of the stored array, and how handled)
Constraint.sign→decode_signs(self.data.sign).Constraint._update_data(sign=…)→ encodes before assign.Constraint.to_matrix_with_rhs/.flat(deprecated) → read via decodedself.sign.io.pyindicator writer →con.sign.values. Critical path, verified.add_constraintsinfinite-RHS check → decodes before string compare.CSRConstraint._signintentionally stays strings (built from decodedcon.sign, transient at solve), so all solver interfaces,dualization, persistent snapshot/diff(<U1sense buffer unchanged), and theConstraintscollection accessors already consume strings — no change needed.Verification
test_dtypes24,test_io47/3-skip,test_optimization -k "milp/simple/basic/lp"944/10-skip (incl. direct-solver LP + polars paths), constraints/dualization/piecewise/indicator/sos all pass.int8,100000bytes vs800000for<U2→ exactly 8.0×.<=/>=/=; mixed-sense model solves correctly; netcdf round-trips int8 → correct decoded strings (old<U2files still load).On-disk note. Saved netcdf now stores
int8sign; old<U2files still load (re-encoded on read).🤖 Generated with Claude Code