Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .github/benchmarks/compare_maros_meszaros.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Create an aggregate current-vs-master Maros-Meszaros report."""

import argparse
import csv


TOLERANCES = ("default", "low", "med", "high")


def load(path):
with open(path, newline="", encoding="utf-8") as source:
rows = list(csv.DictReader(source))
return {(row["problem"], row["tolerance"]): row for row in rows}


def percent_change(baseline, current):
if baseline == 0.0:
return "n/a"
return f"{100.0 * (current - baseline) / baseline:+.1f}%"


def main():
parser = argparse.ArgumentParser()
parser.add_argument("baseline")
parser.add_argument("current")
parser.add_argument("report")
parser.add_argument("--threshold", type=float, default=25.0)
args = parser.parse_args()

baseline = load(args.baseline)
current = load(args.current)
if baseline.keys() != current.keys():
missing = sorted(baseline.keys() - current.keys())
new = sorted(current.keys() - baseline.keys())
raise RuntimeError(f"result sets differ; missing={missing}, new={new}")

lines = [
"## Maros–Mészáros dense benchmark",
"",
"Solve time is the sum of DAQP's reported solve time over every attempted "
"problem (best of three identical runs per problem). A problem is counted "
"as solved only when DAQP returns success and the KKT residual checks pass.",
]
regression = False

for title, predicate in (
("Dense subset", lambda row: True),
("Positive-definite subset", lambda row: row["posdef"] == "1"),
):
lines.extend(
[
"",
f"### {title}",
"",
"| Tolerance | Problems | Base solved | Current solved | Δ solved | "
"Base solve time | Current solve time | Δ time |",
"|:--|--:|--:|--:|--:|--:|--:|--:|",
]
)
for tolerance in TOLERANCES:
base_rows = [
row for (_, setting), row in baseline.items()
if setting == tolerance and predicate(row)
]
current_rows = [
row for (_, setting), row in current.items()
if setting == tolerance and predicate(row)
]
base_solved = sum(int(row["solved"]) for row in base_rows)
current_solved = sum(int(row["solved"]) for row in current_rows)
base_time = sum(float(row["solve_time_s"]) for row in base_rows)
current_time = sum(float(row["solve_time_s"]) for row in current_rows)
time_change = 100.0 * (current_time - base_time) / base_time if base_time else 0.0
solved_change = current_solved - base_solved
if current_solved < base_solved or time_change > args.threshold:
regression = True
lines.append(
f"| {tolerance} | {len(base_rows)} | {base_solved} | "
f"{current_solved} | {solved_change:+d} | {base_time:.4f} s | "
f"{current_time:.4f} s | {percent_change(base_time, current_time)} |"
)

lines.extend(
[
"",
f"Time regressions are flagged above {args.threshold:g}%; any decrease "
"in the solved count is also a regression.",
"",
"⚠️ Regression detected." if regression else "✅ No regression detected.",
]
)
report = "\n".join(lines) + "\n"
with open(args.report, "w", encoding="utf-8") as output:
output.write(report)
print(report, end="")
raise SystemExit(1 if regression else 0)


if __name__ == "__main__":
main()
122 changes: 122 additions & 0 deletions .github/benchmarks/export_maros_meszaros.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# Copyright 2022 Stéphane Caron
# Copyright 2023-2024 Inria
# Adapted from qpsolvers/maros_meszaros_qpbenchmark.
"""Export the Maros-Meszaros dense subset to DAQP's native dense layout."""

import argparse
import os
import struct

import numpy as np
import scipy.io as spio
import scipy.sparse as spa
from qpbenchmark.utils import is_posdef


DAQP_INF = 1e30
EQ_TOL = 1e-10


def load_mat(path):
mat = spio.loadmat(path)
P = mat["P"].astype(float).tocsc()
q = mat["q"].T.flatten().astype(float)
A = mat["A"].astype(float).tocsc()
lower = mat["l"].T.flatten().astype(float)
upper = mat["u"].T.flatten().astype(float)
n = int(mat["n"].T.flatten()[0])
m = int(mat["m"].T.flatten()[0])
assert A.shape == (m, n)

A = A.copy()
A.data[A.data > 9e19] = np.inf
A.data[A.data < -9e19] = -np.inf
lower[lower > 9e19] = np.inf
lower[lower < -9e19] = -np.inf
upper[upper > 9e19] = np.inf
upper[upper < -9e19] = -np.inf

# The MAT files store A = vstack([C, eye(n)]).
return P, q, A[:-n], lower[:-n], upper[:-n], lower[-n:], upper[-n:]


def converted_constraint_count(C, lower, upper, box_lower):
"""Match MarosMeszaros.count_constraints after its format conversion."""
equal = upper - lower < EQ_TOL
inequality_rows = np.asarray(np.logical_not(equal)).nonzero()
G = spa.vstack([C[inequality_rows], -C[inequality_rows]], format="csc")
h = np.hstack([upper[inequality_rows], -lower[inequality_rows]])
finite = h < np.inf
inequalities = G[finite].shape[0] if G.size > 0 else 0
equalities = C[np.asarray(equal).nonzero()].shape[0]
return inequalities + equalities + box_lower.shape[0]


def export_problem(output_dir, name, P, q, C, lower, upper, box_lower, box_upper):
H = np.asarray(P.todense(), dtype=np.float64)
H = 0.5 * (H + H.T)
A = np.asarray(C.todense(), dtype=np.float64)
blower = np.concatenate([box_lower, lower]).astype(np.float64)
bupper = np.concatenate([box_upper, upper]).astype(np.float64)
n = H.shape[0]
m = blower.size
sense = np.zeros(m, dtype=np.int32)

