Performance Implications of Isotopic-FRAME!-is-Action

Making the isotopic state of frames be "interpret this frame as an action when run through a word reference" is a deep change. The implications haven't been fully absorbed, and there are some things I noticed that create troubles for optimization.

Looking again at a simple example:

>> f: make frame! :append
== make frame! [
    series: ~
    value: ~
    part: ~
    dup: ~
    line: ~

>> f.value: 5

>> append5: isotopic f
== ~make frame! [
    series: ~
    value: 5
    part: ~
    dup: ~
    line: ~
]~  ; isotope

>> append5 [a b c]
== [a b c 5]

That's cool, for sure. And it removes the number of things we have to name in the system (I was particularly annoyed by having to differentiate "action" and "activation" when there was a separate ACTION! datatype).

But What If The Frame Is Modified...?

Say the user writes this:

>> append5 (f.series: [d e f], [a b c])
== ???

When running the specialization append5 we'd already decided that the series parameter wasn't specialized and needed to be fulfilled. But the user is specializing the series parameter during the fulfillment.

At best, this is semantically messy (and can manifest as hard-to-comprehend behavior when the example is less obvious than this one). At worst, the internal bookkeeping of the evaluator gets confused and it crashes due to having the slot it's filling changed out from under it.

We Could Snapshot The Frame... But Snapshots Aren't Free

If we are forced to make a snapshot of the frame state at the start of execution, then that means making a copy, which takes time and space.

The space isn't actually the problem...because we can just put the snapshot in the frame space we're already making for the function call.

Where we pay is that it effectively adds an extra traversal of the arguments. We're traversing the space to make the copy (with some slots unspecialized and needing to be fulfilled). Then we're traversing the space again to do the fulfillment of the unspecialized arguments.

With no snapshot, we could leave the memory for the frame cells completely garbage at the outset... and fill them as we go with either a fulfillment or a specialization. Empirically, avoiding the snapshot could save as much as 5% of the runtime.

Another Issue: No Moment To Cache Optimizations

When there was a separate "make an action! out of this frame" step, the action was a way of saying "freeze!" on the arguments, so they could no longer be changed. So it avoids the problem of changes during fulfillment.

But it did something else: it meant properties of the arguments could be remember things like "what's the first unspecialized argument slot".

(It might seem that it would be easy to find the first unspecialized argument slot. But this isn't just a search from the beginning of the frame, because it's possible to reorder arguments or have partial specializations. So it's tricky.)

How About Just Freezing The Frames If They're Executed?

The simplest idea here is just to say that once you invoke a frame, it's can't change its fields anymore. Then that freezing process can do the caching of properties that accelerate action execution, and all is well.

If you want to keep a frame mutable, then COPY it before making it isotopic. The issue is just that changes to the frame won't be seen by the isotope you made.

Do Technicalities Like This Make Me Question FRAME!-as-Action?

Well, it's a delicate balance of choices, and I'm still feeling it out.

The worry is that the number of concerns being exposed to users is such that the properties of "the kind frame you can run is different enough that you basically have to think of it as a different type".

To avoid that in this case, it's better to do the freezing implicitly vs. saying that you need a special routine like RUNS to bless a frame as runnable. This way, RUNS can be understood as simply taking in a plain frame! and giving back an isotopic frame!... something you could easily write yourself.

Crafting a uniformity of experience with FRAME! so it doesn't feel like it's making up for a missing ACTION! type is certainly something to continue to be mindful of. But performance needs to be minded too at some point, so I'm doing what I can about that.

1 Like

Another Performance Wrinkle: Looking In Two Places

During the argument gathering process, when the evaluator is visiting a slot it needs to have one of two things on hand:

  • the parameter specification (so it can know if it's a refinement or quoted or whatever while it gathers it)
  • the specialized value

With the idea that FRAME!s are just automatically transmuted into actions by virtue of being isotopic, we are saying that the specialization comes from the frame's cell for that slot if it's not "trash" (~). But if it is trash, we have to look somewhere else for the parameter information (namely the "parent" of the frame--whatever frame this was copied or instantiated from).

Having to look in two different places adds a not-insignificant cost. It means traversing two lists instead of one (incrementing two unrelated pointers), and is also a locality issue from not being able to just work on one location.

That caused me to wonder if the moment I describe of a Force_Frame_Runnable() that locks the frame and caches optimizations could sneakily write in the parameter information over any unset cells. This would be done only once, and execution could be faster.

But this would weave a tangled web. It would mean that an actual specialized frame would wind up looking like this after an execution:

>> f
== make frame! [
    series: ~#[parameter! [types: [any-series?] refinement: ~false~ ...]]~
    value: 5
    part: ~#[parameter! [types: [integer! any-series?] refinement: ~true~ ...]]~
    dup: ~#[parameter! [types: [integer!] refinement: ~true~ ...]]~
    line: ~#[parameter! [types: ~null~  refinement: ~true~ ...]]~

(If you're wondering what a PARAMETER! is, it's an internal type which I've been inching toward exposing as a user-visible type for manipulating parameters. Basically a compressed object--something like R3-Alpha's EVENT!--which only has very specific fields pertinent to parameter fulfillment.)

Hence I've been wracking my brain trying to figure out how to preserve the illusion of these fields being unset, when they've actually got a parameter specification in them.

It's a difficult illusion to implement, and trying to do so starts bending the codebase pretty far from the goal of simplicity, just to accomplish some more performance.

WHAT IF... We Embrace PARAMETER!-as-Unspecialized?

This way the "optimization" of using an unspecialized parameter slot to hold a parameter definition becomes the actual way in which parameters can be specified and reflected.

You could build a function from scratch by making a FRAME! with these isotopic parameters in it, and then ADAPT'ing some code into it.

>> f: make frame! [x: isotopic make parameter! [types: [integer!]]
== make frame! [
    x: ~#[parameter! [types: [integer!] refinement: ~false~ ...]]~

>> test: adapt f [print ["x is" x]]
== ~#[frame! ...]~

>> test 10
x is 10

This is starting to look very much like my prophetic post: "Seeing all ACTION!s as Variadic FRAME!-makers". The idea gives the reflection we'd want of being able to extract the parameter information, as well as to be able to modify it. The help string could actually go inside the parameter as well, which would solve some bloat that we experience due to putting this in a copied state elsewhere.

This would also mean that you could specialize parameters to be trash, as an isotopic parameter would be the only kind of value you couldn't specialize to. The term for an isotopic parameter! could be "unspecialized?", so above unspecialized? f.series would return true.

What About Downsides?

Obviously the rendering is hideous, and I lament losing the elegance of the ~ that trash had. The web console has much more leeway than a pure text one (for this and other rendering difficulties), like being able to show a collapsed form you can expand for more details.

We could try workarounds, like simply abbreviating when things are inside the frame, and then blow them up when they were extracted:

>> f: make frame! [x: isotopic make parameter! [types: [integer!]]
== make frame! [
    x: ~#[parameter! ...]~

>> f.x
== ~#[parameter! [
    types: [integer!]
    refinement: false
    description: ~null~
]]~  ; isotope

You wouldn't be able to use DEFAULT to act on unspecialized fields, like f.x: default [...], unless DEFAULT was rethought to consider unspecialized states as one of the things it considers "non-valued".

Once you overwrote an unspecialized slot, there wouldn't be an easy way to undo it. Not necessarily important... just mentioning that it's different from today, where you can put a value in a slot and then change your mind by just overwriting it with trash again.

Using an unspecialized frame state wouldn't cause an error on access (though we'd presume there'd be relatively few places you could pass an isotopic parameter as an argument, especially since they're not legal in parameter fulfillments since they represent the unspecialized state).

Still Only Partial Exposure of FRAME! Magic

The code that runs from a frame still lurks behind the scenes. When you say adapt frame [...] to put code into it, you get back a FRAME! that looks identical to the one you put in--at least as far as the parameters and specializations are concerned.

This is kind of par for the course. In JavaScript, digging into what it gives back for a function doesn't really give a body when you drill down...just the prototype:


And of course, native code won't show a body (though we conceivably could expose the C code as a string, at the cost of having to ship that C code in the executable and take up space).

It's good to look at something like JavaScript just to get a reminder that nothing is perfect, and at least Ren-C's model is packing some serious functionality.

1 Like