Benchmarks

Reproducible performance figures for pylinkage’s public API.

Every number here comes from calling the library the way a user would, through the documented entry points. Nothing on this page measures a prototype, a hypothetical optimisation, or a reimplementation kept only in the benchmark suite.

Reproducing these numbers

uv run python benchmarks/run_benchmarks.py

The harness prints the tables below as Markdown. Add --json results.json for machine-readable output, or --quick to check that the harness runs without waiting for publication-quality repeat counts.

Each measurement is the median of 7 repeats, with warmup runs discarded so that numba’s one-off JIT compilation is not charged to the steady-state figures. The spread column is the full range across repeats as a fraction of the median, and is reported so you can tell a solid number from a noisy one.

Environment

These figures were collected on a laptop, not dedicated benchmarking hardware. Treat the ratios as meaningful and the absolute values as indicative.

Component

Version

pylinkage

1.1.0

python

3.11.14

platform

Linux-7.1

cpu

AMD Ryzen 7 7840U

numpy

2.4.6

numba

0.66.0

scipy

1.17.1

Solver

Simulating the reference four-bar (two grounds, a crank, and an RRRDyad).

Measurement

Median

Unit

Spread

Notes

Linkage.step()

325,718

steps/s

10.8%

pure-Python generator

Linkage.step_fast()

1,675,768

steps/s

2.0%

numba JIT, compilation excluded by warmup

step_fast() speedup

5.1

x

ratio of the two medians above

step_fast() is the numba-backed path and is roughly 5x faster on this mechanism. The gap is what makes optimisation practical: an optimiser run spends essentially all of its time inside the solver.

The ratio was 6.4x in earlier releases. It narrowed because step() itself got about a quarter faster, not because the solver regressed.

Note that step_fast() pays JIT compilation on its first call. That cost is excluded here because it is paid once per process, but it is the reason a single short simulation may not feel faster than step().

PSO throughput

Particle swarm optimisation of the same four-bar, with a cheap fitness function, so the figure reflects the optimiser and solver rather than the objective.

Measurement

Median

Unit

Spread

Notes

particle_swarm_optimization()

20,117

evaluations/s

72.3%

100 particles x 100 iterations

Full optimisation run

0.502

s

59.1%

~10,100 fitness evaluations

Evaluations are counted at the fitness function itself rather than inferred from n_particles * iterations, so the figure stays correct even if the swarm’s internal call pattern changes.

The spread here is much wider than elsewhere – it is the least trustworthy number on this page – because a half-second run is dominated by CPU frequency scaling. Read the order of magnitude and nothing finer: on the order of ten thousand fitness evaluations per second on a four-bar, meaning a realistic optimisation budget is governed by how expensive your fitness function is.

Synthesis

Classical synthesis entry points, timed end to end.

Measurement

Median

Unit

Spread

Notes

function_generation()

0.07

ms

72.6%

3 angle pairs (Freudenstein); 1 solution

path_generation()

677.35

ms

0.7%

4 precision points, max_solutions=10; 10 solutions

motion_generation()

0.66

ms

3.6%

3 poses; 10 solutions

The three differ by four orders of magnitude, and the reason is structural rather than incidental:

  • function_generation() solves Freudenstein’s equation directly. For three angle pairs that is a small linear system, hence microseconds.

  • motion_generation() applies Burmester theory to a fixed set of poses. The work is bounded by the number of poses.

  • path_generation() has no prescribed timing, so coupler orientation at each precision point is a free variable. It runs Burmester synthesis once per candidate orientation, over a grid that grows exponentially with the number of precision points, which is why it costs hundreds of milliseconds.

Where path_generation() spends its time

Cost depends strongly on which points you ask for, not just how many. Both of these are four precision points returning ten solutions:

Precision points

Time

[(0,0), (1,1), (2,1), (3,0)] (the README example)

164 ms

[(0,1), (1,2), (2,1.5), (3,0)] (benchmarked above)

685 ms

Points that admit solutions early are found early, and the search stops. The table at the top of this section reports the slower of the two, so treat it as an upper bound rather than a typical figure.

Profiled on the README’s points, the time splits as:

Stage

Share

Burmester synthesis over the orientation grid

87%

Verifying candidates by simulation

9%

The search dominates. Verification used to be 67% of the runtime; it now runs through the numba solver, which is why it no longer does.

max_solutions (default 10) is the knob that controls the cost, because the search stops as soon as it has that many confirmed solutions. Measured on the README’s points:

max_solutions

Time

Solutions

1

87 ms

1

5

129 ms

5

10 (default)

165 ms

10

20

550 ms

20

None

1108 ms

79

If you need path_generation() faster — in a loop, or behind an interactive control — lower max_solutions. Asking for one solution instead of ten is roughly half the cost.

Warning

Cost grows exponentially in the number of precision points, because the orientation grid is searched over one free angle per point after the first. Five points can take several seconds and still return nothing. Three or four points are the practical range, as the docstring’s “best results with 3-5 points” implies more gently than the timings warrant.

Note

n_orientation_samples is deprecated in favour of orientation_resolution, which is the per-axis grid resolution the old argument was silently folded into. The grid holds orientation_resolution ** (n_points - 1) candidates, so the exponential above is visible in the signature rather than discovered at runtime. The default of 6 is unchanged, and old calls are translated, so no result moves. See Deprecations.

Lowering the resolution is rarely the trade it looks like: a coarser grid returns no solutions more often than it returns fewer. On three of six test point sets, shrinking it took the result from ten solutions to zero. The dense grid earns its cost. Use max_solutions to control the time; that one is monotone.

What is not benchmarked here

The benchmarks/ directory also contains benchmark_geometry.py, benchmark_integrated.py, and benchmark_optimizations.py. Those are development explorations — they compare candidate implementations, some of which were never shipped — and their output should not be quoted as pylinkage performance figures. Only run_benchmarks.py and benchmark_solver.py measure the library as released.