4

Consider the recursive subroutine append_until_exhausted. The recursion occurs in the middle of the body. I want to place it at the end for further processing, that is to say a simple tail call (without any optimisation, which in Perl typically involves a goto). You can change anything but the signature of the subroutine and the two helper subroutines.

The algorithms involving numerics look stupid because are a condensation/obfuscation of my real code, but the code execution path/structure of subroutine calls is unchanged.

use 5.032;
use strictures;
use experimental qw(signatures);

# Returns mostly one value, sometimes multiple,
# and an occasional end condition which will cause
# the recursion to end because then the for loop will
# iterate over an empty list.
# This sub is also called from elsewhere,
# do not change, do not inline.
sub some_complicated_computation($foo) { # → ArrayRef[$foo]
    return [] if $foo > 45;
    return $foo % 5
        ? [$foo + 1]
        : [$foo + 2, $foo + 3];
}

# do not inline
sub make_key($foo) { # → Str
    chr(64 + $foo / 5)
}

sub append_until_exhausted($foo, $appendix) { # → HashRef[ArrayRef[$foo]]
    my $computed = some_complicated_computation($foo);
    for my $new_foo ($computed->@*) {
        {
            push $appendix->{make_key $new_foo}->@*, $new_foo;
        }
        __SUB__->($new_foo, $appendix);
    }
    return $appendix;
}

my $new_appendix = append_until_exhausted(
    7, # start value for foo
    { dummy => [], dummy2 => [], dummy3 => [], }
);

The goal here is for me to understand the principle so I can apply it in similar situations and in similar languages. It does not help if you suggest some {Sub::*, B::*, XS} magic.

daxim
  • 39,270
  • 4
  • 65
  • 132

2 Answers2

3

Since your recursive call is within a loop, you can't make your function tail-recursive. Well, when some_expensive_computation returns 0 or 1 elements, you can, but as soon as it returns two, it's over.

I'd suggest using a stack instead. Basically, change your sub append_until_exhausted to:

sub append_until_exhausted_stack($init_foo, $appendix) { # → HashRef[ArrayRef[$foo]]
    my @stack = ($init_foo);
    while (@stack) {
        my $foo = pop @stack;
        my $computed = some_complicated_computation($foo);
        for my $new_foo (@$computed) {
            push @{$appendix->{make_key $new_foo}}, $new_foo;
        }
        push @stack, @$computed;
    }
    return $appendix;
}

Small caveat: it does not perform the work in the same order as your original function. If that matters to you, then see Ikegami's answer.

I've quickly benchmarked it, and it appears to be a bit less than 10% faster than the recursive implementation, so not that much. Bencmarking code below:

sub append_until_exhausted($foo, $appendix) { # → HashRef[ArrayRef[$foo]]
    my $computed = some_complicated_computation($foo);
    for my $new_foo (@$computed) {
        {
            push @{$appendix->{make_key $new_foo}}, $new_foo;
        }
        __SUB__->($new_foo, $appendix);
    }
    return $appendix;
}


sub append_until_exhausted_stack($init_foo, $appendix) { # → HashRef[ArrayRef[$foo]]
    my @stack = ($init_foo);
    while (@stack) {
        my $foo = pop @stack;
        my $computed = some_complicated_computation($foo);
        for my $new_foo (@$computed) {
            push @{$appendix->{make_key $new_foo}}, $new_foo;
        }
        push @stack, @$computed;
    }
    return $appendix;
}

use Benchmark qw(:all);

cmpthese(2000, {
         'Recursive' => sub {
             append_until_exhausted(7, { dummy => [], dummy2 => [], dummy3 => [] })},
         'Stack'   => sub {
             append_until_exhausted_stack(7, { dummy => [], dummy2 => [], dummy3 => [] })},
         });

Which yields the following results:

            Rate Recursive     Stack
Recursive 1384/s        --       -8%
Stack     1505/s        9%        --

I've tried optimizing it a bit by adding special cases to avoid pushing something on the stack and removing it right away but it barely impacts the performance (for instance, doing $foo = $computed->[0]; redo when @$computed == 1). Might be worth trying with your actual code though.

Dada
  • 6,313
  • 7
  • 24
  • 43
  • Your so-called `@stack` is a queue. (A stack would be push-pop instead of push-shift). It also does things in a different order than the original, so the result isn't going to be the same. (And simply switching to a stack by replacing `shift` with `pop` isn't going to do the trick either.) See my answer for a correct solution. – ikegami Nov 13 '20 at 20:08
  • @ikegami Yup, I definitely wanted to use `pop` rather than `shift`. And indeed things are not done in correct order, I forgot to add a warning about that as well. Thanks – Dada Nov 13 '20 at 22:17
3

Let's start with a simple example.

sub fact($n) {
   return 1 if $n == 0;
   return $n * fact($n-1);
}

To make something tail-recursive, you need to pass the information needed to perform the tail operation along with the call.

sub _fact($n, $acc) {
   return $acc if $n == 0;
   return _fact($n-1, $n * $acc);
}

