2

I have a question about the difference between two solutions to a problem. The problem asks to transform a list to a truncated list like so:

?- reduce([a,a,a,b,b,c,c,b,b,d,d],Z).
Z = [a,b,c,b,d].

This first solution needs an extra step that reverses the list:

reduce([X|Xs],Z) :-
   reduce(X,Xs,Y,[X]),
   reverse(Y,Z).

reduce(X,[L|Ls],Y,List) :-
    (  X=L
    -> reduce(X,Ls,Y,List)
    ;  reduce(L,Ls,Y,[L|List])
    ).
reduce(_,[],Y,Y).

The second solution does not require reverse/2:

reduced([X|Xs],Result) :- 
    reduced(Xs,List),
    List=[A|_],
    (  A=X
    -> Result=List
    ;  Result=[X|List]
    ),
    !.
reduced(Result,Result).

What are the optimization considerations when performing recursion before or after a series of statements? Does the order of the conditions matters? My inclination is to think that doing all the recursion upfront is the way to go, especially because building the list backwards is necessary here.

repeat
  • 18,496
  • 4
  • 54
  • 166
lefunction
  • 301
  • 2
  • 16

4 Answers4

7

When you optimize anything, make sure to measure first! (most of us tend to forget this....)

When you optimize Prolog, look out for the following:

  • Tail recursion tends to do better (so there goes your "before or after series of statements" question);
  • Avoid creating choice points you don't need (this depends on the Prolog implementation)
  • Use an optimal algorithm (as in, don't traverse a list twice if you don't have to).

A solution that is "optimized" for a more or less standard Prolog implementation will look maybe a bit different. I will name it list_uniq (in analogy to the command-line uniq tool):

list_uniq([], []). % Base case
list_uniq([H|T], U) :-
    list_uniq_1(T, H, U). % Helper predicate

list_uniq_1([], X, [X]).
list_uniq_1([H|T], X, U) :-
    (   H == X
    ->  list_uniq_1(T, X, U)
    ;   [X|U1] = U,
        list_uniq_1(T, H, U1)
    ).

It is different from the reduce0/2 by @CapelliC because it uses lagging to avoid the inherent non-determinism of [X|Xs] and [X,X|Xs] in the first argument.

Now to the claim that it is "optimized":

  • It traverses the list exactly once (no need for reversing)
  • It it tail-recursive
  • It does not create and discard choice points

You will get the same 12 inferences as @CapelliC, and if you then use a somewhat longer list, you will start to see differences:

?- length(As, 100000), maplist(=(a), As),
   length(Bs, 100000), maplist(=(b), Bs),
   length(Cs, 100000), maplist(=(c), Cs),
   append([As, Bs, Cs, As, Cs, Bs], L),
   time(list_uniq(L, U)).
% 600,006 inferences, 0.057 CPU in 0.057 seconds (100% CPU, 10499893 Lips)
As = [a, a, a, a, a, a, a, a, a|...],
Bs = [b, b, b, b, b, b, b, b, b|...],
Cs = [c, c, c, c, c, c, c, c, c|...],
L = [a, a, a, a, a, a, a, a, a|...],
U = [a, b, c, a, c, b].

The same query with reduce0, reduce1, reduce2 from @CapelliC's answer:

% reduce0(L, U)
% 600,001 inferences, 0.125 CPU in 0.125 seconds (100% CPU, 4813955 Lips)
% reduce1(L, U)
% 1,200,012 inferences, 0.393 CPU in 0.394 seconds (100% CPU, 3050034 Lips)
% reduce2(L, U)
% 2,400,004 inferences, 0.859 CPU in 0.861 seconds (100% CPU, 2792792 Lips)

So, creating and discarding choice points with cuts (!) has a price, too.

However, list_uniq/2, as it stands, can be wrong for queries where the first argument is not ground:

?- list_uniq([a,B], [a,b]).
B = b. % OK

?- list_uniq([a,A], [a]).
false. % WRONG!

