Skip to content

Splice a tail-position application of a function literal into the enclosing body #295

Description

@Unisay

Problem

#293 and #294 eliminated the scope-call closures, but the trailing-call fold stops one step short: when the scope call's tail returned a function literal, the pushed application lands on that literal and stays there. From Golden.LongApplyChain.Test on current main, applySecond = lift2 (const identity) compiles to:

local Golden_LongApplyChain_Test_applySecond_S_w = function(a_S_133, b_S_134)
  local v_S_625 = (function()
    ...
  end)()
  return (function(v1_S_626)
    if "Data.Maybe∷Maybe.Just" == v_S_625[1] then
      ...
    end
  end)(b_S_134)
end

The (function(v1_S_626) … end)(b_S_134) tail is a beta-redex in Lua clothing: one closure allocation and one extra call per invocation — per chain node in the 40-deep apply chain of that golden. The same shape sits in Golden.BugListGenericEq.Test's eq (per list-element comparison), in Golden.StringCodePoints.Test's unsafeCodePointAt0 ordering scope ((function(y_S_1601) … end)(1)), and in Golden.Issue37.Test / Golden.ArrayOfUnits.Test.

Why the pipeline cannot fix this today

collapseTailScopeCall (#230) pattern-matches a nullary callee — FunctionCall (Function [] …) [] — because splicing a parameterized call needs a binding for the parameters, not just a statement splice. foldCallThroughScopeCall (#294) is what builds these shapes: it moves the application inward onto the returned literal, but it does not reduce it. And the IR's own beta reduction cannot see them — these literals only materialize during lowering and Lua-level rewriting.

Approach

Generalize collapseTailScopeCall: when the last statement of a function body is return (function(p1, …, pn) <stmts> end)(a1, …, an) and every parameter is named, splice as a single simultaneous binding followed by the body:

local p1, …, pn = a1, …, an
<stmts>

The one-statement local is the exact translation of Lua's call binding: the whole right-hand list is evaluated before any name binds (so an argument reading an outer x can never see a parameter named x), a multi-valued expression last in the list expands, missing values fill with nil, and extra values are evaluated then discarded — the same adjustment rules a call performs. And unlike #294's fold, the arguments do not move across any scope boundary: the call site already was the parent's tail return, so they are evaluated in the same environment at the same program point. The tail-position safety argument from #230 carries over verbatim for <stmts>.

Conditions on top of the existing rule:

  • every parameter is ParamNamed: a ParamVararg cannot be bound by a local, and ParamUnused has no name to bind (IR-derived literals always name their parameters, so declining costs nothing);
  • zero parameters with nonempty arguments declines (local = a1 does not exist); zero parameters with zero arguments is exactly the current rule, so the natural implementation is a generalization where the binding statement degenerates to nothing;
  • the local budget from Collapse a tail-position IIFE into the enclosing function body #230 additionally counts the n parameter locals;
  • the vararg check on <stmts> and the self-re-application after the merge are inherited unchanged.

A duplicate parameter name (function(x, x)) binds the last occurrence in Lua, and local x, x = a, b reproduces exactly that; still, declining on duplicates is one ordNub away and removes the need to reason about it.

The alias local this leaves behind (local v1_S_626 = b_S_134 above) is an accepted cost: one register slot inside the budget, no allocation, no call. Cleaning single-use aliases is a separate (IR- or Lua-level copy-propagation) concern.

Prerequisites / Relations

Builds directly on #230 (PR #293) and PR #294 — the fold is what exposes the redex in tail position. Independent of the magic-do redesign tracked in #228. Non-tail occurrences (a literal application in a local initializer, e.g. the v_S_625 header above) are out of scope: splicing those needs return-to-assignment rewriting, a different transform.

Verification / Measurement

applySecond_S_w becomes straight-line (zero closures per invocation):

local Golden_LongApplyChain_Test_applySecond_S_w = function(a_S_133, b_S_134)
  local v_S_625 = (function()
    ...
  end)()
  local v1_S_626 = b_S_134
  if "Data.Maybe∷Maybe.Just" == v_S_625[1] then
    ...
  end
end

The splice is visible in the LongApplyChain, BugListGenericEq, StringCodePoints, Issue37 and ArrayOfUnits goldens; eval goldens unchanged. The bench/ci FNEW census should drop wherever the shape sits in a function body, with the corresponding trace-abort sites disappearing from the trace reports.

Metadata

Metadata

Assignees

Labels

OptimisationA Compiler Optimisationarea: codegenLua code generation / printingenhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions