Skip to content

BUG: Significant copy performance regression for structured arrays #28194

Closed

Description

@bonpyt

Describe the issue:

We have observed a performance regression when copying a large structured array.
Before:

START
0.3912498950958252
0.12642884254455566
0.1266167163848877
0.12655949592590332
0.1264510154724121

After:

START
2.4238221645355225
2.457045316696167
2.1723172664642334
2.454754114151001
2.4571428298950195

We have tracked it down to this MR: #18398

The previous code had a special case for copying packed structured arrays (dtype_transfer.c) leading to the whole array getting copied as one.
If we copy the array as one, the itemsize and stride are the same, therefore Numpy can dispatch to a contiguous copy rather than a strided copy here: lowlevel_strided_loops.c.src)
What happens after the MR is that we copy the fields individually, but the data for a single field is not contiguous in memory so we have to do a strided copy.

We managed to fix it by putting the special case into the void_to_void_get_loop, but it would be great to get this fixed upstream and you might have a nicer way of getting the performance back:

diff --git a/numpy/_core/src/multiarray/convert_datatype.c b/numpy/_core/src/multiarray/convert_datatype.c
index a0f09b71a3..39967be732 100644
--- a/numpy/_core/src/multiarray/convert_datatype.c
+++ b/numpy/_core/src/multiarray/convert_datatype.c
@@ -3212,7 +3212,23 @@ void_to_void_get_loop(
 {
     if (PyDataType_NAMES(context->descriptors[0]) != NULL ||
             PyDataType_NAMES(context->descriptors[1]) != NULL) {
-        if (get_fields_transfer_function(
+
+        int is_builtin = context->descriptors[0]->type_num < NPY_NTYPES_LEGACY && context->descriptors[1]->type_num < NPY_NTYPES_LEGACY;
+
+        if (PyArray_EquivTypes(context->descriptors[0], context->descriptors[1]) &&
+            !PyDataType_REFCHK(context->descriptors[0]) && !PyDataType_REFCHK(context->descriptors[1]) &&
+            ( !PyDataType_HASFIELDS(context->descriptors[1]) ||
+              is_dtype_struct_simple_unaligned_layout(context->descriptors[1])) &&   is_builtin  )
+            {
+            *out_loop = PyArray_GetStridedCopyFn(0,
+                            strides[0], strides[1],
+                            context->descriptors[0]->elsize);
+            if (out_loop == NULL){
+                return -1;
+            }
+            *out_transferdata = NULL;
+        }
+        else if (get_fields_transfer_function(
                 aligned, strides[0], strides[1],
                 context->descriptors[0], context->descriptors[1],
                 move_references, out_loop, out_transferdata,

Reproduce the code example:

import sys
import numpy as np
import time


dtype = np.dtype([
    ('a', np.float32),
    ('b', np.float32),
    ('c', np.float32),
    ('d', np.float32),
    ('e', np.float32),
    ('f', np.float32),
    ('g', np.float32),
    ('h', np.float32),
    ('i', np.float32),
])

SHAPE = (100000000,)

def test(dtype, shape):
    x = np.zeros(shape=SHAPE, dtype=dtype)
    y = np.zeros(shape=SHAPE, dtype=dtype)

    def f():
        x[:] = y[:]
        

    for _ in range(5):
        start = time.time()
        f()
        end = time.time()
        print(end-start, flush=True)

test(dtype, SHAPE)

Error message:

Python and NumPy Versions:

Regression between 1.20.3 and 1.21 (but still happens on latest master).

Runtime Environment:

No response

Context for the issue:

No response

Activity

  1. seberg commented on Jan 20, 2025

    @seberg
    Member

    Hmmm, I don't remember if there may have been an intentional angle to this or whether it was just unintentional when re-organizing the cast setup.

    The place where you put it seems about right and if you are up for it a PR would be very welcome. There are two things I think that would be good to look into on first thought:

    1. It would be good to check if this cannot be pushed into get_fields_transfer_function that function needs all the offsets, etc. anyway, so it would avoid duplicating work, I suspect (and be simpler if we consider that we can delete that helper).
    2. I am a bit worried that the logic might break down if the dtype includes a nested structured dtype that does not have a packed layout.
      And if that is a problem, my first thought for a good solution would be if we could query a dtype on whether it is "trivially assignable/copyable". But that might require a new flag? And I am not sure if the propagation/setting logic is obvious.
      (If it is a gigantic difference one could think about special casing neested structured dtypes here.)
  2. bonpyt commented on Jan 20, 2025

    @bonpyt
    ContributorAuthor

    I'm happy to have a go at a PR.

    Regarding your points:

    1. Yes, that sounds sensible.
    2. I haven't worked with the Numpy codebase before so would be a bit hesitant to make larger changes like adding a new flag.
  3. seberg commented on Mar 14, 2025

    @seberg
    Member

    Just saw this and thought that a PR is still nice. The logic here is actually correct it seems to me (i.e. the code would only trigger for packed structs, although we still should move it into get_fields_transfer_function, I think. Just to avoid doing work twice).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions