Skip to content

BUG: aligned access for the descriptor fields struct on 32bit abi3t - #31759

Closed
ngoldbaum wants to merge 1 commit into
numpy:mainfrom
ngoldbaum:fix-alignment-abi3t
Closed

ngoldbaum wants to merge 1 commit into
numpy:mainfrom
ngoldbaum:fix-alignment-abi3t

Conversation

@ngoldbaum

@ngoldbaum ngoldbaum commented Jun 25, 2026 •

Copy link
Copy Markdown
Member

Over in PyO3/rust-numpy#556 work is ongoing to add abi3t support for crates that depend on PyO3 and rust-numpy.

There is a failing test run for win32 3.15t, in a test that accesses the metadata field for a datetime dtype instance.

I was able to track this down to the uint64 flags field on the PyArray_Descr_fields and _PyArray_LegacyDescr_fields structs.

On a 32 bit free-threaded build, sizeof(PyObject) is 20 bytes. That places the flags field at a 4-byte aligned address instead of an 8-byte aligned address, the compiler inserts some padding bytes, and suddenly the fields access function is returning addresses at the wrong offset for all fields after the flags entry.

The fix I landed on was to define the flags as two 32 bit integers. Each gets read individually, and the compiler inserts no more packing bytes into the struct. Inside the flags access macro, we re-assemble the lo and hi parts of the 64 bit int.

Also adds a static assert in case a future change to sizeof(PyObject) breaks our assumptions here. Also adds a regression test to access the datetime metadata field, like the rust-numpy test did.

I used Claude to help identify and fix this bug.

@ngoldbaum ngoldbaum added this to the 2.5.1 release milestone Jun 25, 2026
@ngoldbaum
ngoldbaum requested a review from kumaraditya303 June 25, 2026 20:45
@ngoldbaum ngoldbaum added 00 - Bug 09 - Backport-Candidate PRs tagged should be backported 39 - free-threading PRs and issues related to support for free-threading CPython (a.k.a. no-GIL, PEP 703) labels Jun 25, 2026
@ngoldbaum ngoldbaum mentioned this pull request Jun 25, 2026
2 tasks done
@ngoldbaum
ngoldbaum force-pushed the fix-alignment-abi3t branch from 729b98f to 4e8fd27 Compare June 25, 2026 20:48
* `npy_uint64 flags` member would be over-aligned and packing bytes would shift
* every later field, so expose it as two 32-bit halves.
*/
#if defined(Py_TARGET_ABI3T)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Maybe this condition should also include e.g. && IS_32BIT.

@ngoldbaum ngoldbaum Jun 25, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Hmm, on second thought I bet this doesn't work at all on a 32-bit GIL-enabled interpreter (which might run an extension built with Py_TARGET_ABI3T, which implies Py_GIL_DISABLED), since PyObject is 8 bytes there and there's no padding to correct for...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

OK, I think what we actually need to fix 32 bit builds is new explicit accessor API functions for all of the fields after flags that might be impacted by this alignment/packing issue. We can handle all of this in NumPy, we just can't handle it in the single GET_ITEM_DATA API function we have now.

That also probably means 32bit builds abi3t can't be supported until NumPy 2.6 because we need API functions to fix this ☹️.

Ping @seberg.

@ngoldbaum

ngoldbaum commented Jun 25, 2026 •

Copy link
Copy Markdown
Member Author

I'm going to close this PR because the approach is unsound and open a followup issue.

@seberg

seberg commented Jun 26, 2026 •

Copy link
Copy Markdown
Member

On a 32 bit free-threaded build, sizeof(PyObject) is 20 bytes. That places the flags field at a 4-byte aligned address instead of an 8-byte aligned address, the compiler inserts some padding bytes, and suddenly the fields access function is returning addresses at the wrong offset for all fields after the flags entry.

All of this sounds like a Python bug though? Python has to guarantee max_align_t alignment of the actual fields.