equal = bupper - blower < EQ_TOL
sense[equal] = 5 # DAQP_ACTIVE | DAQP_IMMUTABLE
blower[equal] = bupper[equal]
blower = np.clip(blower, -DAQP_INF, DAQP_INF)
bupper = np.clip(bupper, -DAQP_INF, DAQP_INF)

with open(os.path.join(output_dir, f"{name}.bin"), "wb") as output:
output.write(struct.pack("<iii", n, m, n))
H.astype("<f8").tofile(output)
q.astype("<f8").tofile(output)
A.astype("<f8").tofile(output)
bupper.astype("<f8").tofile(output)
blower.astype("<f8").tofile(output)
sense.astype("<i4").tofile(output)


def main():
parser = argparse.ArgumentParser()
parser.add_argument("data_dir")
parser.add_argument("output_dir")
args = parser.parse_args()
os.makedirs(args.output_dir, exist_ok=True)

index = []
for filename in sorted(os.listdir(args.data_dir)):
if not filename.endswith(".mat"):
continue
name = filename[:-4]
problem = load_mat(os.path.join(args.data_dir, filename))
P, q, C, lower, upper, box_lower, box_upper = problem
n = P.shape[0]
converted_m = converted_constraint_count(C, lower, upper, box_lower)
if n > 1000 or converted_m > 1000:
continue

export_problem(args.output_dir, name, *problem)
# MarosMeszarosDensePosdef applies this check after `to_dense()`.
index.append(
(
name,
n,
box_lower.size + C.shape[0],
bool(is_posdef(np.asarray(P.todense()))),
)
)

with open(os.path.join(args.output_dir, "index.txt"), "w", encoding="utf-8") as output:
for name, n, m, posdef in index:
output.write(f"{name} {n} {m} {int(posdef)}\n")

posdef_count = sum(row[3] for row in index)
print(f"Exported {len(index)} dense problems ({posdef_count} positive definite)")


if __name__ == "__main__":
main()
60 changes: 60 additions & 0 deletions .github/benchmarks/maros_meszaros_comparison_git.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/bin/bash
# Compare the current DAQP library against a git ref on Maros-Meszaros dense QPs.

set -euo pipefail

if [ "$#" -lt 4 ]; then
echo "usage: $0 <current-build> <base-ref> <maros-data-dir> <output-dir> [threshold] [repeats]"
exit 2
fi

CURRENT_BUILD="$(cd "$1" && pwd)"
BASE_REF="$2"
MAROS_DATA="$(cd "$3" && pwd)"
OUTPUT_DIR="$(mkdir -p "$4" && cd "$4" && pwd)"
THRESHOLD="${5:-25}"
REPEATS="${6:-3}"
REPO_ROOT="$(git rev-parse --show-toplevel)"
SCRIPT_DIR="$REPO_ROOT/.github/benchmarks"
EXPORTED_DIR="$OUTPUT_DIR/exported"
CURRENT_LIB="$CURRENT_BUILD/interfaces/daqp-julia/libdaqp.so"

if [ ! -f "$CURRENT_LIB" ]; then
echo "Current libdaqp.so not found at $CURRENT_LIB"
exit 1
fi

python3 "$SCRIPT_DIR/export_maros_meszaros.py" "$MAROS_DATA" "$EXPORTED_DIR"

cc -O3 -I"$REPO_ROOT/include" "$SCRIPT_DIR/maros_meszaros_runner.c" \
-L"$(dirname "$CURRENT_LIB")" -ldaqp -lm -o "$OUTPUT_DIR/maros_meszaros_runner"

echo "Benchmarking current library"
LD_LIBRARY_PATH="$(dirname "$CURRENT_LIB")${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
"$OUTPUT_DIR/maros_meszaros_runner" "$EXPORTED_DIR" \
"$OUTPUT_DIR/current.csv" "$REPEATS"

TEMP_REPO="$(mktemp -d)"
trap 'rm -rf "$TEMP_REPO"' EXIT
git clone --no-checkout "$REPO_ROOT" "$TEMP_REPO/daqp_base" >/dev/null 2>&1
git -C "$TEMP_REPO/daqp_base" checkout --detach "$BASE_REF" >/dev/null
cmake -S "$TEMP_REPO/daqp_base" -B "$TEMP_REPO/daqp_base/build" \
-DCMAKE_BUILD_TYPE=Release >/dev/null
cmake --build "$TEMP_REPO/daqp_base/build" -- -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)" >/dev/null

BASE_LIB="$(find "$TEMP_REPO/daqp_base/build" -name libdaqp.so -type f -print -quit)"
if [ -z "$BASE_LIB" ]; then
echo "Baseline build did not produce libdaqp.so"
exit 1
fi

echo "Benchmarking baseline library $BASE_REF"
LD_LIBRARY_PATH="$(dirname "$BASE_LIB")${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
"$OUTPUT_DIR/maros_meszaros_runner" "$EXPORTED_DIR" \
"$OUTPUT_DIR/master.csv" "$REPEATS"

COMPARE_RESULT=0
python3 "$SCRIPT_DIR/compare_maros_meszaros.py" \
"$OUTPUT_DIR/master.csv" "$OUTPUT_DIR/current.csv" \
"$OUTPUT_DIR/comparison.md" --threshold "$THRESHOLD" || COMPARE_RESULT=$?
exit "$COMPARE_RESULT"
Loading
Loading