dr-skot / json-stash

serialize and deserialize javascript objects to json
1 stars 1 forks source link

Async/Await variant to prevent UI freeze #2

Open Oxydation opened 1 week ago

Oxydation commented 1 week ago

Hello, I am using json-stash successfully to store one big object with nested objects and cross-references, which works great - thanks for your work! The time to create the json string from the object increases linear to the amount of objects. Currently for a larger object it takes about 800ms to create the json string during which the UI freezes. The save process is done every 5- 10s, to make sure, no data is lost (offline angular app).

I tried to offload the stash-function to a web worker, but the data has to be sent already as a json string, so all information is lost about classes etc.

Could you think of a possibility to allow a non-blocking version of json-stash, e.g. with async/await (Promises) or another non-blocking solution?

Thanks!

dr-skot commented 1 week ago

Let me look into it. I think it would mean breaking the task into chunks and wrapping them in setTimeout() calls to intermittently cede thread control.

dr-skot commented 1 week ago

Okay, I've published a separate npm package json-stash-async that adds awaitable versions of stash and unstash, called stashAsync and unstashAsync. Can you give that a try and see if it unfreezes your UI?

Oxydation commented 1 week ago

Thanks for your fast reponse! Uninstalled the original json-stash, installed json-stash-async and used const save = await stashAsync(saveGame); to serialize the data. I tried it, but unfortunantely, for a smaller save file (just to test it) instead of 110ms, it does take ~110000ms or more. It seems to hang in there somewhere for a long time.

dr-skot commented 1 week ago

Oof. Alright, do you think you could send me that sample data, or something equivalent, to test with?

Thanks, S

On Sat, Nov 16, 2024, at 2:39 PM, Mathias wrote:

Thanks for your fast reponse! Uninstalled the original json-stash, installed json-stash-async and used const save = await stashAsync(saveGame); to serialize the data. I tried it, but unfortunantely, for a smaller save file (just to test it) instead of 110ms, it does take ~110000ms or more. It seems to hang in there somewhere for a long time.

— Reply to this email directly, view it on GitHub https://github.com/dr-skot/json-stash/issues/2#issuecomment-2480754291, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAN74QBQJYWNP64TDFYALCL2A6NQNAVCNFSM6AAAAABRQMUWNCVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDIOBQG42TIMRZGE. You are receiving this because you commented.Message ID: @.***>

Oxydation commented 1 week ago

Sample data should be easy to create. For a relative simple data structure it takes ~120ms. I just added some simple copies of the variables. Added some minimal duration output:

const start = Date.now();      
 const save = await stashAsync({
        id: "12345-12345-12345-12345",
        name: "12345-12345-12345-12345",
        name1: "12345-12345-12345-12345",
        name2: "My World",
        name3: "My World",
        name4: "My World",
        name5: "My World",
        saveVersion: 40,
        totalElapsedTime: 170000000,
        totalOfflineTime: 170000000,
        lastSavedTime: 170000000,
        lastSavedTime1: 170000000,
        lastSavedTime2: 170000000,
        lastSavedTime3: 170000000,
        lastSavedTime4: 170000000,
        lastSavedTime5: 170000000,
        lastSavedTime6: 170000000,
        lastSavedTime7: 170000000,
        lastSavedTime8: 170000000,
        lastSavedTime9: 170000000
      });
  const saveDuration = Date.now() - start;
  console.log("Saved game state within " + saveDuration + "ms")

If you have no luck with this example data, I could debug your code to find out where the time is lost. Thanks!

dr-skot commented 5 days ago

Oh, sorry, I thought you were working with more complicated objects. That one is saving in 30ms for me with stashAsync and 1ms with stash (running inside a jest test in my IDE). Those numbers might be too small for meaningful performance optimizing. The async version wraps some operations in setTimeout and awaits promises... just the overhead of that could account for 30ms.

I'll try to cook up some more complicated objects to inflate the numbers. What's the environment where you're seeing 120ms for that one? And didn't you say you had one that was taking 110 seconds? And originally you had one that took 800ms even with non-async stash, right?

Oxydation commented 5 days ago

In this example I omitted the largest part, my "world" object with a lot of nested objects.

To give you a better idea, I have attached a example json result, with only one player, one settlement and one building, so the end result is not a 500KB save file, but more like 2-3MB. So in a gist, its something like this: world -> 10 players -> 20 settlements each -> each settlement does have 1 to 13 buildings. Also every player does have statistic infos with some large maps, then a worldmap with occupied tiles and info.

test.json

Test machine is already ~11 years old now (i7-4700MQ @2.4GHz), which is not the fastest compared to modern laptops, but issue can also be observed on a mobile device (e.g. Dimensity 8100-Ultra processor from 2022).

And yes, the original save time was about 800ms with the non-async stash. I do have some ideas to split up the big object into multiple parts, but the amount of data stays the same and is subject to grow, Another solution could be to store all data without rehydration info into IndexedDb and use json-stash only for save-file export/import.

How did you write your non-blocking code? I was able to implement a solution without setTimeout, which I have used for loading data after being offline for a while. For this, every x iterations (e.g. 800) I return this:

while (offlineTimeLeft > 0) {
  // omitted code to handle offline time chunk

 currentBatchIteration++;
      if (currentBatchIteration > 800) {
        currentBatchIteration = 0;
         // omitted code to update info dialog, inform user about current progress

        await new Promise(requestAnimationFrame);
      }
}
dr-skot commented 5 days ago

Ah great, thanks for the data! I've set up this codepen to unstash and restash it. Can you give that a try and let me know what timings you get? I'm seeing <30ms on both my laptop and my phone, but both are a little newer than what you're using (2020 MacBook Pro, last year's iPhone).

dr-skot commented 5 days ago

The non-blocking strategy I was trying is basically

await new Promise((resolve) => { setTimeout(() =>resolve(fn()), 0) })

where fn() is something I thought could plausibly be understood as a single unit of processing. In the case of stash it's the serialize function that gets called when a node is encountered in the data structure that isn't an array or plain object. (If it's an array or plain object, stash recurses into it.)

Maybe requestAnimationFrame is a better alternative... but I think the approach would be the same:

await new Promise((resolve) => { requestAnimationFrame(() =>resolve(fn())) })

And I am seeing that with really large objects stashAsync is unacceptably slow. Apparently that much timeout/promise wrapping is creating a lot of overhead.

It would be much cleaner if we could just give the stashing task to another thread. I'm going to look into whether a web worker solution is possible, though I think you might be right that the problem of getting class defs to the worker could prove to be a dealbreaker.

dr-skot commented 5 days ago

Oh hold on I think I'm understanding your code now.

await new Promise(requestAnimationFrame)

is equivalent to

await new Promise((resolve) => requestAnimationFrame(resolve))

ie, it yields the thread to the UI and then continues. I'll change stashAsync to use that strategy and see if we get better results.

...

Hm, nope. That slowed it down a lot. Makes sense: it forces each step of the process into a different frame, and a frame is typically about 17ms, so that adds up fast.

Oxydation commented 4 days ago

Hm, nope. That slowed it down a lot. Makes sense: it forces each step of the process into a different frame, and a frame is typically about 17ms, so that adds up fast.

That is why I processed it in batches, so for every x actions (in my case 800 update(gameTime) loops). Waiting to long lags and acting to fast does slow it down. Not sure if you can batch your code, do resolve x objects and then create the Promise.

Ah great, thanks for the data! I've set up this codepen to unstash and restash it. Can you give that a try and let me know what timings you get? I'm seeing <30ms on both my laptop and my phone, but both are a little newer than what you're using (2020 MacBook Pro, last year's iPhone).

Thanks for setting that up. I get following times on my laptop - numbers look fine to me. This is the sync variant, right? unstashed in 25.799999997019768ms stashed in 36.900000005960464ms

I am currently checking my saveGame, and removing e.g. the worldMap reduces stash time 100ms alone. So not sure, if there are some other issues at play, e.g. angular in development mode or something else. Update: I found out, that every AI player had stored a lot of unused archived data - I cleared that and now I halved sync save time (from 850ms to 420ms). The save file also reduced to 1.7MB from 3.2MB original.

dr-skot commented 4 days ago

Yeah those numbers look good! Is it possible you were including more than thestash operation when you did your timings before? Or were you stashing larger objects?

Oxydation commented 4 days ago

Nope, it was only the stash operation alone. I reduced total time used to 330ms by cleaning up and rebuild things to regenerate on load instead of storing in the save file. So essentially yes, the objects were larger and there were more of them. So I guess in the end, even if there is async variant, I have to reduce the amount of objects and optimize them.

dr-skot commented 3 days ago

Okay, I think I've got stashAsync closer to the desired behavior. I set up another codepen where you can try it out by stashing a large object and then pressing a button to prove the UI stays responsive while the save is in progress.

Processing time is longer for stashAsync than stash, but not unacceptably so. Basically it watches the clock while saving and yields the thread every 100ms or so. That seems frequent enough, based on how responsive the button feels.

Let me know if it's working for you. This is a new version of json-stash-async, v4.0.5

Oxydation commented 1 day ago

Testing with the codepen was successful. The async variant does take abou 2x as long but is non-blocking, which looks promising!

With the previous blocking stash I takes about 200ms currently, so the blocking part is minimal. But this is growing continously if time passes, so the async comes in handy.

Tested with your newest version and this is the result, for a previous 200ms save (~730kb json data): Stash took 2519.8, store 22.9 = 2542.70ms total Stash took 2976.3, store 33.4 = 3009.70ms total

So this is actually ~ 10x slower than sync. Also it does lag, because 100ms is not as fluent - switching a menu stutters. 33ms would be 30FPS, which would be fluent for the human eye. Could you make this value configurable? Or set it closer to 40 or 50ms?

I may continue to use the sync version and automatically switch to the async one, if the average saving time is e.g. > 600ms. The problem stays somehow: While saving, the game cannot be updated, because I would have a corrupted state. So 3s no progress in game could be a lot.

Still, this is by far the best solution, thanks for that!

dr-skot commented 1 day ago

New version 4.0.6 allows a third parameter to stashAsync and unstashAsync which is how many ms to wait before yielding the thread (the second parameter is the array of serializers to use; pass undefined to use the stasher's defaults).

You can try it out at that same codepen address, where I added a new field for the duration between thread yields.

Let me know what your results are. Curious why you're seeing 10x slowdown with async in your app when the codepen example shows more like 3x...

dr-skot commented 1 day ago

Actually on further reflection the slowdown difference isn't so mysterious. Presumably your game tasks the thread a lot more than my codepen example, so whenever stashAsync relinquishes the thread it has to wait longer to get it back.

Oxydation commented 16 hours ago

Tested it by setting value to 33ms (so ~ 30 FPS) and it does look fine to me. UI does not really freeze and stash duration ist about the same as with 100ms yield interval. Thanks for that!

Not sure about the game tasks - it is running currently all in the main thread and there is a game loop with looping like this requestAnimationFrame(async (timestamp) => await this.gameLoop(timestamp));

Within the gameloop I check if the auto save interval as elapsed and then starting the save process. While saving, the game itself is not really running. If I would do both at the same time, the save file would not be a snapshot.

"Pseudo code" for the game loop

async gameLoop(timestamp: number) {
  delta= timestamp - lastTimeStamp
  world.update(delta)

  if (elapsed > autoSaveInterval) {
     await saveState()
 }
  requestAnimationFrame(async (timestamp) => await this.gameLoop(timestamp));
}

Currently a user can play for about 70 days until save time might be noticeable if using sync stash which does fit the current content, but this is subject to change. So I may probably find another method to store the current state much faster. My idea was to put all properties of a class into an interface. And only this interface on every class is stored and to serialize/deserialize a simple util could be used. Deserialization could take longer but that is usually only done once and not every couple seconds.