Performance Implications of Antiform-FRAME!-is-Action

Making the antiform state of frames be "interpret this frame as an action when referenced through a WORD!" 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: anti f
== ~make frame! [
    series: ~
    value: 5
    part: ~
    dup: ~
    line: ~
]~  ; anti

>> 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 average total runtime of the interpreter.

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 studied...to 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 frozen...you 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 an antiform. 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)
    -or-
  • the specialized value

With the idea that FRAME!s are just automatically transmuted into actions by virtue of being antiform, we are saying that the specialization comes from the frame's cell for that slot if it's not "nothing" (~ antiform). But if it is nothing, 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 antiform parameters in it, and then ADAPT'ing some code into it.

>> f: make frame! [x: anti make parameter! [integer!]]
== make frame! [
    x: ~#[parameter! [integer!]]~  ; anti
]

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

>> 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 the unset state 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! [integer!]
== make frame! [
    x: ~#[parameter! ...]~
]

>> f.x
== ~#[parameter! [integer!]]  ; anti

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 nothing 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 antiform 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:

image

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

So I think the answer here is just to give users the choice. If you want more efficiency, instead of just ANTI-ing the frame, you can freeze it as well: :cold_face:

append5: anti freeze f

(A little unfortunate that reads as if it's negating the freezing. I guess we could have a longer word for antiformify or antiformize if you wanted to be clearer. I wouldn't want to call it ANTIFORM as that is a good noun for a variable holding an antiform.)

Since a frozen frame can be reliably trusted to be used as reference for the parameter typechecking from start to finish, there's no need to make a copy to start with. But a non-frozen frame has to be copied or the system must do so automatically.

We could say that RUNS will freeze a frame that isn't already frozen by default.

runs f
=> 
anti freeze ensure frame! f

If people are upset that their frame can't be modified after they use RUNS, then they can choose between either runs copy f, or anti f... each of which offers more flexibility for some cost (either memory cost once for the copy, or on every call with the anti).

More General Note On Using Up Frames vs. Copying

It's a fairly large efficiency point that when you do something like an ENCLOSE that when you EVAL the FRAME! you receive, it uses that for the function call. So EVAL FRAME will effectively destroy it from the point of view of the person who calls EVAL. I've figured it is easy enough to say eval copy frame if you intend to use the frame multiple times.

I think this should extend to RUN, which is variadic (EVAL will not gather unspecialized parameters from the callsite, but rather consider them to be null).

>> f: make frame! :append

>> f.value: [d e]

>> run f [a b c]
== [a b c [d e]]

So in my thinking here, passing a frame to EVAL or RUN means it should be consumed. But presumably not antiform frames, which we could assume are intended for multiple executions.

It gets a bit fuzzier in other cases which accept FRAME! but know you don't intend to treat it disposably...such as passing it as a predicate. However they ran the frame, it would have to make a copy...ideally threaded in with the execution. We could restrict it to say such arguments insist you pass them frozen frames, in order to guide you toward efficient choices. :thinking:

Residue of When FRAME!s "had-an" ACTION!

The original "actions are antiform frames" change still had under the hood two different formats for what underlied a FRAME!. Each had a different cell layout...one inherited from legacy action, and the other from frame.

We're still going to need the two layouts for purposes of optimization. When you ADAPT some code onto an existing frame, you're creating a new function identity...but you're reusing the other frame's argument definitions and typechecking, adding nothing. So it makes sense for the action's new implementation array (called a "Details") to serve as the identity for that action. But when you make a specialization you are reusing the implementation while providing arguments, meaning the variables are the unique thing (called a Varlist).

If we standardized on all FRAME! cells pointing at a Varlist, it would be consistent and make the implementation easier. But it would be wasteful. I'd like it if the implementation could completely hide the optimization, so that to the user's eyes it seems like Details-based frames are equivalent to frozen Varlist frames.

I think I can do this. One place where we lose a bit would be if you just want to copy an action's identity for the purposes of avoiding it being able to be hijacked...and that lightweight copying mechanic was all that COPY of an action meant. But now, if COPY on a frame insists on giving you something where the fields can be edited, then we can't use that. we could have a fused FROZEN-COPY operation that would notice it could make an optimization? :-/ I can try that for now.

I've written up some of the issues that have arisen in another thread.

But I wanted to mention something else that's emerged:

Antiform PARAMETER! Being Ornery/"unset" is A Headache

One thing is a good idea: that's the premise of having FRAME! cells with antiform parameters in them be the representation of an unfulfilled parameter (or "Hole").

This means a function's interface can be expressed without having a special hidden "bit" to say what's a parameter description vs. a specialized parameter value ("locals" historically were specializations to nothing, but now you can use any value that isn't a parameter antiform). The antiform parameter state becomes the "I'm an unfulfilled parameter" bit, which is already a sunk cost in the system for how to deal with it.

It does mean that if you want to actually take an antiform parameter as an argument, it has to be a ^META parameter...even though it's stable. But that's not such a problem.

What is a headache is having antiform parameters be just as hard to handle as nothings/tripwires.

When you look at what I mention in the other thread about how you can't really "re-skin" actions anyway (at least with trivial mechanics), I think this suggests that whatever the "MAKE FRAME!" operator is needs to go back to filling the unfulfilled arguments with antiform blank. Antiform parameters need to be friendly when reflecting specs.

The optimization/correctness issues simply need to be addressed some other way.

It ultimately turned out that this was a problem, and a problem that is resolved now...by pushing back to where NOTHING (antiform blank, e.g. ~ antiform) is the only stable form that requires you to use ^META conventions.

I followed through the implications of this, to really get all the ducks in a row.

The results are converging on something quite coherent and useful:

>> append/
== ~#[frame! "append" [series value part dup line]]]~  ; anti

>> words of append/
== [series value part dup line]

>> append.dup
== ~#[parameter! :[any-number? pair!]]~  ; anti

>> append.dup.text
== "Duplicates the append a specified number of times"

>> append.dup.optional  ; seems better than ".is-refinement"
== ~okay~  ; anti

>> append.dup.spec  ; would ".types" be better?  :-/
== [any-number? pair!]

Here we see the real power coming from the Ren-C concepts. We're exploiting the duality of FRAME! and action, where the antiform of a frame is the action. And then further, the idea that there's a separate dot operation for tuple picking...we can keep that meaning so that it still works on the antiform frame, to select out the antiform parameters.

And MAKE FRAME! goes back to unsetting slots:

>> make frame! append/
== #[frame! [
    series: ~
    value: ~
    part: ~
    dup: ~
    line: ~
]]

While COPY UNRUN can be used to get a FRAME! that lets you tinker with the interface:

>> copy unrun append/  ; UNRUN is just UNQUASI META for antiform frames
== #[frame! [
    series: ~#[parameter! [
        ~void~ any-series? port! map! object! module! bitset!]
    ]~
    value: ~#[parameter! [~void~ element? splice?]]~
    part: ~#[parameter! :[any-number? any-series? pair!]]~
    dup: ~#[parameter! :[any-number? pair!]]~
    line: ~#[parameter! :[]]~
]]

This stuff may look obvious or clear in retrospect (well it should look clear and obvious, though you'd probably have to use it to really have it sink in.) But it's quite hard to reason about and adjust in a system that has to keep running.

1 Like