reduce0/2 and reduce1/2 can be wrong, too:

?- reduce0([a,B], [a,b]).
false.

?- reduce1([a,B], [a,b]).
false.

As for reduce2/2, I am not sure about this one:

?- reduce2([a,A], [a,a]).
A = a.

Instead, using the definition of if_/3 from this answer:

list_uniq_d([], []). % Base case
list_uniq_d([H|T], U) :-
    list_uniq_d_1(T, H, U). % Helper predicate

list_uniq_d_1([], X, [X]).
list_uniq_d_1([H|T], X, U) :-
    if_(H = X,
        list_uniq_d_1(T, H, U),
        (   [X|U1] = U,
            list_uniq_d_1(T, H, U1)
        )
    ).

With it:

?- list_uniq_d([a,a,a,b], U).
U = [a, b].

?- list_uniq_d([a,a,a,b,b], U).
U = [a, b].

?- list_uniq_d([a,A], U).
A = a,
U = [a] ;
U = [a, A],
dif(A, a).

?- list_uniq_d([a,A], [a]).
A = a ;
false. % Dangling choice point

?- list_uniq_d([a,A], [a,a]).
false.

?- list_uniq_d([a,B], [a,b]).
B = b.

?- list_uniq_d([a,A], [a,a]).
false.

It takes longer, but the predicate seems to be correct. With the same query as used for the other timings:

% 3,000,007 inferences, 1.140 CPU in 1.141 seconds (100% CPU, 2631644 Lips)
Community
  • 1
  • 1
  • @false It does not fail; it succeeds with `dif(A, a)` (which is also wrong).... Or am I trippin? –  Jun 24 '15 at 11:17
  • 1
    I meant,`list_uniq([a,A],[a]), A = a`. The `A` was so suggestive :-) – false Jun 24 '15 at 11:20
  • 1
    @WillNess: Do you have a suggestion for a module name? – false Jun 24 '15 at 13:26
  • 1
    @false you mean for `if_` and stuff? Maybe`reified_truth`? Or there was something else that I missed? – Will Ness Jun 24 '15 at 18:57
  • 2
    @WillNess: Just a catchy module name. `reified_truth` is a bit long. I though about `reif` (which means in German ripe, as a "funny" pun). – false Jun 24 '15 at 22:07
  • 2
    @false "reif" is good. it also reads in two parts, "re:if", so it's evocative. good name. can it be "re/if", is the slash allowed? – Will Ness Jun 24 '15 at 22:33
  • 2
    @WillNess: Will do! Thank you! – false Jun 24 '15 at 22:34
  • 1
    @false glad to be of help. :) – Will Ness Jun 24 '15 at 22:38
  • @repeat An altogether different approach which imposes even less overhead if first argument is ground would be to use "preconditions", as sort of hinted towards by [this answer](http://stackoverflow.com/questions/31047765/generating-subsets-using-length-2-and-ord-subset-2/31048178#31048178); `precondition/1` checks (at run time) if preconditions have already been met (using attributed variables), or confirms them; then, either `list_uniq_1/3` is used, or if the preconditions are not met, `list_uniq_d_1/3`. –  Jun 26 '15 at 16:12
  • @repeat Using complile time term expansion to avoid the `call` in `if_` is orthogonal to this, of course. –  Jun 26 '15 at 16:15
  • @Boris. Check out my latest answer! – repeat Jul 10 '15 at 10:21
2

profiling seems the easier way to answer to efficiency questions:

% my own
reduce0([], []).
reduce0([X,X|Xs], Ys) :- !, reduce0([X|Xs], Ys).
reduce0([X|Xs], [X|Ys]) :- reduce0(Xs, Ys).

% your first
reduce1([X|Xs],Z) :- reduce1(X,Xs,Y,[X]), reverse(Y,Z).
reduce1(X,[L|Ls],Y,List) :-
    X=L -> reduce1(X,Ls,Y,List);
    reduce1(L,Ls,Y,[L|List]).
reduce1(_,[],Y,Y).

% your second
reduce2([X|Xs],Result) :- 
    reduce2(Xs,List),
    List=[A|_],
    (A=X -> Result=List;
    Result=[X|List]),!.
reduce2(Result,Result).

SWI-Prolog offers time/1:

4 ?- time(reduce0([a,a,a,b,b,c,c,b,b,d,d],Z)).
% 12 inferences, 0.000 CPU in 0.000 seconds (84% CPU, 340416 Lips)
Z = [a, b, c, b, d].

5 ?- time(reduce1([a,a,a,b,b,c,c,b,b,d,d],Z)).
% 19 inferences, 0.000 CPU in 0.000 seconds (90% CPU, 283113 Lips)
Z = [a, b, c, b, d] ;
% 5 inferences, 0.000 CPU in 0.000 seconds (89% CPU, 102948 Lips)
false.

6 ?- time(reduce2([a,a,a,b,b,c,c,b,b,d,d],Z)).
% 12 inferences, 0.000 CPU in 0.000 seconds (83% CPU, 337316 Lips)
Z = [a, b, c, b, d].

your second predicate performs like mine, while the first one seems to leave a choice point...

Order of conditions it's of primary importance, given the resolution strategy Prolog implements. In naive implementations, like mine IL, tail recursion optimization was recognized only when the recursive call was the last, and preceded by a cut. Just to be sure it's deterministic...

CapelliC
  • 59,646
  • 5
  • 47
  • 90
2

This answer is a direct follow-up to @Boris's answer.

To estimate the runtime we can expect once if_/3 is compiled, I made list_uniq_e/2 which is just like @Boris's list_uniq_d/2 with the if_/3 compiled manually.

list_uniq_e([], []). % Base case
list_uniq_e([H|T], U) :-
    list_uniq_e_1(T, H, U). % Helper predicate

list_uniq_e_1([], X, [X]).
list_uniq_e_1([H|T], X, U) :-
    =(H,X,Truth),
    list_uniq_e_2(Truth,H,T,X,U).

list_uniq_e_2(true ,H,T,_,   U ) :- list_uniq_e_1(T,H,U).
list_uniq_e_2(false,H,T,X,[X|U]) :- list_uniq_e_1(T,H,U).

Let's compare the runtime (SWI Prolog 7.3.1, Intel Core i7-4700MQ 2.4GHz)!

First up, list_uniq_d/2:

% 3,000,007 inferences, 0.623 CPU in 0.623 seconds (100% CPU, 4813150 Lips)

Next up, list_uniq_e/2:

% 2,400,003 inferences, 0.132 CPU in 0.132 seconds (100% CPU, 18154530 Lips)

For the sake of completeness reduce0/2, reduce1/2, and reduce2/2:

% 600,002 inferences, 0.079 CPU in 0.079 seconds (100% CPU, 7564981 Lips)
% 600,070 inferences, 0.141 CPU in 0.141 seconds (100% CPU, 4266842 Lips)
% 600,001 inferences, 0.475 CPU in 0.475 seconds (100% CPU, 1262018 Lips)

Not bad! And... this is not the end of the line---as far as optimizing if_/3 is concerned:)

Community
  • 1
  • 1
