From 1ae1ca8f08d80b397f817a4cefdc7ef8236834ff Mon Sep 17 00:00:00 2001 From: 40% Date: Thu, 29 Oct 2026 08:05:15 +0800 Subject: [PATCH 1/6] Speed up Term construction for single-variable terms Avoid sorted, the key lambda, and the generator used to build the hash for the common single-variable case by storing the fresh argument tuple directly and hashing the SCIP variable pointer. Handle the constant term explicitly as well. --- CHANGELOG.md | 1 + src/pyscipopt/expr.pxi | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b8597bf..3ac1c7cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Made expression fields read-only to prevent accidental runtime modification - Methods taking a list of variables (e.g. `addConsKnapsack()`) no longer convert it to a NumPy array to check its dimension - Speed up `PowExpr._evaluate` via the C-level API +- Speed up `Term` construction for single-variable terms - `PowExpr._evaluate` now raises `ValueError("math domain error")` for a negative base with a non-integer exponent and `OverflowError` when the result overflows. ### Removed diff --git a/src/pyscipopt/expr.pxi b/src/pyscipopt/expr.pxi index 46414b2ca..c6f96c99b 100644 --- a/src/pyscipopt/expr.pxi +++ b/src/pyscipopt/expr.pxi @@ -79,8 +79,21 @@ cdef class Term: cdef Py_ssize_t hashval def __init__(self, *vartuple: Variable): - self.vartuple = tuple(sorted(vartuple, key=lambda v: v.getIndex())) - self.hashval = hash(tuple(v.ptr() for v in self.vartuple)) + cdef Py_ssize_t n = len(vartuple) + cdef Variable var + if n == 0: + # The constant term; the empty args tuple is already canonical. + self.vartuple = vartuple + self.hashval = hash(vartuple) + elif n == 1: + # A single variable is trivially sorted, so avoid `sorted` and the + # generator, and build the one-element pointer tuple for the hash. + var = vartuple[0] + self.vartuple = vartuple + self.hashval = hash((var.scip_var,)) + else: + self.vartuple = tuple(sorted(vartuple, key=lambda v: v.getIndex())) + self.hashval = hash(tuple(v.ptr() for v in self.vartuple)) def __getitem__(self, idx): return self.vartuple[idx] From f806404eff4447f120b1124927120d0c43779027 Mon Sep 17 00:00:00 2001 From: 40% Date: Thu, 29 Oct 2026 22:26:42 +0800 Subject: [PATCH 2/6] Make Variable.ptr/getIndex cpdef and tighten Term fast path Expose Variable.ptr() and Variable.getIndex() as cpdef so Cython callers avoid Python dispatch, and reuse them in Term instead of reading scip_var directly. In the single-variable Term path, fetch the variable with PyTuple_GET_ITEM (the *args tuple is exact, so no bounds check is needed) and keep the Variable type check through the cast, so non-Variable arguments still raise TypeError instead of being read as SCIP_VAR*. --- src/pyscipopt/expr.pxi | 14 +++++++++----- src/pyscipopt/scip.pxd | 3 +++ src/pyscipopt/scip.pxi | 4 ++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/pyscipopt/expr.pxi b/src/pyscipopt/expr.pxi index c6f96c99b..79b560a9c 100644 --- a/src/pyscipopt/expr.pxi +++ b/src/pyscipopt/expr.pxi @@ -81,16 +81,20 @@ cdef class Term: def __init__(self, *vartuple: Variable): cdef Py_ssize_t n = len(vartuple) cdef Variable var + if n == 0: - # The constant term; the empty args tuple is already canonical. + # The constant term; the empty args tuple is already canonical self.vartuple = vartuple self.hashval = hash(vartuple) + elif n == 1: - # A single variable is trivially sorted, so avoid `sorted` and the - # generator, and build the one-element pointer tuple for the hash. - var = vartuple[0] + # A single variable is trivially sorted, so avoid sorted and the generator. + # `vartuple` is the exact tuple built from *args, so PyTuple_GET_ITEM needs no + # bounds check; the `` cast keeps the Variable type check. + var = PyTuple_GET_ITEM(vartuple, 0) self.vartuple = vartuple - self.hashval = hash((var.scip_var,)) + self.hashval = hash((var.ptr(),)) + else: self.vartuple = tuple(sorted(vartuple, key=lambda v: v.getIndex())) self.hashval = hash(tuple(v.ptr() for v in self.vartuple)) diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index c5053ebba..92e879579 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -2284,6 +2284,9 @@ cdef class Variable(Expr): @staticmethod cdef create(SCIP_VAR* scipvar) + cpdef size_t ptr(self) noexcept + cpdef int getIndex(self) noexcept + cdef class Constraint: cdef SCIP_CONS* scip_cons # can be used to store problem data diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 214b8bad5..019e9eddb 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -1562,7 +1562,7 @@ cdef class Variable(Expr): cdef Variable copy(self, bint copy=True): return self - def ptr(self): + cpdef size_t ptr(self) noexcept: return (self.scip_var) def __repr__(self): @@ -1697,7 +1697,7 @@ cdef class Variable(Expr): """ return SCIPvarIsInLP(self.scip_var) - def getIndex(self): + cpdef int getIndex(self) noexcept: """ Retrieve the unique index of the variable. From 4664651513a0f6858a92ace6a462877da103c816 Mon Sep 17 00:00:00 2001 From: 40% Date: Thu, 29 Oct 2026 22:45:04 +0800 Subject: [PATCH 3/6] Use PyTuple_GET_ITEM in Term._evaluate and Py_ssize_t for indices Term.vartuple only ever holds Variables (guaranteed by __init__ and __mul__), so the unchecked PyTuple_GET_ITEM is safe in _evaluate and matches what __eq__ and __mul__ already do. Take the lengths from len(self.vartuple) instead of len(self), which is a PyObject_Length call dispatching through Term.__len__, and use Py_ssize_t for the lengths and loop counters: list/tuple sizes are Py_ssize_t, and storing them in an int would truncate silently. --- src/pyscipopt/expr.pxi | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/pyscipopt/expr.pxi b/src/pyscipopt/expr.pxi index 79b560a9c..60934d5f1 100644 --- a/src/pyscipopt/expr.pxi +++ b/src/pyscipopt/expr.pxi @@ -83,14 +83,14 @@ cdef class Term: cdef Variable var if n == 0: - # The constant term; the empty args tuple is already canonical + # The constant term; the empty args tuple is already canonical. self.vartuple = vartuple self.hashval = hash(vartuple) elif n == 1: # A single variable is trivially sorted, so avoid sorted and the generator. - # `vartuple` is the exact tuple built from *args, so PyTuple_GET_ITEM needs no - # bounds check; the `` cast keeps the Variable type check. + # `vartuple` is the exact tuple built from *args, so PyTuple_GET_ITEM needs + # no bounds check; the `` cast keeps the Variable type check. var = PyTuple_GET_ITEM(vartuple, 0) self.vartuple = vartuple self.hashval = hash((var.ptr(),)) @@ -111,12 +111,12 @@ cdef class Term: if Py_TYPE(other) is not Term: return False - cdef int n = len(self) + cdef Py_ssize_t n = len(self.vartuple) cdef Term _other = other - if n != len(_other) or self.hashval != _other.hashval: + if n != len(_other.vartuple) or self.hashval != _other.hashval: return False - cdef int i + cdef Py_ssize_t i cdef Variable var1, var2 for i in range(n): var1 = PyTuple_GET_ITEM(self.vartuple, i) @@ -131,13 +131,13 @@ cdef class Term: def __mul__(self, Term other): # NOTE: This merge algorithm requires a sorted `Term.vartuple`. # This should be ensured in the constructor of Term. - cdef int n1 = len(self) - cdef int n2 = len(other) + cdef Py_ssize_t n1 = len(self.vartuple) + cdef Py_ssize_t n2 = len(other.vartuple) if n1 == 0: return other if n2 == 0: return self cdef list vartuple = [None] * (n1 + n2) - cdef int i = 0, j = 0, k = 0 + cdef Py_ssize_t i = 0, j = 0, k = 0 cdef Variable var1, var2 while i < n1 and j < n2: var1 = PyTuple_GET_ITEM(self.vartuple, i) @@ -170,11 +170,11 @@ cdef class Term: cdef double res = 1.0 cdef SCIP* scip_ptr = sol.scip cdef SCIP_SOL* sol_ptr = sol.sol - cdef int i = 0, n = len(self) + cdef Py_ssize_t i, n = len(self.vartuple) cdef Variable var for i in range(n): - var = self.vartuple[i] + var = PyTuple_GET_ITEM(self.vartuple, i) res *= SCIPgetSolVal(scip_ptr, sol_ptr, var.scip_var) if res == 0: # early stop return 0.0 From f9d1e34bf67ddf72d0943cc46ba6fb8ae929d507 Mon Sep 17 00:00:00 2001 From: 40% Date: Thu, 29 Oct 2026 22:59:41 +0800 Subject: [PATCH 4/6] Build the Term hash during the merge in Term.__mul__ hash(tuple(v.ptr() for v in res.vartuple)) goes through a generator and calls the Python-level Variable.ptr() once per variable. Collect the pointers in the merge loop instead and hash the resulting tuple, which keeps the hash identical to the one Term.__init__ builds for the same variables. --- src/pyscipopt/expr.pxi | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/pyscipopt/expr.pxi b/src/pyscipopt/expr.pxi index 60934d5f1..6223c66f1 100644 --- a/src/pyscipopt/expr.pxi +++ b/src/pyscipopt/expr.pxi @@ -136,7 +136,12 @@ cdef class Term: if n1 == 0: return other if n2 == 0: return self - cdef list vartuple = [None] * (n1 + n2) + cdef Py_ssize_t m = n1 + n2 + cdef list vartuple = [None] * m + # The variable pointers are collected during the merge, so the hash can be + # built without a generator that calls the Python-level `Variable.ptr()` + # once per variable. + cdef list ptrs = [None] * m cdef Py_ssize_t i = 0, j = 0, k = 0 cdef Variable var1, var2 while i < n1 and j < n2: @@ -144,23 +149,29 @@ cdef class Term: var2 = PyTuple_GET_ITEM(other.vartuple, j) if var1.getIndex() <= var2.getIndex(): vartuple[k] = var1 + ptrs[k] = var1.ptr() i += 1 else: vartuple[k] = var2 + ptrs[k] = var2.ptr() j += 1 k += 1 while i < n1: - vartuple[k] = PyTuple_GET_ITEM(self.vartuple, i) + var1 = PyTuple_GET_ITEM(self.vartuple, i) + vartuple[k] = var1 + ptrs[k] = var1.ptr() i += 1 k += 1 while j < n2: - vartuple[k] = PyTuple_GET_ITEM(other.vartuple, j) + var2 = PyTuple_GET_ITEM(other.vartuple, j) + vartuple[k] = var2 + ptrs[k] = var2.ptr() j += 1 k += 1 cdef Term res = Term.__new__(Term) res.vartuple = tuple(vartuple) - res.hashval = hash(tuple(v.ptr() for v in res.vartuple)) + res.hashval = hash(tuple(ptrs)) return res def __repr__(self) -> str: From fe8433d65173b107be6154c87136780e2de8b544 Mon Sep 17 00:00:00 2001 From: 40% Date: Thu, 29 Oct 2026 23:04:17 +0800 Subject: [PATCH 5/6] Update CHANGELOG for the Term performance changes --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac1c7cfb..5ff26d702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ - Made expression fields read-only to prevent accidental runtime modification - Methods taking a list of variables (e.g. `addConsKnapsack()`) no longer convert it to a NumPy array to check its dimension - Speed up `PowExpr._evaluate` via the C-level API -- Speed up `Term` construction for single-variable terms +- Speed up `Term.__init__`, `Term.__eq__`, and `Term.__mul__` via the C-level API - `PowExpr._evaluate` now raises `ValueError("math domain error")` for a negative base with a non-integer exponent and `OverflowError` when the result overflows. ### Removed From 9e9be5775f0e678a942eb4bd3d63c2ed94450c51 Mon Sep 17 00:00:00 2001 From: 40% Date: Fri, 30 Oct 2026 16:13:32 +0800 Subject: [PATCH 6/6] use PyTuple_GET_SIZE replace __len__ --- src/pyscipopt/expr.pxi | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pyscipopt/expr.pxi b/src/pyscipopt/expr.pxi index 6223c66f1..ce89f558e 100644 --- a/src/pyscipopt/expr.pxi +++ b/src/pyscipopt/expr.pxi @@ -53,7 +53,7 @@ from cpython.long cimport PyLong_Check from cpython.number cimport PyNumber_Check from cpython.object cimport Py_LE, Py_EQ, Py_GE, Py_TYPE from cpython.ref cimport PyObject -from cpython.tuple cimport PyTuple_GET_ITEM +from cpython.tuple cimport PyTuple_GET_ITEM, PyTuple_GET_SIZE from libc.math cimport cos as c_cos from libc.math cimport exp as c_exp from libc.math cimport fabs as c_fabs @@ -79,7 +79,7 @@ cdef class Term: cdef Py_ssize_t hashval def __init__(self, *vartuple: Variable): - cdef Py_ssize_t n = len(vartuple) + cdef Py_ssize_t n = PyTuple_GET_SIZE(vartuple) cdef Variable var if n == 0: @@ -111,9 +111,9 @@ cdef class Term: if Py_TYPE(other) is not Term: return False - cdef Py_ssize_t n = len(self.vartuple) + cdef Py_ssize_t n = PyTuple_GET_SIZE(self.vartuple) cdef Term _other = other - if n != len(_other.vartuple) or self.hashval != _other.hashval: + if n != PyTuple_GET_SIZE(_other.vartuple) or self.hashval != _other.hashval: return False cdef Py_ssize_t i @@ -126,13 +126,13 @@ cdef class Term: return True def __len__(self): - return len(self.vartuple) + return PyTuple_GET_SIZE(self.vartuple) def __mul__(self, Term other): # NOTE: This merge algorithm requires a sorted `Term.vartuple`. # This should be ensured in the constructor of Term. - cdef Py_ssize_t n1 = len(self.vartuple) - cdef Py_ssize_t n2 = len(other.vartuple) + cdef Py_ssize_t n1 = PyTuple_GET_SIZE(self.vartuple) + cdef Py_ssize_t n2 = PyTuple_GET_SIZE(other.vartuple) if n1 == 0: return other if n2 == 0: return self @@ -181,7 +181,7 @@ cdef class Term: cdef double res = 1.0 cdef SCIP* scip_ptr = sol.scip cdef SCIP_SOL* sol_ptr = sol.sol - cdef Py_ssize_t i, n = len(self.vartuple) + cdef Py_ssize_t i, n = PyTuple_GET_SIZE(self.vartuple) cdef Variable var for i in range(n):