Skip to content
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

Improve sorted entity adapter sorting performance #4361

Merged
merged 7 commits into from
May 9, 2024

Conversation

markerikson
Copy link
Collaborator

This PR:

  • Updates the sorting logic for sorted entity adapters to hopefully be more efficient most of the time

Previously we were always getting the list of items via Object.values(state.entities). However, that is always going to give us an array of items in insertion order, not the most recent sorted order.

If we instead go state.ids.map(id => state.entities[id]), we at least start with a sorted array.

However, there's a couple tricky cases. If a set of updates replaced an item's ID, that throws things out of whack. It's easier to fall back to the old behavior. There are also apparently a couple test cases where we can end up with duplicate IDs and have to watch for that.

On my own machine, the perf test case with 100K items gave me numbers like this with the existing code:

Upsert: sortComparer called: 3,058,124 times
Duration to upsert an item: 1,177 ms
Update: sortComparer called: 1,529,076 times
Duration to update an item: 1,139 ms

After this fix, it drops to:

Upsert: sortComparer called: 1,629,402 times
Duration to upsert an item: 533 ms
Update: sortComparer called: 100,032 times
Duration to update an item: 544 ms

That's still not fast, but it's a 50% improvement.

I may have another idea or two around tracking updated items, removing those, and re-inserting them, but will need to try those later.

Copy link

codesandbox bot commented Apr 18, 2024

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

Copy link

netlify bot commented Apr 18, 2024

Deploy Preview for redux-starter-kit-docs ready!

Name Link
🔨 Latest commit d0b3ba5
🔍 Latest deploy log https://app.netlify.com/sites/redux-starter-kit-docs/deploys/66218b86f457630009970d92
😎 Deploy Preview https://deploy-preview-4361--redux-starter-kit-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

Copy link

codesandbox-ci bot commented Apr 18, 2024

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 0ca3c60:

Sandbox Source
rsk-github-issues-example Configuration
@examples-query-react/basic Configuration
@examples-query-react/advanced Configuration
@examples-action-listener/counter Configuration
rtk-esm-cra Configuration

Copy link

github-actions bot commented Apr 18, 2024

size-limit report 📦

Path Size
1. entry point: @reduxjs/toolkit (cjs, production.min.cjs) 14.32 KB (+1.56% 🔺)
1. entry point: @reduxjs/toolkit/react (cjs, production.min.cjs) 14.44 KB (+1.52% 🔺)
1. entry point: @reduxjs/toolkit/query (cjs, production.min.cjs) 22.18 KB (+0.93% 🔺)
1. entry point: @reduxjs/toolkit/query/react (cjs, production.min.cjs) 24.16 KB (+0.9% 🔺)
2. entry point: @reduxjs/toolkit (without dependencies) (cjs, production.min.cjs) 7.71 KB (+2.71% 🔺)
3. createEntityAdapter (.modern.mjs) 5.4 KB (+3.86% 🔺)

Copy link

netlify bot commented Apr 18, 2024

Deploy Preview for redux-starter-kit-docs ready!

Name Link
🔨 Latest commit 0ca3c60
🔍 Latest deploy log https://app.netlify.com/sites/redux-starter-kit-docs/deploys/663c22fbf1d8fb0008af9ca0
😎 Deploy Preview https://deploy-preview-4361--redux-starter-kit-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@markerikson
Copy link
Collaborator Author

markerikson commented Apr 19, 2024

Revised numbers if we reset the sort counter right before the upsert:

// 100K items, without fix
Upsert: sortComparer called: 1,529,445 times
Duration to upsert an item: 1,379 ms
Update: sortComparer called: 1,529,444 times
Duration to update an item: 1,482 ms

// 100K items, with fix
Upsert: sortComparer called: 100,027 times
Duration to upsert an item: 847 ms
Update: sortComparer called: 100,032 times
Duration to update an item: 803 ms

10K items, with fix
Upsert: sortComparer called: 10,017 times
Duration to upsert an item: 92 ms
Update: sortComparer called: 10,024 times
Duration to update an item: 71 ms

@phryneas
Copy link
Member

