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 (HDF5) — reads are much faster on mixed-topology meshes (~10× on the bracket). On a single-block mesh both libraries spend most of their time in gzip, which HDF5 runs on one thread; since v16.15.0 meshio++ writes gzip datasets in ~1 MiB row chunks compressed in parallel, and inflates any row-chunked gzip dataset in parallel on read, so that time now divides across cores (a SEQ build, as in the Linux wheels, still runs it on one).
  • MED (HDF5) — with the Eigen-backed Fortran↔C transpose fused with the node reorder, MED writes faster (~1.1× in benchmark/results.csv). Reads measured 0.81–0.93× there; v16.15.0 removed the two zero-fills of every dataset the reader immediately overwrites and made the family-to-region pass one sweep over the entities instead of one per family, which brings the single-block read level with pure Python.
  • Gmsh binary — the reader decodes straight from the slurped buffer into an owning array that is moved into the cell block (no copy), so reads are ~1.7× faster; the writer buffers each block into one write. benchmark/results.csv (July 2026) shows the write at 0.68×, but re-measuring on v16.14.0 against meshio 5.3.5 put it at about 1.9×: the recorded figure does not reproduce, and the notebook's next run replaces it.
  • 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. MDPA is the ~1× control: a C++ reader exists, but Python's read() deliberately keeps calling the pure-Python one (see formats), so this benchmark measures the same code on both sides of the comparison.

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.

v16.18–v16.21: I/O and operation parallelism ​

Four releases moved more of the remaining serial work into C++ and onto more cores, all byte-identical to what came before (test_io_baseline.py pins the writers; bench_ops --hash pins the operations across backends and thread counts):

  • A shared text tokenizer (v16.21.0, detail/text_cursor.hpp): mdpa, su2, avsucd and tecplot parse string_view tokens of the mapped file instead of allocating a std::string per line and token — on bench.py's M meshes, su2 reads 3.5× faster, avsucd 2.9×, tecplot 2.1×, and the C++ mdpa reader (used by every flat binding but not by Python's own read(), see formats) 3.2×. VTU ASCII arrays and XDMF DataItems parse straight into their typed array the same way.
  • Nineteen more readers through FileSource (v16.20.0; see memory-mapped reading), and ASCII writers (VTU, VTP, legacy VTK, Medit, Tecplot, OpenFOAM points, Abaqus, Ansys .cdb, LS-DYNA) formatting rows in parallel chunks instead of serially or through a per-row heap string.
  • Raw appended VTU (v16.21.0, appended=True/--appended; see VTU): on a 216,000-hexahedron grid, 2.6× faster to write and 6.9× faster to read than inline binary, and 1.6×/1.4× with zlib.
  • Operation parallelism (v16.18.0–v16.19.0): the distance kernel (shrinkwrap, remesh_volume, sample_distance/distance_to_surface), welding (clean, merge), the shared facet table's counting sort (extract_surface, isosurface, slice), cell_data_to_point_data/gradient/hessian's gather, compute_normals, remesh_volume and remesh's setup passes, and the proximity_graph pair search (neighbor_pairs, in the C++ core since v16.18.0 — see proximity graphs) all moved from a serial or hash-map-based pass to a parallel one.

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.

OFF, Medit, XYZ and UNV: second reader batch ​

The 112-row matrix covers these four readers on the same M/L harness inputs, compiler settings, SEQ/OpenMP/TBB modules and thread limits as the MFM experiment. Before modules come from the preserved original archive, with the four source files verified against its source commit; after modules and source snapshots are separately archived under build/text-io-batch2/. Timing medians use seven uninstrumented repeats after warmup. Allocation counts are three identical warmed native-dispatch reads in separate instrumented processes; they include Python dispatch/binding overhead and are cumulative ordinary operator new requests, not peak memory. Digests include geometry, point/cell/field data and regions and agree across both stages and all seven configurations for every format/size pair.

SEQ readerM read before → afterL read before → afterM allocation calls before → afterL allocation calls before → after
OFF3.6 → 0.9 ms9.0 → 2.5 ms22,097 → 4754,498 → 48
Medit ASCII38.0 → 33.8 ms148.7 → 132.5 ms140,017 → 49526,889 → 41
XYZ1.8 → 1.0 ms4.5 → 2.5 ms58,455 → 29,487144,621 → 72,693
UNV95.5 → 99.6 ms387.2 → 394.5 ms5,377,168 → 5,237,20020,778,542 → 20,251,694

OFF replaces both file extraction and its count-line stream with TextStream over a FileSource. Medit retains its comment-aware tokenizer but returns views and parses bounded prefixes. XYZ retains comment/column/delimiter/PTS and molecular-file rules while viewing its source lines and numeric tokens; header names remain owned and the output arrays are copied. UNV normalizes short Fortran real fields into a terminated stack buffer instead of an owned string; long fields still have an owned fallback. None changes writer code, public API or installed headers, so C++ ABI 22 is unchanged. The remaining private-cursor consolidation and dtype-switch work are not part of this batch.

The 32-row SEQ confirmation records before→after and after→before orders rather than replacing the original sweep. OFF, Medit and XYZ retain read gains in both orders. UNV is an allocation-only result, not a speedup: M reads are 94.43→95.04 and 98.51→96.21 ms; L reads are 388.35→389.39 and 385.29→391.07 ms, with no material slowdown. Writer medians fluctuate despite identical implementations, notably UNV M (210.55→214.77 and 179.73→211.94 ms); no writer speedup is claimed. Keep those noisy observations in the evidence. Direct native suites run on MESHIO/NATIVE/KRATOS; fresh-process Python tests exercise both forced buffered and mmap-attempt paths, CRLF/no-final-newline and owned results after unlinking and replacing the source.

EnSight, VTK, Ansys, Patran and Tecplot: stream batch ​

The 140-row matrix and 40-row SEQ confirmation preserve matching file/mesh digests before and after on all seven configurations. The driver uses the harness's M/L inputs with binary=False for EnSight, VTK and Ansys, so it actually measures their ASCII paths; Tecplot and Patran use their usual text writers. Archived modules/source/compile commands and raw logs are in build/text-io-batch3/. The timing/count methodology is the same as the second batch, including separate instrumentation and both SEQ measurement orders.

SEQ readerM read before → afterL read before → afterM allocation calls before → afterL allocation calls before → after
EnSight ASCII16.3 → 16.9 ms60.5 → 58.9 ms57 → 5449 → 46
VTK ASCII20.0 → 20.7 ms76.2 → 75.1 ms67 → 6159 → 53
Ansys ASCII408.2 → 416.4 ms2127.8 → 2164.5 ms6,615,646 → 6,615,64525,592,351 → 25,592,350
Patran mesh292.3 → 287.7 ms1353.3 → 1330.9 ms5,611,802 → 5,611,80221,721,422 → 21,721,422
Tecplot57.6 → 57.2 ms455.8 → 443.2 ms257,406 → 257,406998,402 → 998,402

These are small stream-overhead cleanups, not bulk-reader speedups. EnSight's case/time/id records, VTK's header-token splitter, Ansys's zone-name record and Patran's result header now use TextStream. Patran's mesh-only harness does not exercise result headers; its existing direct C++ and native/reference Python text/binary-result tests cover that migration, with no result-reader performance claim. Tecplot's allocating stream helper had no call sites: removing it has no runtime effect, and its counts remain identical. Confirmation SEQ read medians do not show a material slowdown; unchanged writer timings remain noisy (including EnSight and Patran L) and are retained rather than interpreted as a gain. All changes are core-private/non-inline implementations; API, installed headers and ABI 22 are unchanged. The remaining allocating tokenizers and private-cursor consolidation remain open.

Femap, PCD and Radioss engine records: fourth batch ​

Femap's comma fields now view its source records, with bounded real parsing and owned titles/results. PCD's header fields view the source; ASCII data, header integers and viewpoint numbers use a terminated stack buffer for short tokens and an owned long-token fallback, retaining the existing C parser's range/errno and embedded-NUL semantics. Radioss engine records use shared blank-separated views and bounded whole-token floating parsing. Radioss fixed/comma fields and include-expanded source lines are not migrated in this batch. No writer, installed header or public API changes; ABI 22 remains unchanged.

The 84-row matrix measures Femap, ASCII PCD (binary=False) and Radioss starter meshes on the harness's M/L inputs, with the same seven configurations and separate timing/allocation instrumentation. All before/after file and full parsed-data digests agree. Archives are under build/text-io-batch4-ascii/. The starter harness does not exercise engine fields: Radioss's direct native/native-reference engine tests and the new long-token/ignored-nonnumeric test cover that path, but no engine performance gain is claimed.

SEQ readerM read before → afterL read before → afterM allocation calls before → afterL allocation calls before → after
Femap328.1 → 211.7 ms1482.7 → 968.5 ms8,480,531 → 6,197,00332,859,077 → 24,013,789
PCD ASCII1.0 → 1.1 ms2.7 → 2.6 ms106 → 96106 → 96
Radioss starter232.6 → 250.9 ms1140.5 → 1141.0 ms5,267,765 → 5,267,76520,742,055 → 20,742,055

Femap cumulative requested bytes drop from 1,713,613,273 to 973,491,843 for M and 6,683,154,139 to 3,808,758,163 for L (about 43%); these are not peak-memory measurements. The 24-row SEQ confirmation retains both orders: Femap M 328.75→193.21 and 319.86→190.42 ms, L 1506.31→978.33 and 1487.66→974.36 ms. Radioss's initial M slowdown does not reproduce (232.27→231.49 and 229.37→231.69 ms). PCD is a small allocation result, not a speedup claim: M 1.03→0.98 and 1.04→0.98 ms; L 2.51→2.69 and 2.59→2.55 ms, with the small one-order regression retained as timing variability rather than hidden. Native tests run on all three mesh backends, and the fresh-process ownership/prefix/error tests also pass on the original archived SEQ module, proving they pin existing behavior rather than broaden parsing.

DEX, IP, FLUX and PERMAS: line-view batch ​

The 112-row matrix and 32-row both-order confirmation retain identical before/after file and full mesh digests on all seven configurations. All four readers now view FileSource lines instead of owning every line; FLUX and PERMAS also view blank-separated body tokens. IP still owns each parenthesis-normalized record and its column names; DEX owns header metadata. Existing numeric-prefix and stream-extraction behavior, writer bytes, public API and installed headers are unchanged, leaving ABI 22 unchanged. DEX requires a nodal field: the harness now supplies benchmark:field = arange(npoints) only for that input, without mutating the shared meshes or changing conformance.

SEQ readerM read before → afterL read before → afterM allocation calls before → afterL allocation calls before → after
DEX9.3 → 8.6 ms37.7 → 33.2 ms233,124 → 186,567877,949 → 702,411
IP1.5 → 1.1 ms3.6 → 2.6 ms39,108 → 26,093101,030 → 67,375
FLUX159.2 → 66.9 ms554.9 → 261.9 ms985,651 → 257,4193,825,865 → 998,433
PERMAS66.7 → 37.7 ms268.7 → 153.6 ms2,470,195 → 1,987,4749,547,803 → 7,690,314

The first FLUX candidate allocated a temporary token vector per record, increasing M/L allocation calls to 2,455,382/9,511,276 despite a read gain. Its original raw matrix is preserved in build/text-io-batch5/after/; the published matrix uses after-refined/, which reuses that vector and reduces requested bytes to 217,824,419/868,814,051 from 433,964,810/1,728,718,042. This is cumulative allocation traffic, not peak RSS. Before source/settings and all archived modules remain in the same tree. Confirmation reproduces all four reader gains, including FLUX M 163.71→65.01 and 162.14→65.65 ms, L 545.95→259.36 and 553.04→262.12 ms. Writer timing fluctuations are retained, including FLUX L 830.57→885.25 ms in the reversed order; writer code is unchanged and no writer performance gain is claimed. Focused native tests cover MESHIO/NATIVE/KRATOS, and Python checks cover source ownership, CRLF/no-final-newline and forced buffered/mmap-attempt reads.

Abaqus, Ansys coded databases, Marc, Nastran and Netgen: deck-line batch ​

These five readers now view their mapped/buffered source lines. Marc retains each included file in stable deque<FileSource> storage through deck parsing and keeps its formatted post-file source alive with the post reader; returned names, sets and arrays remain owned. Netgen views its inflated gzip buffer instead of copying it into a string stream, retaining concatenated-member handling and count bounds. Abaqus data-section rows and Nastran flattened logical cards remain owned; their tokenizers are not closed by this batch. No installed headers/API/writer changes; ABI 22 is unchanged.

The 140-row matrix has identical before/after file and mesh digests across all seven configurations. The source/module/settings archives are in build/text-io-batch6-lines/; the published after archive is after-refined/. The first Netgen candidate copied each view into a temporary string before stripping it, leaving M allocation calls essentially unchanged; its raw evidence remains in after/, and the refinement strips the view directly. The harness uses normal text/deck writers, not gzip, includes or Marc post results; those paths are covered by native/native-reference fixtures and fresh-process source-ownership tests, including a nested Marc include.

SEQ readerM read before → afterL read before → afterM allocation calls before → afterL allocation calls before → after
Abaqus93.3 → 89.5 ms369.1 → 322.9 ms2,711,303 → 2,407,41810,444,757 → 9,270,901
Ansys coded database179.8 → 166.1 ms718.0 → 597.4 ms5,143,990 → 4,840,07319,900,568 → 18,726,691
Marc deck156.2 → 154.9 ms628.8 → 567.4 ms4,242,675 → 3,938,76516,405,217 → 15,231,347
Nastran212.8 → 206.4 ms871.7 → 798.6 ms7,338,703 → 6,988,15828,345,645 → 26,996,182
Netgen67.6 → 56.9 ms267.9 → 213.3 ms2,220,753 → 1,916,8548,568,399 → 7,394,529

The 40-row confirmation retains both SEQ orders and reader gains. Netgen M reads are 65.47→55.38 and 65.70→54.95 ms, L 258.96→210.18 and 259.36→215.44 ms. All readers reduce cumulative requested bytes as well as allocation calls; these are not peak-memory measurements. Unchanged writer timings remain noisy, including Abaqus L 133.99→163.92 ms in the reversed order; no writer speedup is claimed. The five-format/ownership/gate tests pass on SEQ/OpenMP/TBB and direct native suites run on MESHIO/NATIVE/KRATOS.

LS-DYNA, Radioss card fields and GiD quoted tokens ​

The 84-row matrix and 24-row both-order confirmation match file/full-mesh digests across both stages and all seven configurations. This batch uses the immediately preceding rebuilt modules as its baseline, with source snapshots and implementation diffs at both stages in build/text-io-batch7/, because Radioss's engine fields were already migrated. Compiler commands and settings match. LS-DYNA views lines and block records over each include's source, explicitly retaining the original split's final empty record. Radioss fixed/comma fields view the stable expanded deck lines; numeric parsing still calls the existing owned card parser. GiD views quoted/unquoted tokens over each record and parses short numbers from terminated stack buffers; metadata names and long-number fallbacks remain owned. No writer, installed-header or API changes; ABI 22 is unchanged.

SEQ readerM read before → afterL read before → afterM allocation calls before → afterL allocation calls before → after
LS-DYNA198.2 → 188.6 ms934.8 → 1000.3 ms3,449,240 → 2,841,42513,372,224 → 11,024,489
Radioss starter230.3 → 241.3 ms1139.8 → 1098.1 ms5,267,759 → 5,267,75920,742,052 → 20,742,052
GiD ASCII126.9 → 137.7 ms561.4 → 576.4 ms1,823,380 → 1,823,3807,043,186 → 7,043,186

The initial timing regressions are retained rather than replaced. Both-order confirmation reproduces no material reader slowdown: LS-DYNA M 192.61→173.98 and 193.61→172.06 ms, L 947.52→890.69 and 938.45→877.37 ms; Radioss M 231.70→220.86 and 233.06→219.69 ms, L 1158.20→1099.74 and 1144.19→1098.79 ms; GiD M 129.59→124.70 and 129.95→122.82 ms, L 610.72→556.40 and 605.44→555.03 ms. Cumulative requested bytes decrease for every reader/configuration, even where allocation call counts stay identical because the numeric strings used small-string storage. They are not peak-memory measurements. Unchanged writer timing noise is retained, with no writer gain claimed. Ownership/prefix tests also pass on the baseline SEQ module; direct native tests cover all three mesh backends, and Python suites include GiD ASCII/binary/HDF5 paths and LS-DYNA/Radioss include/metadata behavior.

Shared-card views and bounded Fortran numbers ​

The 196-row matrix and 56-row both-order confirmation preserve file/full-mesh digests across both stages and all seven configurations. Six readers (LS-DYNA, Ansys coded databases, Marc, Radioss, Patran and UNV) use core-private card/token views and short terminated number buffers; Nastran also uses the bounded numeric counterpart while keeping its reassembled logical cards owned. The installed keyword_card.hpp owning API is unchanged. Direct tests compare values, signed zero, error messages, D/implicit exponents, range limits, embedded NULs and stack/owned-buffer boundaries against that API. No writer/public-header/signature/default changes; ABI 22 is unchanged. Source/module/settings archives are in build/text-io-batch8-cards/, against the preceding rebuilt modules.

