5

I am reading Structure and Interpretation of Computer Programs (SICP) and would like to make sure that my thinking is correct.

Consider the following simple stream using the recursive definition:

(define (integers-starting-from n)
    (cons-stream n (integers-starting-from (+ n 1))))

(define ints (integers-starting-from 1))

(car (cdr-stream (cdr-stream (cdr-stream (cdr-stream ints)))))

If we adopt the implementation in SICP, whenever we cons-stream, we are effectively consing a variable and a lambda function (for delayed evaluation). So as we cdr-stream along this stream, nested lambda functions are created and a chain of frames is stored for the evaluation of lambda functions. Those frames are necessary since lambda functions evaluate expressions and find them in the enclosing frame. Therefore, I suppose that in order to evaluate the n-th element of the stream, you need to store n extra frames that take up linear space.

This is different from the behavior of iterators in other languages. If you need to go far down the stream, much space will be taken. Of course, it is possible to only keep the direct enclosing frame and throw away all the other ancestral frames. Is this what the actual scheme implementation does?

Will Ness
  • 70,110
  • 9
  • 98
  • 181
blancmange
  • 115
  • 6

3 Answers3

2

Short answer, yes, under the right circumstances the directly enclosing environment is thrown away.

I don't think this would happen in the case of (car (cdr-stream (cdr-stream (cdr-stream (... but if you instead look at stream-refin sect. 3.5.1:

(define (stream-ref s n)
  (if (= n 0)
      (stream-car s)
      (stream-ref (stream-cdr s) (- n 1))))

and if you temporarily forget what you know about environment frames but think back to Chapter 1 and the disussion of recursive vs iterative processes, then this is a iterative process because the last line of the body is a call back to the same function.

So perhaps your question could be restated as: "Given what I know now about the environmental model of evaluation, how do iterative processes use constant space?"

As you say it's because the ancestral frames are thrown away. Exactly how this happens is covered later in the book in chapter 5, e.g., sect. 4.2 "Sequence Evaluation and Tail Recursion", or if you like the videos of the lectures, in lecture 9b.

A significant part of Chapter 4 and Chapter 5 covers the details necessary to answer this question explicitly. Or as the authors put it, to dispel the magic.

codybartfast
  • 7,323
  • 3
  • 21
  • 25
  • the OP seems to claim there's a nesting of `n` environment frames to get to the nth element of a stream. Your answer seems to go along with this claim. (?) I make the claim in my answer below that there are *no* nested frames here whatsoever. See if it convinces you. :) – Will Ness Jan 26 '20 at 15:18
  • @WillNess We have an expression of the form (g (f (f (f (f a))))), regardless of the implementation of f or g, doesn't this result in a nested series of calls to apply and eval? If so, doesn't each call to apply result in the environment being extended (sect. 4.1.1)? – codybartfast Jan 27 '20 at 12:24
  • the expression is `(car (cdr-stream (cdr-stream (cdr-stream (cdr-stream ints)))))`. `cdr-stream` is a function, it is defined as `(define (cdr-stream s) (force (cdr s)))`. Scheme is eager, the argument is evaluated *in the current environment* before its value is passed to the function. `car` also has its argument evaluated before returning its `car` field. so the expression's evaluation proceeds with the help of the stack, (contd.) – Will Ness Jan 27 '20 at 16:44
  • `(eval expression) = (let* ((s1 (cdr-stream (cdr-stream (cdr-stream (cdr-stream ints)))))) (car s1)) = (let* ((s2 (cdr-stream (cdr-stream (cdr-stream ints)))) (s1 (force (cdr s2))) ) (car s1)) = (let* ((s3 (cdr-stream (cdr-stream ints))) (s2 (force (cdr s3))) (s1 (force (cdr s2))) ) (car s1)) = ...`. there's no environment here but the current environment. the nested calls are unraveled into a chain of calls on the sack (illustrated here with `let*`). such a form of the unraveled code is known as [static single assignment form](https://en.wikipedia.org/wiki/Static_single_assignment_form). – Will Ness Jan 27 '20 at 16:44
  • \*(a chain of calls on the \*stack) --- to your specific question, the environment is extended when evaluating the argument entails a function call; and after its value is found, upon return from that function the extended environment is not needed any longer and is gone, destroyed, popped off the stack. new environments are only created by `let` and friends (incl. function calls), and kept (as part of a closure) only if a `lambda` is created and returned from inside that nested scope. I once addressed a similar misconception by an asker [here](https://stackoverflow.com/a/48851869/849891). – Will Ness Jan 27 '20 at 17:04
  • As you say, the environment is dropped *after* the argument is evaluated, but to evaluate that argument we need to apply the inner procedure which requires a new environment, and then we need to evaluate its arguments, ..., ..., ..., (btw, let* creates a new environment for each variable binding) – codybartfast Feb 02 '20 at 07:32
  • my `let*` translation can be misleading as it technically is defining a nested scope. but notice the innermost expression is the first, the original environment is all it needs. and all the `s_i`s the same, they are temporary variables that can be discarded immediately after being used. with this optimization, the more direct translation could be the operationally-equivalent `set!`-based one: `(let ((temp (cdr-stream ints))) (set! temp (cdr-stream temp)) (set! temp (cdr-stream temp)) ... (set! temp (cdr-stream temp)) (car temp))`. notice the`{a} ; {b} ; {c} ; ... ; {n}` structure. in same env. – Will Ness Feb 03 '20 at 05:43
2

I think it's worth pointing out that the analysis of space usage in cases like this is not always quite simple.

For instance here is a completely naïve implementation of force & delay in Racket:

(define-syntax-rule (delay form)
  (λ () form))

(define (force p)
  (p))

And we can build enough of something a bit compatible with SICP streams to be dangerous on this:

(define-syntax-rule (cons-stream kar kdr)
  ;; Both car & cdr can be delayed: why not?  I think the normal thing is
  ;; just to delay the cdr
  (cons (delay kar) (delay kdr)))

(define (stream-car s)
  (force (car s)))

(define (stream-cdr s)
  (force (cdr s)))

(define (stream-nth s n)
  (if (zero? n)
      (stream-car s)
      (stream-nth (stream-cdr s) (- n 1))))

(Note there is lots missing here because I am lazy.)

And on that we can build streams of integers:

(define (integers-starting-from n)
  (cons-stream n (integers-starting-from (+ n 1))))

And now we can try this:

(define naturals (integers-starting-from 0))

(stream-nth naturals 10000000)

And this last thing returns 10000000, after a little while. And we can call it several times and we get the same answer each time.

But our implementation of promises sucks: forcing a promise makes it do work each time we force it, and we'd like to do it once. Instead we could memoize our promises so that doesn't happen, like this (this is probably not thread-safe: it could be made so):

(define-syntax-rule (delay form)
  (let ([thunk/value (λ () form)]
        [forced? #f])
    (λ ()
      (if forced?
          thunk/value
          (let ([value (thunk/value)])
            (set! thunk/value value)
            (set! forced? #t)
            value)))))

All the rest of the code is the same.

Now, when you call (stream-nth naturals 10000000) you are probably going to have a fairly bad time: in particular you'll likely run out of memory.

The reason you're going to have a bad time is two things:

  • there's a reference to the whole stream in the form of naturals;
  • the fancy promises are memoizing their values, which are the whole tail of the stream.

What this means is that, as you walk down the stream you use up increasing amounts of memory until you run out: the space complexity of the program goes like the size of the argument to stream-nth in the last line.

The problem here is that delay is trying to be clever in a way which is unhelpful in this case. In particular if you think of streams as objects you traverse generally once, then memoizing them is just useless: you've carefully remembered a value which you will never use again.

The versions of delay & force provided by Racket memoize, and will also use enormous amounts of memory in this case.

You can avoid this either by not memoizing, or by being sure never to hold onto the start of the stream so the GC can pick it up. In particular this program

(define (silly-nth-natural n)
  (define naturals (integers-starting-from 0))
  (stream-nth naturals n))

will not use space proportional to n, because once the first tail call to stream-nth is made there is nothing holding onto the start of the stream any more.

Another approach is to make the memoized value be only weakly held, so that if the system gets desperate it can drop it. Here's a hacky and mostly untested implementation of that (this is very Racket-specific):

(define-syntax-rule (delay form)
  ;; a version of delay which memoizes weakly
  (let ([thunk (λ () form)]
        [value-box #f])
    (λ ()
      (if value-box
          ;; the promise has been forced
          (let ([value-maybe (weak-box-value value-box value-box)])
            ;; two things that can't be in the box are the thunk
            ;; or the box itself, since we made those ourselves
            (if (eq? value-maybe value-box)
                ;; the value has been GCd
                (let ([value (thunk)])
                  (set! value-box (make-weak-box value))
                  value)
                ;; the value is good
                value-maybe))
          ;; the promise has not yet been forced
          (let ((value (thunk)))
            (set! value-box (make-weak-box value))
            value)))))

I suspect that huge numbers of weak boxes may make the GC do a lot of work.

  • naturally memoization takes up memory. just as in Haskell, with memoized streams if you name it, it will stay (in memory). `(stream-nth naturals 10000000)` will realize the whole prefix in memory, but `(stream-nth (integers-starting-from 0) 10000000)` won't -- although will have to collect a lot of garbage. Every tool has its trade-offs. --- I'm not so sure about `silly-nth-natural`'s guarantees, it will depend on the compiler. here's some [recent Q/A](https://stackoverflow.com/a/59271882/849891) about Chez code IIRC with exactly this problem. (also see OP's comments there). – Will Ness Jan 26 '20 at 21:14
  • oftentimes streams must be memoized. e.g. the usual/classic definition of the Hamming numbers stream consumes itself at three different paces. Python has to introduce a special duplicating device, `tee` (google "python tee hamming"), to overcome this, precisely because its generators (or is it iterators?) are non-memoized (so, again, trade-offs). (And it's not quite right, it creates three copies IIUIC when it could be reusing just one memoized portion of the stream.) (contd.) – Will Ness Jan 26 '20 at 21:34
  • so with memoized streams, the definition must have the head ref internally (the problem mentioned above) and either the compiler copes properly, or we will have to restructure the code to make it, as sown in the answer I linked. so, yeah, this all is nontrivial and there's probably no *one-size-fits-all* solution. :) the weak-boxes solution relies on the system hitting the wall first, so already it won't be performant. -- re delaying the `car`, the SRFI re-do of streams went this way, but really all they had to do was to fix the `take` code, and then use extra explicit `delay` here and there. – Will Ness Jan 26 '20 at 21:40
  • I discuss implementing Hamming numbers sequence with SICP streams in Scheme in [this answer](https://stackoverflow.com/a/55808095/849891). (doing it "yourself" in a low-level language is quite a good exercise. I once tried doing it with hand-rolled "object"-like producers, splitters, multipliers, etc... lots of hidden problems come to light that way.) all this got me thinking now whether there are hidden performance issues with that "classic" code (seen in the question in that link above) as well! – Will Ness Jan 26 '20 at 22:08
  • @WillNess: I don't think SE comments are the place for extended discussion, but yes, I am aware of these issues. My answer was just meant to make OP aware of tsome of them too. My `silly-nth-natural` is not closely equivalent to question you pointed to I think: in particular mine is lambda-lifted so the compiler doesn't have to prove anything about the impossibility of reference. However, again, this is not the right forum for big discussions on this I think. –  Jan 28 '20 at 09:26
  • "`silly-nth-natural` is lambda-lifted" indeed looks like you are correct here and were correct about TCO kicking in and I was mistaken about that. thanks for replying! – Will Ness Jan 28 '20 at 10:10
1

"nested lambda functions are created"

nope. There is no nested scope. In

(define integers-starting-from 
  (lambda (n)
    (cons-stream n 
       (integers-starting-from (+ n 1)))))

definition, in the (integers-starting-from (+ n 1)) call, integers-starting-from is a function and (+ n 1) is its argument. So when that call is made, the value of (+ n 1) is found first and only then the execution enters the integers-starting-from definition body. Thus there's no more reference to n being held by anything, at that point.

Scheme is an eager programming language, not a lazy one.

So the lambda inside the result of cons-stream holds a reference to the call frame at first, yes, but there is no nesting of environments in the tail of that stream after that tail is produced. The value of (+ n 1) is already obtained, before the new lambda is created and returned as part of the next cons cell representing the stream's next state, i.e. the stream's tail.

Unfolding the calls to lambda functions as the equivalent let expressions, we get

(define ints (integers-starting-from 1))
=
(define ints (let ((n 1))
    (cons-stream n (integers-starting-from (+ n 1)))))
=
(define ints (let ((n 1))
    (cons n (lambda () (integers-starting-from (+ n 1))))))

and the call proceeds as

(car (cdr-stream (cdr-stream ints)))
=
(let* ((ints       (let ((n 1))
                      (cons n 
                        (lambda () 
                           (integers-starting-from (+ n 1))))))
       (cdr-ints   ((cdr ints)))
       (cddr-ints  ((cdr cdr-ints)))
       (res        (car cddr-ints)))
   res)
=
(let* ((ints       (let ((n 1))
                      (cons n 
                        (lambda () 
                           (integers-starting-from (+ n 1))))))
       (cdr-ints   ((cdr ints))
                     =
                   ((let ((n 1))
                       (lambda () (integers-starting-from (+ n 1)))))
                     =
                   (let ((n 1))
                       ((lambda () (integers-starting-from (+ n 1)))))
                     =
                   (let ((n 1))
                       (integers-starting-from (+ n 1)))
                     =
                   (integers-starting-from 2)   ;; args before calls!
                     =
                   (let ((n 2))
                      (cons n 
                        (lambda () 
                           (integers-starting-from (+ n 1))))))
       (cddr-ints  ((cdr cdr-ints)))
       (res        (car cddr-ints)))
  res)
=
(let* ((ints       (let ((n 1))
                      (cons n            ;; inde-
                        (lambda ()
                           (integers-starting-from (+ n 1))))))
       (cdr-ints   (let ((n 2))
                      (cons n            ;;      pen-
                        (lambda () 
                           (integers-starting-from (+ n 1))))))
       (cddr-ints  (let ((n 3))
                      (cons n            ;;           dent
                        (lambda () 
                           (integers-starting-from (+ n 1))))))
       (res        (car cddr-ints)))
  res)
=
(let* ((res        (let ((n 3))
                      n)))
  res)
=
3

So there's no nested lambdas here. Not even a chain of lambdas, because the implementation is non-memoizing. The values for cdr-ints and cddr-ints are ephemeral, liable to be garbage-collected while the 3rd element is being calculated. Nothing holds any reference to them.

Thus getting the nth element is done in constant space modulo garbage, since all the interim O(n) space entities are eligible to be garbage collected.

In (one possible) memoizing implementation, each lambda would be actually replaced by its result in the cons cell, and there'd be a chain of three -- still non-nested -- lambdas, congruent to an open-ended list

(1 . (2 . (3 . <procedure-to-go-next>)))

In programs which do not hold on to the top entry of such chains, all the interim conses would be eligible for garbage collection as well.

One such example, even with the non-memoizing SICP streams, is the sieve of Eratosthenes. Its performance characteristics are consistent with no memory retention of the prefix portions of its internal streams.

Will Ness
  • 70,110
  • 9
  • 98
  • 181
  • cons-stream is a special form. (cons-stream a b) is equivalent to (cons a (delay b)). – blancmange Jan 26 '20 at 16:00
  • yes this is what I'm using. I also take `(delay p)` to be equivalent to `(lambda () p)`. if instead `delay` is a special form, which memoizes its results, we'll get the chain of lambdas, but still no nesting of any frames. – Will Ness Jan 26 '20 at 16:04
  • [here](https://sarabander.github.io/sicp/html/3_002e5.xhtml) in the SICP book it says, *" `Delay` can be a special form such that `(delay ⟨exp⟩)` is syntactic sugar for `(lambda () ⟨exp⟩)`"* which is exactly what I am using. – Will Ness Jan 26 '20 at 16:14
  • Thx. I think I know what's going on here. I previously thought that the function is not tail-recursive, but now it seems indeed tail-recursive. – blancmange Jan 26 '20 at 16:15
  • tail or no tail, it calculates the resulting value - the cons cell - and returns it. – Will Ness Jan 26 '20 at 16:16
  • You are right. Every time when integers-starting-from is called, a new frame whose parental frame is the global frame is created (I previously mistakenly thought it was within the lambda frame somehow) and will return the result. So the only nested frames here are the one of integers-starting-from and the one of the lambda. By "the implementation is non-memoizing.", you mean that no memo-proc function wrapping around the delayed expression? – blancmange Jan 26 '20 at 17:37
  • something like that, yes. there can actually be more than one ways to memoize. what you refer to is in the book; I prefer to actually replace the lambda in the cons cell in which it was first created, with the calculated value. so after the value is calculated, the lambda is gone forever (unlike the version in the book which memoizes the value inside the lambda). – Will Ness Jan 26 '20 at 17:41