leanprover / leanprover/lean4

codegen: ref-count instruction destroying tail recursion

Open
#11,795 3 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

bug code-generator P-medium
Dominant language
Lean
Stars
9.2k
Forks
990
Avg merge
1d 17h
Merged PRs (30d)
175

Description

Prerequisites
Description
import Lean
import Std

open Lean Meta

@[inline] def forInNewArray {α : Type u} {σ β : Type v} {m : Type v → Type w} [Inhabited α]
    (as : Array α) (s : σ) (kcons : (a : α) → (σ → m β) → σ → m β) (knil : σ → m β) : m β :=
  let sz := as.size
  let rec @[specialize] loop (i : Nat) (s : σ) : m β :=
    if i < sz then
      let a := as[i]!
      kcons a (loop (i+1)) s
    else
      knil s
  loop 0 s

@[specialize] def forInNewAssoc {m : Type v → Type w} (as : Std.DHashMap.Internal.AssocList α β) (init : σ) (kcons : (a : α) → β a → (σ → m δ) → σ → m δ) (knil : σ → m δ) : m δ :=
  match as with
  | .nil => knil init
  | .cons k v t => kcons k v (forInNewAssoc t · kcons knil) init

set_option trace.Compiler.saveBase true in
def size : MetaM Nat := do
  let env ← getEnv
  forInNewArray env.constants.map₁.inner.inner.buckets ()
      (fun b kcontinue _ =>
        forInNewAssoc b ()
          (fun _ _ kcontinue _ => do
            pure ()
            kcontinue ())
          fun _ => kcontinue ())
      fun _ => pure 0

run_meta size
Context

This is code generated for nested for loops by the new do elaborator.

Steps to Reproduce

Run the program.

Expected behavior: run_meta size terminates successfully

Actual behavior: stack overflow

Observe the output of size after specialization.

Expected behavior: No redundant code

Actual behavior: forInNewAssoc._at_.size.spec_0 is redundant and forInNewAssoc._at_.size.spec_0._at_.forInNewArray.loop._at_.size.spec_1.spec_1 is just peeling off one layer of the loop forInNewAssoc._at_.forInNewAssoc._at_.size.spec_0._at_.forInNewArray.loop._at_.size.spec_1.spec_1.spec_2. As far as I can tell, every call to the former could be replaced by a call to the latter.

Versions

Lean 4.28.0-nightly-2025-12-24
Target: x86_64-unknown-linux-gnu

Additional Information

I did a bit of diagnosis. Note that the LCNF output has all recursive calls in tail position, so I would tail call elimination to trigger. Alas, it doesn't. Here's the last few items of the stack trace:

#1 forInNewArray.loop._at_.size.spec_1._redArg
#2 forInNewAssoc._at_.forInNewAssoc._at_.size.spec_0._at_.forInNewArray.loop._at_.size.spec_1.spec_1.spec_2._redArg
#3 forInNewAssoc._at_.size.spec_0._at_.forInNewArray.loop._at_.size.spec_1.spec_1._redArg
#4 forInNewArray.loop._at_.size.spec_1._redArg
#5 forInNewAssoc._at_.size.spec_0._at_.forInNewArray.loop._at_.size.spec_1.spec_1._redArg
#6 forInNewArray.loop._at_.size.spec_1._redArg
#7 forInNewAssoc._at_.size.spec_0._at_.forInNewArray.loop._at_.size.spec_1.spec_1._redArg
#8 forInNewArray.loop._at_.size.spec_1._redArg
#9 forInNewAssoc._at_.size.spec_0._at_.forInNewArray.loop._at_.size.spec_1.spec_1._redArg
#10 forInNewArray.loop._at_.size.spec_1._redArg
#11 forInNewAssoc._at_.size.spec_0._at_.forInNewArray.loop._at_.size.spec_1.spec_1._redArg
#12 forInNewArray.loop._at_.size.spec_1._redArg
#13 forInNewAssoc._at_.forInNewAssoc._at_.size.spec_0._at_.forInNewArray.loop._at_.size.spec_1.spec_1.spec_2._redArg

One reason that the calls aren't eliminated is the supposed tail call in forInNewArray.loop._at_.size.spec_1._redArg:

        let x_16 : tobj := Nat.add x_5 x_15;
        let x_17 : obj := forInNewAssoc._at_.size.spec_0._at_.forInNewArray.loop._at_.size.spec_1.spec_1._redArg x_1 x_2 x_3 x_4 x_16 x_1 x_14 x_6 x_7 x_8 x_9 ◾;
        dec x_16;
        ret x_17

Why is there a dec x_16? x_16 is passed as the 5th arg to forInNewAssoc._at_.size.spec_0._at_.forInNewArray.loop._at_.size.spec_1.spec_1._redArg, and that function takes its 5th arg by borrow. This borrow has been introduced by the borrow pass. Ouch!! It appears that borrow inference may destroy tail calls. I don't think it should do that!

Impact

Add 👍 to issues you consider important. If others are impacted by this issue, please ask them to add 👍 to it.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by running the minimal reproducer and inspecting the specialized output and stack trace shown in the issue. Trace the borrow pass around the generated dec/ret sequence in forInNewArray.loop and its forInNewAssoc caller; done means the redundant wrapper is removed or tail calls are preserved, so run_meta size terminates without stack overflow.

Written by the indexing model from the issue text.

Assessment

Domain
compilers
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.