Skip to content

Benchmarks

How does the meshio++ C++ core compare with the original pure-Python meshio? The benchmark/ folder times read and write conversions on the formats that both libraries support, on the same in-memory mesh.

Both libraries expose an identical Mesh / read / write API, so the harness (benchmark/bench.py) hands one geometry to each and times it. The legacy pure-Python meshio is imported from source (it needs no build); meshio++ is the installed package. The headline input is the bundled example.msh — a real Gmsh mesh of a mechanical bracket (~52k nodes, ~293k cells, mixed triangles + tetrahedra). Reproduce everything with benchmark/01_benchmark.ipynb.

Where the C++ core helps (and where it doesn't)

meshio++ moves the parsing/serialising hot loops into C++, so the win is largest exactly where pure-Python is slowest — text/ASCII formats:

  • VTU binary + zlib — the zlib block compression parallelises across cores (the C++ core defaults to an OpenMP backend with dynamic scheduling, which also load-balances hybrid P+E-core CPUs), so this is the biggest win: ~16× write, ~2.3× read.
  • VTU ASCII — the C++ number formatter and parser are several times faster than the Python/numpy text path (~7× write, ~5× read).
  • XDMF read — much faster on mixed-topology meshes (~10× on the bracket), roughly even on a single-block mesh.
  • MED (HDF5) — with the Eigen-backed Fortran↔C transpose fused with the node reorder, MED is now at parity or better (~1.2× write, ~1.0× read).
  • Gmsh binary — the writer buffers each block into one write, and the reader decodes straight from the slurped buffer into an owning array that is moved into the cell block (no copy): ~1.4× write, ~1.8× read.
  • VTK binary — writes at parity (fused gather+byte-swap, one write). Reads now beat pure-Python both on single-cell-type meshes (~1.45×, the connectivity NDArray is moved straight into the cell block) and on mixed-topology meshes (~1.1× on the bracket, up from ~0.4× originally): reader output buffers skip the zero-fill they immediately overwrite, and the per-type block copy is chunked across threads so its first-touch page faults are serviced concurrently. Endianness conversion uses single-instruction bswap intrinsics throughout.

For plain binary dumps, pure-Python meshio streams the whole array through numpy's fromfile/tofile at C speed — a high bar — but with the redundant connectivity copies removed (the reader now adopts the byte-swapped buffer as the cell array in the common single-type case), meshio++'s VTK/Gmsh reads now land at or above parity there. HDF5 (MED, XDMF) is even-to-faster. A Python-only format (MDPA) is the ~1× control.

These formats were previously slower in meshio++ (VTK/Gmsh binary and MED read all landed at 0.2–0.6×); the current numbers reflect an optimisation pass — bulk-buffered binary I/O (one write per section, fused gather+byte-swap; bulk memcpy decode on read), zero-copy cell reconstruction (the connectivity buffer is reshaped and moved into the cell block, not copied), a real parallel backend (OpenMP by default), thread-capping for the memory-bandwidth-bound loops, and Eigen for the MED transpose. Output stays byte-identical throughout (the round-trip and reference-file tests are the gate).

This is the honest shape of it: meshio++ is a large win for text and compute-bound formats (ASCII, zlib) and now at or above parity on the binary and HDF5 formats too — including single-cell-type binary reads, which the zero-copy reconstruction brought level with (or past) numpy's vectorised fromfile. Mixed-topology binary reads, which can't adopt the buffer directly, land just under parity.

Real mesh (example.msh)

Read/write time (log scale) and speedup on the actual bracket mesh:

read/write timings on example.msh

speedup on example.msh

Speedup = legacy time / meshio++ time. Bars in the shaded region mean meshio++ is faster; to the left of the dashed line the pure-Python numpy path wins.

Does the speedup grow with mesh size?

Both libraries are O(n), so the relative speedup settles to a per-format constant on non-trivial meshes — but the edges behave differently by format:

  • Text formats (VTU ASCII) — the write speedup climbs out of the small-mesh regime as fixed per-call overheads amortise, then plateaus. A large real mesh realises the full speedup; a tiny one does not.
  • Compressed binary (VTU + zlib) — the OpenMP-parallel zlib compression grows with size as there is more work to spread across cores.
  • Plain binary (VTK/Gmsh) — writes track parity; single-cell-type reads now match or beat numpy (the connectivity buffer is adopted with no copy).

speedup vs mesh size

Parallel backend

The C++ core parallelises with a compile-time backend (AUTO → OpenMP by default). Memory-bandwidth-bound loops (byte-swap, transpose, gather) are thread-capped because they saturate bandwidth after a few threads; compute-bound loops (zlib, base64) use all cores. Check the active backend with python -c "import meshioplusplus._core as c; print(c.__parallel_backend__)" — if it prints stl without TBB linked, parallel_for runs sequentially.

Reproducing

sh
uv pip install --python .venv matplotlib jupyter nbconvert ipykernel
cd benchmark
../.venv/bin/jupyter nbconvert --to notebook --execute --inplace 01_benchmark.ipynb

The notebook records the machine, library versions, and the inputs (the bundled example.msh bracket plus a synthetic tetrahedral cube and a size sweep), runs the harness, writes results.csv, and regenerates the plots above. Numbers are single-machine and indicative — the shape of the result is the point, not the exact factors.

Mesh-backend benchmarks

The C++ core's mesh backend (MESHIO / NATIVE / KRATOS) is an exclusive compile-time choice, so benchmark/bench_backends.sh builds one benchmark binary per backend (cpp/benchmark/bench_backends.cpp, enabled with -DMESHIOPLUSPLUS_BUILD_BENCHMARKS=ON) and collates a CSV (benchmark/results_backends.csv). Method mirrors the Python harness: warmup + median of 5 (std::chrono), a synthetic structured tet cube (default 6·35³ = 257k tets over 46k shared points), and four kinds of rows:

  • ingest — building the mesh through the uniform ingestion API (the reader side's cost);
  • traverse — a full writer-side accessor sweep;
  • to_modelpart (KRATOS only) — the one-time GetModelPart() materialization: Nodes, Elements/Conditions, variables, and the automatic tag SubModelParts;
  • write/read per format — full file round-trips (gmsh 4.1 binary, vtu binary+zlib, vtk binary, medit ASCII, su2).

Representative single-machine numbers (257k tets):

opmeshionativekratos
ingest0.7 ms1.0 ms0.8 ms
traverse0.6 ms0.7 ms0.8 ms
to_modelpart65 ms
gmsh 4.1 binary write / read17 / 3.6 ms17 / 8.4 ms18 / 2.8 ms
vtu (binary+zlib) write / read28 / 19 ms19 / 38 ms25 / 16 ms
medit ASCII write / read79 / 59 ms70 / 63 ms89 / 63 ms

The takeaway: because ingestion is move-based for canonical (Float64/Int64) arrays and the KRATOS backend materializes its ModelPart lazily, format I/O costs the same under every backend (differences above are run-to-run noise on parse-bound paths); the only real extra is the explicit, one-time to_modelpart conversion — the O(n) entity-creation pass any Kratos exchange has to pay. Reproduce with:

sh
./benchmark/bench_backends.sh          # optional: grid size, e.g. `... 50`

Released under the MIT License.