SEQ readerM read before → afterL read before → afterM allocation calls before → afterL allocation calls before → after
LS-DYNA171.6 → 160.0 ms891.7 → 916.4 ms2,841,428 → 2,328,21211,024,492 → 9,029,996
Ansys coded database152.4 → 146.6 ms603.5 → 563.7 ms4,840,073 → 4,700,10518,726,691 → 18,199,843
Marc deck147.1 → 124.7 ms581.9 → 473.4 ms3,938,765 → 2,806,06615,231,347 → 10,860,888
Radioss starter221.2 → 245.5 ms1210.2 → 1198.6 ms5,267,765 → 4,882,85320,742,055 → 19,246,183
Patran neutral288.2 → 294.0 ms1414.9 → 1821.8 ms5,611,802 → 5,611,80221,721,422 → 21,721,422
Nastran197.9 → 213.2 ms839.1 → 895.8 ms6,988,158 → 6,754,87826,996,182 → 26,055,382
UNV91.2 → 103.1 ms374.7 → 400.9 ms5,237,200 → 5,237,20020,251,694 → 20,251,694

Initial regressions are retained, including Patran L's reader 1414.88→1821.78 ms and unchanged writer 1221.52→1413.31 ms. Confirmation reproduces reader gains for LS-DYNA, Ansys, Marc and Patran, with no material confirmed SEQ slowdown for the other three. Patran M reads are 293.46→263.01 and 289.81→264.15 ms, L 1330.39→1219.53 and 1274.99→1190.54 ms. Marc L reads are 582.27→457.77 and 566.54→465.27 ms. Nastran's small increases (M 193.94→197.97, L 784.05→796.93 ms in the reversed order) and all writer fluctuations remain in the confirmation; Nastran/Radioss/UNV have no speedup claim. UNV's benchmark takes the already migrated free-field mesh path, so unchanged allocation counts/bytes are expected; fixed-record fixtures cover its migrated paths. Patran's call counts stay identical while requested bytes fall from 703,906,827→457,390,491 (M) and 2,736,711,739→1,783,419,723 (L), primarily smaller token-vector storage. These are cumulative requests, not peak RSS. Marc post results and other non-mesh records are covered by fixtures, not those mesh timing rows.

The initial confirmation launch accidentally permitted an editable-install rebuild, which failed on the full /tmp filesystem before any confirmation timing row was collected. Its failure log remains in the archive; recovery explicitly disables editable rebuilds and uses project-local temporary storage with the same archived modules and measurement paths. The focused gates passed 908 Python tests (four optional skips) per SEQ/OpenMP/TBB module and 118 native tests per MESHIO/NATIVE/KRATOS backend. The remaining allocating Abaqus/Netgen tokenizers and Nastran logical-card storage are separate work, not claimed as closed here.

Shared byte and record cursors ​

Eight format-private adapters now share the core-private TextCursor/RecordCursor implementations: Gmsh, VTK legacy, EnSight ASCII, UNV, MFEM, MDPA, GiD and Femap. They retain comments, quoting, binary positioning, diagnostics and any deliberately owned normalized names/tokens. Gmsh still reads integer identifiers through the floating path (2.0 and 1e3), and tests cover hexadecimal coordinates, signed zero, prefix position/failure behavior, long fields, embedded NULs and source replacement. Installed headers, writer code/bytes and public APIs are unchanged; ABI 22 is unchanged. The 224-row matrix and 64-row both-order confirmation match file/full exposed-mesh digests across both stages and all seven configurations, against the preceding rebuilt modules with matching compiler commands. Source/module/settings archives are in build/text-io-batch9-cursors/.

SEQ readerM read before → afterL read before → after
Gmsh 4.1 ASCII22.2 → 20.5 ms89.1 → 74.8 ms
VTK legacy ASCII19.9 → 16.6 ms78.2 → 60.0 ms
EnSight ASCII16.4 → 13.6 ms62.7 → 48.9 ms
UNV95.4 → 102.8 ms404.9 → 407.1 ms
MFEM113.5 → 129.1 ms854.5 → 861.9 ms
MDPA native31.8 → 36.0 ms125.3 → 130.7 ms
GiD ASCII126.4 → 140.9 ms694.5 → 662.1 ms
Femap neutral204.7 → 215.2 ms1078.1 → 970.4 ms

Allocation calls and cumulative requested bytes are identical for every format/configuration: this is consolidation/bounded prefix parsing, not an allocation-cleanup claim. Both SEQ orders reproduce Gmsh, VTK and EnSight reader gains: Gmsh L 82.81→69.99 and 81.28→69.03 ms, VTK L 76.04→54.05 and 75.36→53.93 ms, EnSight L 58.35→44.40 and 58.66→44.82 ms. The other adapters are neutral within the retained fluctuations, not speedups; GiD's reversed-order increases (M 118.42→122.72, L 533.69→550.47 ms) oppose the first order's modest gains, while Femap L rises 915.60→922.10 and 918.92→923.10 ms. Initial regressions and unchanged-writer noise remain published, including Gmsh L writes 297.61→390.37 ms and VTK L confirmation writes 158.98→201.77 ms.

Gmsh 4.1 uses a directly tested single-type tetrahedral input, without changing its mixed-cell conformance rejection. MDPA is explicitly read through _core.mdpa_read at both stages: its public Python shim deliberately uses the Python reference and would not measure the native cursor. The CSV identifies that reader; its digest covers the standard mesh exposed by the binding, while native MDPA tests cover property/side-channel behavior. The initial input-selection and Python-MDPA digest failures remain in the archive, before any complete matrix was recorded. ASCII selection is explicit for Gmsh, VTK and EnSight; binary/other grammar paths are covered by fixtures, not those timing rows. Focused gates passed 1,147 Python tests with one expected failure per SEQ/OpenMP/TBB module, 240 native tests per MESHIO/NATIVE/KRATOS backend and 174 ownership/compatibility tests on the archived baseline SEQ module.

Remaining deck-token views ​

Abaqus comma fields/data-section rows, Netgen whitespace fields, Nastran logical-card chunks/fields and FLAC3D ASCII rows now use bounded views. Nested include sources and inflated gzip buffers remain live through parsing. Ordinary Nastran large fields are adjacent source views; tolerated mixed free/fixed continuations retain stable owned concatenations in a deque rather than extending a view across commas. Duplicates, trailing empty fields, numeric-prefix leniency, names/sets, connectivity and writers are unchanged. The 112-row matrix and 32-row both-order confirmation preserve file/full-mesh digests across stages and all seven configurations. They compare the preceding rebuilt modules with matching compiler commands/settings; archives are in build/text-io-batch10-tokens/. No installed headers/public APIs changed; ABI 22 is unchanged.

SEQ readerM read before → afterL read before → afterM allocation calls before → afterL allocation calls before → after
Abaqus86.0 → 52.1 ms335.7 → 198.3 ms2,407,418 → 1,613,0169,270,901 → 6,220,739
Nastran233.7 → 196.1 ms916.9 → 723.0 ms6,754,878 → 6,171,67626,055,382 → 23,828,820
Netgen59.0 → 38.7 ms232.8 → 141.4 ms1,916,854 → 1,426,3267,394,529 → 5,518,201
FLAC3D ASCII70.2 → 41.6 ms285.3 → 153.7 ms2,220,781 → 818,5218,568,429 → 3,170,489

Both SEQ orders reproduce reader gains: Abaqus L 320.06→179.13 and 314.77→177.55 ms, Nastran L 822.18→655.01 and 811.48→662.90 ms, Netgen L 219.87→136.29 and 219.46→136.82 ms, FLAC3D L 277.26→147.94 and 272.31→147.93 ms. All four reduce allocation calls and cumulative requested bytes in every configuration. SEQ L requested bytes fall from 1,068,508,549→657,547,999 (Abaqus), 2,432,190,351→1,703,386,391 (Nastran), 792,157,678→473,771,897 (Netgen) and 782,706,085→201,687,829 (FLAC3D); these are cumulative requests, not peak RSS. Unchanged-writer noise remains published, including reversed-order Abaqus L writes 131.75→164.42 ms and Netgen L 265.34→289.25 ms; no writer speedup is claimed.

Focused tests pass 346 Python cases per SEQ/OpenMP/TBB module and 60 native cases per MESHIO/NATIVE/KRATOS backend. The archived baseline also passes 187 ownership/compatibility cases, including long native prefixes, source replacement, 300 mixed-format Nastran joins, nested Abaqus includes and Netgen gzip. The native Abaqus include-local/global id behavior predates this batch and differs from the Python reference for the cross-include set in that test; its expected data is pinned directly rather than claiming new reference compatibility. Gzip, binary FLAC3D and richer set/property paths are fixture-covered but not measured by the ASCII mesh rows.

Source-copy removal and temporary ownership guards ​

DEX numeric tokens now view the source and use bounded prefix parsing; D/d-exponent normalization uses a short terminated stack buffer or an owned fallback for long tokens. Gmsh time/metadata headers, OBJ trimmed rows and OpenFOAM vector/field text retain existing source views rather than first constructing strings. PLY's ASCII face path names its row owner explicitly. This is not a dangling-read crash fix: the preceding TextStream(std::string&&) already moved temporary strings into mOwned. That owning overload is retained and directly tested with short and long temporaries. A const owning temporary, which cannot move into the owner, is now rejected; the genuinely borrowing TextCursor also rejects owning rvalues. Public APIs, installed headers, writer code/bytes and ABI 22 are unchanged.

The 140-row matrix and 40-row both-order confirmation preserve file/full-mesh digests against the preceding rebuilt modules and across all seven configurations, with matching compiler commands/settings. Archives, the corrected ownership audit and the retained test-key failure/recovery are in build/text-io-batch11-lifetimes/. Gmsh timings select ASCII; OBJ, OpenFOAM and DEX also use text paths. PLY timing uses its default binary writer/reader, not an ASCII performance claim; ASCII faces are exercised by its fixtures and source-replacement tests. OpenFOAM field-copy removal is fixture-covered, not timed by the geometry-only mesh input.

SEQ readerM read before → afterL read before → afterM allocation calls before → afterL allocation calls before → after
Gmsh 4.1 ASCII18.4 → 21.4 ms70.2 → 76.6 ms56 → 5645 → 45
OBJ2.60 → 2.60 ms6.33 → 6.39 ms64,100 → 44,180161,286 → 108,986
PLY binary2.01 → 1.97 ms4.59 → 4.97 ms132,377 → 132,377326,779 → 326,779
OpenFOAM geometry212.7 → 225.4 ms874.5 → 880.6 ms8,565,369 → 8,565,36933,499,026 → 33,499,026
DEX8.98 → 7.89 ms36.75 → 30.09 ms186,570 → 140,042702,414 → 526,926

DEX gains reproduce in both SEQ orders (M 8.57→6.99 and 8.46→7.00 ms, L 35.21→29.82 and 34.89→29.30 ms). OBJ L reads are 6.07→5.77 and 6.07→5.74 ms; other copy-removal paths are neutral or fluctuating, not reader speedup claims. The initial Gmsh slowdown is much smaller in confirmation (M 19.47→20.20 and 19.34→19.72 ms, L 69.01→68.90 and 69.12→69.51 ms); PLY M changes direction between orders (2.02→1.85 and 1.99→2.28 ms). All observed timing rows remain published. In particular, unchanged OBJ M writes increase in both orders (6.14→6.52 and 5.78→6.64 ms), an unfavorable observation requiring follow-up rather than a broad no-writer-regression claim. DEX and OBJ reduce cumulative allocation requests in all configurations; SEQ L bytes fall 52,300,098→46,859,989 and 10,825,536→9,407,320 respectively. Gmsh, PLY binary and OpenFOAM geometry allocations/bytes are unchanged.

The focused gates pass 615 Python tests with one optional skip per SEQ/OpenMP/TBB module, 78 selected native cases per MESHIO/NATIVE/KRATOS backend (one external checkMesh test skipped), and 201 source-ownership cases on the archived baseline SEQ module. Seven standalone cursor parity/lifetime tests pass AddressSanitizer and UndefinedBehaviorSanitizer. DEX long exponents, signed zero and embedded-NUL prefixes preserve native semantics; source replacement checks both mapped and buffered reads in fresh processes.

Dtype-hoisted OFF, IP, FLUX and PERMAS writers ​

Roadmap §3.1.2's first writer batch. The OFF, IP, FLUX and PERMAS writers build one core-private detail::DoubleView/detail::Int64View per point, connectivity or reference array before the row loop, instead of calling read_double/read_int (a dispatch_dtype switch) per element. The view points into the array when its dtype already is float64/int64 and otherwise makes one converted copy, element for element as read_double/read_int convert it, so the written bytes cannot change. Readers are untouched. The change lives in non-inline .cpp bodies and a private header: no installed header, signature or layout changes, and C++ ABI 22 is unchanged.

The 112-row matrix and 32-row two-round confirmation use the harness's canonical float64/int64 inputs, the zero-copy case, through strict Python dispatch. File and full-mesh digests agree across stages and all seven configurations. Reader and writer allocation calls and requested bytes are identical before and after in every configuration: canonical inputs allocate no view storage.

SEQ writerM write, round 1M write, round 2L write, round 1L write, round 2
OFF6.64 → 6.29 ms6.98 → 6.88 ms13.07 → 13.87 ms14.23 → 13.21 ms
IP4.09 → 4.25 ms4.60 → 4.51 ms9.92 → 10.50 ms10.56 → 9.87 ms
FLUX229.32 → 224.40 ms231.05 → 225.72 ms850.81 → 860.19 ms895.45 → 863.10 ms
PERMAS73.23 → 74.44 ms81.27 → 74.05 ms287.84 → 300.61 ms302.76 → 275.20 ms

These timings are neutral: every L row changes direction between rounds, and the writers spend their time formatting numbers, not dispatching on the dtype. No writer speedup is claimed for canonical inputs. The unchanged readers fluctuate the same way (PERMAS L 150.38→158.57 and 153.02→155.99 ms; FLUX L 293.26→301.37 and 389.78→287.40 ms), and all rows remain published. The hoist pays off for non-canonical inputs (float32 points, int32 connectivity), where the per-element switch is replaced by one parallel conversion. test_text_io_dtypes.cpp and test_text_io_dtypes.py pin byte identity across all ten dtypes and in 2-D and 3-D, and check that a caller's arrays are not modified.

Dtype-hoisted Gmsh, FEBio, Z88, Patran, Femap and MDPA writers ​

Roadmap §3.1.2's second writer batch, in the same shape as the first: the Gmsh 2.2/4.1 (ASCII and binary), FEBio, Z88, Patran, Femap and MDPA writers read their points, connectivity, per-cell tags/types/properties and data arrays through one private detail::DoubleView/detail::Int64View per array, instead of a read_double/read_int dtype switch per element. Z88's constraint and surface-load arrays, MDPA's per-value formatter (now a small array-level helper) and Femap's mesh fingerprint use views too. Readers are untouched. The change is in non-inline .cpp bodies and a private header, so installed headers and C++ ABI 22 are unchanged.

The 168-row matrix (SEQ at one thread; OpenMP and TBB at 1/4/8) and the 48-row two-round confirmation use the canonical float64/int64 benchmark inputs, where a view is zero-copy. Gmsh timing selects its ASCII 4.1 path; the binary paths and the 2.2 writer are covered by the dtype tests. Written-file and parsed-mesh SHA-256 values agree across stages, backends and thread counts, and reader and writer allocation calls and requested bytes are identical before and after in every configuration: canonical inputs allocate no view storage.

SEQ writerM write, round 1M write, round 2L write, round 1L write, round 2
Gmsh 4.1 ASCII101.3 → 99.9 ms100.3 → 91.7 ms387.5 → 386.1 ms383.9 → 384.4 ms
FEBio79.4 → 75.7 ms81.3 → 76.4 ms312.2 → 300.6 ms310.0 → 305.2 ms
Z88170.1 → 175.6 ms172.0 → 172.7 ms703.3 → 687.6 ms674.2 → 675.6 ms
Patran472.5 → 482.2 ms480.7 → 479.4 ms1859.0 → 1892.4 ms1873.5 → 1831.7 ms
Femap201.4 → 224.2 ms208.3 → 210.5 ms799.4 → 846.0 ms862.7 → 833.0 ms
MDPA136.9 → 138.2 ms133.6 → 127.4 ms514.7 → 525.9 ms486.3 → 514.2 ms

These timings are neutral: FEBio writes are the only ones that improve in all four cells (about 1.5–5%); the others change direction between rounds, and Femap's and MDPA's first-round increases are not reproduced in round 2 but are retained. The writers spend their time formatting numbers, so no canonical-input speedup is claimed. The unchanged readers fluctuate by more than the writers do (Femap L 1850.3 → 1472.8 and 1805.0 → 1784.9 ms; Z88 L 639.2 → 761.4 and 750.1 → 764.5 ms), which bounds how much any one cell can be trusted. All rows remain published. The hoist matters for non-canonical inputs (float32 points, int32 connectivity), where the per-element switch becomes one parallel conversion.

test_text_io_dtypes.cpp and test_text_io_dtypes.py now pin byte identity against canonical storage across all ten dtypes, in 2-D and 3-D, for every writer in both batches, and check that the caller's arrays are not modified. NATIVE and KRATOS ingest arrays as canonical float64/int64, so on those backends the check exercises the same path through converted copies.

Measured on this session's container (4 cores, GCC 13.3 with -O3 -DNDEBUG -ffp-contract=off, SEQ/OpenMP/TBB modules built from the same tree), seven repeats after one warmup, TBB capped with tbb::global_control and OpenMP with OMP_NUM_THREADS; absolute times are not comparable with the earlier batches' machine. The first matrix hashed MDPA's parsed properties_0 dictionary through its pointer bytes, which varied between processes while every written file was identical; the digest now hashes the dictionary's text and MDPA was re-measured, so the published MDPA rows come from that second run and the other formats from the first.

