Skip to content

Add nsmh and re2 handling - #272

Merged
adperezm merged 9 commits into
ExtremeFLOW:mainfrom
timofeymukha:feature/nmsh_io
Sep 28, 2026
Merged

adperezm merged 9 commits into
ExtremeFLOW:mainfrom
timofeymukha:feature/nmsh_io

Conversation

@timofeymukha

Copy link
Copy Markdown
Contributor

Alrighty, this PR adds classes and routines to work with nmsh and re2 mesh files. The code is written by Fable 5.1, with a lot of steering from my side after its original implementation to try and fit the structure of the package better.

  • the io module contains re2 and nmsh io, plus the corresponding Data classes. This corresponds heirarchically to pymech's hexdata.
  • the datatypes module adds the corresponding types for the meshes, e.g. NmshMesh, both derived from a CornerMesh base type. The latter provides a converter routine to Mesh.
  • The Router type is enhanced to work with records to shuffle around non-trivial numpy dtypes.
  • A helper for linearly distributing data across ranks is added and used throughout the package, removing some duplication.
  • Some tests.

This is the gist of it. I haven't verified every line of code, but I did go through each routine to see if it looks reasonable. Some routines in more details, like the one for building the trilinear basis (which is why it is so commented). Basically spent about a day on it, so not pure vibe coding :-).

timofeymukha and others added 8 commits September 23, 2026 13:25
…Mesh data type

The io.nmsh subpackage holds the byte layout of the .nmsh format together
with collective MPI-IO readers and writers. Elements are distributed
linearly over the ranks, as Neko and the fld readers do, and zone and curve
records are handed to the rank that owns their element. The io.re2
subpackage reads NEKTON .re2 meshes.

NmshMesh in datatypes is the in-memory form of a mesh file, the topological
sibling of Mesh: corner vertices with global point ids, boundary zones and
curved edges. It can be gathered and distributed, validated in parallel, and
converted to a Mesh with the GLL points of the straight-sided geometry
(datatypes.nmsh_geometry).

The linear load-balanced distribution that was written out by hand in the
fld, ADIOS2 and HDF5 readers, the mesh partitioner and the probe scatter is
now a shared helper in io.utils, together with an atomic file writer and
record-based collectives for structured arrays.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ers of two

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
hemi.re2 and the hemi.nmsh written from it by Neko's rea2nbin, taken from
the Neko examples, exercise the readers on a real mesh with curve and
boundary condition records.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The re2 reader is now collective like the nmsh reader: elements are
distributed linearly and the curve and boundary condition records are
handed to the rank owning their element. Records are upcast to the double
precision layout of version #v002 on reading, which is also the version the
new writer produces, so that a mesh can be read, modified in memory and
written back. Reading and writing hemi.re2 reproduces the file byte for
byte.

Re2Mesh in datatypes mirrors NmshMesh: from_file, write, gather (to all
ranks or to one), distribute, validate and to_sem_mesh. The behaviour the
two share lives in the CornerMesh base class.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The trilinear geometry applies to any mesh given by element corners, not
only to nmsh files, and is now imported directly by the CornerMesh base.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Router now accepts structured (record) dtypes in all_to_all, all_gather,
gather_in_root and scatter_from_root: records are sent as blocks of bytes
with an explicit MPI datatype, so counts stay in records. The nmsh and re2
readers and the corner mesh data types use Router instead of their own
helpers, and record_datatype lives in comm.router for the MPI-IO calls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Which rank owns which items is a property of the communicator rather than
of file I/O, so linear_distribution and linear_owner now live next to Router
in the comm package. AtomicOutput stays in io.utils.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@adperezm adperezm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! thank you.

I have just some comments that come from my own lack of knowledge perhaps.

With the functionality, I will unfortunately need to just trust the tests, as I have nowhere near enough time to check, given that I am not familiar with the formats in question (re2 and nmsh)