sub fact($n) {
   return _fact($n, 1);
}

This particular solution relies on the fact that multiplication is commutative. (We replaced 1*2*3*4 with 1*4*3*2.) So we still need a generic approach.


A generic approach would involve passing the tail as a callback. This means that

if (TERMINAL_COND())
   return TERMINAL_VALUE();
} else {
   return TAIL(recursive(HEAD()))
}

becomes

# Extra argument $tail
if (TERMINAL_COND()) {
   return $tail->(TERMINAL_VALUE());   # Tail call
} else {
   return recursive(HEAD(), sub {      # Tail call
      return $tail->(TAIL($_[0]);      # Tail call
   });
}

This gives us the following:

sub _fact($n, $tail) {
   return $tail->(1) if $n == 0;
   return _fact($n-1, sub($fact) {
      return $tail->( $fact * $n );
   });
}

sub fact($n) {
   return _fact($n, sub($fact) { $fact });
}

This is basically how Promises work.

# Promise is a fictional class akin
# to the JS one with the same name.

sub fact_p($n) {
   return Promise->new(1) if $n == 0;
   return fact_p($n-1)->then(sub($fact) {
      return $fact * $n;
   });
}

fact_p($n)->done(sub($fact) {
   say $fact;
});

What you have is a lot trickier because you have multiple recursive calls. But we can still apply the same technique.

# Loop body
sub __append_until_exhausted($appendix, $computed, $i, $tail) {
   if ($i == $computed->@*) {
      return $tail->();  # TC
   } else {
      my $new_foo = $computed->[$i];
      push $appendix->{make_key $new_foo}->@*, $new_foo;
      return _append_until_exhausted($appendix, $new_foo, sub {  # TC
         return __append_until_exhausted($appendix, $computed, $i+1, $tail);  # TC
      });
   }
}

# Function body
sub _append_until_exhausted($appendix, $foo, $tail) {
   my $computed = some_complicated_computation($foo);
   return __append_until_exhausted($appendix, $computed, 0, $tail);  # TC
}

# Public interface
sub append_until_exhausted($appendix, $foo) {
   return _append_until_exhausted($appendix, $foo, sub {  # TC
      return $appendix;
   });
}

We can avoid all the extra copies of $appendix as follows:

sub append_until_exhausted($appendix, $foo) {
   local *helper2 = sub($computed, $i, $tail) {
      if ($i == $computed->@*) {
         return $tail->();  # TC
      } else {
         my $new_foo = $computed->[$i];
         push $appendix->{make_key $new_foo}->@*, $new_foo;
         return helper1($new_foo, sub {  # TC
            return helper2($computed, $i+1, $tail);  # TC
         });
      }
   };

   local *helper1 = sub($foo, $tail) {
      my $computed = some_complicated_computation($foo);
      return helper2($computed, 0, $tail);  # TC
   };

   return helper1($foo, sub {  # TC
      return $appendix;
   });
}

Perl doesn't perform tail-call elimination, and function calls are rather slow. You'd be better off using an array as a stack.

This performs the work in the same order as the original:

sub append_until_exhausted($foo, $appendix) {
   my @todo = [ $foo, undef, 0 ];
   while (@todo) {
      my $todo = $todo[-1];
      \my ( $foo, $computed, $i ) = \( @$todo );
      $computed //= some_complicated_computation($foo);
      if ($i == $computed->@*) {
         pop(@todo);
         next;
      }

      my $new_foo = $computed->[$i++];
      push $appendix->{make_key $new_foo}->@*, $new_foo;
      push @todo, [ $new_foo, undef, 0 ];
   }

   return $appendix;
}

If you don't mind doing the complicated computation out of order (while still preserving the result), the above simplifies to the following:

sub append_until_exhausted($foo, $appendix) {
   my @todo = some_complicated_computation($foo);
   while (@todo) {
      my $computed = $todo[-1];
      if (!$computed->@*) {
         pop(@todo);
         next;
      }

      my $new_foo = shift(@$computed);
      push $appendix->{make_key $new_foo}->@*, $new_foo;
      push @todo, some_complicated_computation($new_foo);
   }

   return $appendix;
}
ikegami
  • 367,544
  • 15
  • 269
  • 518
  • @Dada, `\my ( $foo, $computed, $i ) = \( @$todo );` aliases `$computed` to `$todo->[1]` – ikegami Nov 13 '20 at 22:45
  • Nevermind, I read your code a bit too fast; you're just avoiding recomputing `some_complicated_computation`, makes sense :) – Dada Nov 13 '20 at 22:55
  • Can you please test code block #7? It has a few bugs, I'm stuck at the out of bound array access. – daxim Nov 14 '20 at 16:16
  • @daxim, I was incrementing `$i` twice, I was missing a parameter in a signature, and I had the parameters in the wrong order in one place. (I follow the old convention of placing output parameters first. This is consistent with how `$self` always comes first. And it becomes really obvious how much sense it makes to place `$appendix` first when you look at the block #8.) Fixed and tested. – ikegami Nov 14 '20 at 16:42