tvcutsem / es-lab

Automatically exported from code.google.com/p/es-lab
23 stars 10 forks source link

Proxy should get the handler's property descriptors during construction. #21

Open erights opened 7 years ago

erights commented 7 years ago

Historical mistake

We made a mistake in the Proxy design (@tvcutsem agrees): The proxy does a [[Get]] on the handler's trap properties each time it wants to trap. Rather, the proxy should have treated the handler object much like an options object, doing all those [[Get]]s up front and remembering these methods internally.

Either way, any user can of course create a taxonomy of partial customizations, using inheritance or copying between partially or fully populated options/handler objects as they wish. This shift would have both pros and cons:

Cons:

Pros:

Cycle checking

Let us say that a non-proxy inherits simply if its [[GetPrototype]] behavior is known to always immediately return its [[Prototype]] or nothing, and do nothing else. An object whose [[GetPrototype]] behavior is not known to do so inherits exotically. All non-exotic objects inherit simply. Only exotic objects can inherits exotically, but most exotic objects inherit simply. These definitions do not care about the behavior of an object's [[SetPrototype]] trap.

A proxy inherits simply iff its target inherits simply and its handler can never provide a getPrototype trap. If the traps were gotten from the handler only once at proxy construction time, this would be a useful distinction. Since both handler and target may be proxies, this definition is recursive on the target. If a proxy inherits simply, then its [[GetPrototype]] behavior either returns the leaf target's [[Prototype]], or nothing if any proxy in the target chain has been revoked. If a proxy inherits simply, then its [[GetPrototype]] behavior does nothing else.

With these definitions, we can change the cycle check to reliably prohibit an inheritance cycle among objects that inherit simply. The cycle check would operate atomically and with no side effects. It would cause no traps. A proxy that wishes to be truly transparent would need to inherit simply, which would not be a burden for almost all uses of proxies. A proxy that inherits exotically thereby opts out of full transparency. The cycle check can sometimes be used to reveal the presence of a proxy that inherits exotically, but it cannot reveal the presence of a proxy that inherits simply.

All Pros and no Cons

Fortunately, because of the invariants, a different change to the spec will give us all the pros, none of the cons, and will likely not break any existing code. We propose to have the proxy inspect the handler at proxy construction time. For a given handler and a given trap name, if any of the following conditions hold:

then we know that a [[Get]] of that trap name on that handler will never return a trapping function. Under these circumstances, the proxy should remember that this trap is absent and not do those [[Get]]s at trapping time. In the case where the handler is a proxy (or possible other exotic objects), this has an observable difference: Doing the [[Get]] could cause other side effects which skipping the [[Get]] does not have. But in neither case could these side effects have caused the [[Get]] to return a trapping function. The absence of these effects under these circumstances likely does not break any existing code.

Cycle checking again

When the trap name in question is getPrototype then the above change becomes more than just a slightly non-transparent optimization. It allows us to distinguish proxies that inherit simply from those that inherit exotically, in order to make exactly the change to cycle detection explained above.

Possible remaining optimizations.

For properties that satisfy the relaxed requirement

then we know that whatever the current value reported for that trap name, it will always report the same value or nothing. If there are no revocable proxies in the handler chain, then we even know that it will always report the same value, period. As part of this overall change, we might want to allow the proxy to cache these trap functions as well. This isn't much of an optimization since these traps will still happen. But it would allow the proxy to avoid these [[Get]]s at trap time. For high speed operations through simple membranes, even this may be significant.

tvcutsem commented 7 years ago

I agree with your analysis and proposal.

I think it's worth going this route to allow proxies to remain transparent for inheritance cycle checks, but I don't think it's worth the hassle purely for the gains in performance.

Ergonomics

We should discuss the ergonomics of how hard it is for proxy authors to define handlers that satisfy the above properties.

In most proxy definitions that I have written or come across, an object literal is used to define the handler in-line. IIUC, with this proposal, the easiest way to satisfy the properties of a stable trap is to wrap the handler in a call to Object.freeze before passing it to the Proxy constructor, and either to inherit from null or to explicitly define all absent traps as undefined. That seems doable, even though explicitly listing all undefined traps feels tedious. Luckily, with in-line __proto__: null, we could still use a single expression to construct a "stable handler".

Overheads?

The checking mechanism proposed here does introduce a fair bit of runtime overhead to proxy construction: the constructor now needs to perform all these tests for all traps (which last I checked was about ~12 methods), although some of the tests may be shared among all traps.

There's also a non-negligible change in the size in memory of a proxy object: today a proxy needs to store only a pointer to its target and its handler. With this proposal, the proxy may need up to ~12 more pointers to store the cached traps, correct?

Coarse-grained vs Fine-grained stability check

I wonder if we can somehow mitigate the two identified overheads by drastically simplifying the construction-time check: instead of trying to check whether each individual trap is stable, we may also test whether the handler and all of its ancestors are frozen.

This reduces the checking overhead for stable traps to a (proto-chain-walking) isFrozen test, and reduces the size overhead to a single flag (indicating whether the handler and all of its ancestors are frozen).

Given that defining frozen handlers is way more ergonomic than calling Object.defineProperty(handler, trapName, {configurable: false, writable: false}) for each trap one wants to mark as stable, I suspect that the coarse-grained stability check may be sufficient in practice.

erights commented 7 years ago

Ok, on the checking, I see three options:

First, I agree that both of the other options beats the first bullet. If the isFrozen check passes, the others would be guaranteed to pass, so we should allow ourselves to skip them. If there is no interesting use case that would benefit from the third option (do both) then I agree the second (only isFrozen up the chain) is simpler.

Oops. Except that isFrozen by itself is not adequate. We also need to ensure that the relevant properties are data properties.

On the memory overhead issue, there is an optimization opportunity that is ironically recursive. If an implementation would rather not pay the memory overhead, and it can tell up front that these [[Get]]s on the handler cannot have any observable side effects, then there would be no observable consequence to re-getting them from the handler each time. If the handler is a frozen non-exotic and the relevant traps are data properties, and so on up the chain, then fine. If any are proxies, then these very rules would enable the outer proxy implementation to tell whether the inner proxy (the handler of the outer proxy) would actually trap the relevant traps, and so on.

This double proxy case sounds esoteric, but it is the essence of the double lifting trick. It would be nice if we could have double lifting and this optimization at the same time.

tvcutsem commented 7 years ago

Indeed, isFrozen is not sufficient, so it seems there is little simplification in trying to leverage 'frozenness'.

I still think there is value in trying to limit the amount of checking a proxy needs to do on the handler during proxy construction. In particular, the problem with checking that a property is absent from the handler is that it requires a proto-chain walk, which is not O(1).

On the other hand, just testing whether any of the handler trap properties is an own, non-configurable, non-writable data property bound to undefined is O(1) (unless the handler is itself a proxy).

So, a simpler rule would be as follows: if a handler wants to let a proxy know it need not trap a certain operation, it can do so by explicitly defining the trap to be an own frozen data property set to undefined. Merely not defining a trap would not be sufficient to suppress trapping that operation.

IMHO this provides a clearer signal, one that will always be visibly present in the code. Especially for getPrototypeOf, where the presence or absence of the trap actually has a semantic meaning beyond simply performance gains (for cycle checking), it may not be a bad idea to explicitly see the getProtoypeOf: undefined declaration in the handler code. WDYT?

erights commented 7 years ago

I probably agree, but uncomfortably. The full optimization is so safe and so close to transparent that it would be nice to allow it without requiring it. Unfortunately, because it is observable, we should not.

The discomfort is that normal ways of writing proxy handler will simply omit traps it does not need. It is a shame that we must forgo this significant optimization opportunity for these.

tvcutsem commented 7 years ago

In my experience, I usually write the handler as an in-line object literal, like so new Proxy(target, {...}). This means the handler is by default extensible, and so even if it omits traps, the proxy can't take that as a reliable signal that they will remain forever undefined.

So if I want to benefit from optimized trapping, I already need to change the common code pattern in one way or another.

erights commented 7 years ago

Good and valid point! I now agree comfortably ;)

caridy commented 7 years ago

my two cents:

  1. asking folks to freeze the handler or make individual members of the handler as non-configurable, non-writable or undefined seems like a stretch to me.

    Most JS developers don't even know what those things mean. But I knowledge that it is pretty late to fix the existing api, maybe we can just introduce Proxy.fast(obj, handler), similar to Proxy.revocable, but with different invariants, or maybe just a flag somehow to signal that different semantics (maybe a 3rd argument for new Proxy()) in way that is backward compatible to read the traps during initialization.

  2. some optimizations can be done without breaking anyone. Maybe just holding internal slots for each trap, and filling those slots when the trap is accessed the first time, and its descriptor signals that it is non-configurable non-writable. It should be easy to spec out (I can help). I think we should do that no matter what the outcome of this proposal is, and then work toward a constructor mechanism that can set those internal slots as earlier as possible could be another proposal.

ljharb commented 7 years ago

I'm going to go out on a limb and claim that any JS developer that doesn't know what those things mean isn't going to be using Proxy, which is a much more complex API than property descriptors.

erights commented 7 years ago

Agree with @ljharb that anyone who does not understand property descriptors is unlikely to directly use proxies successfully anyway. They are likely to make good use of libraries that use proxies internally in order to implement a simpler API. These library authors are the proxy users we should be concerned with.

@caridy the optimization we have in mind is similar to your (2). In comparison, your's has interesting pros and cons:

In any case, both our proposal and your alternative (2) have the feature that no new API surface is needed. Good use of descriptors can already provide implementations what they need to optimize.

Jamesernator commented 7 years ago

Something to be aware of although I'm not really sure of there's any practical use cases is that there was an article that showed that you could even use a Proxy as a handler for another Proxy.

The article is located here, hopefully no one has done this in practice, but it's likely at least someone has done it for logging reasons.

erights commented 7 years ago

Proxy as handler for another proxy is indeed an important use case. This is the double lifting technique. We carefully constrained the proxy design so this would work well.

Loirooriol commented 7 years ago

Pros: The trapping mechanism can avoid an extra runtime [[Get]] potentially being a bit faster.

I want to note that potentially it's not only "a bit faster". It can be a lot faster. Consider this case:

let p = Object.freeze(Object.create(null));
for (let i = 0; i < n; ++i) {
  p = new Proxy(p, p);
}

Then, something simple like p.foo has an awful exponential cost O(2^n). If I understood correctly, with the proposed optimizations, all the [[Get]]s on the handlers could be avoided, and thus it would be cost only O(n). Of course this may be a rare example, but the improvement is great!

bmeurer commented 7 years ago

It seems that most of this is concerned with making the Proxy constructor itself slower, which doesn't sound ideal either for many use cases. I'm also not 100% sure what kind of problem is this trying to solve: Is there a lack of trust that VMs will be able to optimize away the [[Get]] on the handler in the common case? If so, then I'd suggest to wait a bit, because we have plans to accomplish that in a non-breaking way without making proxy construction slower or having to change the spec.

ljharb commented 7 years ago

@bmeurer would your technique be applicable to all VMs, or just v8? If it's not applicable to all, then I'm not sure it addresses the problem in a generic way.

bmeurer commented 7 years ago

@ljharb I can only speak for V8. But past experience tells us that important optimizations tend to spread into other JSVMs. For example inline caching (IC) is now in every JSVM. And to optimize proxies you just need to extend the IC machinery a bit and not only check the hidden class of the proxy object it self, but also that of the handler. There are nuances, but in general I'd say this should be applicable everywhere.

erights commented 7 years ago

preserve-proxy-transparency.pdf

Attached is the slideshow I was preparing in order to present this as a proposal to tc39. It stops at the point where I realized this proposal cannot possibly work, so it is now withdrawn. The attached slideshow is for historical interest only.

The sense in which it cannot work is that a transparent membrane cannot avoid trapping on getPrototypeOf. Membranes are by far the main use-case for proxies. If we can't fix the cycle check for membranes, we may as well not bother.

erights commented 7 years ago

Why must the proxies in a membrane trap on getPrototypeOf? Say we have

In the initial conditions,

In order for these initial conditions to be a correct membrane configuration, a [[GetPrototypeOf]] on xp in these conditions must return yp. In the scenarios considered by this proposal, xp's handler would not have a getPrototypeOf trap and xp's target's [[Prototype]] would be yp.

In the first step, the current execution sets x's [[Prototype]] to z, such as by x.__proto__ = z; Remember that x, y, and z are plain non-exotic objects, so this action has only the obvious meaning.

In the next step, the code with access to d calls Reflect.getPrototypeOf(d.x) This does a [[GetPrototypeOf]] on xp.

In order to write a correct membrane, xp's handler must have a getPrototypeOf trap, giving it the opportunity to see what x's [[Prototype]] is now. Seeing that it is now z, it can then update xp's target's [[Prototype]] to be zp.

However, if a [[GetPrototypeOf]] on xp returns an answer atomically, based on the membrane's knowledge at the time (as would happen in the absence of a getPrototype trap), then [[GetPrototypeOf]] can only return yp, because the membrane never knew to look again at more recent wet state.

tvcutsem commented 7 years ago

@erights Summarizing your argument for my own understanding:

  1. This proposal's main motivation is to be able to cycle-check inheritance chains even in the presence of proxies (otherwise the presence of proxies could be "revealed")
  2. The mechanism for a proxy to participate in the cycle-check is to opt-out of virtualizing getPrototypeOf.
  3. Membrane proxies cannot opt-out because they need to virtualize getPrototypeOf to keep their prototype in-sync with that of their target.
erights commented 7 years ago

@tvcutsem Yes. Well put. One additional part of my thinking:

The main use case for proxies, and the use case for which we invented proxies and weakmaps, is membranes. Only the membrane use of proxies can approximate transparency anyway. So if proxies cannot opt-out, then as a transparency repair proposal, this proposal is rather useless.

The arguments about possible efficiency payoffs are not affected by this new observation.