-
-
Notifications
You must be signed in to change notification settings - Fork 11.8k
ENH: support no-copy pickling for any array that can be transposed to a C-contiguous array #28105
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
Hi, I’m new to contributing to NumPy and would appreciate some guidance on the following:
Thank you! |
Yes, see step 5 https://numpy.org/devdocs/dev/index.html#development-process-summary. I'm also not totally sure what the policy is on changing the array pickle format, since people use pickles for uses they are not well-suited but we still need to support those uses anyway... |
seberg
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few comments, we should discuss it but I think we can change it so long we make sure that the new NumPy can read the old pickles (and that also means adding a test that would currently fail, I think).
Also you need a test for the non-contiguous protocol=5 (unless there already is).
numpy/_core/numeric.py
Outdated
|
|
||
| def _frombuffer(buf, dtype, shape, order): | ||
| return frombuffer(buf, dtype=dtype).reshape(shape, order=order) | ||
| def _frombuffer(buf, dtype, shape, order, axis): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function must still be able to deal with old pickles. It is effectively public API.
A test should have existed to find this. We should use pickle.dumps(...) on a current NumPy version, take that string and ensure that pickle.loads(...) on it still works (i.e. freeze the pickle in time).
This looks like it breaks existing pickles and we shouldn't.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've given axis a default value None and added a test on a legacy pkl dumped in 2.2.1.
Should I keep this test in code? Or should I move it in test_regression.py?
numpy/_core/src/multiarray/methods.c
Outdated
| if (PyArray_IS_C_CONTIGUOUS((PyArrayObject *)self)) { | ||
| order = 'C'; | ||
| picklebuf_args = Py_BuildValue("(O)", self); | ||
| rev_perm = Py_BuildValue("O", Py_None); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might use PyTuple_Pack() where possible (even refactor).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't get it, you mean construct an empty tuple instead of None to rev_perm on c_contiguous and f_contiguous path?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean I would avoid Py_BuildValue when it is easy to do so, and it is here. But also OK to keep: this is inherited.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Applied as you suggested.
numpy/_core/src/multiarray/methods.c
Outdated
| for (int i = n - 1, check_s = 1; i >= 0; i--) { | ||
| if (check_s * PyArray_DESCR(self)->elsize != | ||
| items[i].stride) { | ||
| return array_reduce_ex_regular(self, protocol); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we assume it works usually and/or the other path is slow anyway, could also just not check this and see if the transpose works out?
Not sure that is better, though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah...Applied, this is much more straightforward!
Not sure if I Py_DECREF correctly. :(
numpy/_core/src/multiarray/methods.c
Outdated
| } else { | ||
| shape = PyObject_GetAttrString((PyObject *)self, "shape"); | ||
| } | ||
| return Py_BuildValue("N(NONNO)", from_buffer_func, buffer, (PyObject *)descr, shape, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't right, you lose rev_perm.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what do you mean lose
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
leak? You need to decref it at some point or use N.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
got it, thanks. never wrote on cpython api before, sorry for these oblivious problems.
There is already a test for that numpy/numpy/_core/tests/test_multiarray.py Lines 4372 to 4383 in 94132ac
|
seberg
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, the new test looks good. A few more comment, and I have one more comment actually: I wonder if we should just pickle C/F contiguous ones without passing the axes order so that they will still unpickle on older versions (because it is easy, not because it is required).
numpy/_core/src/multiarray/methods.c
Outdated
| } else { | ||
| shape = PyObject_GetAttrString((PyObject *)self, "shape"); | ||
| } | ||
| return Py_BuildValue("N(NONNO)", from_buffer_func, buffer, (PyObject *)descr, shape, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
leak? You need to decref it at some point or use N.
numpy/_core/tests/test_multiarray.py
Outdated
| x = np.arange(24).reshape(3, 4, 2) | ||
| # generated by same code in 2.2.1 | ||
| data_dir = os.path.join(os.path.dirname(__file__), 'data') | ||
| legacy_pkl_path = os.path.join(data_dir, "legacy_contiguous.pkl") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, this works (I suspect you could use dtype=np.int8 or so and just copy the whole bytes string into the code here also), but just if you like it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I used byte str in code and added more examples
numpy/_core/src/multiarray/methods.c
Outdated
| if (PyArray_IS_C_CONTIGUOUS((PyArrayObject *)self)) { | ||
| order = 'C'; | ||
| picklebuf_args = Py_BuildValue("(O)", self); | ||
| rev_perm = Py_BuildValue("O", Py_None); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean I would avoid Py_BuildValue when it is easy to do so, and it is here. But also OK to keep: this is inherited.
numpy/_core/tests/test_multiarray.py
Outdated
| assert_equal(f_contiguous_array, depickled_f_contiguous_array) | ||
|
|
||
| def test_transposed_contiguous_array(self): | ||
| transposed_contiguous_array = np.random.rand(2, 3, 4).transpose((1, 0, 2)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The transpose example is not chosen carefully, maybe you should just have multiple examples with pytest.parametrize and make at least one 4-5 dimensional so this is less likely to happen:
a = np.ones((1, 2, 3))
a.transpose(1, 0, 2).transpose(1, 0, 2).shape == a.shape
so I don't know from the test if the back-transpose is filled correctly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
added more test case
numpy/_core/src/multiarray/methods.c
Outdated
|
|
||
| PyObject* shape = NULL; | ||
| if(order == 'K') { | ||
| shape = PyObject_GetAttrString((PyObject *)transposed_array, "shape"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Old messy code, so you can ignore: but we have a PyArray_IntTupleFromIntp helper to use PyArray_NDIM and PyArray_DIMS directly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thx, applied
ngoldbaum
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spotted some issues. Please give this entire PR a careful once-over to make sure you're catching error cases and appropriately propagating errors when they happen. If you're feeling ambitious you could try writing test cases for the error cases, error cases tend to be poorly tested and buggy.
numpy/_core/tests/test_multiarray.py
Outdated
| @pytest.mark.parametrize('transposed_contiguous_array', | ||
| [np.random.rand(2, 3, 4).transpose((1, 0, 2)), | ||
| np.random.rand(2, 3, 4, 5).transpose((1, 3, 0, 2))] + | ||
| [np.random.rand(*np.arange(2, 7)).transpose(np.random.permutation(5)) for _ in range(3)]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use a seeded RNG local to the test instead of the global
np.randomRNG
This is still using the global np.random RNG. If you want the test to be reproducible, you need to use a seeded RNG local to the test, e.g. using this function.
numpy/_core/src/multiarray/methods.c
Outdated
| if (transposed_array != NULL) { | ||
| Py_DECREF(transposed_array); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| if (transposed_array != NULL) { | |
| Py_DECREF(transposed_array); | |
| } | |
| Py_XDECREF(transposed_array); |
This pattern is common enough it has its own macro in the C API. There are some other common operations that have an X variant that does NULL checking.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thx, applied
| else { | ||
| shape = PyArray_IntTupleFromIntp(PyArray_NDIM(self), | ||
| PyArray_SHAPE(self)); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PyArray_IntTupleFromIntp can fail so you need to check if shape is NULL here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thx, added
numpy/_core/src/multiarray/methods.c
Outdated
| if (transposed_array != NULL) { | ||
| Py_DECREF(transposed_array); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| if (transposed_array != NULL) { | |
| Py_DECREF(transposed_array); | |
| } | |
| Py_XDECREF(transposed_array); |
numpy/_core/src/multiarray/methods.c
Outdated
| picklebuf_class = PyObject_GetAttrString(pickle_module, "PickleBuffer"); | ||
| Py_DECREF(pickle_module); | ||
| if (picklebuf_class == NULL) { | ||
| if (npy_cache_import_runtime("pickle", "PickleBuffer", &picklebuf_class)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| if (npy_cache_import_runtime("pickle", "PickleBuffer", &picklebuf_class)) { | |
| if (npy_cache_import_runtime("pickle", "PickleBuffer", &picklebuf_class) == -1) { |
A little surprised the pickle tests pass given this bug...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed. sorry i forgot checking the return value of npy_cache_import_runtime properly.
Since npy_cache_import_runtime will only return -1 or 0, I think it didn't actually cause a bug in the improper version so pytest didn't detect this.
|
ping |
|
Thanks for the ping and sorry for dropping this, I'm going to take another look at this. |
ngoldbaum
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, sorry for taking so long to get back to this.
This is looking much better than the last time I reviewed, thanks for the careful updates.
I'm approving but I want another experienced developer to look this over, since changing the pickle format has annoying backward compatibility concerns associated with making any mistakes.
@seberg or maybe @mhvk would either of you be interested in giving this another pass?
seberg
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for forgetting about this (it was great that you made many of the other smaller cleanups here!).
I think it's good, I have one substantial ask, unfortunately. Because I really think we should try to allow old NumPy versions to load most pickles and I don't think this does.
|
seems something's wrong with freebsd's ci |
|
@IndifferentArea don't worry about it. But if you would like to see it green, try merging/rebasing the main branch. That would likely fix it. |
|
Thanks @IndifferentArea |
|
@charris @seberg This PR introduced some linting errors on my local system. For example in numpy/_core/tests/test_multiarray.py line 4452 a trailing whitespace. The CI was green for the PR, in particular the lint test: https://github.com/numpy/numpy/actions/runs/14085614702/job/39448799495. Not sure what happened here. The ruff version used in the CI is a bit older, but on my system it gives the same errors. I will create a PR to update ruff and address the trailing whitespace. |
|
It probably needed a rebase before merging. |
|
Awesome to see it merged! |
As discussed in #26878, support no-copy pickling for any array that can be transposed to a C-contiguous array.