Just leaving this here - I suspect that pre-sorting the incoming array and manually merging them might save a bunch of comparisons on top (assuming that we don't only insert at the end):

function merge(models: readonly T[], state: R): void {
    const entities = state.entities as Record<Id, T>
    const oldEntities = Object.values(state.ids).map((id: Id) => entities[id])
    const newEntities = models.slice().sort(sort)

    // Insert/overwrite all new/updated
    models.forEach((model) => {
      entities[selectId(model)] = model
    })

    const newSortedIds: Id[] = []
    let o = 0,
      n = 0
    while (o < oldEntities.length && n < newEntities.length) {
      const oldEntity = oldEntities[o] as T,
        oldId = selectId(oldEntity),
        newEntity = newEntities[n]

      // old entity has been overwritten by new entity, skip comparison
      if (entities[oldId] !== oldEntity) {
        o++
        continue
      }

      const comparison = sort(oldEntity, newEntity)
      if (comparison < 0) {
        newSortedIds.push(oldId)
        o++
        continue
      }
      const newId = selectId(newEntity)
      if (comparison > 0) {
        newSortedIds.push(newId)
        n++
        continue
      }
      newSortedIds.push(oldId)
      o++
      newSortedIds.push(newId)
      n++
    }
    while (o < oldEntities.length) {
      newSortedIds.push(selectId(oldEntities[o]))
      o++
    }
    while (n < newEntities.length) {
      newSortedIds.push(selectId(newEntities[n]))
      n++
    }
    if (!areArraysEqual(state.ids, newSortedIds)) {
      state.ids = newSortedIds
    }
  }

This was just a way around merge which will only work for insert/upsert, but I believe my approach would also work with updating ids - it just needs the sorted entries from before, the merged object and the inserted/updated objects (also sorted). Could just as well be a method argument like you did.
Feel free to swipe anything if you feel it's a useful approach.

@markerikson markerikson force-pushed the feature/4252-entity-adapter-sorting branch from 36e15d4 to 428bc2f Compare April 20, 2024 22:59
@markerikson markerikson force-pushed the feature/4252-entity-adapter-sorting branch from 9e38eb3 to bece99b Compare April 20, 2024 23:50
@markerikson
Copy link
Collaborator Author

Current benchmark numbers:

Existing logic:

Original Setup: sortComparer called 1,529,087 times in 839ms
Insert One (random): sortComparer called 1,529,107 times in 1,018ms
Insert One (middle): sortComparer called 1,529,120 times in 1,311ms
Insert One (end): sortComparer called 1,529,137 times in 1,348ms
Add Many: sortComparer called 1,546,947 times in 1,283ms
Update One (end): sortComparer called 1,546,945 times in 1,338ms
Update One (middle): sortComparer called 1,546,948 times in 1,375ms
Update One (replace): sortComparer called 1,547,010 times in 1,403ms

Binary Insertion / this PR:

Original Setup: sortComparer called 1,529,043 times in 642ms
Insert One (random): sortComparer called 17 times in 569ms
Insert One (middle): sortComparer called 16 times in 452ms
Insert One (end): sortComparer called 17 times in 487ms
Add Many: sortComparer called 16,728 times in 519ms
Update One (end): sortComparer called 101,033 times in 590ms
Update One (middle): sortComparer called 101,035 times in 586ms
Update One (replace): sortComparer called 101,055 times in 700ms

I haven't tried to do any other perf profiling to measure where the rest of the overhead is coming from. I'd guess that Immer is a large chunk of it. But still, that's a lot fewer comparisons for those cases.

@markerikson
Copy link
Collaborator Author

markerikson commented Apr 22, 2024

Latest times with additional Immer-related optimizations:

Original Setup: sortComparer called 1,529,339 times in 600ms
Insert One (random): sortComparer called 16 times in 349ms
Insert One (middle): sortComparer called 16 times in 441ms
Insert One (end): sortComparer called 16 times in 377ms
Add Many: sortComparer called 16,710 times in 384ms
Update One (end): sortComparer called 101,034 times in 550ms
Update One (middle): sortComparer called 101,036 times in 492ms
Update One (replace): sortComparer called 101,055 times in 655ms

The overhead seems to primarily be in Immer at this point:

image

@markerikson
Copy link
Collaborator Author

This should be an improvement, let's get it out.

@markerikson markerikson merged commit 7314c49 into master May 9, 2024
48 checks passed
@unadlib
Copy link

unadlib commented May 14, 2024

I apologize for the sudden comment on this merged PR.

Here's a performance comparison test between Immer and Mutative that focuses solely on "Insert One (end)."
The results are as follows:

// 10K items
Original Setup: sortComparer called 119,925 times in 40ms
Insert One (end): sortComparer called 13 times in 12ms
Use only Immer: sortComparer called 0 times in 10ms
Use only Mutative: sortComparer called 0 times in 2ms

// 100K items
Original Setup: sortComparer called 1,529,198 times in 349ms
Insert One (end): sortComparer called 17 times in 165ms
Use only Immer: sortComparer called 0 times in 120ms
Use only Mutative: sortComparer called 0 times in 24ms

// 1000K items
Original Setup: sortComparer called 18,604,782 times in 3,559ms
Insert One (end): sortComparer called 19 times in 2,859ms
Use only Immer: sortComparer called 0 times in 1,787ms
Use only Mutative: sortComparer called 0 times in 368ms

Here's the test code:

const initialItems = generateItems(INITIAL_ITEMS)

measureComparisons('Original Setup', () => {
  store.dispatch(entitySlice.actions.upsertMany(initialItems))
})

measureComparisons('Insert One (end)', () => {
  store.dispatch(
    entitySlice.actions.upsertOne({
      id: nanoid(),
      position: 0.9998,
      name: 'test',
    }),
  )
})

measureComparisons('Use only Immer', () => {
  produce(store.getState(), (draft) => {
    const id = nanoid();
    draft.entity.ids.push(id)
    draft.entity.entities[id] = {
      id,
      position: 0.9998,
      name: 'test',
    }
  })
})

measureComparisons('Use only Mutative', () => {
  create(store.getState(), (draft) => {
    const id = nanoid();
    draft.entity.ids.push(id)
    draft.entity.entities[id] = {
      id,
      position: 0.9998,
      name: 'test',
    }
  })
})

I know this performance test is quite specific. If you have any further ideas or questions, I’d be more than happy to discuss them.

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.

3 participants