repeat
  • 18,496
  • 4
  • 54
  • 166
  • 1
    This is really great. The point about using preconditions is that in the more general case it allows us to use _different_ algorithms based on the current constraints already present on the arguments to a predicate. Using attributed variables lets us propagate the constraints "secretly" to any predicate that cares to check for them. All of the `*_d` predicates that you and @false have showed lately can be glued onto existing "standard" predicates for basically zero cost. –  Jun 26 '15 at 16:22
  • By "constraints" in my previous comment I mean for example `ground(X)`, `is_list(X)`, `sort(X, X)`, etc. For example, `member_d` is `member` is `member_chk` is `ord_memberchk` if `precondition( sort(X, X) )`; this gets rid of choice points, too! –  Jun 26 '15 at 16:25
  • @Boris. Sounds good, but I'm not yet convinced. I'm sceptical about the "attributed variables" idea... maybe I didn't get it, but AFAIK you lose the attributes once a var becomes nonvar, right? – repeat Jun 26 '15 at 16:42
  • Reading the SWI-Prolog manual at the moment. If I actually manage to implement a working solution I will try to answer [this question](http://stackoverflow.com/questions/30924607/more-determinism-for-memberd-2) –  Jun 26 '15 at 16:50
1

Hoping this is an even better follow-up to @Boris's answer than my last try!

First, here's @Boris's code again (100% original):

list_uniq_d([], []). % Base case
list_uniq_d([H|T], U) :-
    list_uniq_d_1(T, H, U). % Helper predicate

list_uniq_d_1([], X, [X]).
list_uniq_d_1([H|T], X, U) :-
    if_(H = X,
        list_uniq_d_1(T, H, U),
        (   [X|U1] = U,
            list_uniq_d_1(T, H, U1)
        )
    ).

Plus some more code for benchmarking:

bench(P_2) :-
   length(As, 100000), maplist(=(a), As),
   length(Bs, 100000), maplist(=(b), Bs),
   length(Cs, 100000), maplist(=(c), Cs),
   append([As, Bs, Cs, As, Cs, Bs], L),
   time(call(P_2,L,_)).

Now, let's introduce module re_if:

:- module(re_if, [if_/3, (=)/3, expand_if_goals/0]).

:- dynamic expand_if_goals/0.

trusted_truth(_=_).  % we need not check truth values returned by (=)/3

=(X, Y, R) :- X == Y,    !, R = true.
=(X, Y, R) :- ?=(X, Y),  !, R = false.  % syntactically different
=(X, Y, R) :- X \= Y,    !, R = false.  % semantically different
=(X, Y, R) :- R == true, !, X = Y.
=(X, X, true).
=(X, Y, false) :- dif(X, Y).

:- meta_predicate if_(1,0,0).
if_(C_1,Then_0,Else_0) :-
   call(C_1,Truth),
   functor(Truth,_,0),          % safety check
   (  Truth == true -> Then_0
   ;  Truth == false , Else_0
   ).

:- multifile system:goal_expansion/2.
system:goal_expansion(if_(C_1,Then_0,Else_0), IF) :-
   expand_if_goals,
   callable(C_1),           % nonvar && (atom || compound)
   !,
   C_1 =.. Ps0,
   append(Ps0,[T],Ps1),
   C_0 =.. Ps1,
   (  trusted_truth(C_1)
   -> IF = (C_0,               ( T == true -> Then_0 ;             Else_0))
   ;  IF = (C_0,functor(T,_,0),( T == true -> Then_0 ; T == false, Else_0))
   ).

And now ... *drumroll* ... lo and behold:)

$ swipl
Welcome to SWI-Prolog (Multi-threaded, 64 bits, Version 7.3.3-18-gc341872)
Copyright (c) 1990-2015 University of Amsterdam, VU Amsterdam
SWI-Prolog comes with ABSOLUTELY NO WARRANTY. This is free software,
and you are welcome to redistribute it under certain conditions.
Please visit http://www.swi-prolog.org for details.

For help, use ?- help(Topic). or ?- apropos(Word).

?- compile(re_if), compile(list_uniq).
true.

?- bench(list_uniq_d).
% 2,400,010 inferences, 0.865 CPU in 0.865 seconds (100% CPU, 2775147 Lips)
true.

?- assert(re_if:expand_if_goals), compile(list_uniq).
true.

?- bench(list_uniq_d).
% 1,200,005 inferences, 0.215 CPU in 0.215 seconds (100% CPU, 5591612 Lips)
true.
Community
  • 1
  • 1
repeat
  • 18,496
  • 4
  • 54
  • 166
  • The expanded and unexpanded versions behave differently. Same bug as SWI's expansion of `once/1`. – false Jul 10 '15 at 11:36