โฑ๏ธ Timing Bertini 1 vs Bertini 2ยถ

Bertini 2 is a from-scratch C++17 rewrite; a fair question is how its speed compares to Bertini 1.7. This tutorial benchmarks the two on the same systems and ends in a plot โ€” and records what was timed and where, so the numbers can be refreshed as the library improves.

Comparing fairlyยถ

The fair way to compare two solvers is to hand them the same problem with the same settings. We build each system once in pybertini and emit it as a Bertini-1 classic input with System.to_classic_input(), which writes the equations and the tracking knobs (precision mode, predictor, tolerances, step cadence). Both solvers read that one file, each builds its own random start system, and solves. We time only the solver subprocess (never parsing/IO) and keep the fastest of a few repeats, serial (OMP_NUM_THREADS=1):

def time_solver(exe, input_text, repeats=3, timeout=600):
    """Fastest serial wall time (seconds) over `repeats` runs of `exe` on `input_text`; (time, count)."""
    best, count = float("inf"), None
    for _ in range(repeats):
        d = tempfile.mkdtemp()
        try:
            open(os.path.join(d, "input"), "w").write(input_text)
            env = dict(os.environ, OMP_NUM_THREADS="1")
            t0 = time.perf_counter()
            r = subprocess.run([os.path.abspath(exe), "input"], cwd=d, env=env,
                               stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
            dt = time.perf_counter() - t0
            if r.returncode == 0:
                best = min(best, dt)
                count = solution_count(d)
        finally:
            shutil.rmtree(d, ignore_errors=True)
    return (best if best < float("inf") else None), count

(The maintained, fuller harness โ€” which also sweeps MPI ranks and appends a committed history.csv โ€” is benchmark/comparison/run_comparison.py; this tutorial is a small self-contained cousin.)

Record the provenanceยถ

Timing numbers are meaningless without context, and they go stale. We capture the date, both solver versions (with the Bertini 2 git commit), and the machine, and stamp them onto the figure:

def provenance(b2_exe, b1_exe):
    """Record WHAT was timed and WHERE, so the numbers can be refreshed and stay interpretable."""
    here = os.path.dirname(os.path.abspath(__file__))
    try:
        commit = subprocess.run(["git", "-C", here, "rev-parse", "--short", "HEAD"],
                                capture_output=True, text=True).stdout.strip()
    except Exception:
        commit = ""
    cpu = platform.processor() or "unknown"
    try:
        if platform.system() == "Darwin":
            cpu = subprocess.run(["sysctl", "-n", "machdep.cpu.brand_string"],
                                 capture_output=True, text=True).stdout.strip() or cpu
    except Exception:
        pass
    b2 = _first_nonempty_version_line(b2_exe) + (" @ " + commit if commit else "")
    return {"date": datetime.date.today().isoformat(),
            "b2": b2,
            "b1": _first_nonempty_version_line(b1_exe),
            "machine": "{} / {} {}".format(cpu, platform.system(), platform.release())}

The resultยถ

Run it (needs matplotlib; Bertini 1 optional):

python python/docs/source/tutorials/bertini1_vs_bertini2_timing/b1_vs_b2_timing.py \
    --bertini2 ./build/core/bertini2 --bertini1 /usr/local/bin/bertini --out .
grouped bar chart of serial wall time, Bertini 1 vs Bertini 2, with provenance caption

How to read it (for the run shown โ€” see the caption for date/versions/machine):

  • Bertini 2 is still slower than Bertini 1 on these problems โ€” from a small factor on the tiny diagonal system to ~25ร— on cyclic-5. This is honest: Bertini 1 is hand-tuned C with its own linear algebra; Bertini 2 trades constant-factor speed for a templated, observable, arbitrary-precision design.

  • The diagonal family diag-3/5/6 is well-conditioned (every path stays in double precision), so its growing slowdown is pure per-step tracking overhead scaling with problem size โ€” a standing performance lever, independent of the endgame.

  • cyclic-5 exercises the endgame. The adaptive-numeric-type endgame (the Cauchy and PowerSeries endgames now compute in hardware complex_dbl while a pathโ€™s conditioning allows, escalating to mpfr only when the trackerโ€™s authority demands it) cut the endgameโ€™s per-operation cost by roughly 10ร—. Where a pathโ€™s endgame stays in double โ€” e.g. the default TotalDegreeBinomial start system โ€” cyclic-5 in adaptive precision is now ~Bertini-1 speed (~0.4 s serial, with 0 of 70 finite paths needing to escalate above double).

    The bar shown here is larger than that, because this benchmark hands both solvers a total-degree classic input, and that start system currently makes Bertini 2โ€™s endgame stall near \(t = 0\) (it takes far too many tiny steps). The run stays mostly in double โ€” so the per-step speedup does apply โ€” but the step count is the problem. That stall, not precision and not the endgameโ€™s arithmetic, is the current top lever for this particular path, and is tracked separately from this endgame work.

Both solvers report the same solution counts (shown under the bars), so this is a like-for-like comparison, not a speed/accuracy trade.