Dtype-hoisted CGNS, MED, libMesh, PCD, GiD and glTF ​

Roadmap §3.1.2's third batch. These formats read their points, connectivity, offsets, tags and data through one private detail::DoubleView/detail::Int64View per array instead of a read_double/read_int dtype switch per element, in the writers and in the per-value loops of the CGNS, MED and PCD readers (MED's polyhedron and polygon offset tables, CGNS coordinate and connectivity columns, PCD's coordinate and normal columns). libMesh builds its per-block p_level views on first use, so only the blocks of written cells are touched; PCD's ASCII writer holds one view per output column; glTF's colour, attribute, normal and region loops are hoisted, and its per-cell gltf_cell_ids keeps its single read because a view per cell would cost more than the switch. The changes are in non-inline .cpp bodies and a private header, so installed headers and C++ ABI 22 are unchanged.

The 140-row matrix (SEQ at one thread; OpenMP and TBB at 1/4/8) and the 40-row two-round confirmation use the canonical float64/int64 inputs through strict Python dispatch, on the HDF5-enabled modules. CGNS and MED outputs embed per-write HDF5 metadata that varies between processes, so their file_sha256 is the digest of every dataset's name, dtype, shape and bytes; every other format hashes the file bytes. All digests, and the parsed-mesh digests, agree across stages, backends and thread counts, and reader and writer allocation calls and requested bytes are identical before and after in every configuration. glTF is write-only, so the round-trip harness cannot time it; it is covered by the dtype tests only.

SEQM write, rounds 1 / 2L write, rounds 1 / 2M read, rounds 1 / 2L read, rounds 1 / 2
CGNS51.1 → 53.1 / 52.6 → 43.6 ms211.3 → 170.7 / 220.4 → 220.9 ms14.6 → 14.7 / 14.7 → 11.8 ms57.9 → 56.6 / 77.6 → 47.6 ms
MED8.5 → 8.5 / 8.5 → 8.4 ms35.7 → 35.5 / 59.7 → 33.7 ms2.4 → 2.4 / 2.4 → 2.6 ms7.1 → 7.6 / 28.4 → 8.1 ms
libMesh101.1 → 95.9 / 93.0 → 94.8 ms376.7 → 352.1 / 408.7 → 413.3 ms137.2 → 157.1 / 173.7 → 161.4 ms620.9 → 708.8 / 633.8 → 716.7 ms
PCD0.5 → 0.7 / 0.5 → 0.7 ms0.9 → 0.8 / 1.0 → 0.7 ms0.2 → 0.2 / 0.2 → 0.2 ms0.5 → 0.5 / 0.5 → 0.4 ms
GiD120.0 → 119.1 / 124.0 → 129.0 ms482.8 → 491.5 / 470.9 → 495.6 ms201.9 → 194.3 / 194.4 → 198.3 ms902.5 → 898.7 / 872.0 → 843.5 ms

These timings are neutral, and two observations are unfavorable and retained rather than explained away. libMesh L reads are about 12% slower in both orders (620.9 → 708.8 and 633.8 → 716.7 ms) although the libMesh reader code is untouched in this batch, so this is not attributable to the hoist and remains open for follow-up (code layout or another cause has not been ruled out). PCD M writes rise by 0.2 ms in both rounds (0.5 → 0.7 ms), while L changes direction; at sub-millisecond scale this is retained as an observation, and no PCD speedup is claimed. The CGNS and MED rows include 2–4x outliers in single cells (MED L reads 28.4 ms, CGNS L reads 77.6 ms) that appear only in the second round's before run; their cause was not isolated, and they are the reason no CGNS or MED claim is made. The hoist pays off for non-canonical inputs, where the per-element switch becomes one parallel conversion.

A first measurement of this batch caught two allocation regressions before publication, which were fixed rather than documented as expected: the PCD reader made a converted double copy of every float32 coordinate column (+3 allocation calls, up to +436 KB requested), and the CGNS reader's std::deque of coordinate views cost two allocations. PCD now converts each column in place with one dtype dispatch per column and CGNS uses a fixed array; the published matrix is the rerun, and the same harness change (dataset digests for the HDF5 outputs) applies to both stages. test_text_io_dtypes.cpp and test_text_io_dtypes.py pin libMesh, GiD and glTF byte for byte against canonical storage across all ten dtypes, and PCD, CGNS and MED, which record each array's dtype in the file, by round-tripped values; every writer is also checked to leave the caller's arrays untouched.

Measured in the same container and with the same build settings as the previous batch, with HDF5 enabled and NetCDF disabled, seven repeats after one warmup.

Dtype-hoisted OBJ, STL, PLY, TetGen, UGRID, DOLFIN, FreeFEM, AVS-UCD, WKT, Triangle, SVG and TikZ writers ​

Roadmap §3.1.2's fourth batch, the simple text writers. They read points, connectivity, tags and data through one private detail::DoubleView/detail::Int64View per array instead of a read_double/read_int dtype switch per element. Arrays that were looked up by name or block inside a row loop (Triangle, TetGen, AVS-UCD, FreeFEM) get one view before the loop; an array absent for a block keeps its original fallback value, so no view is made for it. The PLY writer holds one view per scalar point property, of the kind its dtype is written as; the PLY reader's per-column loop and scalar header read are unchanged. The changes are in non-inline .cpp bodies and a private header, so installed headers and C++ ABI 22 are unchanged.

The 252-row matrix (SEQ at one thread; OpenMP and TBB at 1/4/8) and the 72-row two-round confirmation cover the nine formats the round-trip harness can time, on the canonical float64/int64 inputs. Triangle writes 2-D points only and the harness inputs are 3-D, and SVG and TikZ are write-only, so those three are covered by the dtype tests alone. Written-file and parsed-mesh SHA-256 values agree across stages, backends and thread counts, and reader and writer allocation calls and requested bytes are identical before and after in every configuration. The before modules are built from d545e00, and the after modules from the same tree plus the batch's diff.

SEQM write, rounds 1 / 2L write, rounds 1 / 2M read, rounds 1 / 2L read, rounds 1 / 2
AVS-UCD145.33 → 143.51 / 146.99 → 148.27 ms568.63 → 554.35 / 558.62 → 539.13 ms85.16 → 87.23 / 80.03 → 87.66 ms337.33 → 334.77 / 328.72 → 335.81 ms
DOLFIN240.56 → 239.58 / 239.59 → 235.01 ms937.75 → 934.54 / 926.88 → 936.87 ms211.29 → 209.22 / 211.74 → 205.76 ms836.79 → 819.79 / 816.98 → 803.70 ms
FreeFEM119.80 → 122.17 / 128.09 → 117.68 ms468.92 → 476.48 / 466.23 → 467.15 ms65.68 → 64.26 / 66.08 → 65.03 ms265.27 → 256.29 / 263.18 → 254.60 ms
OBJ8.16 → 8.30 / 8.07 → 7.97 ms19.71 → 20.58 / 18.95 → 19.71 ms5.06 → 5.23 / 5.06 → 5.29 ms12.63 → 13.62 / 12.47 → 13.10 ms
PLY2.21 → 2.16 / 2.30 → 2.10 ms4.82 → 5.29 / 5.09 → 4.85 ms4.01 → 3.95 / 3.84 → 3.94 ms9.62 → 9.85 / 9.40 → 9.87 ms
STL47.31 → 49.84 / 54.56 → 47.65 ms129.59 → 120.00 / 119.78 → 118.93 ms22.93 → 23.02 / 23.00 → 22.81 ms60.44 → 57.93 / 56.45 → 57.31 ms
TetGen125.03 → 122.04 / 129.69 → 118.69 ms480.00 → 473.45 / 483.45 → 469.04 ms110.68 → 116.20 / 116.88 → 114.66 ms518.14 → 518.21 / 500.29 → 502.56 ms
UGRID96.07 → 96.28 / 101.01 → 95.72 ms377.36 → 362.46 / 370.52 → 363.86 ms59.16 → 59.42 / 62.89 → 60.25 ms237.63 → 239.00 / 236.66 → 240.33 ms
WKT44.50 → 42.76 / 44.07 → 43.32 ms107.75 → 109.45 / 109.27 → 109.30 ms25.23 → 23.49 / 23.44 → 23.21 ms59.75 → 60.75 / 59.57 → 58.55 ms

These timings are neutral. Most writer cells move by a few percent in either direction, and the writers spend their time formatting numbers, so no canonical-input speedup is claimed. Two retained observations point the same way in both rounds. OBJ L writes are about 4% slower (19.71 → 20.58 and 18.95 → 19.71 ms); this is the writer follow-up promised by the OBJ M-write observation, whose M writes are now neutral (8.16 → 8.30 and 8.07 → 7.97 ms), so that earlier increase is not reproduced. The unchanged OBJ reader moves by a similar amount in the same direction (L 12.63 → 13.62 and 12.47 → 13.10 ms), which suggests a shift common to the module rather than a writer effect, but this was not isolated. The hoist pays off for non-canonical inputs, where the per-element switch becomes one parallel conversion.

test_text_io_dtypes.cpp and test_text_io_dtypes.py extend the earlier batches to these writers. Every file a writer produces, including companions such as TetGen's .ele and DOLFIN's mesh-function files, is compared with canonical storage across all ten dtypes. UGRID (integer labels), AVS-UCD (integer materials) and DOLFIN (float versus int mesh functions) choose their output by whether an array is float or integer, so they are compared with the same class in float64 or int64 rather than always float64/int64. PLY records property dtypes in its header and is checked by round-tripped values.

Dtype-hoisted SU2, Netgen, Elmer, MPHTXT, Code_Aster, Nastran, Tecplot, UNV, Fluent and Radioss writers ​

Roadmap §3.1.2's fifth batch, the deck and structured-text writers. They read points, connectivity, tags and data through one private detail::DoubleView/detail::Int64View per array instead of a read_double/read_int dtype switch per element. Writers that address cells by (block, row) (SU2's zones and markers, Elmer's partition labels, Fluent's ansys:zone, Radioss's cards and sides) keep one view per block, made on first use, so a block the original never read cannot start throwing. Tecplot's per-component columns, UNV's field blocks and Netgen's index and identification tables are viewed once per array. Per-cell helpers whose caller cannot hold a view (Fluent's fl_ring) and single scalar header reads keep their read_int. The changes are in non-inline .cpp bodies and a private header, so installed headers and C++ ABI 22 are unchanged.

The 280-row matrix (SEQ at one thread; OpenMP and TBB at 1/4/8) and the 80-row two-round confirmation cover all ten formats on the canonical float64/int64 inputs; Elmer's output is a directory, so its file digest covers every file in it. Written-file and parsed-mesh SHA-256 values agree across stages, backends and thread counts. The before modules are built from 01aa7d6 and the after modules from 5f09e29.

Allocations. Reader allocation calls and requested bytes are identical before and after everywhere. Writer allocation calls are not quite identical: the per-block view caches are vectors sized by the number of cell blocks, which add a constant number of allocation calls per write, independent of mesh size and thread count: +2 for SU2 (34 → 36 on M, 36 → 38 on L; about 80 bytes), +1 for Fluent and +1 for Radioss. The Fluent writer's allocation count also varies by a few calls between repeats at several threads in the unchanged before module too (for example 8,823,343, 8,823,321 and 8,823,343 for three repeats), so the harness records the median of three repeats and tolerates 0.01%; any larger spread would fail the run.

SEQM write, rounds 1 / 2L write, rounds 1 / 2M read, rounds 1 / 2L read, rounds 1 / 2
Fluent568.2 → 614.0 / 578.7 → 570.6 ms2421.0 → 2596.6 / 2454.7 → 2402.7 ms699.3 → 816.2 / 692.8 → 711.4 ms3332.2 → 4022.4 / 3189.1 → 3282.8 ms
Code_Aster88.6 → 104.0 / 93.1 → 86.6 ms349.6 → 354.2 / 351.5 → 356.4 ms355.9 → 366.1 / 355.4 → 316.0 ms1612.8 → 1840.9 / 1685.4 → 1600.3 ms
Elmer104.9 → 109.5 / 104.1 → 102.4 ms414.3 → 448.5 / 408.0 → 405.9 ms133.5 → 156.5 / 133.2 → 133.9 ms648.7 → 801.1 / 597.3 → 574.6 ms
MPHTXT68.9 → 69.9 / 67.7 → 66.1 ms276.1 → 281.9 / 275.5 → 264.2 ms41.8 → 48.8 / 42.9 → 43.9 ms202.7 → 221.8 / 197.3 → 202.6 ms
Nastran645.0 → 690.5 / 660.7 → 643.0 ms2685.7 → 2560.8 / 2552.0 → 2522.2 ms383.8 → 392.4 / 377.9 → 383.1 ms1697.3 → 1548.8 / 1538.1 → 1528.4 ms
Netgen113.1 → 123.6 / 117.9 → 114.9 ms461.3 → 466.1 / 440.7 → 442.3 ms84.1 → 90.4 / 82.8 → 81.9 ms337.3 → 339.5 / 331.0 → 334.4 ms
Radioss656.3 → 716.0 / 661.9 → 683.0 ms2831.8 → 2684.0 / 2659.4 → 2666.0 ms508.1 → 527.1 / 502.9 → 505.4 ms2171.4 → 2112.8 / 2063.9 → 2054.6 ms
SU2125.6 → 129.0 / 125.4 → 129.1 ms494.1 → 472.8 / 505.3 → 478.3 ms49.2 → 50.1 / 48.1 → 50.5 ms205.8 → 204.9 / 206.8 → 200.8 ms
Tecplot86.6 → 95.9 / 86.5 → 90.8 ms343.0 → 338.7 / 336.9 → 355.8 ms123.3 → 158.1 / 120.8 → 124.9 ms655.7 → 534.3 / 562.5 → 533.0 ms
UNV307.2 → 317.5 / 300.3 → 318.9 ms1178.6 → 1204.8 / 1249.0 → 1197.6 ms225.7 → 236.4 / 222.3 → 230.3 ms941.5 → 863.5 / 897.2 → 882.8 ms

These timings are neutral, and not separable from a measurement-order effect. Across the twenty writer cells, round 1 (before measured first) has the after module slower by a median of +3.1% (16 of 20 cells slower), and round 2 (after measured first) has it faster by a median of −1.3% (8 of 20 slower): the module measured second in a round tends to be slower. The readers, which this batch does not touch, show the same pattern (median +4.2% in round 1 and +0.5% in round 2), so the writer differences are no larger than the drift seen in code that did not change. No writer speedup or slowdown is claimed; every row is retained.

The first run of this matrix stopped at 8 OpenMP threads because the harness required three identical allocation counts; the cause was the Fluent jitter described above, present in both modules, and the harness was relaxed as described rather than the row dropped. The finished stages were kept and the rest re-run in the same session. test_text_io_dtypes.cpp and test_text_io_dtypes.py extend the earlier batches to these writers, including integer tags that exercise the per-block lookups (unv:pid/unv:mid, nastran:ref, radioss:part, mphtxt:geom, ansys:zone, gmsh:physical for SU2). Every file a writer produces, walked recursively for Elmer, is compared with canonical storage across all ten dtypes; SU2 and Netgen choose their region tags by whether an array is float or integer, so they are compared with the same class in float64 or int64.

Dtype-hoisted VTKHDF, MFEM, Marc, FLAC3D, EnSight, XDMF, LS-DYNA, MFM, DEX, H5M, MFF, VTP, VTS, OpenFOAM, XYZ and MDPA-table loops ​

Roadmap §3.1.2's sixth batch, the remaining per-element loops. After the earlier batches the roughly 123 remaining read_double/read_int calls were classified: single header or scalar values, small name decodes, one-cell lookups and per-call helpers stay (glTF's gltf_cell_ids, Fluent's fl_ring, the PLY reader's coordinate columns, most of CGNS's and Gmsh's reads). The per-element ones were hoisted: the VTKHDF, MFEM, Marc, FLAC3D, EnSight, LS-DYNA, MFM, DEX, MFF, VTP, VTS, OpenFOAM, XYZ and MDPA-table writers, the GiD HDF5 reader's column loops, XDMF's mixed-topology header scan, and Gmsh's periodic-pair validation. Where one array is read once in its own dtype (VTKHDF's piece merge and points, XDMF's integer values, H5M's connectivity shift) the dtype is switched once per array and values are converted straight into the destination, so no converted copy is added; elsewhere one view per array is made before the loop, and writers that look cells up by block keep one lazily made view per block. The changes are in non-inline .cpp bodies and a private header, so installed headers and C++ ABI 22 are unchanged.

The 420-row matrix (SEQ at one thread; OpenMP and TBB at 1/4/8) and the 120-row two-round confirmation cover the fifteen of these formats the round-trip harness can time, on the canonical float64/int64 inputs, on the HDF5-enabled modules. VTS needs a lattice mesh and OpenFOAM's hoisted loop needs boundary patches the harness input does not have, so VTS is covered by its existing tests only. HDF5-backed outputs (H5M, VTKHDF, XDMF's .h5) are digested by dataset name, dtype, shape and bytes, and H5M's tstt/history record, which holds the write's timestamp, is excluded; the first run of this matrix hashed it and showed a spurious file-digest difference, so H5M was re-measured alone with that fix and its published rows come from that rerun. Written-file and parsed-mesh SHA-256 values agree across stages, backends and thread counts. The before modules are built from b75ff79 and the after modules from b85ea7a.

Allocations. Reader allocation calls and requested bytes are identical before and after everywhere. Writer allocation calls differ only by constants from the per-column and per-block cache vectors, independent of mesh size and thread count: +1 call (+40 bytes) for Marc and +2 calls (+240 bytes) for XYZ. One OpenFOAM configuration (8 OpenMP threads, M) shows −11 calls (−312 bytes), the same scheduling-dependent jitter seen in the Fluent writer, which the harness tolerates to 0.01% per repeat.

SEQM write, rounds 1 / 2L write, rounds 1 / 2M read, rounds 1 / 2L read, rounds 1 / 2
DEX64.26 → 64.51 / 69.91 → 69.74 ms234.70 → 248.52 / 232.38 → 250.11 ms17.66 → 18.52 / 16.68 → 17.68 ms60.40 → 70.42 / 66.14 → 73.71 ms
EnSight7.28 → 5.92 / 11.15 → 6.31 ms27.81 → 25.88 / 31.95 → 22.76 ms2.99 → 3.24 / 3.80 → 2.96 ms10.27 → 15.09 / 9.21 → 9.76 ms
FLAC3D185.40 → 169.40 / 209.91 → 207.20 ms734.50 → 687.27 / 749.69 → 675.90 ms123.98 → 124.80 / 134.43 → 148.69 ms453.04 → 544.55 / 456.90 → 414.76 ms
H5M89.02 → 87.79 / 84.80 → 82.77 ms296.87 → 318.22 / 307.43 → 312.00 ms22.11 → 22.04 / 22.20 → 22.53 ms78.48 → 78.31 / 81.98 → 79.84 ms
LS-DYNA388.53 → 401.67 / 397.13 → 448.89 ms1841.16 → 1773.61 / 1824.26 → 1911.06 ms354.56 → 388.43 / 357.96 → 399.66 ms1799.80 → 1861.25 / 1814.12 → 1778.26 ms
Marc773.25 → 764.17 / 774.14 → 741.92 ms3192.52 → 3252.28 / 3113.02 → 3061.13 ms298.32 → 320.90 / 292.45 → 300.06 ms1300.57 → 1267.52 / 1309.63 → 1260.33 ms
MDPA169.81 → 169.23 / 173.44 → 164.79 ms669.97 → 663.68 / 668.42 → 650.02 ms1363.27 → 1542.71 / 1458.11 → 1386.98 ms5807.70 → 5684.09 / 5761.55 → 5864.17 ms
MFEM160.27 → 180.94 / 167.56 → 159.54 ms626.16 → 612.71 / 597.19 → 655.74 ms313.67 → 356.91 / 326.37 → 332.46 ms1450.34 → 1392.60 / 1422.43 → 1393.20 ms
MFF1.04 → 0.36 / 0.84 → 0.85 ms0.51 → 0.72 / 0.32 → 0.43 ms0.02 → 0.02 / 0.02 → 0.03 ms0.03 → 0.03 / 0.02 → 0.03 ms
MFM280.44 → 278.73 / 255.71 → 244.07 ms979.18 → 955.38 / 973.95 → 967.21 ms153.79 → 152.87 / 171.66 → 151.89 ms667.60 → 631.27 / 612.28 → 648.01 ms
OpenFOAM908.34 → 920.43 / 911.14 → 946.15 ms3816.32 → 3911.22 / 3671.21 → 3898.58 ms480.02 → 476.51 / 458.78 → 499.93 ms2132.10 → 2117.31 / 1948.20 → 1995.52 ms
VTKHDF105.88 → 104.19 / 109.13 → 103.78 ms430.31 → 404.18 / 386.89 → 401.58 ms33.79 → 32.39 / 33.49 → 30.76 ms130.12 → 142.52 / 136.73 → 143.21 ms
VTP20.82 → 19.75 / 21.58 → 19.71 ms48.56 → 53.38 / 50.71 → 60.60 ms2.52 → 2.59 / 2.85 → 2.39 ms6.12 → 6.24 / 5.86 → 6.47 ms
XDMF78.61 → 83.25 / 78.19 → 74.59 ms282.97 → 261.65 / 263.94 → 267.14 ms18.95 → 18.72 / 18.60 → 17.98 ms63.16 → 63.08 / 63.29 → 66.79 ms
XYZ12.24 → 7.35 / 7.64 → 7.24 ms16.25 → 16.04 / 16.75 → 20.04 ms2.34 → 1.99 / 2.32 → 2.17 ms4.86 → 4.79 / 4.85 → 5.67 ms

These timings are neutral. Across the thirty writer cells the median change is about −1.2% in round 1 (before measured first; 11 of 30 cells slower) and −1.0% in round 2 (after measured first; 13 of 30 slower), with no order effect like the one in the previous batch; the readers, which are mostly untouched, move by +0.3% and +2.1%. Individual cells scatter by several percent to tens of percent in both directions. No writer speedup or slowdown is claimed; every row is retained. The MFF cells are sub-millisecond and meaningless as ratios. DEX L looked slower in both rounds for both its hoisted writer (234.70 → 248.52 and 232.38 → 250.11 ms) and its untouched reader (60.40 → 70.42 and 66.14 → 73.71 ms), so it was re-measured alone in three interleaved before/after rounds of seven repeats: the medians are writes 260.9 → 237.6 ms and reads 69.2 → 70.4 ms, so the first observation does not reproduce.

The first run of this matrix stopped when its background time limit was reached after all fourteen main stages; the two confirmation rounds were then run from the same modules. test_text_io_dtypes.cpp and test_text_io_dtypes.py extend the earlier batches to these writers: EnSight (ASCII and binary), FLAC3D (ASCII and binary), DEX, MFM, Marc, LS-DYNA, MFEM, MFF and XYZ byte for byte, and VTP, VTKHDF, H5M and XDMF, which record dtypes in the file, by round-tripped values. H5M stores connectivity as Int32/Int64/UInt32/UInt64 only, so its round-trip test skips the other index dtypes.

WASM size after dtype hoisting ​

Roadmap §3.1.2. Each dispatch_dtype site instantiates its body once per dtype, so the hoisting batches could have grown the WASM artifacts. The wasm.yml job's Report artifact size step answers it: v16.30.0, released before any batch, against v16.31.0, which carries all six.

Artifactv16.30.0v16.31.0Change
meshioplusplus_wasm.wasm (SEQ)12,240,518 B12,229,812 B−10,706 B (−0.09%)
meshioplusplus_wasm_mt.wasm (threaded)12,966,704 B12,961,386 B−5,318 B (−0.04%)

Both artifacts shrank (v16.31.0 also carries the text-cursor readers, so the figure is the net of both), so no site falls back to one converting view. The figures come from the wasm.yml runs of the two tag pushes, 37109655002 and 37301176188.

Dtype-hoisted gradient, diff, refine and convert_cells loops ​

Roadmap §3.3.2. gradient's per-cell loader (grad_load_cell) read each corner's node id, coordinates and field values through read_int/read_double, and diff did the same per element for its array comparison, its cell connectivity and its unordered point correspondence; refine and convert_cells (Elevate and the subdividing modes) wrote each new point and point-data value through write_double. Each of those switched on the dtype once per value. Now each array is taken once through the existing Int64View/DoubleView, and the writes go through DoubleSink, a new write-side twin in the core-private detail/typed_view.hpp: a hot loop stores into a plain double*, and one Commit() converts the appended range into the destination with exactly write_double's rounding and saturation. On a float64 destination the sink is the array itself and Commit() does nothing. The sink covers only the appended rows, so the copied originals are never round-tripped through double (that would corrupt an int64 array's large values). The changes are in .cpp bodies and a private header; installed headers and C++ ABI 22 are unchanged. partition's dual-graph map was the only leftover of the roadmap item; it is flat now.

New meshioplusplus_bench_ops rows give the loops something to measure: gradient_gg (Green-Gauss, which runs grad_load_cell on every cell; the existing gradient row is least squares), diff (the cube against its jittered twin), and *_narrow rows (gradient_narrow, diff_narrow, refine_narrow, elevate_narrow, linearize_narrow) that run on the same meshes stored as float32 points and data and int32 connectivity. On canonical float64/int64 input a view is zero-copy, so the gain there is only the removed per-element branch; the narrow rows are where a converted copy is made.

Determinism. The 88 rows of tiers M and L (162,000 and 750,000 tetrahedra), SEQ at one thread and OpenMP at 1, 4 and 8, all carry the digest of the pre-change run (BASELINE= sweep), and agree across backends and thread counts; all 176 rows. TBB is not installed in the development container, so its leg was skipped. The container has four cores and is shared; the sweep above is a median of 3.

SEQ is not slower. A 3-run median scatters by up to 15 percent on rows whose code did not change (linearize read +9% and +17%), so the SEQ comparison was repeated with the old and new binaries interleaved: three rounds, each a median of seven runs, tier M, one thread.

Row (M, 162,000 cells)BeforeAfterChange
diff23.06 ms20.13 ms−12.7%
diff_narrow23.07 ms22.59 ms−2.1%
gradient_gg117.64 ms111.60 ms−5.1%
gradient_narrow115.02 ms118.04 ms+2.6%
gradient (least squares)415.51 ms421.62 ms+1.5%
refine97.50 ms96.94 ms−0.6%
refine_narrow104.27 ms98.70 ms−5.3%
elevate43.12 ms42.62 ms−1.1%
elevate_narrow45.60 ms42.92 ms−5.9%
linearize (unchanged code)3.55 ms3.52 ms−1.1%

diff gains about 13 percent, gradient_gg and the narrow refine/elevate rows gain about 5 percent, and nothing is slower beyond the noise (linearize, untouched, is the control and sits at −1%). The least-squares gradient row is dominated by the stencil fit, not by grad_load_cell. These are modest gains: the per-element switch was a predictable branch, and the real saving is the one that scales with narrow-dtype input. The OpenMP rows of the 3-run sweep scatter in both directions by more than the gains above, so no parallel speedup is claimed.

test_typed_view.cpp pins DoubleSink against write_double for all ten dtypes (rounding, saturation, NaN and infinities, an untouched prefix, an empty range); the existing gradient, diff, refine, convert_cells and operation-golden suites cover the call sites.

Reused scratch in gradient, and reserved output in the surface and cleaning operations ​

Roadmap §3.2.2 and §3.2.3. gradient allocated about eight heap blocks per cell inside parallel_for: its gradient buffer, a GradCell (corners, values, mean and face rings), a GradStencil in the least-squares branch, and the Green-Gauss and least-squares accumulators. They are now thread_local and reset for every use; grad_load_cell already resets every member it fills, so no value carries over between cells and the arithmetic and its order are untouched. Four smaller changes follow the same rule of moving allocation without changing a result: marching (the engine under isosurface and slice) keeps its at-most-four-corner ring in a std::array and reserves its three staged output blocks from per-kind face counts; clean keeps its sorted-copy and coordinate vectors per thread; feature_edges reserves its output from the crease-edge count, which bounds what it keeps; and convert_cells reserves the polygon fan's exact size (an n-gon is n − 2 triangles). The polyhedron fan is left alone: its size is only known per cell, and reserving size + extra per cell would reallocate every cell and go quadratic. The changes are in .cpp bodies, so installed headers and C++ ABI 22 are unchanged.

New meshioplusplus_bench_ops rows: gradient_surface (Green-Gauss on the triangulated surface, the 2-D ring path) and simplexify_ragged (convert_cells Simplexify of the polyhedron-and-polygon mesh the reorder_ragged row uses).

Determinism. The 96 rows of tiers M and L (162,000 and 750,000 tetrahedra), SEQ at one thread and OpenMP at 1, 4 and 8, all carry the digest of the pre-change run (BASELINE= sweep) and agree across backends and thread counts; all 192 rows. TBB is not installed in the development container, so its leg was skipped. The container has four cores and is shared.

SEQ is not slower. A 3-run median scatters by tens of percent on this container, so each step was also measured with the old and new binaries interleaved: three rounds, each a median of seven runs, tier M, one thread.

Row (M, 162,000 cells)BeforeAfterChange
gradient (least squares)412.59 ms305.12 ms−26.0%
gradient_gg (Green-Gauss, 3-D)108.04 ms68.78 ms−36.3%
gradient_narrow (float32/int32 input)110.64 ms67.93 ms−38.6%
gradient_surface (2-D ring path)2.88 ms1.98 ms−31.4%
hessian (two gradient passes)257.19 ms174.87 ms−32.0%
slice8.32 ms7.71 ms−7.3%
isosurface15.94 ms15.37 ms−3.6%
feature_edges313.36 ms304.95 ms−2.7%
clean / clean_weld81.31 / 187.44 ms82.05 / 185.65 ms+0.9% / −1.0%
simplexify_ragged372.70 ms372.41 ms−0.1%

The gradient family gains 26 to 39 percent and reproduces in the full SEQ sweep at both sizes (−22% to −41% at M, −25% to −37% at L). The sweep's OpenMP rows at 4 threads, the core count, show the same family 15 to 33 percent faster, except gradient_surface at L, a 4 ms row, which is unchanged. The other changes are small or neutral: slice, isosurface and feature_edges gain a few percent, and clean, clean_weld and simplexify_ragged are neutral within noise, so those edits are kept only as removed allocations that cost nothing measurable. Single-measurement swings in the sweep that did not reproduce are not claimed: undo_green read −35% at M in one run and +1% in the interleaved re-measure (the change does not touch it). The 8-thread OpenMP rows oversubscribe the four cores and scatter run to run (gradient_surface at L read 16 to 24 ms over five repeats against a single 12 ms baseline run), so they cannot separate a change from noise and no 8-thread claim is made.

thread_local scratch keeps the capacity of the largest cell a thread has seen for the life of the thread; a cell's face rings are the only part that scales with the cell, so this is bounded by the largest polyhedron in the mesh.

Scratch in the per-cell and per-polyhedron loops, and a CSR source list in convert_cells ​

Roadmap §3.2.2 and §3.2.3. Four loops allocated a vector per cell inside parallel_for: quality's corner coordinates (and, for polyhedra, its face rings), point_data_to_cell_data's node list, and the polyhedron node list of the centroid helpers in interpolate and partition. They are thread_local now and reset on every use (cell_rings and read_corner_coords clear their outputs on entry), so nothing carries over between cells. davg_cell_nodes also drops its per-polyhedron std::unordered_set for a linear scan that keeps first-seen order, which is the summation order, so the floating-point sums are unchanged. The interpolate and partition helpers keep their sort and unique, whose ascending order is their summation order.

convert_cells Simplexify of a polyhedron block now counts the raw faces once and reserves the tetrahedra, parent ids and source lists from that count, and stores the points each new point averages as one flat list with offsets (CSR) instead of one std::vector per new point, which was one heap block per face and per cell. feature_edges counts its skin faces before pushing their rings. subdivide.cpp kept the vector-of-vectors form until it got a bench_ops row; see the next section. The changes are in .cpp bodies, so installed headers and C++ ABI 22 are unchanged; read_corner_coords keeps its exported signature, which is why quality uses scratch vectors and not std::array.

New meshioplusplus_bench_ops rows: quality, quality_ragged, data_average, data_average_ragged, interpolate_cells (a cell field of the ragged mesh onto itself, the path that reaches the centroid helper), partition_ragged (an 8-part space-filling-curve cut, so no KaHIP is needed) and feature_edges_ragged.

Determinism. The 88 rows of tiers M and L plus the 44 of tier S, SEQ at one thread and OpenMP at 1, 4 and 8, carry the digest of the pre-change run (BASELINE= sweep) and agree across backends and thread counts; all 264 rows, before and after. TBB is not installed in the development container, so its leg was skipped. The full C++ suite passes on the OpenMP build (1,810 of 1,816; the six skips are optional codecs, locales and tools).

SEQ is not slower. Interleaved old and new binaries, three rounds, each a median of seven runs, tier M, one thread:

Row (M, 162,000 cells)BeforeAfterChange
data_average8.02 ms4.97 ms−38.0%
data_average_ragged59.18 ms27.10 ms−54.2%
simplexify_ragged327.61 ms278.75 ms−14.9%
quality_ragged271.84 ms233.95 ms−13.9%
feature_edges275.47 ms245.94 ms−10.7%
feature_edges_ragged262.63 ms239.46 ms−8.8%
interpolate_cells649.63 ms609.13 ms−6.2%
quality53.05 ms49.90 ms−5.9%
partition / partition_ragged47.07 / 172.19 ms46.41 / 170.00 ms−1.4% / −1.3%

The data-average and polyhedron rows gain the most, because they allocated per polyhedron; the tetrahedral quality, partition and partition_ragged rows are within noise, so those edits are kept as removed allocations that cost nothing measurable. interpolate read +12.6% in the same table, but its point-data path runs none of the changed code and six further interleaved rounds put it at +3% (0.239 against 0.232 s), inside the round-to-round scatter. The sweep's OpenMP rows at 4 threads are mixed at tier M (quality +23% in one run, −12% at L) and show no claim either way for the same reason: a 3-run median on this four-core shared container scatters by tens of percent, and the 8-thread rows oversubscribe it.

thread_local scratch keeps the capacity of the largest cell a thread has seen for the life of the thread, so it is bounded by the largest polyhedron in the mesh.

CSR source lists in subdivide ​

Roadmap §3.2.2. subdivide kept the plain-average source nodes of each new apex point as one std::vector<std::int64_t> per polyhedron, a heap block per cell. It now stores them as one flat node list plus offsets, the form convert_cells Simplexify uses, and reserves the offsets by the block's cell count (at most one apex per cell). The point-data and coordinate averages read the slices in the same order, so the sums are unchanged. The change is in a .cpp body: installed headers and C++ ABI 22 are unchanged.

New meshioplusplus_bench_ops rows: subdivide_ragged (the polyhedron block of the ragged mesh, where every cell gets an apex) and read_stl (the ASCII STL reader over the surface mesh, which the registry writes as ASCII; it has no code change here and is the control for the noise below, and the baseline for the reader migration of §3.1.1.1). The registry read helper takes the mesh to write, so it also serves the surface rows.

Determinism. subdivide_ragged rows of tiers M and L, SEQ at one thread and OpenMP at 1 and 4, carry the digest of the pre-change run (BASELINE= sweep) and agree across backends and thread counts; all 24 rows, before and after. TBB is not installed in the development container, so its leg was skipped.

SEQ is not slower. Interleaved old and new binaries, three rounds, each a median of seven runs, tier M, one thread:

RoundBeforeAfter
1473.0 ms440.3 ms
2487.9 ms429.2 ms
3469.1 ms452.8 ms
Mean476.7 ms440.7 ms (−7.6%)

The gain is small because the allocation was one block per polyhedron, while the per-face child, face_nodes and apex-triangle vectors of the same loop still allocated several blocks per face; the next section removes those. A single sweep of five runs at tier L gives 2.27 → 2.16 s on SEQ and 2.31 → 2.08 s on OpenMP at one thread, inside the noise of that harness (the unchanged read_stl row moves by up to ±10% between sweeps).

Flat child faces in subdivide ​

Roadmap §3.2.2. The output loop of subdivide built, for every parent face of m nodes, a std::vector<std::vector<std::int64_t>> of child faces, a face_nodes vector for the original face and one three-node vector per apex triangle: 2 + m heap blocks per child polyhedron. It then moved the nested result into AddPolyhedronBlock, whose nested overload counts and flattens it a second time. The staged block now holds the CSR triple AddPolyhedronBlock already takes (nodes, row offsets, face offsets), appended to directly, and moves it into the CSR overload. Child order, the order of faces inside a child (the original face, then the apex triangles in ring order) and every node id are unchanged. The change is in a .cpp body: installed headers and C++ ABI 23 are unchanged.

The three vectors, and the parent ids, are reserved once per block. A polyhedron input block states every cell's face and node counts, so its totals are summed exactly; a rectangular block repeats one cell shape, so the first cell's counts times the cell count serve as a hint. A parent cell with F faces and S ring nodes adds F children, F + S rows and 4S nodes. No reserve is made per cell, which would defeat the geometric growth of the vectors.

Determinism. subdivide_ragged rows of tiers M and L, SEQ at one thread and OpenMP at 1 and 4, carry the digest of the pre-change run (BASELINE= sweep) and agree across backends and thread counts; all 24 rows, before and after. A regression test, Subdivide.ChildLayoutIsPinnedForConvexNonConvexAndMixedInput, pins the exact layout (every child, face and node in order) of a non-convex prism, a wedge, a polyhedron block subdivided a second time and a mixed two-block mesh, with digests taken from the nested-vector implementation. TBB is not installed in the development container, so its leg was skipped.

SEQ is not slower. Medians of five runs, one thread unless noted, before and after in separate sweeps on the same machine (the unchanged read_stl row is the control):

BackendTierBeforeAfter
SEQM (162,000 cells)478.8 ms247.2 ms (−48%)
SEQL (750,000 cells)2453.3 ms1179.7 ms (−52%)
OpenMP, 1 threadM471.9 ms245.6 ms (−48%)
OpenMP, 1 threadL2114.7 ms1263.1 ms (−40%)
OpenMP, 4 threadsM544.6 ms266.0 ms (−51%)
OpenMP, 4 threadsL2389.3 ms1298.3 ms (−46%)

subdivide is serial in its output loop, so OpenMP at four threads gains nothing over one thread; the gain is the same on every backend. The sweeps were not interleaved, but the change is several times the ±10% the control row moves between sweeps.

CSR staging in convert_cells ​

Roadmap §3.2.2. convert_cells staged every polygon and polyhedron block it passes through unchanged (every ragged block Linearize, Elevate and Simplexify do not convert) as a std::vector<std::vector<std::int64_t>> of rows, or a vector of vectors of vectors of faces, a heap block per row and per face, then handed the nested result to AddPolygonBlock or AddPolyhedronBlock, whose nested overloads count and flatten it again; Linearize also remapped every id through the nest. CcellsOutBlock now holds the CSR triple those functions take (nodes, row offsets, face offsets), filled straight from the input block with exact reserves (the input states every row and face size), remapped as one flat array, and moved into the CSR overloads. Rows, faces and node ids come out in the same order. The change is in a .cpp body: installed headers and C++ ABI 23 are unchanged.

New meshioplusplus_bench_ops row: linearize_ragged (Linearize of the ragged mesh, where both blocks pass through, so the row is the copy and nothing else). A regression test, ConvertCells.LinearizeRenumbersPolygonAndPolyhedronBlocksThroughThePrune, pins the renumbering of both ragged blocks when the quadratic block beside them loses its mid-edge nodes, with expectations computed by hand and checked on the old and the new code.

Determinism. All 48 rows of simplexify_ragged, linearize_ragged, linearize, elevate, linearize_narrow, elevate_narrow, agglomerate and read_stl (tiers M and L, SEQ at one thread and OpenMP at 1 and 4) carry the digest of the pre-change run (BASELINE= sweep) and agree across backends and thread counts; all rows, before and after. TBB is not installed in the development container, so its leg was skipped.

Medians of five runs, linearize_ragged:

BackendTierBeforeAfter
SEQM (162,000 cells)82.0 ms16.1 ms (−80%)
SEQL (750,000 cells)407.5 ms95.1 ms (−77%)
OpenMP, 1 threadL444.6 ms95.5 ms (−79%)
OpenMP, 4 threadsL461.6 ms95.0 ms (−79%)

The other rows that go through convert_cells (simplexify_ragged, linearize, elevate and the _narrow pair) do not pass a large ragged block through and move within the noise of the unchanged read_stl control, which swings by up to ±40% between sweeps on its few-millisecond rows. simplexify_ragged is +2% at tier L on SEQ: it converts its polygons rather than copying them.

A null result: agglomerate. The same staging struct and a nested merged_cells of one vector per external face are in agglomerate.cpp, and the roadmap listed them with this item. A rewrite to a flat store, with agg_closed taking a range of it, gave identical digests and a row of −5% to +2% on SEQ and OpenMP at one thread at tier L (the unchanged code ran −8% to +11% between two sweeps), so the allocations are not where agglomerate spends its time (the facet table and the grouping are). It was reverted rather than kept without a measured gain, and the numbers are in the CSV under agglomerate_rewrite_reverted. Agglomerate.MergedLayoutIsPinnedWithAndWithoutCoplanarFusion stays: it pins the exact layout, including fused coplanar rings and the reversed winding of faces seen from a group's far side, which no test pinned before.

Pipeline row: what a shared facet table could save ​

Roadmap §3.3.1.1. A topology-preserving step followed by a step that reads the facets (Smooth then ExtractSurface) builds a facet table twice over the same cells: smooth.cpp's boundary pass and surface.cpp each fill their own records (SmoothFacetRecord, SurfaceFacetRecord, with different payloads) and group them through group_facet_slots. The roadmap asked for a measurement before any layout was changed, so meshioplusplus_bench_ops gains the row pipeline_smooth_surface (a Smooth of ten iterations on the jittered volume, then ExtractSurface, through run_pipeline_steps). It changes no library code, so installed headers and C++ ABI 23 are unchanged.

Determinism. The row's digests agree across SEQ and OpenMP at 1 and 4 threads, tiers M and L (all 18 rows, with the extract_surface and smooth_volume rows it is made of).

Medians of three runs, tier L (750,000 cells):

Backendextract_surfacesmooth_volumepipeline_smooth_surface
SEQ0.535 s12.29 s13.13 s
OpenMP, 1 thread0.527 s11.93 s12.49 s
OpenMP, 4 threads0.359 s3.36 s3.70 s

Where the time goes. Temporary steady_clock timing around the record fill and the key grouping (not kept) gave, on SEQ at tier L, about 0.50 s of extract_surface's 0.54 s (the table is the operation) and about 0.42 s of smooth_volume's 12.3 s. A cache that spared the second reader its build would save at most about 0.47 s of the chain's 13.1 s on SEQ (3.6%), and at most all of extract_surface on four OpenMP threads (0.36 s of 3.70 s, 10%). It matters only when the step before the reader is cheap: with one smoothing iteration in place of ten (a one-off run, SEQ, tier L) the chain took 2.24 s and the table was about 0.46 s of it (20%).

Conclusion. The saving is bounded by one table build, a few percent for the default Smooth and not more than a fifth at one iteration, and it needs the two operations to share one record structure first (they do not today), plus either a cache on the Mesh, which changes the layout test_abi_layout.cpp pins on all three backends (Tier A), or a cache owned by the pipeline executor and additive overloads of every reader. That is not worth an ABI bump on this evidence, so the roadmap records it under Deliberately not, to be reopened by a workload that chains several facet readers after cheap topology-preserving steps. The row stays, so that workload can be measured.

The STL ASCII reader on the text cursor ​

Roadmap §3.1.1.1. The ASCII STL reader did a getline, an lstrip that copied the line, a std::vector<std::string> of tokens per line and parse_double on a std::string; it already included text_cursor.hpp, but only for TextStream. It now reads the file through open_source (mapped above the size threshold), takes lines with split_lines and tokens with split_blanks into one reused vector of views, and parses the last three tokens of each data line with parse_double_prefix, the bounded form of the lenient parse it replaces. Lines are skipped by the same rule (blank, or starting after leading whitespace with solid, outer loop, endloop, endfacet or endsolid), a line with fewer than three tokens is skipped, and a row count that is not a multiple of four is still Malformed ascii STL. The binary reader takes its records from the same mapping instead of a second stream. A file of 80 to 83 bytes, too short to hold a triangle count, is read as ASCII as before (the size >= 84 guard keeps the count read inside the mapping), and the file-size check, the skipped header line of the fallback and the under-80-byte path are unchanged. Output is byte-identical, and the change is in .cpp bodies, so installed headers and C++ ABI 22 are unchanged.

Eight tests pin the ASCII behaviour: exponents and signs, CRLF line endings, tabs, form feeds and blank lines, short lines, a truncated facet, the under-80-byte file, an empty solid, and files of 80, 81 and 83 bytes. The first seven passed against the old reader before it was changed; the 80 to 83 byte test came with the new size >= 84 guard. A ninth checks the binary reader's single-precision vertices.

Determinism. The read_stl rows of tiers M and L, SEQ at one thread and OpenMP at 1 and 4, carry the digest of the pre-change run (BASELINE= sweep) and agree across backends and thread counts; all 12 rows, before and after. TBB is not installed in the development container, so its leg was skipped. The full C++ suite passes on a SEQ build (1,819 of 1,825; the six skips are optional codecs, locales and tools). benchmark/bench.py and the Python tests need the extension module, which the development container cannot build, so they were not run; the Python reader is unchanged.

SEQ is not slower; it is faster. Interleaved old and new binaries, three rounds, each a median of seven runs, one thread:

RoundM beforeM afterL beforeL after
122.7 ms9.5 ms47.3 ms27.8 ms
225.0 ms9.6 ms71.0 ms26.6 ms
323.6 ms14.4 ms45.8 ms34.1 ms
Mean23.8 ms11.2 ms (−53%)54.7 ms29.5 ms (−46%)