EDIT: OK, it does guarantee for the whole object, but if PyObject isn't a multiple of that it seems to me we are in deep trouble. At that point the whole PyObject_GetTypeData() function becomes useless for many objects. And I think the whole thing is broken, because .tp_basicsize = -sizeof(my_struct) is unclear that it'll do the right thing either, because it'll presumably not ensure padding to max_align_t there.

Or in other words: The only real solution to me seems for Python to ensure max_align_t size in a sense. That may not sound great, but anything else means that these patterns becomes unusable without extreme care in 32bit.

@da-woods

Copy link
Copy Markdown
Contributor

And I think the whole thing is broken, because .tp_basicsize = -sizeof(my_struct) is unclear that it'll do the right thing either, because it'll presumably not ensure padding to max_align_t there.

I believe that with will align to max_align_t (https://github.com/python/cpython/blob/285d96dd784620fe8afb02688abe72a916555061/Objects/typeobject.c#L5133 and https://github.com/python/cpython/blob/285d96dd784620fe8afb02688abe72a916555061/Objects/typeobject.c#L6088).

That makes PyObject_GetTypeData correct on an object declared with negative size (which is how it's intended to be used).

But I think it makes the assumption that

struct S{
   PyObject_HEAD
   int data1;
   int data2;
};

is safe to access through a

struct T{
    int data1;
    int data2;
};

pointer dodgy. (I think I did claim it was dodgy at the time, but failed to identify this as the reason).

So it really means that it's only safe if you consistently treat a PyObject* and opaque or non-opaque.

@seberg

seberg commented Jun 26, 2026 •

Copy link
Copy Markdown
Member

Ah, thanks for the note @da-woods, right. If Python does insert the extra alignment for such types, then a type defined via the opaque API can talk to others safely.

But it is still super annoying :(. Sure, using accessors functions works, but it means that we are far worse off then stable ABI objects here. I don't find it very enticing.

Part of me wonders if the right thing wouldn't be to just add the extra padding in NumPy if running on Python 3.15+. That breaks all existing Python 3.15 downstream that are already build against NumPy 2.5 which are also:

  • 32bit
  • free-threaded
  • (It doesn't break other builds, because for them nothing changes.)

But 🤷, waiting for 3.16+ wouldn't be better, it's not like NumPy has a release for 3.15+ available yet, so in a sense we can do that?

Of course... We can also rip this all out and introduce a giant family of getter functions... In theory this affects pretty much all our objects (in practice it may not but for those others adding padding would also do nothing).

(EDIT: I dunno, maybe that is crazy and Cython might be a bit annoying about it as well if the size is Python dependent? And I just really need to warm up to "accessors all the way" even if everything is designed to not require this.
But I of course already find a per-object GetFields function an unnecessary hedge for a future where ABI flexibility reasons that I really don't begin to see being useful as it seems to assume opaque API as the only API and even then seems rather pointless to me.)

@seberg

seberg commented Jun 26, 2026

Copy link
Copy Markdown
Member

pointer dodgy. (I think I did claim it was dodgy at the time, but failed to identify this as the reason).

We were discussing it being dodgy because of aliasing rules mostly, IIRC. If you still think that, then we would have to just stop this all. For the alignment issue, manual alignment is at least conceivable (i.e. align the normal ABI with what an opaque API would do in practice for its objects as I said above).

@da-woods

Copy link
Copy Markdown
Contributor

pointer dodgy. (I think I did claim it was dodgy at the time, but failed to identify this as the reason).

We were discussing it being dodgy because of aliasing rules mostly, IIRC.

Yes indeed. I can't claim to have had an really useful advanced insight here.

Cython might be a bit annoying about it as well if the size is Python dependent?

For the moment I'm assuming that it's hard to mix opaque and non-opaque modules useful (or at least, they can only mix one way because we can always make things opaque). The only complication is the memoryview internal classes. I'll have to go back and check if they have this alignment issue on 32 bits. If not, it'll be luck rather than judgment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

00 - Bug 39 - free-threading PRs and issues related to support for free-threading CPython (a.k.a. no-GIL, PEP 703)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants