Skip to content

Commit 8a0b773

Browse files
h-g-sCopilot
andcommitted
docs: add RCPSP benchmark, gurobipy comparison, and trim bench descriptions
- Add benchmarks/rcpsp_bench.py: random RCPSP generator with 4 configs (2R/4R × sparse/dense precedences), signal-based timeout, gurobipy support - Add benchmarks/tsp_flow_bench.py: gurobipy native comparison function - Add benchmarks/queens_bench.py: gurobipy native comparison function - Rename 'gurobi-native' identifier to 'gurobipy' across all benchmark scripts - docs/bench.rst: - Replace math formulations in n-Queens/TSP/RCPSP sections with concise descriptions referencing the corresponding :ref: labels in examples.rst - Add 'gurobipy' column to all CPython benchmark tables with measured data - Update n-Queens and TSP numbers from latest benchmark run - Add full RCPSP section with 4 configs × CPython + PyPy tables - Update summary text with gurobipy vs python-mip comparisons - docs/bibliography.rst: add [GaGr78] entry for Gavish-Graves 1978 - docs/examples.rst: add .. _queens-label: and .. _rcpsp-label: cross-ref targets - docs/Makefile: add LATEXMKOPTS=-interaction=nonstopmode to avoid LaTeX hangs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c70e5d9 commit 8a0b773

6 files changed

Lines changed: 864 additions & 100 deletions

File tree

benchmarks/queens_bench.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,48 @@ def bench_pmip(n, solver_name, build_only=False, max_solve_sec=10.0):
127127
return t_build, t_solve, str(status).split(".")[-1]
128128

129129

130+
131+
# ── native gurobipy benchmark ─────────────────────────────────────────────────
132+
133+
def bench_gurobi_native(n, build_only=False, max_solve_sec=10.0):
134+
"""Build n-queens with the native gurobipy API (no python-mip layer)."""
135+
import gurobipy as gp
136+
from gurobipy import GRB
137+
138+
t0 = time.perf_counter()
139+
env = gp.Env(empty=True)
140+
env.setParam("OutputFlag", 0)
141+
env.start()
142+
m = gp.Model(env=env)
143+
144+
x = m.addVars(n, n, vtype=GRB.BINARY)
145+
146+
for i in range(n):
147+
m.addConstr(gp.quicksum(x[i, j] for j in range(n)) == 1)
148+
for j in range(n):
149+
m.addConstr(gp.quicksum(x[i, j] for i in range(n)) == 1)
150+
for k in range(2 - n, n - 1):
151+
cells = [(i, i - k) for i in range(n) if 0 <= i - k < n]
152+
if len(cells) >= 2:
153+
m.addConstr(gp.quicksum(x[i, j] for i, j in cells) <= 1)
154+
for k in range(2, 2 * n - 1):
155+
cells = [(i, k - i) for i in range(n) if 0 <= k - i < n]
156+
if len(cells) >= 2:
157+
m.addConstr(gp.quicksum(x[i, j] for i, j in cells) <= 1)
158+
159+
m.update()
160+
t_build = time.perf_counter() - t0
161+
162+
if build_only:
163+
return t_build, 0.0, "—"
164+
165+
m.setParam("TimeLimit", max_solve_sec)
166+
t1 = time.perf_counter()
167+
m.optimize()
168+
t_solve = time.perf_counter() - t1
169+
return t_build, t_solve, str(m.Status)
170+
171+
130172
# ── highspy high-level benchmark ─────────────────────────────────────────────
131173

