← Contents

Chapter 14

Calls and Parameter Binding

Calling something is the most frequent non-trivial act in a Raku program, and in a tree-walker it is also one of the most expensive: the floor for a trivial call was around forty-six nanoseconds when this chapter was first measured, and only about a quarter of that was dispatch. Where the other three quarters went is what shaped the optimizer of Chapter 27 — and, later, what got most of the same win back inside the interpreter. Both are easier to see once the mechanism is laid out.

A call happens in three phases: build the argument list, activate the callee, bind the parameters. Each has a fast path, and each fast path exists because something measurable was in the way.

Building the argument list

evalArgs evaluates the argument expressions into one flat ValueList:

// src/Interpreter.cpp — evalArgs, abridged
} else if (a->kind == NK::Unary && ((Unary*)a.get())->op == "|") {
    Value v = eval(…);                                  // a Slip: |@a / |%h
    if (v.t == VT::Array || v.t == VT::Range)
        for (auto& x : v.flatten()) args.push_back(x);
    else if (v.t == VT::Hash && v.hash)
        for (auto& kv : *v.hash) {                      // |%h → named args
            Value p = Value::pair(kv.first, kv.second);
            p.namedArg = true; args.push_back(p);
        }
} else {
    Value v = eval(a.get());
    if (v.t == VT::Pair && a->kind == NK::Pair &&
        !((PairExpr*)a.get())->quotedKey && ident)
        v.namedArg = true;
    args.push_back(std::move(v));
}

Two things to notice.

An argument list is just a ValueList. Named arguments are ordinary Pair values carrying a namedArg flag; the positional/named split happens later, at bind time. That is why spreading (|@a), slurping (*@rest) and flattening are all list operations rather than a separate protocol.

And that list is the most frequent one in the tree, which is why it stopped being a std::vector. One is built, filled, passed and destroyed on every interpreted call, and for the one- or two-element shape almost the whole of its cost was the heap block: 32 nanoseconds against a call that takes about 265. ValueList now takes small blocks off a thread-local free list — allocation is a pop, release is a push — and that shape costs 9.5 ns. See The list container in Chapter 12 for the container itself, including the reason the free list is kept per exact capacity rather than rounding every small request up to one size class: an argument list and a million-element array want opposite things from that policy, and they are the same type.

*Only a syntactic pair is a named argument. f(k => 1) and f(:k(1)) pass a named argument; f($pair) and f(3 => 4) pass a positional one, even though all four produce a Pair. The test is on the expression* kind, and it also excludes a quoted key — f('a' => 1) is positional, per Raku's rule. Getting this wrong makes every module that passes pairs around behave subtly differently, so the check is deliberately narrow.

Activating the callee

// src/Interpreter.h
Value callCallable(const Value& codeVal, ValueList args,
                   const std::vector<ExprPtr>* rwArgs = nullptr,
                   bool ownFrame = false, bool arityCheck = false);
Value callCallableRaw(const Value& codeVal, ValueList args,
                      const std::vector<ExprPtr>* rwArgs,
                      bool ownFrame = false, bool arityCheck = false);

callCallable is a thin wrapper layer: if the routine has been &r.wrap({…})'d, it runs the wrapper stack, each level able to callsame into the next; otherwise it passes straight through:

// src/Interpreter.cpp — callCallable
if (codeVal.code && !codeVal.code->wrappers.empty()) { /* run the stack */ }
return callCallableRaw(codeVal, std::move(args), rwArgs);

callCallableRaw handles the special callables first — a native FFI sub, a Format, junction autothreading, a multi-dispatcher, a builtin — and then activates a user routine:

PooledFrame frame;                                     // a per-call frame, reused
auto& env = frame.env;
c.state.env->parent = c.closure ? c.closure : global_; // once
env->parent = c.state.env;        // frame → state env → closure → … → global
tctx_.dynStack.push_back(caller_scope);                // the OTHER chain

The frame is pooled, not freshly allocated, and the reason is one of the sharper measurements in this book. A call frame is a shared_ptr<Env>: a control block, an Env, and the hash buckets inside it, allocated and destroyed per call. A thread-local pool of up to thirty-two hands one back instead, clearing the map but keeping its bucket array — the same trick the pad vector plays with its capacity. A frame that anything captured (a closure, an rwLink, a state chain) fails a use_count test on release and is simply dropped rather than reused, which is what makes the reuse safe.

For a while only callCallableRaw had it, and invokeMethod did not. That asymmetry alone was the difference between a sub call at about twice Rakudo's cost and a method call at 5.8 times it.

Two facts are established here and everything else depends on them.

The callee's parent is its lexical closure, not its caller. Free variables resolve through the scope the routine was written in. The caller's scope goes on dynStack, used only for $*foo.

Each activation bumps frameTop, and a routine — as opposed to a bare block — records its own frame number as curRoutineFrame. Those two counters drive the cooperative control flow in the next chapter.

The call registers

A handful of one-shot parameters are passed to the next activation without widening every signature:

// src/Interpreter.h
static thread_local Value* topicWriteback_;
static thread_local Value* builtinTopicWB_;
static thread_local bool noAutothread_;
static thread_local int loopPhaserCtl_;
static thread_local const std::vector<Value*>* pendingRwSlots_;

Each is set immediately before a call and consumed at the top of callCallableRaw. topicWriteback_ is how @a.grep({ $_++; True }) writes into @a's element: the driver points it at the element, and the mutated implicit $_ is copied back after the call.

They are static thread_local and the comment in the header explains why in one sentence: as plain members they were written by every call on every thread, and they were ThreadSanitizer's top report — 2,761 lines on a program with no sharing in it at all. The set-then-consume window is contiguous within one thread, so thread-local is not a workaround, it is exactly their semantics.

Binding parameters

bindParams maps the argument list onto the signature. There is a fast path and a general path.

// src/Interpreter.cpp — bindParams
if (simple) {                       // all plain positional $ params, no nameds
    for (size_t i = 0; i < params.size(); i++) {
        Value v = i < args.size() ? args[i]
                                  : typedDefault(params[i].type, '$');
        v.readonly = true;
        env->define(params[i].name, std::move(v));
    }
    return;
}
for (auto& a : args)                // general: split named from positional
    if (isNamedArg(a)) named[a.s] = a.pairVal ? *a.pairVal : Value::any();
    else positional.push_back(a);

Whether a signature qualifies for the fast path is a static property of the signature, so it is decided once and stored on the first parameter:

// src/Ast.h — Param
mutable DecidedOnce<signed char> sigSimple{-1};   // only element [0] is read

The general path covers everything else:

What is not checked here

Type constraints, where clauses and :D/:U smileys are not enforced for an ordinary, non-multi call. Single dispatch is largely duck-typed at the bind boundary. Those checks live in scoreCandidate, which is multi dispatch's business (Chapter 16).

There is one exception, typeCheckBind, used when a lone candidate is being bound and a mismatch should raise X::TypeCheck::Binding. It caches its verdict on the parameter — but only once true:

// src/Ast.h — Param
mutable DecidedOnce<signed char> typeKnown{0};

The asymmetry is important and the header says why: a type name that is unresolvable now can become resolvable later, when a class further down the file is declared or a module is loaded. Caching the positive answer is safe; caching the negative one is a bug that would only show up in a program whose declarations are ordered a particular way.

The Callable, and what it caches

// src/Value.h — Callable, abridged
std::string pkg, name;
const std::vector<Param>* params;      // borrowed from the AST
const std::vector<StmtPtr>* body;      // borrowed from the AST
std::shared_ptr<Env> closure;
StateSlot state;                       // { shared_ptr<Env> env; once_flag init; }
BuiltinFn builtin;                     // set ⇒ this is a builtin
ValueList candidates;                  // multi-dispatch candidates
ValueList wrappers;                    // .wrap stack, outermost last
PublishedOnce<signed char> hoistNeed{-1};
PublishedOnce<signed char> arityShape{-1};
int arityMaxPos = 0, arityReqPos = 0; bool arityUnbounded = false;
PublishedOnce<signed char> catchScan{-1};
Stmt* catchBlkCache = nullptr;

The state env and its once_flag are wrapped in a StateSlot for a reason worth knowing: std::once_flag is not copyable, so a bare pair of members would make Callable uncopyable. The holder's copy constructor is empty, which also gets the semantics right — a cloned routine starts with fresh state slots rather than sharing the origin's, as it does in Rakudo.

The four published-once fields are all static properties of the routine's AST that callCallableRaw used to recompute on every call: whether anything needs hoisting, whether an arity pre-check applies and what its bounds are, and whether the body contains an inline CATCH block. Each is cheap once and worthless repeated — a parse that calls a routine 73,603 times paid for them 73,603 times.

state.env is created exactly once, under that once_flag, and chained between the per-call frame and the closure. That is the whole implementation of state: the variable lives in a scope that is created once per routine rather than once per call.

is rw write-back

Because arguments are Value copies, a mutated is rw parameter must be written back. The call site passes the argument expressions alongside the values, and on a normal return each such parameter's final value is written back by re-resolving its expression:

// src/Interpreter.cpp — copyOutRw
if ((p.isRw || p.sigil == '\\') && pi < rwArgs->size())
    if (Value* lv = lvalue((*rwArgs)[pi].get()))
        *lv = env->vars[p.name];

setupRwLinks additionally arranges for an assignment inside the callee to push through immediately, so the caller sees the change mid-call. The bookkeeping that keeps those two mechanisms from fighting is described in Chapter 12.

The whole thing is guarded by a sticky flag, anyRwLinks_, so a program that never uses is rw never runs the per-assignment hook at all.

Lvalue-mode method calls

$obj[$i] = $v on a class whose AT-POS is return-rw @!arr[$i] must write the real element, not a returned copy. That needs a channel from the subscript site into the routine's return-rw:

// src/Interpreter.h — ExecContext
int wantLvalue = 0;      // 0 off, else the callFrames depth being served
Value* lvalueOut = nullptr;

The subscript-lvalue path sets wantLvalue to the current frame depth plus one before invoking AT-POS; a return-rw executing at exactly that depth fills lvalueOut with the address of its operand. The pointer survives the frame because its target lives in the object's shared containers.

Matching on depth rather than on a plain flag is what keeps an inner call from accidentally answering an outer call's request.

What a call costs

Measured against the runtime library, 2 million iterations, minimum of six:

Shapens/call
direct C++ call, user-sub shape46.3
cached BuiltinFn* call47.4
by-name lookup: hash, unordered_map::find, std::function55.0

The lookup tax is real but modest at 8 to 9 nanoseconds. The important number is the floor: about 46 nanoseconds for a trivial call, nearly all of it the ValueList — at the time, a heap-allocating std::vector built per call.

Dispatch was a quarter of the overhead. The argument list was the rest. That finding shaped two different pieces of work, years apart in the book's ordering and months apart in fact.

The first is the optimiser of Chapter 27: its opening pass gives fixed-arity subs direct Value parameters and removes the list entirely, which buys more than any lookup cache can. That only ever helped compiled code.

The second is the free list above, which attacked the same allocation from inside the interpreter and took most of it: the one-argument shape from 32.35 to 9.48 ns, fib 8.8% fewer instructions, a two-argument call loop 13%. The lesson is not that the optimiser was unnecessary — it removes the list rather than making it cheap, and it is still the larger win where it applies. It is that a cost identified once can be worth attacking twice, from different sides, and that "the allocation is the cost" survived being true for long enough to be acted on in two entirely different places.