The row times the read of the surface of the M and L volume meshes (tier labels are the volume's cell counts, 162,000 and 750,000). Round 2's old L value and round 3's new values are outliers of the shared container; the medians of the three rounds are 23.6 → 9.6 ms (M) and 47.3 → 27.8 ms (L). What remains in the read is the point de-duplication (dedup), which keys an unordered_map<std::string, …> by 24 bytes per vertex row.

Frozen bucket grid in DistanceQuery, ABI 23 ​

Roadmap §3.3.1.2. build_distance_query grouped its (bucket, triangle) pairs in parallel and then moved each bucket into a SpatialGrid, an unordered_map of one vector per cell, one at a time; every nearest-triangle query then paid one node lookup per cell its shell visited. The grid is built once and only searched, so DistanceQuery now holds a BucketTable (the new installed header detail/bucket_table.hpp): the keys in first-seen order, the offsets of each bucket and the ids of every bucket in flat arrays, and an open-addressing index at most half full that maps a key to its bucket. The build scans the run sizes in parallel and fills the flat id array in parallel, so there is no vector per bucket; the index is filled serially over flat memory. Shell and box traversals, the ascending ids of each bucket and the occupied box are SpatialGrid's, so the triangle-id tie-break of sd_nearest_triangle sees the same candidates in the same order. SpatialGrid itself is unchanged, so merge, interpolate, conservative_interpolate and periodic are untouched. The roadmap suggested a sorted bucket table; a hash index was chosen instead because a shell visits many cells and a binary search per cell would have made queries slower.

DistanceQuery changes layout (232 → 272 bytes), a Tier A change, so C++ ABI 22 becomes 23 (the ABI table); test_abi_layout.cpp pins DistanceQuery, BucketTable (160 bytes) and BucketView (16), and the pins compile on the MESHIO, NATIVE and KRATOS mesh backends. The language bindings do not expose DistanceQuery.

New meshioplusplus_bench_ops row: distance_build (the grid and normal tables of a surface's triangle soup, with the soup made outside the timed runs; in hash mode its digest walks every bucket of the occupied box, so a change of the grid cannot hide behind the normal tables).

The build was never the cost. distance_build takes about 3 ms at tier M and 10 ms at tier L, against 0.04 to 9 s for the operations that call it, so the roadmap's serial map insertion is a fraction of a percent of any of them. The gain below comes from the queries: a probe over contiguous memory instead of a pointer chase per visited cell. The serial index build costs a little (the row is within noise of the old build in the interleaved runs, and a few percent either way in single sweeps).

Determinism. The 72 rows of the six distance rows at tiers M and L, SEQ at one thread and OpenMP at 1 and 4, carry the digest of the pre-change run (BASELINE= sweep) and agree across backends and thread counts; all 72 rows, before and after. TBB is not installed in the development container, so its leg was skipped. The full C++ suite passes on a SEQ build (1,826 of 1,832; the six skips are optional codecs, locales and tools), including seven BucketTable tests that compare it with a serially filled SpatialGrid over random shells and boxes, an empty table, one bucket, negative and 2^40-offset keys, and a thousand-bucket probe test. The Python wrappers (compute_sdf, hausdorff and the rest) need the extension module, which the development container cannot build, so their tests were not run here.

Queries are not slower. Interleaved old and new binaries, three rounds, each a median of seven runs (five at tier L); the raw rounds. Mean of the three rounds:

RowSEQ, MOpenMP 4, MOpenMP 4, L
compute_sdf1851 → 1703 ms (−8.0%)503 → 424 ms (−15.8%)1060 → 1000 ms (−5.7%)
hausdorff933 → 899 ms (−3.7%)290 → 237 ms (−18.4%)1218 → 1167 ms (−4.2%)
sample_distance835 → 796 ms (−4.8%)214 → 192 ms (−10.1%)2257 → 2006 ms (−11.1%)
distance_to_surface809 → 777 ms (−3.9%)209 → 199 ms (−4.7%)not run
shrinkwrap45.5 → 47.1 ms (+3.6%)15.8 → 15.1 ms (−4.9%)73.5 → 67.4 ms (−8.3%)

shrinkwrap at SEQ tier M is the one row that reads slower on average; it is a 45 ms call whose rounds (44.7 to 45.9 ms before, 45.4 to 48.7 ms after) overlap, and it reads faster at four threads. A single earlier OpenMP sweep had shown shrinkwrap +12 to +18% and hausdorff +8% in two cells; the interleaved rounds above do not reproduce it, which marks that sweep as noise (five runs each, one container).

TetGen and Triangle readers on the text cursor ​

Roadmap §3.1.1.1. Both readers opened a classic-locale stream, took each line with getline, split it with a TextStream into one std::string per token, kept every token of the file in a vector of strings, and parsed each with strtoll(c_str()) or parse_double(std::string). They now map the file with FileSource, walk it with TextCursor::Line, split blank-separated tokens into views (split_blanks) and parse them in place: TetGen with strtoll_token and parse_double_prefix, the lenient forms it replaces, and Triangle with TextCursor::IntPrefix and DoublePrefix, whose "consumed nothing" result is the old end == begin check. The views point into the mapped file, so TetGen keeps its two sources in read_tetgen's scope beside their tokens, and Triangle's token stream owns its source on the heap, which a move of the stream cannot relocate (the buffered FileSource stores its bytes in a std::string, whose small buffer would move). The order of errors is unchanged: the .node file is read and checked before the .ele file is opened. Output is byte-identical, and the change is in .cpp bodies, so installed headers and C++ ABI 23 are unchanged.

The two readers differ in one rule, which the tests pin so a shared tokenizer cannot blur it: Triangle strips a comment from the first # anywhere on a line, while TetGen skips only a line that starts with #, so a # after data on a TetGen data line adds tokens and fails the size check.

New meshioplusplus_bench_ops rows: read_tetgen (the tetrahedral cube with its point field, so an attribute column is parsed) and read_triangle (a planar lattice of about as many triangles as the cube has tetrahedra, from a new 2-D helper, because the cube has three-component points). Both go through the registry's reader, with the .node/.ele pair written once outside the timed region.

Seventeen new TetGen cases (a new test_tetgen.cpp) and nineteen Triangle cases pin the behaviour, and all of them passed against the old readers before either was changed: comments, blank lines, CRLF, tabs, form feeds and vertical tabs, tokens split across lines, lenient numbers (1.5abc, 12xyz, a lone + or -), nan, inf, an overflow to infinity, a hexadecimal float and an underflow to zero (recorded from the reader as it was, signed NaN included), saturated integers, zero- and one-based numbering, every named ReadError, a lone .node file as a point cloud, and the .poly paths (inline and sibling vertices, markers, skipped holes and regions, every malformed section). The Triangle fuzz regression corpus passes before and after.

Determinism. The read_tetgen and read_triangle rows of tiers M and L, SEQ at one thread and OpenMP at 1 and 4, carry the digest of the pre-change run (BASELINE= sweep) and agree across backends and thread counts; all 24 rows, before and after. TBB is not installed in the development container, so its leg was skipped. The full C++ suite passes on a SEQ build (1,862 of 1,868; the six skips are optional codecs, locales and tools). benchmark/bench.py and the Python tests need the extension module, which the development container cannot build, so they were not run; the Python readers are unchanged.

SEQ is not slower; it is faster. Interleaved old and new binaries, three rounds, each a median of seven runs, one thread; the raw rounds. Mean of the three rounds:

RowTier M, beforeTier M, afterTier L, beforeTier L, after
read_tetgen62.7 ms32.3 ms (−48%)360.4 ms203.8 ms (−43%)
read_triangle69.0 ms28.5 ms (−59%)422.6 ms209.2 ms (−51%)

The tier labels are the cube's cell counts (162,000 and 750,000); the Triangle lattice has about as many triangles. The remaining time of each read was not profiled.

FreeFEM and UGRID ASCII readers on the text cursor ​

Roadmap §3.1.1.1. The FreeFEM reader took each line of a classic-locale ifstream with getline, split it with a TextStream into one std::string per token, and parsed values with strtoll(c_str()) and parse_double(std::string). It now maps the file with FileSource, takes lines from a TextCursor, splits them into views with split_blanks and parses them in place with strtoll_token and parse_double_prefix, the lenient forms it replaces; the row bound size / 2 uses the source's size instead of a second stat of the file. The UGRID reader already mapped an ASCII file, but next_token copied every token into a std::string and called strtoll/parse_double on it; it now returns a view and parses the same two ways. The binary UGRID flavours, which stream from an ifstream, are untouched. FreeFEM's skip rule for blank and whitespace-only lines, its exact three-token header, its per-row token counts and every error message are unchanged, and so is UGRID's lack of any line structure. The change is in .cpp bodies, so installed headers and C++ ABI 23 are unchanged.

One intentional difference. UGRID's next_token skipped blanks with std::isspace, which follows the process's C locale, while text_is_blank is the six classic blank characters. They differ only under a non-"C" setlocale for bytes at or above 0x80 (for example 0xA0 in a Latin-1 locale), where the old reader could split a token on a byte that is not a blank in the file's encoding. The migrated reader no longer depends on the locale, which is the rule for number I/O in this project. No test added here exercises the difference, because it needs a non-"C" locale installed.

New meshioplusplus_bench_ops rows: read_freefem (a .msh file; the registry is given the format, since .msh resolves to Gmsh by extension) and read_ugrid (a .ugrid file, which carries no binary key and so reads as ASCII), both over the tetrahedral cube with its point field.

Sixteen new FreeFEM cases (a new test_freefem_text.cpp) and fourteen UGRID cases (a new test_ugrid_text.cpp) pin the behaviour, and all of them passed against the old readers before either was changed: blank and whitespace-only lines, CRLF, tabs, form feeds and vertical tabs, extra tokens on a row, a row split across lines (refused for FreeFEM), UGRID's tokens in any layout, lenient numbers (1.5abc, 2xyz, a lone -, a word with no digits), nan, inf, an overflow to infinity, a hexadecimal float and an underflow to zero (recorded from the readers as they were, signed NaN included), saturated integers, UGRID's connectivity range check, every named ReadError, and the count bounds. They read values through the dtype-neutral helpers, so they hold on the NATIVE backend, which widens points to Float64. The FreeFEM and UGRID fuzz regression corpora pass before and after.

Determinism. The read_freefem and read_ugrid rows of tiers M and L, SEQ at one thread and OpenMP at 1 and 4, carry the digest of the pre-change run (BASELINE= sweep) and agree across backends and thread counts; all 24 rows, before and after. TBB is not installed in the development container, so its leg was skipped. The full C++ suite passes on the MESHIO build (1,892 of 1,898) and on the NATIVE build (1,901 of 1,907); the six skips are optional codecs, locales and tools. benchmark/bench.py and the Python tests need the extension module, which the development container cannot build, so they were not run; the Python readers are unchanged.

SEQ is not slower; it is faster. Interleaved old and new binaries, three rounds, each a median of seven runs, one thread; the raw rounds. Mean of the three rounds:

RowTier M, beforeTier M, afterTier L, beforeTier L, after
read_freefem43.8 ms26.3 ms (−40%)217.1 ms116.5 ms (−46%)
read_ugrid45.8 ms21.6 ms (−53%)201.3 ms73.1 ms (−64%)

The tier labels are the cube's cell counts (162,000 and 750,000). One old read_ugrid round at tier L (237 ms against 179 and 188) is an outlier of the shared container; the medians of the three rounds, 188 → 70 ms, give the same picture. The remaining time of each read was not profiled.

OBJ reader on the text cursor, and the OBJ writer profile ​

Roadmap §3.1.1.1. The OBJ reader opened a classic-locale ifstream, took each line with getline, trimmed it with std::isspace, built a TextStream and a std::string tag per line, a std::vector<double> per vn/vt row (kept in a vector<vector<double>>), and per f item a std::string, a substr and a std::stoll. It now maps the file with FileSource, takes lines from a TextCursor, reads the tag as a view, stores v points and vn/vt rows flat (a width and a row count, with the "rows of one attribute with different lengths" check made when the array is built, as before), and parses each face item's leading integer in place with a local obj_stoll that keeps std::stoll's contract (the integer prefix of the item before any /, std::invalid_argument when there is no digit, std::out_of_range beyond int64). Numbers on v, vn and vt lines are still read through TextStream, because their grammar is a stream extraction's (a word, inf, nan or a hexadecimal float stores 0 and ends the line, an overflow stores the largest finite value), which a rewrite onto from_chars would change. The change is in .cpp bodies, so installed headers and C++ ABI 23 are unchanged.

Profile first. The roadmap kept an OBJ writer observation open (M neutral, L about 4% slower, the unchanged reader drifting the same way) and asked for a profile of both paths rather than another timing sweep. Callgrind over the M-tier surface (5,402 points and 10,800 triangles, with a vn and a vt per point) gives: the old reader spends about 36% of its instructions in TextStream::operator>>(double&) (a bounded copy and parse_double per number, about 500 instructions each), about 15% in read_obj itself, and the rest in operator new/free, std::string operations and the std::stoll/getline library paths, so the per-line and per-item allocation was close to half of it. The writer spends about 80% of its instructions inside libc's vsnprintf for the %.17g coordinate and attribute text (snprintf_c, then __printf_fp), and the OBJ code around it is the rest; %.17g is the format that makes the round trip exact, so it is not replaced. A writer whose cost is the C library's float formatting moves with the container's load and the library version, not with the OBJ code, which is why its L timing drifted with an unchanged reader. The observation is closed on that evidence: there is no OBJ-specific cost to remove.

New meshioplusplus_bench_ops rows: read_obj and write_obj, over the triangle surface of the tetrahedral cube with a normal and a texture coordinate per point (so vn and vt lines are parsed and written), through the registry. The read row writes the file once outside the timed region; the write row's digest is that of the mesh read back from what it wrote.

Forty cases (a new test_obj_text.cpp) pin the behaviour, and all of them passed against the old reader before it was changed: comments and blank lines, CRLF, tabs, form feeds and vertical tabs, a last line with no newline, a vertex short of three numbers, a bare v, extra vertex tokens, exponents and signs, inf/nan/hexadecimal values and overflow (recorded as the stream extraction gave them), 1.5abc, tags that only look like tags (v1, vp, V, v,1), zero-width and mismatched vn/vt rows and the order of their errors, the face item forms 1, 1/2, 1//3, 1/2/3, indices that are negative, zero, out of range or beyond int64, the std::invalid_argument and std::out_of_range of a bad item, block splitting on face size, group numbering, empty trailing groups and long tokens. The existing round-trip test and the OBJ fuzz regression corpus pass before and after. A differential fuzz of 130,000 random files (random tags, separators, numbers, signs, exponents, words and line endings) found no difference between the old and the new reader in the returned mesh, the error type or the message.

Determinism. The read_obj and write_obj rows of tiers M and L, SEQ at one thread and OpenMP at 1, 4 and 8, carry the digest of the pre-change run (BASELINE= sweep) and agree across backends and thread counts; all 32 rows, before and after. TBB is not installed in the development container, so its leg was skipped. The Python extension cannot be built in the development container, so benchmark/bench.py and the Python tests were not run; the Python reader is unchanged.

SEQ is not slower; it is faster. Interleaved old and new binaries, three rounds, each a median of seven runs, one thread; the raw rounds. Mean of the three rounds:

RowTier M, beforeTier M, afterTier L, beforeTier L, after
read_obj8.44 ms3.97 ms (−53%)24.46 ms12.44 ms (−49%)
write_obj (unchanged code)14.57 ms18.10 ms41.50 ms36.65 ms

The tier labels are the cube's cell counts (162,000 and 750,000); the surface mesh the rows time is smaller. The writer rows are the same code before and after and differ by −12% to +24% between rounds, which is the container's noise (the first round alone moves them by 30%), the same drift as the retained observation. What remains in the read is the number parse of the v, vn and vt lines.

OpenFOAM and Elmer readers on the text cursor ​

Roadmap §3.1.1.1, the last two readers of the item. OpenFOAM already mapped each file (read_whole) and parsed lines with a TextStream, but the ASCII path copied the data several times: strip_comments_and_header built the whole file again character by character, then walked it with getline and openfoam_strip, appending every kept line to a third string, and parse_points_ascii, parse_faces_ascii and parse_int_list_ascii took a getline string, a stripped copy and (points, faces) a third copy and a fresh stream per line; the field list parser did the same, and detect_format reopened the file with an ifstream just to read two header lines. The comment pass now copies the runs between slashes in bulk, the header pass and the parsers walk views of the stripped text with a TextCursor (a reused buffer holds a point's text with its parentheses blanked), the header is detected from the mapping the body is parsed from (one open, not two), and the nonuniform field list parses its scalar entries with parse_double_prefix. The quirks stay: openfoam_strip trims only space, tab, CR and LF, a FoamFile line followed by its brace on the next line leaks the block into the body (the header pass ends at depth 0 on the first line), an unterminated /* swallows the rest of the file, a points line that gives fewer than three numbers is skipped, a face line without both parentheses is skipped, and the count line is any all-digit line. The binary readers are unchanged. Elmer walked each text file with an ifstream and getline, copied each of three coordinate tokens into a std::string for parse_double, and read mesh.names with two copies of every line. It now maps each file, walks it with a TextCursor, parses coordinates in place with parse_double_token (the whole token must be a number, as before) and folds case in mesh.names without copying. Line numbers, the separators (space, tab and CR only) and every message are unchanged. Both changes are in .cpp bodies, so installed headers and C++ ABI 23 are unchanged.

Two defects the pin tests and a differential fuzz found in the OpenFOAM reader, fixed first and in their own commits so the migrated reader is compared with a reader that has them fixed. A points file read short of the faces' point ids (one line on one physical line, a line with two numbers, a word) left the cell reconstruction indexing past the end of the point list: a points file written on one line crashed the reader, and a skipped point read from whatever memory followed. read_raw_polymesh now checks every face's point ids, the owner and neighbour lengths and ids, and each patch's face range, and throws ReadError. And a binary list's count comes from the file's own text, so a truncated or edited points, faces, owner, zone or field file was read past its end and a count of billions was allocated; each binary list now has to lie inside the file. An ASCII field whose values do not match its declaration (a vector line with two numbers, a uniform vector with none) was read from uninitialised capacity and is now skipped with a warning, and a nonuniform list's reservation is capped by the text it can come from. These change behaviour only for inputs that were undefined before.

New meshioplusplus_bench_ops rows: read_openfoam (the tetrahedral cube with its point field, as a .foam case whose constant/polyMesh the writer creates) and read_elmer (the cube's tetrahedra plus the triangles of its surface, as a mesh directory, so the boundary file is parsed too). Both go through the registry's reader with the case written once outside the timed region.

Fifty-one cases (a new test_openfoam_text.cpp) and thirty-one (test_elmer_text.cpp) pin the behaviour. The formatting cases (comments, CRLF, tabs, blank lines, header layouts, a missing final newline, a wrong count, several entries per line) compare each variant with the reference case written by write_openfoam or write_elmer; the odd cases (skipped lines, inf, nan, hexadecimal and overflowing coordinates, form feeds, bad ids, counts and names, an unknown element type with and without mLenient, missing files, a big-endian arch) record the readers' own text; the field cases cover uniform and nonuniform scalars and vectors, CRLF, short lists and the two field errors. Each passed against the pre-migration reader before it was changed (the points-on-one-line case crashed it; its expectation is the new ReadError). The existing 28 OpenFOAM and 13 Elmer cases pass before and after. A differential fuzz compared the pre-migration object (its symbols renamed) with the migrated one over mutated cases (comments, deleted, duplicated, truncated and CRLF lines, replaced tokens drawn from numbers, words, parentheses, huge and negative ids, and a lenient flag for Elmer): 12,000 OpenFOAM cases (about a quarter refused) and 18,000 Elmer cases (about half refused), including time-directory fields and mesh.names, with no difference in the mesh, the field values, the error type or the message.

Determinism. The read_openfoam and read_elmer rows of tiers M and L, SEQ at one thread and OpenMP at 1, 4 and 8, carry the digest of the pre-change run (BASELINE= sweep) and agree across backends and thread counts; OpenFOAM and Elmer, 16 rows each, before and after. TBB is not installed in the development container, so its leg was skipped. The Python extension cannot be built in the development container, so benchmark/bench.py and the Python tests were not run; the Python readers are unchanged.

SEQ is not slower; it is faster. Interleaved old and new binaries, one thread, each a median of seven runs (OpenFOAM, three rounds) or nine (Elmer, five rounds); OpenFOAM rounds and Elmer rounds. Mean of the rounds:

RowTier M, beforeTier M, afterTier L, beforeTier L, after
read_openfoam317.6 ms264.0 ms (−17%)1614.1 ms1217.3 ms (−25%)
read_elmer50.6 ms46.2 ms (−9%)280.0 ms259.0 ms (−8%)

The tier labels are the cube's cell counts (162,000 and 750,000). A callgrind run of the migrated OpenFOAM read at tier M puts about 39% of the read in read_raw_polymesh (of which the comment and header pass is about 5% of the read), about 28% in the per-cell reconstruction (reconstruct_cell, a tetrahedron's build_tetra alone about 14%) and the rest in assembling the mesh, so the tokenizer was never most of it, which is why the gain is a fifth to a quarter and not a half. Elmer's gain is the size the roadmap predicted for a per-line copy: its remaining time is the from_chars and parse_double of the numbers and the element accumulation.

CSR ragged readers and reorder ​

Roadmap §3.2.1. The EnSight (nsided/nfaced), CGNS (NGON_n/NFACE_n), Tecplot (face-based zones), VTKHDF and Fluent readers, and reorder, build the ragged (flat, rowOffsets, faceOffsets) triple directly and hand it to the CSR AddPolygonBlock/AddPolyhedronBlock overloads, instead of one std::vector per cell and per face that the nested overload then flattened again. The polyhedron<N> grouping (N the unique node count, first-seen order) the EnSight and CGNS readers share is one core-private helper, detail/polyhedron_groups.hpp; a section whose cells all land in one group hands over its staged arrays without a copy. reorder computes each new cell's size serially, prefix-sums the offsets, then fills at those fixed positions in parallel_for_bw, so its output stays independent of the thread count. Every changed body is in a src/cpp/src file, so there is no ABI change.

The benchmark is the new reorder_ragged, read_ragged_ensight and read_ragged_tecplot rows of meshioplusplus_bench_ops (tiers M and L, 162,000 and 750,000 tetrahedra recast as polyhedra, plus one triangle each as a polygon block), SEQ and OpenMP at 1 and 4 threads, median of 3 on a shared four-core container (all 60 rows). The digest of every row equals the pre-change digest and agrees across backends and thread counts (BASELINE= sweep).

Row (M / L cells)SEQ, 1 threadOpenMP, 4 threads
EnSight read80.3 → 17.7 / 504.8 → 137.6 ms96.5 → 17.8 / 567.3 → 144.8 ms
Tecplot read343.0 → 251.6 / 1983.7 → 1442.4 ms357.9 → 266.0 / 2060.2 → 1357.3 ms
reorder_ragged453.3 → 416.5 / 2197.0 → 2097.5 ms294.5 → 278.8 / 1763.7 → 1275.9 ms

EnSight reads are 3.7 to 5.4 times faster and Tecplot's 1.3 to 1.5 times; reorder of ragged blocks gains 5 to 40 percent and SEQ is not slower. Most of reorder_ragged's time is the node-adjacency and ordering passes, which this change does not touch. The unchanged reorder and reorder_hilbert rows move by up to 20 percent in either direction between the two sweeps, which is the noise floor of this container. VTKHDF and CGNS need HDF5 and cgnslib, which the development container lacks, and Fluent's reader has no writer to produce a polyhedral file, so those three are covered by their tests and not timed here.

WASM's Int32Array row offsets overflow past 2³¹ node entries in one ragged block, the same limit as the connectivity.

Python boundary: class cache, byte order and ragged rows ​

Roadmap §3.4.3. bindings/python/np_conversions.hpp converts a Mesh on every call into the core and back. Three per-call costs were removed: the Mesh and Region class lookup (now a leaked cache), the per-array dtype.byteorder attribute lookup (now a read of the descriptor's byte-order character), and the per-node and per-row work of ragged blocks (a row that is already an integer array is copied with one insert, and a ragged block returns as views of one buffer instead of one array per row).

benchmark/bench_boundary.py times clean on each case, so both directions are in one number and no file I/O is involved. clean runs on the sequential STL backend here and is not parallel in these rows, so SEQ is not slower by construction.

Case (min of 7)BeforeAfter
200,000 polygons0.316 s0.134 s
50,000 polyhedra0.530 s0.256 s
tiny mesh, 20,000 calls0.445 s0.410 s

The tiny-mesh case moves by under 10 percent, within the run-to-run noise (its median ran 0.57 → 0.48 s): each call also pays the Python wrapper in clean, which is far larger than the lookups removed. The class cache matters to callers that go straight to a binding in a loop. Reproduce with python benchmark/bench_boundary.py --repeats 7 on the base and on the changed build.

Flat dual graph in partition ​

Roadmap §3.3.1. partition_dual_graph (the KaHIP method's input) paired facets through one serial std::unordered_map from facet key to first owner, appended each edge to a std::vector<std::int64_t> per cell, then sorted and deduplicated those lists. It now groups the facet records with detail::group_facet_slots (the sorted-run table that smooth, surface and face_mesh already use): a run's head is the first record in stored order, which is the owner the map kept, and each later record with a different parent adds an owner-parent edge, so a non-manifold facet still connects every later parent to its first owner. The neighbour lists are one flat array (degree count, prefix sum, fill), sorted and deduplicated in place per cell in parallel_for, then packed into the CSR the library takes. Each list is sorted before it is packed, so the graph does not depend on fill order. The change is in a .cpp body: installed headers and C++ ABI 23 are unchanged. The non-KaHIP path (partition's shared-node ghost layers) is a different neighbour definition and is untouched.

The partition row of meshioplusplus_bench_ops already runs this path in a KaHIP build (Auto resolves to KaHIP), over the tetrahedral cube into 8 parts.

Determinism. The digest of every row equals the pre-change one on SEQ, OpenMP and TBB at 1, 4 and 8 threads, tiers M and L (BASELINE= sweep), against a KaHIP v3.25 build.

SEQ is not slower. Interleaved, six rounds, tier M (162,000 cells), OMP_NUM_THREADS=1:

RowBefore (median / best)After (median / best)
partition1.002 / 0.975 s0.919 / 0.860 s

KaHIP's own partitioning dominates the row, so the gain is the graph build alone. The single (not interleaved) OpenMP sweeps read 12 to 15 percent faster at tier M for 1, 4 and 8 threads and 3 to 12 percent at tier L; the TBB sweep is within noise (−10.6 to +1.4 percent), so only the SEQ figure above is claimed.

Reserved output in agglomerate and interfaces ​

Roadmap §3.2.2. The four performance-inefficient-vector-operation diagnostics left in agglomerate.cpp (the outline of a planar patch) and interfaces.cpp (the add_i64, add_f64 and measures lists) now reserve what the code already holds: the patch's directed-edge count (the sum of its face sizes) for the edge list and the outline and heads and ring derived from it, the number of output blocks for the per-type lists and the cell-data array lists, and each block's facet count for its per-facet columns (times the block's width for the connectivity, plus one for a polygon block's offsets). The audit's diagnostics over those three files go from 24 to 20 (Clang-Tidy 18.1.3, the version in the development container; the four cleared are these). No count is read from a file header. Changes are in .cpp bodies: installed headers and C++ ABI 23 are unchanged.

meshioplusplus_bench_ops gains the rows region_adjacency and find_interface, which the interface operations lacked, over the tetrahedral cube cut into a lower and an upper Cell region (a planar interface), and their digests include the report.

Determinism. The digests of agglomerate, region_adjacency and find_interface equal the pre-change ones on SEQ, OpenMP and TBB at 1, 4 and 8 threads, tiers M and L.

SEQ is not slower, and not faster. Interleaved, six rounds, tier M, median (best): agglomerate 0.635 (0.581) → 0.633 (0.593) s, find_interface 0.488 (0.447) → 0.490 (0.444) s, region_adjacency 0.512 (0.437) → 0.472 (0.447) s. The medians move less than the spread between rounds, so these reserves are a clean audit and a tidier allocation pattern, not a measured speedup: the vectors involved are patch- or block-sized.

Reserved output in the formats ​

Roadmap §3.2.2, the last of it. A fresh audit of the formats (Clang-Tidy 23.1.1, 2026-10-07) still listed the 44 performance-inefficient-vector-operation diagnostics of the first count, in 23 translation units; each now reserves before its loop. The rule was a bound the code already holds, never a count read from a file on its own, and the sites sort into two kinds:

  • 39 with an exact bound in hand: the size of the container the loop walks (a record's words in Abaqus .fil, the tokens of a FEBio facet, the result sets of Femap and OP2, Marc's increments, MFEM's face and element lists, the sizes of xplt's blocks, the regions or blocks of the mesh being written in LS-DYNA, VTK, VTKHDF, UNV and Tecplot, and the like), a fixed ten (an LS-DYNA element's node fields) or, for the Ansys .rst sector rotations, the sector count the reader already allocates two vectors of.
  • 5 from a count the file declares, each clamped by what the input can hold: the three Ansys .rst DOF label lists (by the header record's length; rst_solution already rejects a larger count before the other two run), libMesh's IntVector (by the input's size over the narrowest item, Width bytes in XDR and two in text), MFEM's communication-group ranks (by the lexer's remaining tokens) and Radioss animation's Texts (by the bytes left over each text's width, the bound Take enforces; a new AnimCursor::Remaining()). A corrupt count therefore reserves no more than the file could fill, and the reader still fails where it failed before.

The rescan of the changed files reports no performance-inefficient-vector-operation diagnostic. The meshes read back by a write and read of the 17 touched formats benchmark/bench.py round-trips (tier M, a point scalar, a point vector and a cell scalar added) have the same digests before and after, and so do the written files except VTKHDF's, whose HDF5 object timestamps differ between two writes of the same mesh anyway. Timings are neutral: tier M, five repeats, every read and write within ±8% both ways (measured beside a concurrent build), as expected for lists that are mostly a handful of entries. The touched readers' tests and fuzz regressions pass under AddressSanitizer and UndefinedBehaviorSanitizer. Changes are in .cpp bodies: installed headers and C++ ABI 23 are unchanged.

Lazy format and operation imports ​

Roadmap §3.4.2. import meshioplusplus used to import all 77 format subpackages, about 130 operation and helper modules and the _core extension: 556 modules. Formats register themselves when imported, so the package now carries a generated table of which subpackage registers which format name and extension (_format_table.py, written by tools/gen_format_table.py and checked by test_format_table.py), and read and write import only the subpackages their path or format name selects. The three registries are read-through: a public read (reader_map, extension_to_filetypes, formats(), or the error message that lists every writer) imports the rest first, so each still shows every format, in the same order for the extensions several formats share. Operations and submodules load through a module __getattr__ (PEP 562); __all__ is unchanged, so from meshioplusplus import * loads everything. Importing the package now loads 221 modules, none of them _core, and numpy is most of what is left.

Wall time, Python 3.12, interleaved old and new in fresh interpreters, 15 each, median (best), SKBUILD_EDITABLE_SKIP set:

OldNew
import meshioplusplus328 (276) ms186 (159) ms
import, then read of one PLY file299 (271) ms196 (168) ms
modules loaded after the import556221
modules loaded after the PLY read556232

The first read of a format pays for loading its subpackage (12.4 ms median for PLY, against 1.1 ms before), so the saving shrinks for a process that touches many formats and is gone for one that lists them all. The command-line tool and the MCP server import the whole package for their option lists and tool tables, so they gain nothing. test_import_footprint.py keeps the formats, operations and _core off the import path and checks that a write and read of one STL file loads stl alone.

VTP encodings for the browser viewer ​

Roadmap §3.4.5.1. The viewer's worker writes every operation result as a zlib-compressed VTP and vtk.js parses it on the main thread. benchmark/bench_vtp_inflate.py writes a triangulated surface (a point scalar, a float64 cell vector and the int64 surface:parent_cell) in three encodings and times the writer and the base64 and inflate steps; benchmark/vtp_inflate.js times vtk.js 32.9.0's XMLPolyDataReader on the same files (vtp_inflate.csv, vtp_inflate_vtkjs.csv). Native SEQ build for the writer, Node 22 for vtk.js, minimum of five runs.

CellsEncodingFileWritevtk.js parseWrite + parse
20 000zlib1.0 MB22.2 ms50.8 ms73.0 ms
20 000raw, 4-byte header2.1 MB2.0 msRangeError
20 000raw, 8-byte header2.1 MB55.6 ms57.6 ms
180 000zlib9.5 MB159.2 ms421.2 ms580.4 ms
180 000raw, 4-byte header19.2 MB30.1 msRangeError
180 000raw, 8-byte header19.2 MB529.7 ms559.8 ms
980 000zlib52.6 MB946.4 ms2575.9 ms3522.3 ms
980 000raw, 4-byte header104.6 MB150.6 msRangeError
980 000raw, 8-byte header104.6 MB3014.5 ms3165.1 ms

The 8-byte-header file has no native writer yet (header_type falls back to the Python reference writer), so its write column is empty and the last column pairs its parse with the native 4-byte-header write, which differs only in the header. Two results. First, vtk.js reads the uncompressed 8-byte-header file and rejects the 4-byte one (start offset of Float64Array should be a multiple of 8), as the roadmap predicted. Second, skipping the inflate does not speed up the parse: vtk.js is 10 to 26% slower on the uncompressed file, because the XML text and base64 decode double with the bytes and fflate's inflate is a small part of its time. What an uncompressed file saves is the producer-side compression, 3.5 to 21% of write plus parse here. The worker's applyOps (read, operations, write) was not measured, because there is no WASM toolchain in the development container, and an uncompressed result is twice the size to hold and transfer.

A typed-array channel for the browser viewer ​

Roadmap §3.4.5, which closes with it, and 3.4.5.1 above, which it supersedes: there is no VTP left on the viewer's render path to compress or not. The worker calls surfaceBuffersOps instead of convertSurfaceOps, which runs the same read, pipeline and surface extraction and returns the surface as the typed arrays a vtkPolyData is built from, numbered as the VTP file numbers its cells (WASM). benchmark/viewer_surface.mjs times both paths on one WASM build and checks, before timing, that the two vtkPolyData hold the same points, cells and arrays value for value: the old path is convertSurfaceOps to a zlib VTP and FS.readFile in the worker, then XMLPolyDataReader on the main thread; the new one is surfaceBuffersOps in the worker, then the vtkPolyData built as Renderer.loadSurface builds it. The inputs are the zlib surfaces of the table above and bench.py's tetrahedral grids at tiers M and L written as VTU with a point vector, a point scalar and a cell scalar. SEQ WASM build (Emscripten 6.0.9), Node 26, vtk.js 32.9.0, median of five, empty pipeline with keepProvenance:

InputCells drawnReadVTP: worker + mainArrays: worker + mainSpeed-up
surface, 20 000 triangles20 0007.8 ms52.3 + 30.4 = 82.7 ms8.2 + 0.3 = 8.4 ms9.8×
surface, 180 000 triangles180 00072.4 ms491.3 + 254.1 = 745.3 ms75.1 + 0.2 = 75.4 ms9.9×
surface, 980 000 triangles980 000406.0 ms2724.5 + 1357.3 = 4081.7 ms409.6 + 0.2 = 409.8 ms10.0×
tetrahedra, tier M (257 250)14 70027.0 ms90.5 + 12.6 = 103.1 ms70.5 + 0.1 = 70.7 ms1.5×
tetrahedra, tier L (998 250)36 300101.6 ms326.8 + 31.7 = 358.5 ms276.2 + 0.2 = 276.3 ms1.3×

Read is readMesh of the same file, which also copies the mesh into JavaScript, so it bounds the read from above. On a surface input the new worker half is the read and little else, and the main-thread half is a fraction of a millisecond at every size: the VTP's write (zlib), its base64 and inflate and vtk.js's XML parse were nine tenths of the old path. On a volume the surface is small and the skin extraction dominates, so the gain is the VTP's share of a smaller total. The main-thread half is what a user feels as a frozen page, and it falls from 13 ms–1.36 s to under 0.3 ms.

The replay stays. Every apply re-reads the file and replays the whole pipeline, which is what makes undo exact (a shortened pipeline replayed from pristine bytes). With the VTP gone, the read is a visible share of an apply. The same build, with surfaceBuffersOps timed against the read bound above:

Inputqualitysmooth, 10 Laplacian passesboth
surface, 180 000 triangles69% of 105 ms26% of 276 ms24% of 305 ms
surface, 980 000 triangles69% of 566 ms25% of 1543 ms22% of 1742 ms
tetrahedra, tier M20% of 135 ms2% of 1099 ms2% of 1159 ms
tetrahedra, tier L20% of 518 ms2% of 4611 ms2% of 4864 ms

The read dominates only the cheapest operation on a large surface. Caching a read mesh, or a prefix of the pipeline, between applies would hold a second copy of the mesh in a 32-bit heap that MEMFS already shares with the staged file, and would make undo depend on cache invalidation; the roadmap records it as not taken.

Every format ​

benchmark/bench.py also times a write and a read of every format meshio++ both writes and reads back, each fed the largest input its conformance declaration says it keeps: the synthetic tetrahedral cube for volume formats, its surface for surface formats (STL, OBJ, PLY, …), its points for point clouds.

An explicitly tested single-type exception also participates: MFM rejects the mixed conformance mesh but round-trips the harness's tetrahedral cube. This does not change its conformance declaration or imply mixed-cell support. Gmsh 4.1 was a second exception until its writer allocated an entity per cell block (its conformance row now passes), so it is selected through the conformance table like any other format, still on the tetrahedral cube. Use --formats mfm,gmsh to measure those reader/writer paths.

sh
python benchmark/bench.py --sizes S,M --out results_all.csv            # every format
python benchmark/bench.py --sizes L --formats vtu,gmsh22,xdmf,med      # a subset
python benchmark/bench.py --sizes M,L --formats mfm --repeats 7      # single-type MFM

The sizes are 6·(n−1)³ tetrahedra for n = 16, 36 and 56 points per edge (about 20k, 250k and 1M). Legacy meshio is optional here: with MESHIO_LEGACY_SRC pointing at a source checkout's src, or meshio installed, the curated comparison above fills its legacy columns; without it only the meshio++ columns are written.

Text I/O: MFM baseline ​

The first prerequisite for roadmap §3.1.1 was MFM benchmark coverage. Its original native reader stored the remaining file in owning token strings, including the discarded reference arrays. The before/after evidence preserves all 14 baseline rows and adds 14 after rows on MESHIO: SEQ at one thread and OpenMP/TBB at 1/4/8 threads. Written-file SHA-256 values and parsed-mesh SHA-256 values (points, block types/connectivity and mfm:ref, including array shapes and dtypes) agree across stages and all configurations; parsed arrays also match the generated input, with the writer's default subdomain value of one.

Recorded on 2026-10-03 against native source commit 3b0791205bd68cdd50d3127d1aa9d08047ba9900, AMD Ryzen 7 255, Release/MESHIO, HDF5/netCDF/zlib enabled. SEQ/TBB use Clang 23.1.1 and OpenMP uses GCC 16.2.1, all with -O3 -DNDEBUG -ffp-contract=off. Timings use the harness's one warmup and median of seven runs, without an allocation interposer or concurrent project builds/tests. TBB is explicitly capped with tbb::global_control; OpenMP uses OMP_NUM_THREADS with dynamic adjustment disabled. Compare each configuration with its own later result, not compiler-to-compiler timings.

SEQ inputCellsRead medianWrite medianReader allocation callsCumulative requested bytes
M tetrahedral grid257,250166.6 ms188.2 ms140,031551,509,791 (525.96 MiB)
L tetrahedral grid998,250576.3 ms523.6 ms526,9132,203,755,809 (2101.67 MiB)

Allocation counting is separate: ordinary global operator new requests around a warmed-up direct native read, with three identical count/byte results per configuration and size. These are cumulative requests, not peak RSS; pathname lengths can affect small allocations, so the same input path is retained before and after. The local build/text-io-mfm/{before,after}/ archives preserve copied native modules with SHA-256 checks, source/helper snapshots, actual compile commands, CMake caches and raw JSON/logs; editable auto-rebuilding is disabled and each measurement explicitly loads its archived module. The after source is anchored at 2d39e4020d2cac0a8372e170eb9ce92296741614 plus the preserved MFM implementation diff; compiler versions and actual MFM compile commands match the baseline.

MFM: bounded token views ​

The reader now uses FileSource, TextStream for the header and blank-separated token views for the body. Bounded prefix helpers retain its lenient C-library number semantics; discarded reference tokens and trailing tokens remain ignored. Mesh arrays own their storage after the source is released. The writer is unchanged. This changes an exported non-inline implementation only: no installed headers, signatures, layouts or default arguments change, C++ ABI 22 stays unchanged, and the single header is regenerated.

SEQ inputOriginal read medianToken-view read medianReader allocation calls afterCumulative requested bytes after
M tetrahedral grid166.6 ms39.1 ms (4.27×)57279,846,415 (266.88 MiB)
L tetrahedral grid576.3 ms248.7 ms (2.32×)591,117,887,823 (1066.10 MiB)

The unchanged writer showed noise in the initial sequential sweep (SEQ M/L 188.2/523.6 → 198.4/591.6 ms), so a separate uninstrumented SEQ confirmation retains both measurement orders, before→after and after→before, with seven repetitions each. M writer medians are 192.1→195.8 and 193.3→194.1 ms; L medians are 536.0→533.9 and 540.9→534.9 ms. No material writer slowdown reproduces. Confirmation read medians are 166.6→38.5 and 171.1→38.6 ms for M, and 742.6→154.8 and 680.3→153.8 ms for L; the original sweep remains in the evidence rather than being replaced by more favorable timings. No parallel speedup or peak-RSS reduction is claimed.

Direct native tests on MESHIO/NATIVE/KRATOS and Python tests cover every supported linear type, native/reference geometry parity, mixed-type write rejection, blank/CRLF input, missing final newline, empty meshes, section truncation, existing lenient prefixes and ownership after removing/overwriting the input. The shared tokenizer and locale guards, plus Gmsh's native/reference ASCII byte comparisons, remain the gate. MFM is removed from the roadmap's remaining stream-reader list; the other readers and dtype-hoisting work remain open.

Operations ​

meshioplusplus_bench_ops (built with -DMESHIOPLUSPLUS_BUILD_BENCHMARKS=ON, src/cpp/benchmark/bench_ops.cpp) times the operations on the same cube — a structured Kuhn tetrahedralisation, every tet positively oriented so its surface is consistently wound — or on its surface: extract_surface, extract_skin, smooth (the surface) and smooth_volume (a jittered cube), refine, elevate (convert_cells to quadratic), merge (welding a mesh onto itself), clean and clean_weld (two unwelded copies of the cube), compute_sdf (48³ grid), decimate (half the surface), partition (8 parts), reorder (RCM) and reorder_hilbert, reorder_ragged (RCM over a polyhedron block and a polygon block built from the cube's tetrahedra) and read_ragged_ensight/read_ragged_tecplot (registry reads of that ragged mesh, from a file written once outside the timed region), optimize_volume (the jittered cube), agglomerate, split (by component), undo_green (a green-closed refinement of every seventh cell), hessian, compute_normals, compute_curvature, shrinkwrap (an inflated copy of the surface onto the surface), voxelize, and since v16.19.0 remesh (a quarter of the surface's points as clusters), remesh_volume (1.5 lattice steps), sample_distance and distance_to_surface (the jittered cube's points against the surface), isosurface (two levels) and slice of a smooth point field, linearize (convert_cells back from quadratic), interpolate (barycentric, onto the jittered cube), repair, sobolev_deform (a surface displacement field) and gradient (least squares, at the points), gradient_gg (Green-Gauss), gradient_surface (Green-Gauss on the surface), simplexify_ragged (convert_cells Simplexify of the ragged mesh), quality/quality_ragged (attach_quality on the cube and on the ragged mesh), data_average/data_average_ragged (point_data_to_cell_data on the cube and on the ragged mesh with a point field), interpolate_cells (a cell field of the ragged mesh onto itself), partition_ragged (an 8-part space-filling-curve cut of the ragged mesh), feature_edges_ragged, diff (the cube against its jittered twin) and the *_narrow rows (gradient_narrow, diff_narrow, refine_narrow, elevate_narrow, linearize_narrow) that run on the same meshes stored as float32 points and data and int32 connectivity. Tiers S, M and L are about 10k, 160k and 750k tetrahedra; --tier XL (about 10M) is opt-in. Output is one CSV row per tier and operation: backend,threads,op,cells,median_s,runs,digest.

The rows render_frame_160x96 and render_ss4_800x480 time the software rasterizer (v16.33.0) on the cube: a terminal-sized frame, and a 4x-supersampled image with smooth shading, every edge and a mapped field. They hash the frame's pixels and its cell-id buffer, so the determinism check below holds the raster to its contract; what they showed about the frame budget is recorded in terminal rendering.

The parallel backend is a compile-time choice, so tools/bench_ops.sh configures one tree per backend (SEQ, OpenMP, TBB; one that fails to configure is skipped) and sweeps OMP_NUM_THREADS, which the benchmark also applies to TBB:

sh
CMAKE_BUILD_PARALLEL_LEVEL=4 tools/bench_ops.sh benchmark/results_ops.csv "SEQ OPENMP TBB" "1 2 4 8" -- --tier S --tier M

Tier XL ​

--tier XL (10,368,000 tetrahedra) builds a derived input only when a selected row reads it, so --ops runs fit in memory; before, the setup held every derived copy of the cube at once and was killed at 13.6 GB on a 15 GiB machine. Every row was run alone at XL, one thread on the SEQ backend, with its peak resident memory: benchmark/xl_ops_seq.csv, and the write-side memory of one XL mesh per format in benchmark/xl_writes.csv. The table and what it decided for streaming writes and out-of-core operations are in the long-run findings. The VTU writer's footprint and the ragged reorder and simplexify rows were then fixed with every digest unchanged; the CSV files carry the new measurements, and the before and after are under what landed. Every row that fits the harness's time cap now completes at XL: undo_green, the last row killed by the out-of-memory killer, runs in 2.05 s with a 12.0 GB peak.

sh
build/bench-ops-seq/meshioplusplus_bench_ops --tier XL --runs 1 --ops feature_edges

Determinism check ​

Every operation's output is meant to be byte-identical across parallel backends and thread counts; the serial phases in several operations exist to guarantee that. --hash makes each row carry a 64-bit digest of its result — points, connectivity, point/cell/field data and regions in block and sorted-name order, plus the result's index maps and counters — taken from the untimed warmup run, and tools/bench_ops.sh then fails if one row's digest differs between any two backends or thread counts. With BASELINE=<earlier.csv> it also fails if a digest differs from that earlier sweep, which is how a change that must not alter output proves it:

sh
CMAKE_BUILD_PARALLEL_LEVEL=4 tools/bench_ops.sh before.csv "SEQ OPENMP TBB" "1 4 8" -- --hash --tier S --tier M --runs 1
# ... change the code ...
BASELINE=before.csv CMAKE_BUILD_PARALLEL_LEVEL=4 tools/bench_ops.sh after.csv "SEQ OPENMP TBB" "1 4 8" -- --hash --tier S --tier M --runs 1

The determinism job in ci.yml runs the first form on the small tier at 1 and 4 threads on every pull request. The digest is also available to the C++ tests (src/cpp/benchmark/mesh_digest.hpp), where golden digests pin an operation's output across a rewrite.

Landing a performance change ​

A performance change starts from a measured or code-verified slowdown in a path a user hits, with the shape of the fix named, and shows its before and after on the harness: benchmark/bench.py for I/O, tools/bench_ops.sh for operations, benchmark/viewer_surface.mjs for the browser viewer's render path. The Clang-Tidy performance audit finds candidates; its findings are advisory and must pass the same gates. The roadmap's performance section closed in October 2026; what was measured and not taken is recorded with its non-goals.

Two findings frame every change. First, the serial phases in the operations are deliberate: each is documented in the code as a determinism pin, not an oversight — output is byte-identical across parallel backends and thread counts, which repeated-run tests and the C++-versus-numpy byte comparisons enforce — so every fix must keep that guarantee and prove it with a SEQ-versus-OpenMP diff, not assert it. Second, every parallel item is conditional on the backend: a SEQ build (and the stl fallback without TBB) runs parallel_for sequentially, so each change must also show that SEQ does not get slower — a parallel sort is O(n log n) where the hash map it replaces is O(n). SEQ is not a corner case: the Linux wheels and the native CLI release binaries are built SEQ, and WASM ships a SEQ build beside the threaded one, so an algorithmic win reaches every user while a parallel one reaches source, conda and threaded-WASM builds.

Every change:

  1. Record the before on SEQ, OpenMP and TBB at 1, 4 and 8 threads: tools/bench_ops.sh before.csv "SEQ OPENMP TBB" "1 4 8" -- --hash --ops <op> --tier M --tier L, or benchmark/bench.py --sizes M,L --formats <format>.
  2. Show the output is byte-identical across backends and thread counts, and to the digests before the change: meshioplusplus_bench_ops --hash, with BASELINE=before.csv for the second sweep (determinism check). The rows each item needs exist since v16.15.0; an item whose operation has none adds one first.
  3. Show SEQ is not slower.
  4. Classify the change by the ABI policy: the body of an exported, non-inline function is free; an inline or template body in an installed header is Tier B; a new function in an installed header is additive and goes in the ABI review; a changed signature, or the layout of an installed type, is Tier A. Regenerate the single header.
  5. Correct every code comment that describes the old algorithm (the items name the stale ones), and update the numbers on this page.

Clang-Tidy performance audit ​

tools/performance-tidy.sh runs the installed Clang-Tidy's built-in performance-* checks as an advisory audit, independently of .clang-tidy's include-hygiene gate. It never applies fixes. Findings are candidates, not measured speedups: a cheap CellView value is intentional, clones must retain their ownership semantics, and changing a public enum's width, signature or installed inline body needs the ABI policy, even when the tool offers a replacement.

sh
# Configure a separate Clang/SEQ tree, then audit its core, C API and native CLI.
tools/performance-tidy.sh --jobs 4
tools/performance-tidy.sh --mesh-backend NATIVE --jobs 4
tools/performance-tidy.sh --mesh-backend KRATOS --jobs 4

# Reuse a configured tree (including Python or optional libraries when enabled).
tools/performance-tidy.sh --build-dir build/performance-tidy-meshio --out build/performance-tidy-meshio/before.json

# A selected-TU scan is explicitly marked as selected, not whole-tree coverage.
tools/performance-tidy.sh --build-dir build/performance-tidy-meshio --file src/cpp/src/formats/ansys.cpp

The default build tree is build/performance-tidy-<mesh-backend>; CMake module scanning is disabled so GCC-only dependency-scanner flags cannot contaminate Clang-Tidy's command line. Set CLANG_TIDY, CC, CXX or PYTHON to choose executables. To cover Python, OpenMP/TBB, WASM or optional dependencies, configure their real compilation databases separately with -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_CXX_SCAN_FOR_MODULES=OFF, and pass --build-dir; the default configuration does not enable Python, WASM or opt-in libraries. Check the report's actual compile_defines, not just WITH_*=ON requests, to see which conditional paths were included.

The JSON report records the tool version, enabled checks, explicit Clang-Tidy profile, database hash, CMake settings, actual compile definitions for the scanned commands, scanned translation units and deduplicated source-location/check findings, with the TUs that reported each one. Installed-header findings are labelled for ABI review rather than automatically rejected or accepted. Each TU also has a raw log beside the report. A non-zero tool exit or parsing error makes the scan fail and marks its report incomplete; a new scan invalidates an earlier successful report before configuration or tool startup. A database containing no first-party sources, a stale command or a requested source without a compile command is an error, never an invitation to guess flags. Third-party and generated single-header sources are excluded.

For clangd's editing feedback, opt in locally through a matching fragment in your user clangd/config.yaml (for example, ~/.config/clangd/config.yaml on Linux), substituting your checkout and build paths. This does not change the repository's include-only .clang-tidy; clangd's default fast-check filter may skip expensive checks, so use the standalone runner for the exhaustive inventory.

yaml
If:
  PathMatch: '/path/to/meshioplusplus/(src/cpp|bindings)/.*'
CompileFlags:
  CompilationDatabase: /path/to/meshioplusplus/build/performance-tidy-meshio
Diagnostics:
  ClangTidy:
    Add: 'performance-*'

The initial audit (2026-10-03, Clang-Tidy 23.1.1, 22 checks, SEQ, HDF5/netCDF/zlib enabled) completed without parsing failures:

Mesh backendFirst-party TUsUnique findingsAdditional boundary coverage
MESHIO189522C API, native CLI, Python/pybind11
NATIVE188285C API, native CLI
KRATOS188286C API, native CLI

The MESHIO inventory groups as follows; a location can have more than one check, so these are diagnostic counts rather than distinct edits:

Candidate classDiagnosticsTriage
Value parameters, copied initializations and moves287Review ownership first; many are pybind11 refcount suggestions, small metadata copies or necessary owned clones.
Vector capacity planning59Closed: the Fluent writer's two mesh-sized lists first, then the operations (agglomerate and interfaces) and the formats; header counts reserve only through a bound the input enforces.
String concatenation, find/character overloads and view conversion91Many are error paths or header parsing; no reader throughput win established yet.
Enum width85Installed enum widths are ABI-sensitive; private enums still need evidence before narrowing.

These counts are a baseline for triage, not a zero-warning target. Public enum-width and nested-vector ingestion-signature suggestions require a separate ABI decision; pybind11 value-parameter suggestions require lifetime/refcount review. Many remaining reservations concern small metadata vectors or untrusted reader counts, and many string-concatenation suggestions are error paths. Prioritize measured hot paths and do not reserve directly from an unchecked file count. Optional CGNS MLL, ADIOS2, TecIO, KaHIP, bzip2, zstd/lz4 and viewer paths, other parallel configurations and WASM remain separate coverage work. For each accepted implementation-only batch, preserve the before report, record timing/allocation evidence, run native and Python parity tests, and apply the determinism check to operation changes. The include-hygiene CI policy remains unchanged.

First batch: Fluent cell-zone list capacity ​

write_ansys now reserves the mesh-derived size of its cell-type list and, for a mixed-type cell zone, the outer row list. This removes repeated capacity growth without changing ingestion, traversal order or serial/parallel work. The two performance-inefficient-vector-operation findings disappear on every mesh backend; complete post-change SEQ scans report 520/283/284 findings for MESHIO/NATIVE/KRATOS, with no new diagnostics. This is an exported non-inline function-body change, with no installed declaration, inline body, signature or layout changes: C++ ABI 22 is unchanged, and the single header is regenerated.

On an AMD Ryzen 7 255 (16 logical CPUs), Clang 23.1.1 Release/SEQ, benchmark/bench.py --sizes M,L --formats ansys --repeats 7 gives the following binary-writer medians. These differences are small enough that the change is treated as an allocation reduction, not an established throughput speedup; no material SEQ slowdown was observed.

InputCellsBeforeAfter
M tetrahedral grid257,250288.8 ms288.6 ms
L tetrahedral grid998,2501123.0 ms1104.1 ms

Counting ordinary global operator new requests around a warmed-up direct native write, with the same output pathname before and after, gives the following reductions on SEQ/libstdc++; ASCII and binary have identical reductions. These are cumulative allocation requests saved per write, not peak RSS measurements. The mixed inputs split alternate squares of 420×420 and 816×816 grids into triangles, keep the other squares as quads, and join both types into zone 7.

InputCellsAllocation calls eliminatedRequested bytes eliminated
M uniform zone257,250181,068,148 (1.02 MiB)
L uniform zone998,250204,395,604 (4.19 MiB)
M mixed zone264,6003821,951,300 (20.93 MiB)
L mixed zone998,7844030,754,276 (29.33 MiB)

The raw auxiliary sweep preserves 112 before/after rows: both M/L inputs, both zone shapes, ASCII/binary, SEQ at 1 thread, and OpenMP/TBB at 1/4/8 threads. Every SHA-256 agrees across backends, thread counts, repeats and the pre-change baseline (5726a4a2). SEQ/TBB use Clang 23.1.1, OpenMP uses GCC 16.2.1; TBB is capped with tbb::global_control, not merely OMP_NUM_THREADS. Its three-repeat writer timings had background build/test activity and the allocation interposer loaded (counting disabled during timing), so they are retained for transparency, not used to claim a speedup; the seven-repeat uninstrumented harness timings above are the primary SEQ comparison.

Native C++ tests cover ASCII/binary output and an explicit triangle/quad zone on MESHIO, NATIVE and KRATOS; Python tests compare that mixed zone byte-for-byte with the reference writer and retain the existing I/O byte baselines. The new test joins cell blocks using ansys:zone, because default block-per-zone inputs do not exercise the mixed-type row list. Run backend CTest suites serially or give them separate temporary directories: mt::temp_path is process-local, so concurrent suites sharing one TMPDIR can overwrite each other's fixtures.

In CI ​

The weekly benchmark workflow (also runnable by hand) runs both: every format at size M, and the operations for SEQ, OpenMP and TBB at 1, 2 and 4 threads, with --hash. It uploads the CSVs as artifacts and prints them in the job summary. Successful default-branch runs also publish immutable records with commit, run/attempt, machine, compiler, dependencies and benchmark parameters to benchmark-data; the benchmark trends page plots that history through the existing Pages deployment. Its timings never fail a build — a hosted runner is noisy, so they are for trends across runs, not for gating one change — but a digest that differs between backends does. Every performance change is expected to show its before and after with these tools (landing a performance change).

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 (src/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_modelpart——65 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.