Comment thread pysemtools/comm/distribution.py Resolved
Comment thread pysemtools/comm/router.py
>>> fh.Read_at_all(offset, [records.view(np.uint8), records.size, rec_t])
>>> rec_t.Free()
"""
rec_t = MPI.BYTE.Create_contiguous(np.dtype(dtype).itemsize)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "record" terminology. Is it a standard?

I knew this as a "custom data type" and I believe that would be clearer to read. But ignore if this is standard.

I am bit confused here. Sorry if I am dense, so here you create one custom data type that has as many bytes as the full message so you just send one "count" (for lack of a better word) instead of >2**32 if you need to?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it really is a standard but record is kind of an alternative synonym to a struct, I'd say. Which is of course a bundle of binary data, which is what the record here is as well. The root may be in Pascal :P, at least there you write stuff like

	sphere = record
			radius: real;
			volume: real;
			surface: real
		end;

I remember learning it at university :D.

I think the idea is that you teach MPI to pass these around and then the limit of 2^32 applies to the structs. Since each packs quite a bit of data, the effective limit is then much larger.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes but I still dont get it.

Here rec_t = MPI.BYTE.Create_contiguous(np.dtype(dtype).itemsize)

You are creating a new dtype that has an itemsize of the dtype that you pass. So in the end, if you pass an array of doubles, then you create a new dtype that has also size of double, so how is the count different?. Are you maybe using this somewhere where you pass a dtype that is different?

I have used this type of thing before, when I was implementing RMDA for interpolation:

row_dtype = MPI.BYTE.Create_hindexed_block(blocklength=bytes_per_row, displacements=row_displacements)
row_dtype.Commit()
# Send

So what I would expect here is that you create a datatype where "one count" is more bytes than you original data type, such that you sort of reduce the total count below the int32 limit. But I do not see how that would happen here.

Again, I might just be too dumb haha.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry if I am annoying, I am just trying to understand a bit, because I do not want to have complex functions for the sake of complexity. If there truly is a need for something we keep it but I do not like overengineered solutions.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe to clarify, if I have an array

array = np.ndarray(2**33, dtype=np.double)

That has more items than int32.

If I just do (as is done in the code now):

        if key not in self._record_types:
            self._record_types[key] = record_datatype(array.dtype)
        rec_t = self._record_types[key]

That rect_t will just be a dtype of the same size of array.dtype

The way to overcome the int32 limit would be to create a new dtype that has more bytes than double, such that the count reduces, so one would need to do something like:

block_dtype = np.dtype([
    ("data", np.float64, (8,))
])

So we pack 8 doubles into one count for the new dtype

then rec_t = record_datatype(block_dtype) would solve the int32 problem. But right now I do not see anything like that.

Or perhaps I am wrong with this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you are right that if you pass an array of doubles nothing happens, and this is intentional because MPI already knows how to handle that, right? This thing is added for passing around mesh entities like

ZONE_DT = np.dtype(
    [
        ("e", "<i4"),
        ("f", "<i4"),
        ("p_e", "<i4"),
        ("p_f", "<i4"),
        ("g", "<i4", (4,)),
        ("t", "<i4"),
    ]
)

There are a some more defined in nmsh_file and re2_file. So one ZONE_DT element is the "record" and now MPI can pass these around.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could also be totally wrong though, and you worked with this stuff far more than I have! I basically thought this is like the stuff in mpi_types.f90 in Neko.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay! I think I see now. array.dtype is used and it is some form of custom data type. We need a new mpi dtype that can communicate it.

I understand now. I think I got sidetracked by some comment I saw in the code regarding the int32 limit.

Using this would not fix the int32 limit by itself. The user must have already packed their data in a different way for that. But this change does allow to have custom data types, since before we were constrained to the basic ones.

Thanks!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the comment may have appeared because I poked the LLM about what that was and it decided to focus on that in the comments :D

Comment thread pysemtools/io/utils.py Resolved
@adperezm
adperezm merged commit c8a8629 into ExtremeFLOW:main Sep 28, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants