-
Notifications
You must be signed in to change notification settings - Fork 16
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
Optimize run to avoid quadratic map cloning #15
Conversation
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.
Thank you for wrapping this awesome work up!
I find that the mapping Storage
is similar to a conceptual tree:
AsyncContext.wrap
saves the active head of the tree and marks it as immutable.- Subsequent modification (
AsyncContext.prototype.run
) to the head of the tree would create a new leaf and make the new leaf the active head.
- Subsequent modification (
- When the wrapped function is called,
Storage
switches to the saved leaf and switch back to the current tip afterfn
is invoked.
- When
AsyncContext.prototype.run
is called,- If the active tip of the tree is immutable,
Storage
creates a new leaf from it and switches to the new leaf as the active head. After thefn
is invoked, the head of the tree is switched back to the previous one. - If the active head of the tree is mutable,
Storage
saves the current value corresponding to theAsyncContext
instance at the head and resets the value after thefn
is invoked.
- If the active tip of the tree is immutable,
AsyncContext.prototype.get
retrieves the value from the active head corresponding to theAsyncContext
instance.
In this sense, I'm wondering if a renaming would describe these operations in a more clear way (See https://github.com/legendecas/proposal-async-context/blob/2220629/src/storage.ts#L11 for more details):
export class AsyncContext<T> {
static wrap<F extends AnyFunc<any>>(fn: F): F {
// No clones in commit.
const wrapHead = Storage.commit();
function wrap(this: ThisType<F>, ...args: Parameters<F>): ReturnType<F> {
const head = Storage.switch(wrapHead);
try {
return fn.apply(this, args);
} finally {
Storage.switch(head);
}
}
return wrap as unknown as F;
}
run<F extends AnyFunc<null>>(
value: T,
fn: F,
...args: Parameters<F>
): ReturnType<F> {
const head = Storage.stage(this);
Storage.set(this, value);
try {
return fn.apply(null, args);
} finally {
Storage.switch(head);
}
}
get(): T | undefined {
return Storage.get(this);
}
}
src/storage.ts
Outdated
* Join will restore the global storage state to state at the time of the | ||
* fork. | ||
*/ | ||
static join<T>(fork: FrozenFork | OwnedFork<T>): void { |
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 believe this method can be merged with Storage.restore
. They all restore the global storage state to state at the time of the fork.
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.
Not quite. join
and restore
(now restore
and switch
respectively) have different assumptions about what can happen to the current global state.
For join
, we assume the the current mappings will be modified (or if it's frozen, reallocated then modified). For restore
, we want to switch back to the state of a wrap's snapshot and return a FrozenFork
so the wrapper can restore the prior state after running. It wouldn't be valid for join
to return a fork.
Co-authored-by: Chengzhong Wu <[email protected]>
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 naming is more intuitive to me now.
Currently, every time
run()
is called, we clone the underlying storageMap
. This is done so that the the modification will not change a map which is held by awrap
's snapshot. I believe "wrapping" (either explicitly withAsyncContext.wrap()
or implicitly throughPromise
s) will be performed 10000x more times in a normal program thanrun()
, so I'm doing everything possible to make thewrap()
method as fast as possible. Further, thatget()
will be called at least at much and probably more asrun()
. So my priorities are:wrap()
must beO(1)
(and ideally require nothing more than a pointer reference)get()
should be as fast as possiblerun()
may be slow, but let's try to make it fastThese priorities lead to code that causes quadratic cloning of the underlying
Map
as we add more keys in nestingrun()
calls. That's not ideal.We can fix this if we know whether a snapshot is held by anyone. If there is, then we must treat the
Map
as immutable (because someone has a direct access to it). If not, then we can directly mutate without worrying.Because no wrapping was done (and there's no await/promises), we didn't have to clone anything.
There are some interesting cases with the new code. Once a snapshot has been taken, further mutations will clone the underlying
Map
and mutate that clone. This comes up when we enter a run from a frozen state, and when we exit a run that has taken a snapshot:We can prove that the new code clones less overall than the old code does. In the old code, every enter would clone. In the new code, we only clone:
So, we're more optimal in all cases:
wrap()
:O(1)
get()
:O(1)
Map.p.get()
run()
:O(1)
(if not wrapped) orO(n)
(if wrapped)There are alternative algorithms using purely-functional immutable data structures, which trade make
run()
andget()
operate atO(1)
andO(n)
, but I don't think that's optimal.