132174
def bench_highspy_hl(n, build_only=False, max_solve_sec=10.0):
@@ -267,6 +309,15 @@ def detect_solvers():
267309
pass
268310
except ImportError:
269311
pass
312+
try:
313+
import gurobipy as gp
314+
env = gp.Env(empty=True)
315+
env.setParam("OutputFlag", 0)
316+
env.start()
317+
gp.Model(env=env)
318+
solvers.append("gurobipy")
319+
except Exception:
320+
pass
270321
try:
271322
import highspy
272323
solvers.append("highspy-hl")
@@ -309,6 +360,8 @@ def detect_solvers():
309360
def _bench(s=solver, _n=n, _bo=build_only):
310361
if s in ("CBC", "HIGHS", "GUROBI"):
311362
return bench_pmip(_n, s, _bo, max_solve_sec)
363+
elif s == "gurobipy":
364+
return bench_gurobi_native(_n, _bo, max_solve_sec)
312365
elif s == "highspy-hl":
313366
return bench_highspy_hl(_n, _bo, max_solve_sec)
314367
elif s == "highspy-batch":

benchmarks/rcpsp_bench.py

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
"""
2+
RCPSP (Resource-Constrained Project Scheduling Problem) model-build benchmark.
3+
4+
Generates random RCPSP instances with varying numbers of jobs, resources, and
5+
precedence densities, then measures how long each python-mip backend takes to
6+
build (but not solve) the MIP model.
7+
8+
Usage:
9+
python benchmarks/rcpsp_bench.py [--build-only] [--verify]
10+
11+
--build-only Measure model creation time only (default behaviour).
12+
--verify Build and solve the smallest instance with CBC to check
13+
that the formulation is correct, then exit.
14+
"""
15+
16+
import argparse
17+
import random
18+
import signal
19+
import sys
20+
import time
21+
from itertools import product
22+
23+
# ── timeout helper (Linux only) ────────────────────────────────────────────────
24+
25+
BUILD_TIMEOUT_SEC = 8
26+
27+
28+
class _Timeout(Exception):
29+
pass
30+
31+
32+
def _alarm_handler(signum, frame):
33+
raise _Timeout()
34+
35+
36+
def _run_with_timeout(fn, *args, **kwargs):
37+
"""Return (elapsed, result) or ('>8s', None) on timeout."""
38+
signal.signal(signal.SIGALRM, _alarm_handler)
39+
signal.alarm(BUILD_TIMEOUT_SEC)
40+
try:
41+
t0 = time.perf_counter()
42+
result = fn(*args, **kwargs)
43+
elapsed = time.perf_counter() - t0
44+
signal.alarm(0)
45+
return elapsed, result
46+
except _Timeout:
47+
return f">{BUILD_TIMEOUT_SEC}s", None
48+
finally:
49+
signal.alarm(0)
50+
51+
52+
# ── random instance generator ──────────────────────────────────────────────────
53+
54+
def make_rcpsp(n_jobs, n_resources, n_prec, p_range=(1, 5), c_range=(4, 8), seed=42):
55+
"""
56+
Generate a random RCPSP instance.
57+
58+
Returns (p, u, c, S) where:
59+
p[j] – processing time of job j (index 0 and n+1 are dummy jobs)
60+
u[j][r] – resource r consumed by job j while executing
61+
c[r] – capacity of resource r
62+
S – list of [pred, succ] precedence pairs (0-indexed, inclusive of dummies)
63+
"""
64+
rng = random.Random(seed)
65+
n_total = n_jobs + 2 # real jobs 1..n_jobs; dummy 0 and n_jobs+1
66+
67+
# Processing times
68+
p = [0] + [rng.randint(*p_range) for _ in range(n_jobs)] + [0]
69+
70+
# Resource usage (0 when no resource needed; dummies use nothing)
71+
u = [[0] * n_resources]
72+
for _ in range(n_jobs):
73+
row = [rng.randint(0, max(1, c_range[0] // 2)) for _ in range(n_resources)]
74+
u.append(row)
75+
u.append([0] * n_resources)
76+
77+
# Resource capacities
78+
c = [rng.randint(*c_range) for _ in range(n_resources)]
79+
80+
# Precedences: dummy 0 → all real jobs, all real jobs → dummy n+1
81+
S = [[0, j] for j in range(1, n_jobs + 1)]
82+
S += [[j, n_jobs + 1] for j in range(1, n_jobs + 1)]
83+
84+
# Extra random precedences among real jobs (forward arcs only to avoid cycles)
85+
added = set()
86+
attempts = 0
87+
while len(added) < n_prec and attempts < n_prec * 20:
88+
i = rng.randint(1, n_jobs - 1)
89+
j = rng.randint(i + 1, n_jobs)
90+
if (i, j) not in added:
91+
added.add((i, j))
92+
S.append([i, j])
93+
attempts += 1
94+
95+
return p, u, c, S
96+
97+
98+
# ── model builders ─────────────────────────────────────────────────────────────
99+
100+
def build_model(solver_name, p, u, c, S, build_only=True):
101+
"""Build the RCPSP MIP model and return the Model object."""
102+
from mip import Model, xsum, BINARY
103+
104+
R = range(len(c))
105+
J = range(len(p))
106+
T = range(sum(p))
107+
n = len(p) - 2 # number of real jobs
108+
109+
m = Model(solver_name=solver_name)
110+
m.verbose = 0
111+
112+
x = [[m.add_var(name=f"x({j},{t})", var_type=BINARY) for t in T] for j in J]
113+
114+
m.objective = xsum(t * x[n + 1][t] for t in T)
115+
116+
for j in J:
117+
m += xsum(x[j][t] for t in T) == 1
118+
119+
for r, t in product(R, T):
120+
m += (
121+
xsum(
122+
u[j][r] * x[j][t2]
123+
for j in J
124+
for t2 in range(max(0, t - p[j] + 1), t + 1)
125+
)
126+
<= c[r]
127+
)
128+
129+
for pred, succ in S:
130+
m += xsum(t * x[succ][t] - t * x[pred][t] for t in T) >= p[pred]
131+
132+
return m
133+
134+
135+
def bench_pmip(solver_name, p, u, c, S, build_only=True):
136+
build_model(solver_name, p, u, c, S, build_only)
137+
138+
139+
# ── native gurobipy benchmark ─────────────────────────────────────────────────
140+
141+
def bench_gurobi_native(p, u, c, S, build_only=True):
142+
"""Build RCPSP with the native gurobipy API (no python-mip layer)."""
143+
import gurobipy as gp
144+
from gurobipy import GRB
145+
146+
R = range(len(c))
147+
J = range(len(p))
148+
T = range(sum(p))
149+
n = len(p) - 2
150+
151+
env = gp.Env(empty=True)
152+
env.setParam("OutputFlag", 0)
153+
env.start()
154+
m = gp.Model(env=env)
155+
156+
x = m.addVars([(j, t) for j in J for t in T], vtype=GRB.BINARY)
157+
158+
m.setObjective(gp.quicksum(t * x[n + 1, t] for t in T), GRB.MINIMIZE)
159+
160+
for j in J:
161+
m.addConstr(gp.quicksum(x[j, t] for t in T) == 1)
162+
163+
for r, t in product(R, T):
164+
m.addConstr(
165+
gp.quicksum(
166+
u[j][r] * x[j, t2]
167+
for j in J
168+
for t2 in range(max(0, t - p[j] + 1), t + 1)
169+
)
170+
<= c[r]
171+
)
172+
173+
for pred, succ in S:
174+
m.addConstr(
175+
gp.quicksum(t * (x[succ, t] - x[pred, t]) for t in T) >= p[pred]
176+
)
177+
178+
m.update()
179+
180+
181+
# ── benchmark configurations ───────────────────────────────────────────────────
182+
183+
CONFIGS = [
184+
dict(name="2R / sparse prec.", n_resources=2, prec_factor=1, p_range=(1, 4)),
185+
dict(name="2R / dense prec.", n_resources=2, prec_factor=3, p_range=(1, 4)),
186+
dict(name="4R / sparse prec.", n_resources=4, prec_factor=1, p_range=(1, 4)),
187+
dict(name="4R / dense prec.", n_resources=4, prec_factor=3, p_range=(1, 4)),
188+
]
189+
190+
SIZES = [10, 20, 30, 50, 75, 100, 150, 200]
191+
192+
193+
# ── main ────────────────────────────────────────────────────────────────────────
194+
195+
def run_benchmarks(build_only=True):
196+
from mip.constants import CBC, HIGHS
197+
198+
import mip.highs as _h
199+
has_highs = _h.has_highs
200+
201+
try:
202+
from mip.constants import GUROBI
203+
import gurobipy # noqa: F401
204+
has_gurobi = True
205+
except Exception:
206+
has_gurobi = False
207+
GUROBI = None
208+
209+
col_w = 16
210+
211+
for cfg in CONFIGS:
212+
n_res = cfg["n_resources"]
213+
prec_factor = cfg["prec_factor"]
214+
p_range = cfg["p_range"]
215+
216+
print(f"\n=== Config: {cfg['name']} ===")
217+
print(f"{'Jobs':<6}", end="")
218+
print(f"{'CBC':>{col_w}}", end="")
219+
if has_highs:
220+
print(f"{'HiGHS':>{col_w}}", end="")
221+
if has_gurobi:
222+
print(f"{'python-mip/Gurobi':>{col_w}}", end="")
223+
print(f"{'gurobipy':>{col_w}}", end="")
224+
print()
225+
226+
for n_jobs in SIZES:
227+
n_prec = prec_factor * n_jobs
228+
p, u, c, S = make_rcpsp(n_jobs, n_res, n_prec, p_range=p_range)
229+
T = sum(p)
230+
n_vars = (n_jobs + 2) * T
231+
232+
row = f"{n_jobs:<6}"
233+
234+
def fmt(elapsed):
235+
return str(round(elapsed, 3) if isinstance(elapsed, float) else elapsed)
236+
237+
# CBC
238+
elapsed, _ = _run_with_timeout(bench_pmip, CBC, p, u, c, S, build_only)
239+
row += f"{fmt(elapsed):>{col_w}}"
240+
241+
# HiGHS via python-mip
242+
if has_highs:
243+
elapsed, _ = _run_with_timeout(bench_pmip, HIGHS, p, u, c, S, build_only)
244+
row += f"{fmt(elapsed):>{col_w}}"
245+
246+
# Gurobi via python-mip
247+
if has_gurobi:
248+
elapsed, _ = _run_with_timeout(bench_pmip, GUROBI, p, u, c, S, build_only)
249+
row += f"{fmt(elapsed):>{col_w}}"
250+
251+
# Gurobi native gurobipy
252+
elapsed, _ = _run_with_timeout(bench_gurobi_native, p, u, c, S, build_only)
253+
row += f"{fmt(elapsed):>{col_w}}"
254+
255+
row += f" (n_vars={n_vars}, T={T})"
256+
print(row)
257+
258+
259+
def verify():
260+
"""Build and solve the smallest instance to check correctness."""
261+
p, u, c, S = make_rcpsp(10, 2, 10, p_range=(1, 4))
262+
from mip.constants import CBC
263+
m = build_model(CBC, p, u, c, S, build_only=False)
264+
m.verbose = 1
265+
m.optimize()
266+
print(f"\nStatus: {m.status} Objective: {m.objective_value}")
267+
268+
269+
if __name__ == "__main__":
270+
parser = argparse.ArgumentParser(description="RCPSP model-build benchmark")
271+
parser.add_argument("--build-only", action="store_true", default=True)
272+
parser.add_argument("--verify", action="store_true")
273+
args = parser.parse_args()
274+
275+
if args.verify:
276+
verify()
277+
else:
278+
run_benchmarks(build_only=True)

0 commit comments

Comments
 (0)