nandi/clonimpublic Fork
⑂ 75fc33e
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/clonim.git
git clone ssh://git@rickub.com/nandi/clonim.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

perf: chunk the lazy seq producers

A lazy seq cost a cons cell, a memoizing thunk and a closure per element, which
put `(count (range 1000000))` at 299 ms where the eager version had been 23 ms.
Chunking amortizes that over a batch: range, map, filter/remove and take now
realize up to 32 elements per step and hand back one kChunk instead of 32
cons cells.

A new kChunk value kind holds the realized run, an offset into it, and the rest
of the seq. Cursor indexes a chunk where it lies rather than copying its
elements out, and still follows cons/lazy links one at a time, so chunked and
unchunked seqs compose freely -- `(concat [1 2] (range))`, `(cons 0 (range 10))`
and `(map f (range) (cycle [10 20]))` all work unchanged.

The visible consequence is that demand rounds up to a batch: taking 3 elements
of a mapped seq runs the fn 32 times. That is what Clojure does, and every case
in examples/chunking.clj was checked against a real Clojure run -- the 32/64
call counts, the interop cases and the infinite-source cases all agree. It does
mean map's fn must not be relied on for per-element side effects; doseq is the
tool for that. examples/lazy.clj says so where it counts calls.

filter draws up to 32 source elements per step and emits whichever pass, rather
than pulling until 32 have passed, so a rare match in a long or infinite source
still costs a bounded amount per step: `(first (filter #(= % 5000) (range)))`
does not realize 32 matches.

reduce also stops materializing its source through toSeq and streams it
instead, which is what lets a fold over a lazy seq hold a chunk at a time.

Compute excluding startup, best-of-N round-robin:

                     before   after
  seqs                853 ms  342 ms
  lazy-bench            0 ms    0 ms   (early exit unaffected)
  persistent-bench    120 ms  120 ms

Two things measured and rejected rather than kept: copying a chunk's elements
into the cursor was worth ~30 ms of the total, and skipping the destructor's
drain worklist for kinds that never queue anything was worth nothing at all --
not enough to justify touching the mechanism that keeps a long chain from
overflowing the stack at teardown.

What remains between this and the eager numbers is the boxed Value: every
mkInt still allocates, which a separate measurement puts at 16 ms per million.
Folding a long seq also still retains its head through the argument that holds
it, so `(reduce + (map inc (filter even? (range 5000000))))` peaks at 713 MB.
Both are their own piece of work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-20T19:14:53-07:00 Browse files
75fc33e parent: 017649f
added examples/chunking.clj +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+;; chunking realizes in batches of 32: demand is rounded up, never exceeded
2+;; beyond a batch, and nothing is recomputed
3+(def calls (atom 0))
4+(def xs (map (fn [x] (reset! calls (inc (deref calls))) x) (range 1000)))
5+(println "built:" (deref calls)) ; 0
6+(println (doall (take 3 xs)) "calls:" (deref calls)) ; (0 1 2) 32
7+(println (doall (take 3 xs)) "calls:" (deref calls)) ; memoized, still 32
8+(println (doall (take 40 xs)) "calls:" (deref calls)) ; 64
9+
10+;; infinite sources stay usable
11+(println (take 5 (filter even? (range))))
12+(println (first (filter (fn [x] (> x 100)) (range))))
13+(println (take 3 (map inc (iterate inc 0))))
14+(println (nth (range) 1000) (take 4 (drop 10 (range))))
15+(println (count (take 100 (cycle [1 2 3]))))
16+
17+;; rare matches over a long source must not pull a whole chunk of matches
18+(println (first (filter (fn [x] (= x 5000)) (range))))
19+
20+;; chunked and unchunked seqs interoperate
21+(println (take 4 (concat [1 2] (range))))
22+(println (count (cons 0 (range 10))))
23+(println (reduce + (cons 100 (map inc (range 5)))))
24+(println (take 3 (map (fn [a b] (+ a b)) (range) (cycle [10 20]))))
new file mode 100644
@@ -0,0 +1,24 @@
1+;; chunking realizes in batches of 32: demand is rounded up, never exceeded
2+;; beyond a batch, and nothing is recomputed
3+(def calls (atom 0))
4+(def xs (map (fn [x] (reset! calls (inc (deref calls))) x) (range 1000)))
5+(println "built:" (deref calls)) ; 0
6+(println (doall (take 3 xs)) "calls:" (deref calls)) ; (0 1 2) 32
7+(println (doall (take 3 xs)) "calls:" (deref calls)) ; memoized, still 32
8+(println (doall (take 40 xs)) "calls:" (deref calls)) ; 64
9+
10+;; infinite sources stay usable
11+(println (take 5 (filter even? (range))))
12+(println (first (filter (fn [x] (> x 100)) (range))))
13+(println (take 3 (map inc (iterate inc 0))))
14+(println (nth (range) 1000) (take 4 (drop 10 (range))))
15+(println (count (take 100 (cycle [1 2 3]))))
16+
17+;; rare matches over a long source must not pull a whole chunk of matches
18+(println (first (filter (fn [x] (= x 5000)) (range))))
19+
20+;; chunked and unchunked seqs interoperate
21+(println (take 4 (concat [1 2] (range))))
22+(println (count (cons 0 (range 10))))
23+(println (reduce + (cons 100 (map inc (range 5)))))
24+(println (take 3 (map (fn [a b] (+ a b)) (range) (cycle [10 20]))))
modified examples/lazy.clj +4 -1
@@ -13,7 +13,10 @@
1313 (println (take 5 (concat [1 2] (range))))
1414 (println (first (map inc (range))) (second (range)) (nth (range) 1000))
1515
16-;; nothing beyond the demand is computed, and each cell is computed once
16+;; nothing beyond the demand is computed, and each cell is computed once.
17+;; Seqs are chunked, so demand is rounded up to a batch of 32: taking 3 runs
18+;; the mapped fn 32 times, exactly as it does in Clojure. Construction still
19+;; runs it zero times, and a second pass recomputes nothing.
1720 (def calls (atom 0))
1821 (def xs (map (fn [x] (reset! calls (inc (deref calls))) x) (range 1000)))
1922 (println "built, calls so far:" (deref calls))
@@ -13,7 +13,10 @@
13 (println (take 5 (concat [1 2] (range))))13 (println (take 5 (concat [1 2] (range))))
14 (println (first (map inc (range))) (second (range)) (nth (range) 1000))14 (println (first (map inc (range))) (second (range)) (nth (range) 1000))
15 15
16-;; nothing beyond the demand is computed, and each cell is computed once16+;; nothing beyond the demand is computed, and each cell is computed once.
17+;; Seqs are chunked, so demand is rounded up to a batch of 32: taking 3 runs
18+;; the mapped fn 32 times, exactly as it does in Clojure. Construction still
19+;; runs it zero times, and a second pass recomputes nothing.
17 (def calls (atom 0))20 (def calls (atom 0))
18 (def xs (map (fn [x] (reset! calls (inc (deref calls))) x) (range 1000)))21 (def xs (map (fn [x] (reset! calls (inc (deref calls))) x) (range 1000)))
19 (println "built, calls so far:" (deref calls))22 (println "built, calls so far:" (deref calls))
modified src/compiler.nim +3 -3
@@ -186,7 +186,7 @@ proc quoteLit(v: Value): string =
186186 var parts: seq[string] = @[]
187187 for (k, val) in v.pairs: parts.add "(" & quoteLit(k) & ", " & quoteLit(val) & ")"
188188 "mkMap(@[" & parts.join(", ") & "])"
189- of kCons, kLazy: err("Can't quote a lazy seq")
189+ of kCons, kChunk, kLazy: err("Can't quote a lazy seq")
190190 of kFn: err("Can't quote a function")
191191
192192 proc emptySeqFix(s: string, elemType: string): string =
@@ -775,7 +775,7 @@ proc tryExpr(f: Value, env: Env, c: Ctx): string =
775775 if hv.len == 0: return ""
776776 "call(" & hv & ", " &
777777 (if ids.len == 0: "emptyArgs" else: "@[" & ids.join(", ") & "]") & ")"
778- of kFn, kCons, kLazy:
778+ of kFn, kCons, kChunk, kLazy:
779779 ""
780780
781781 proc genInto(f: Value, dst: string, env: Env, c: Ctx) =
@@ -811,7 +811,7 @@ proc genInto(f: Value, dst: string, env: Env, c: Ctx) =
811811 (if parts.len == 0: "newSeq[(Value, Value)]()" else: "@[" & parts.join(", ") & "]") & ")")
812812 of kFn:
813813 err("Can't emit a function literal")
814- of kCons, kLazy:
814+ of kCons, kChunk, kLazy:
815815 err("Can't emit a lazy seq literal")
816816 of kList:
817817 if f.items.len == 0:
@@ -186,7 +186,7 @@ proc quoteLit(v: Value): string =
186 var parts: seq[string] = @[]186 var parts: seq[string] = @[]
187 for (k, val) in v.pairs: parts.add "(" & quoteLit(k) & ", " & quoteLit(val) & ")"187 for (k, val) in v.pairs: parts.add "(" & quoteLit(k) & ", " & quoteLit(val) & ")"
188 "mkMap(@[" & parts.join(", ") & "])"188 "mkMap(@[" & parts.join(", ") & "])"
189- of kCons, kLazy: err("Can't quote a lazy seq")189+ of kCons, kChunk, kLazy: err("Can't quote a lazy seq")
190 of kFn: err("Can't quote a function")190 of kFn: err("Can't quote a function")
191 191
192 proc emptySeqFix(s: string, elemType: string): string =192 proc emptySeqFix(s: string, elemType: string): string =
@@ -775,7 +775,7 @@ proc tryExpr(f: Value, env: Env, c: Ctx): string =
775 if hv.len == 0: return ""775 if hv.len == 0: return ""
776 "call(" & hv & ", " &776 "call(" & hv & ", " &
777 (if ids.len == 0: "emptyArgs" else: "@[" & ids.join(", ") & "]") & ")"777 (if ids.len == 0: "emptyArgs" else: "@[" & ids.join(", ") & "]") & ")"
778- of kFn, kCons, kLazy:778+ of kFn, kCons, kChunk, kLazy:
779 ""779 ""
780 780
781 proc genInto(f: Value, dst: string, env: Env, c: Ctx) =781 proc genInto(f: Value, dst: string, env: Env, c: Ctx) =
@@ -811,7 +811,7 @@ proc genInto(f: Value, dst: string, env: Env, c: Ctx) =
811 (if parts.len == 0: "newSeq[(Value, Value)]()" else: "@[" & parts.join(", ") & "]") & ")")811 (if parts.len == 0: "newSeq[(Value, Value)]()" else: "@[" & parts.join(", ") & "]") & ")")
812 of kFn:812 of kFn:
813 err("Can't emit a function literal")813 err("Can't emit a function literal")
814- of kCons, kLazy:814+ of kCons, kChunk, kLazy:
815 err("Can't emit a lazy seq literal")815 err("Can't emit a lazy seq literal")
816 of kList:816 of kList:
817 if f.items.len == 0:817 if f.items.len == 0:
modified src/core.nim +44 -17
@@ -198,12 +198,17 @@ proc lazyOf(c: Cursor): Value =
198198 mkCons(x, lazyOf(cc)))
199199
200200 proc lazyMap(f: Value, c: Cursor): Value =
201+ ## Chunked, which means f runs for up to ChunkSize elements as soon as the
202+ ## first is demanded -- the same trade Clojure makes. f must not be relied on
203+ ## for per-element side effects; `doseq` and `run!` are the tools for that.
201204 let cur = c
202205 mkLazy(proc (): Value =
203206 var cc = cur
204- if not hasNext(cc): return NilV
205- let x = next(cc)
206- mkCons(call(f, @[x]), lazyMap(f, cc)))
207+ var xs = newSeqOfCap[Value](ChunkSize)
208+ while xs.len < ChunkSize and hasNext(cc):
209+ xs.add call(f, @[next(cc)])
210+ if xs.len == 0: return NilV
211+ mkChunk(xs, 0, lazyMap(f, cc)))
207212
208213 proc lazyMapN(f: Value, cs: seq[Cursor]): Value =
209214 let curs = cs
@@ -224,23 +229,32 @@ proc lazyMapIndexed(f: Value, i: int64, c: Cursor): Value =
224229 mkCons(call(f, @[mkInt(i), x]), lazyMapIndexed(f, i + 1, cc)))
225230
226231 proc lazyFilter(pred: Value, c: Cursor, keep: bool): Value =
232+ ## Draws up to ChunkSize source elements per step and emits whichever pass,
233+ ## rather than pulling until ChunkSize have passed: that keeps the work per
234+ ## step bounded when matches are rare in a long or infinite source.
227235 let cur = c
228236 mkLazy(proc (): Value =
229237 var cc = cur
230- while hasNext(cc):
231- let x = next(cc)
232- if truthy(call(pred, @[x])) == keep:
233- return mkCons(x, lazyFilter(pred, cc, keep))
234- NilV)
238+ while true:
239+ var xs = newSeqOfCap[Value](ChunkSize)
240+ var drawn = 0
241+ while drawn < ChunkSize and hasNext(cc):
242+ let x = next(cc)
243+ if truthy(call(pred, @[x])) == keep: xs.add x
244+ inc drawn
245+ if xs.len > 0: return mkChunk(xs, 0, lazyFilter(pred, cc, keep))
246+ if drawn == 0: return NilV)
235247
236248 proc lazyTake(n: int, c: Cursor): Value =
237249 if n <= 0: return mkList(@[])
238250 let cur = c
239251 mkLazy(proc (): Value =
240252 var cc = cur
241- if not hasNext(cc): return NilV
242- let x = next(cc)
243- mkCons(x, lazyTake(n - 1, cc)))
253+ var want = min(n, ChunkSize)
254+ var xs = newSeqOfCap[Value](want)
255+ while xs.len < want and hasNext(cc): xs.add next(cc)
256+ if xs.len == 0: return NilV
257+ mkChunk(xs, 0, lazyTake(n - xs.len, cc)))
244258
245259 proc lazyDrop(n: int, c: Cursor): Value =
246260 let cur = c
@@ -271,9 +285,18 @@ proc lazyDropWhile(pred: Value, c: Cursor): Value =
271285 cc = peek)
272286
273287 proc lazyRange(i, hi, step: int64, bounded: bool): Value =
288+ ## Chunked: one thunk and one chunk per ChunkSize elements rather than a
289+ ## cons and a thunk each. An unbounded range stays lazy -- it just realizes
290+ ## a bounded batch at a time.
274291 mkLazy(proc (): Value =
275- if bounded and ((step > 0 and i >= hi) or (step < 0 and i <= hi)): return NilV
276- mkCons(mkInt(i), lazyRange(i + step, hi, step, bounded)))
292+ var cur = i
293+ var xs = newSeqOfCap[Value](ChunkSize)
294+ while xs.len < ChunkSize:
295+ if bounded and ((step > 0 and cur >= hi) or (step < 0 and cur <= hi)): break
296+ xs.add mkInt(cur)
297+ cur += step
298+ if xs.len == 0: return NilV
299+ mkChunk(xs, 0, lazyRange(cur, hi, step, bounded)))
277300
278301 proc lazyIterate(f, x: Value): Value =
279302 mkLazy(proc (): Value = mkCons(x, lazyIterate(f, call(f, @[x]))))
@@ -636,12 +659,16 @@ proc registerCore*() =
636659 def "remove", proc (a: seq[Value]): Value =
637660 lazyFilter(a[0], cursor(a[1]), keep = false)
638661 def "reduce", proc (a: seq[Value]): Value =
662+ ## Streams the source rather than materializing it, so folding a lazy seq
663+ ## holds one chunk at a time instead of the whole sequence.
639664 let f = a[0]
640665 if a.len == 2:
641- let s = toSeq(a[1])
642- if s.len == 0: return call(f, @[])
643- var acc = s[0]
644- for i in 1 ..< s.len: acc = call(f, @[acc, s[i]])
666+ var acc = NilV
667+ var first = true
668+ for x in elems(a[1]):
669+ if first: acc = x; first = false
670+ else: acc = call(f, @[acc, x])
671+ if first: return call(f, @[])
645672 return acc
646673 var acc = a[1]
647674 for x in elems(a[2]): acc = call(f, @[acc, x])
@@ -198,12 +198,17 @@ proc lazyOf(c: Cursor): Value =
198 mkCons(x, lazyOf(cc)))198 mkCons(x, lazyOf(cc)))
199 199
200 proc lazyMap(f: Value, c: Cursor): Value =200 proc lazyMap(f: Value, c: Cursor): Value =
201+ ## Chunked, which means f runs for up to ChunkSize elements as soon as the
202+ ## first is demanded -- the same trade Clojure makes. f must not be relied on
203+ ## for per-element side effects; `doseq` and `run!` are the tools for that.
201 let cur = c204 let cur = c
202 mkLazy(proc (): Value =205 mkLazy(proc (): Value =
203 var cc = cur206 var cc = cur
204- if not hasNext(cc): return NilV207+ var xs = newSeqOfCap[Value](ChunkSize)
205- let x = next(cc)208+ while xs.len < ChunkSize and hasNext(cc):
206- mkCons(call(f, @[x]), lazyMap(f, cc)))209+ xs.add call(f, @[next(cc)])
210+ if xs.len == 0: return NilV
211+ mkChunk(xs, 0, lazyMap(f, cc)))
207 212
208 proc lazyMapN(f: Value, cs: seq[Cursor]): Value =213 proc lazyMapN(f: Value, cs: seq[Cursor]): Value =
209 let curs = cs214 let curs = cs
@@ -224,23 +229,32 @@ proc lazyMapIndexed(f: Value, i: int64, c: Cursor): Value =
224 mkCons(call(f, @[mkInt(i), x]), lazyMapIndexed(f, i + 1, cc)))229 mkCons(call(f, @[mkInt(i), x]), lazyMapIndexed(f, i + 1, cc)))
225 230
226 proc lazyFilter(pred: Value, c: Cursor, keep: bool): Value =231 proc lazyFilter(pred: Value, c: Cursor, keep: bool): Value =
232+ ## Draws up to ChunkSize source elements per step and emits whichever pass,
233+ ## rather than pulling until ChunkSize have passed: that keeps the work per
234+ ## step bounded when matches are rare in a long or infinite source.
227 let cur = c235 let cur = c
228 mkLazy(proc (): Value =236 mkLazy(proc (): Value =
229 var cc = cur237 var cc = cur
230- while hasNext(cc):238+ while true:
231- let x = next(cc)239+ var xs = newSeqOfCap[Value](ChunkSize)
232- if truthy(call(pred, @[x])) == keep:240+ var drawn = 0
233- return mkCons(x, lazyFilter(pred, cc, keep))241+ while drawn < ChunkSize and hasNext(cc):
234- NilV)242+ let x = next(cc)
243+ if truthy(call(pred, @[x])) == keep: xs.add x
244+ inc drawn
245+ if xs.len > 0: return mkChunk(xs, 0, lazyFilter(pred, cc, keep))
246+ if drawn == 0: return NilV)
235 247
236 proc lazyTake(n: int, c: Cursor): Value =248 proc lazyTake(n: int, c: Cursor): Value =
237 if n <= 0: return mkList(@[])249 if n <= 0: return mkList(@[])
238 let cur = c250 let cur = c
239 mkLazy(proc (): Value =251 mkLazy(proc (): Value =
240 var cc = cur252 var cc = cur
241- if not hasNext(cc): return NilV253+ var want = min(n, ChunkSize)
242- let x = next(cc)254+ var xs = newSeqOfCap[Value](want)
243- mkCons(x, lazyTake(n - 1, cc)))255+ while xs.len < want and hasNext(cc): xs.add next(cc)
256+ if xs.len == 0: return NilV
257+ mkChunk(xs, 0, lazyTake(n - xs.len, cc)))
244 258
245 proc lazyDrop(n: int, c: Cursor): Value =259 proc lazyDrop(n: int, c: Cursor): Value =
246 let cur = c260 let cur = c
@@ -271,9 +285,18 @@ proc lazyDropWhile(pred: Value, c: Cursor): Value =
271 cc = peek)285 cc = peek)
272 286
273 proc lazyRange(i, hi, step: int64, bounded: bool): Value =287 proc lazyRange(i, hi, step: int64, bounded: bool): Value =
288+ ## Chunked: one thunk and one chunk per ChunkSize elements rather than a
289+ ## cons and a thunk each. An unbounded range stays lazy -- it just realizes
290+ ## a bounded batch at a time.
274 mkLazy(proc (): Value =291 mkLazy(proc (): Value =
275- if bounded and ((step > 0 and i >= hi) or (step < 0 and i <= hi)): return NilV292+ var cur = i
276- mkCons(mkInt(i), lazyRange(i + step, hi, step, bounded)))293+ var xs = newSeqOfCap[Value](ChunkSize)
294+ while xs.len < ChunkSize:
295+ if bounded and ((step > 0 and cur >= hi) or (step < 0 and cur <= hi)): break
296+ xs.add mkInt(cur)
297+ cur += step
298+ if xs.len == 0: return NilV
299+ mkChunk(xs, 0, lazyRange(cur, hi, step, bounded)))
277 300
278 proc lazyIterate(f, x: Value): Value =301 proc lazyIterate(f, x: Value): Value =
279 mkLazy(proc (): Value = mkCons(x, lazyIterate(f, call(f, @[x]))))302 mkLazy(proc (): Value = mkCons(x, lazyIterate(f, call(f, @[x]))))
@@ -636,12 +659,16 @@ proc registerCore*() =
636 def "remove", proc (a: seq[Value]): Value =659 def "remove", proc (a: seq[Value]): Value =
637 lazyFilter(a[0], cursor(a[1]), keep = false)660 lazyFilter(a[0], cursor(a[1]), keep = false)
638 def "reduce", proc (a: seq[Value]): Value =661 def "reduce", proc (a: seq[Value]): Value =
662+ ## Streams the source rather than materializing it, so folding a lazy seq
663+ ## holds one chunk at a time instead of the whole sequence.
639 let f = a[0]664 let f = a[0]
640 if a.len == 2:665 if a.len == 2:
641- let s = toSeq(a[1])666+ var acc = NilV
642- if s.len == 0: return call(f, @[])667+ var first = true
643- var acc = s[0]668+ for x in elems(a[1]):
644- for i in 1 ..< s.len: acc = call(f, @[acc, s[i]])669+ if first: acc = x; first = false
670+ else: acc = call(f, @[acc, x])
671+ if first: return call(f, @[])
645 return acc672 return acc
646 var acc = a[1]673 var acc = a[1]
647 for x in elems(a[2]): acc = call(f, @[acc, x])674 for x in elems(a[2]): acc = call(f, @[acc, x])
modified src/runtime.nim +79 -34
@@ -17,7 +17,7 @@ const
1717 type
1818 Kind* = enum
1919 kNil, kBool, kInt, kFloat, kStr, kKeyword, kSymbol,
20- kList, kVector, kMap, kSet, kCons, kLazy, kFn
20+ kList, kVector, kMap, kSet, kCons, kChunk, kLazy, kFn
2121
2222 VNode* = ref object
2323 ## A trie node: leaves hold values, internal nodes hold children.
@@ -70,6 +70,12 @@ type
7070 of kCons:
7171 head*: Value
7272 tl*: Value ## rest of the seq: a cons, a lazy seq, a coll, or nil
73+ of kChunk:
74+ ## A run of elements already realized together, plus the rest of the seq.
75+ ## One of these per 32 elements replaces 32 cons cells and 32 thunks.
76+ chunk*: seq[Value]
77+ coff*: int ## index of the first element not yet consumed
78+ ctl*: Value ## rest of the seq beyond this run
7379 of kLazy:
7480 thunk*: proc (): Value {.closure.}
7581 cached*: Value
@@ -107,6 +113,10 @@ proc `=destroy`*(x: var ValueObj) =
107113 `=destroy`(x.head)
108114 if not x.tl.isNil: pendingFree.add x.tl # +1, outlives the release below
109115 `=destroy`(x.tl)
116+ of kChunk:
117+ `=destroy`(x.chunk)
118+ if not x.ctl.isNil: pendingFree.add x.ctl
119+ `=destroy`(x.ctl)
110120 of kLazy:
111121 `=destroy`(x.thunk)
112122 if not x.cached.isNil: pendingFree.add x.cached
@@ -407,6 +417,16 @@ proc mkFn*(name: string, f: proc (args: seq[Value]): Value {.closure.}): Value =
407417
408418 proc mkCons*(h, t: Value): Value = Value(kind: kCons, head: h, tl: t)
409419
420+const ChunkSize* = 32
421+ ## Elements realized per step by a chunked producer. The same batch size
422+ ## Clojure uses: big enough to amortize the per-step allocation, small enough
423+ ## that an infinite seq still costs a bounded amount to look at.
424+
425+proc mkChunk*(xs: sink seq[Value], off: int, rest: Value): Value =
426+ ## Normalizes an exhausted chunk away, so a cursor never sees an empty run.
427+ if off >= xs.len: return (if rest.isNil: NilV else: rest)
428+ Value(kind: kChunk, chunk: xs, coff: off, ctl: rest)
429+
410430 proc mkLazy*(f: proc (): Value {.closure.}): Value =
411431 Value(kind: kLazy, thunk: f, cached: nil, forced: false)
412432
@@ -423,43 +443,64 @@ proc force*(v: Value): Value =
423443 cur
424444
425445 proc isSeqNode(v: Value): bool =
426- not v.isNil and v.kind in {kCons, kLazy}
446+ not v.isNil and v.kind in {kCons, kChunk, kLazy}
427447
428448 type Cursor* = object
429- ## Walks any seqable value without materializing it. Cons/lazy chains are
430- ## followed link by link; concrete collections are indexed.
431- node: Value
432- backing: seq[Value]
449+ ## Walks any seqable value without materializing it. A chunk is indexed
450+ ## where it lies -- copying its elements into the cursor would undo the point
451+ ## of chunking -- a concrete collection is indexed through `backing`, and
452+ ## cons/lazy links are followed one at a time. `node` is the unrealized rest.
453+ run: Value ## kChunk currently being indexed, else nil
454+ backing: seq[Value] ## concrete collection being indexed, else empty
433455 idx: int
434- isNode: bool
456+ node: Value
435457
436458 proc cursor*(v: Value): Cursor =
437- ## Forces nothing: a cons/lazy value is walked link by link, and `hasNext`
438- ## is the only thing that ever forces. So building `(take 3 (map f xs))`
439- ## runs `f` zero times until something asks for an element.
440- if v.isNil: return Cursor(isNode: false)
441- if v.kind in {kCons, kLazy, kNil}: Cursor(isNode: true, node: v)
442- else: Cursor(isNode: false, backing: toSeq(v))
459+ ## Forces nothing: `hasNext` is the only thing that ever forces, so building
460+ ## `(take 3 (map f xs))` runs `f` zero times until something asks for an
461+ ## element.
462+ if v.isNil: return Cursor()
463+ if v.kind in {kCons, kChunk, kLazy, kNil}: Cursor(node: v)
464+ else: Cursor(backing: toSeq(v))
443465
444466 proc hasNext*(c: var Cursor): bool =
445- if not c.isNode: return c.idx < c.backing.len
446- c.node = force(c.node)
447- if c.node.isNil or c.node.kind == kNil: return false
448- if c.node.kind == kCons: return true
449- # The tail bottomed out in a concrete collection, as `(cons x [1 2])` does.
450- # Switch to indexing it rather than reporting the seq as finished.
451- c.isNode = false
452- c.backing = toSeq(c.node)
453- c.idx = 0
454- c.idx < c.backing.len
467+ while true:
468+ if not c.run.isNil:
469+ if c.idx < c.run.chunk.len: return true
470+ c.run = nil
471+ c.idx = 0
472+ elif c.idx < c.backing.len:
473+ return true
474+ if c.node.isNil: return false
475+ c.node = force(c.node)
476+ if c.node.isNil or c.node.kind == kNil:
477+ c.node = nil
478+ return false
479+ case c.node.kind
480+ of kCons:
481+ return true # the element is c.node.head
482+ of kChunk:
483+ c.run = c.node
484+ c.idx = c.node.coff
485+ c.backing = @[]
486+ c.node = c.node.ctl
487+ else:
488+ # The tail bottomed out in a concrete collection, as `(cons x [1 2])`
489+ # does. Switch to indexing it rather than reporting the seq as finished.
490+ c.backing = toSeq(c.node)
491+ c.idx = 0
492+ c.node = nil
455493
456494 proc next*(c: var Cursor): Value =
457- if c.isNode:
458- result = c.node.head
459- c.node = c.node.tl
460- else:
495+ if not c.run.isNil:
496+ result = c.run.chunk[c.idx]
497+ inc c.idx
498+ elif c.idx < c.backing.len:
461499 result = c.backing[c.idx]
462500 inc c.idx
501+ else:
502+ result = c.node.head # valid only just after hasNext saw a cons
503+ c.node = c.node.tl
463504
464505 iterator elems*(v: Value): Value =
465506 ## The one way to walk a collection in core: works for lists, vectors, maps,
@@ -472,6 +513,7 @@ proc seqFirst*(v: Value): Value =
472513 let f = force(v)
473514 if f.isNil: return NilV
474515 if f.kind == kCons: return f.head
516+ if f.kind == kChunk: return f.chunk[f.coff]
475517 var c = cursor(f)
476518 (if hasNext(c): next(c) else: NilV)
477519
@@ -481,6 +523,9 @@ proc seqRest*(v: Value): Value =
481523 let f = force(v)
482524 if f.isNil or f.kind == kNil: return mkList(@[])
483525 if f.kind == kCons: return (if f.tl.isNil: mkList(@[]) else: f.tl)
526+ if f.kind == kChunk:
527+ let r = mkChunk(f.chunk, f.coff + 1, f.ctl)
528+ return (if r.isNil or r.kind == kNil: mkList(@[]) else: r)
484529 let xs = toSeq(f)
485530 (if xs.len <= 1: mkList(@[]) else: mkList(xs[1 .. ^1]))
486531
@@ -512,7 +557,7 @@ proc hashValue*(v: Value): uint32 =
512557 of kStr: mixHash(1'u32, uint32(hash(v.s)))
513558 of kKeyword: mixHash(2'u32, uint32(hash(v.s)))
514559 of kSymbol: mixHash(3'u32, uint32(hash(v.s)))
515- of kList, kVector, kCons, kLazy:
560+ of kList, kVector, kCons, kChunk, kLazy:
516561 # sequentials are `=` when their elements are, so they hash alike
517562 var h = 7'u32
518563 for x in elems(v): h = mixHash(h, hashValue(x))
@@ -540,7 +585,7 @@ proc items*(v: Value): seq[Value] =
540585 var r = newSeqOfCap[Value](v.m.cnt)
541586 for e in mapEntries(v.m): r.add e.key
542587 r
543- of kCons, kLazy:
588+ of kCons, kChunk, kLazy:
544589 var r: seq[Value] = @[]
545590 var c = cursor(v)
546591 while hasNext(c): r.add next(c)
@@ -560,7 +605,7 @@ proc count*(v: Value): int =
560605 of kVector: v.vec.cnt
561606 of kMap, kSet: v.m.cnt
562607 of kStr: v.s.len
563- of kCons, kLazy:
608+ of kCons, kChunk, kLazy:
564609 # realizes the whole seq, which is the honest cost of counting one
565610 var n = 0
566611 var c = cursor(v)
@@ -582,7 +627,7 @@ proc equals*(a, b: Value): bool =
582627 if a.kind == kInt and b.kind == kFloat: return float64(a.i) == b.f
583628 if a.kind == kFloat and b.kind == kInt: return a.f == float64(b.i)
584629 # every sequential thing is `=` to every other with the same elements
585- const Seqs = {kList, kVector, kCons, kLazy}
630+ const Seqs = {kList, kVector, kCons, kChunk, kLazy}
586631 if a.kind in Seqs and b.kind in Seqs:
587632 var ca = cursor(a)
588633 var cb = cursor(b)
@@ -610,7 +655,7 @@ proc equals*(a, b: Value): bool =
610655 if not equals(e.val, mapGet(b.m, e.key, missing)): return false
611656 true
612657 of kFn: a == b
613- of kList, kVector, kCons, kLazy: false # handled above
658+ of kList, kVector, kCons, kChunk, kLazy: false # handled above
614659 # ---------------------------------------------------------------- printing
615660 proc escapeStr(s: string): string =
616661 result = "\""
@@ -637,7 +682,7 @@ proc toStr*(v: Value, readable: bool): string =
637682 of kStr: (if readable: escapeStr(v.s) else: v.s)
638683 of kKeyword: ":" & v.s
639684 of kSymbol: v.s
640- of kList, kCons, kLazy:
685+ of kList, kCons, kChunk, kLazy:
641686 # printing a lazy seq realizes it, exactly as in Clojure
642687 var parts: seq[string] = @[]
643688 for x in elems(v): parts.add toStr(x, readable)
@@ -726,7 +771,7 @@ proc toSeq*(v: Value): seq[Value] =
726771 if v.isNil: return @[]
727772 case v.kind
728773 of kNil: @[]
729- of kList, kVector, kSet, kCons, kLazy: v.items
774+ of kList, kVector, kSet, kCons, kChunk, kLazy: v.items
730775 of kStr:
731776 var r: seq[Value] = @[]
732777 for c in v.s: r.add mkStr($c)
@@ -17,7 +17,7 @@ const
17 type17 type
18 Kind* = enum18 Kind* = enum
19 kNil, kBool, kInt, kFloat, kStr, kKeyword, kSymbol,19 kNil, kBool, kInt, kFloat, kStr, kKeyword, kSymbol,
20- kList, kVector, kMap, kSet, kCons, kLazy, kFn20+ kList, kVector, kMap, kSet, kCons, kChunk, kLazy, kFn
21 21
22 VNode* = ref object22 VNode* = ref object
23 ## A trie node: leaves hold values, internal nodes hold children.23 ## A trie node: leaves hold values, internal nodes hold children.
@@ -70,6 +70,12 @@ type
70 of kCons:70 of kCons:
71 head*: Value71 head*: Value
72 tl*: Value ## rest of the seq: a cons, a lazy seq, a coll, or nil72 tl*: Value ## rest of the seq: a cons, a lazy seq, a coll, or nil
73+ of kChunk:
74+ ## A run of elements already realized together, plus the rest of the seq.
75+ ## One of these per 32 elements replaces 32 cons cells and 32 thunks.
76+ chunk*: seq[Value]
77+ coff*: int ## index of the first element not yet consumed
78+ ctl*: Value ## rest of the seq beyond this run
73 of kLazy:79 of kLazy:
74 thunk*: proc (): Value {.closure.}80 thunk*: proc (): Value {.closure.}
75 cached*: Value81 cached*: Value
@@ -107,6 +113,10 @@ proc `=destroy`*(x: var ValueObj) =
107 `=destroy`(x.head)113 `=destroy`(x.head)
108 if not x.tl.isNil: pendingFree.add x.tl # +1, outlives the release below114 if not x.tl.isNil: pendingFree.add x.tl # +1, outlives the release below
109 `=destroy`(x.tl)115 `=destroy`(x.tl)
116+ of kChunk:
117+ `=destroy`(x.chunk)
118+ if not x.ctl.isNil: pendingFree.add x.ctl
119+ `=destroy`(x.ctl)
110 of kLazy:120 of kLazy:
111 `=destroy`(x.thunk)121 `=destroy`(x.thunk)
112 if not x.cached.isNil: pendingFree.add x.cached122 if not x.cached.isNil: pendingFree.add x.cached
@@ -407,6 +417,16 @@ proc mkFn*(name: string, f: proc (args: seq[Value]): Value {.closure.}): Value =
407 417
408 proc mkCons*(h, t: Value): Value = Value(kind: kCons, head: h, tl: t)418 proc mkCons*(h, t: Value): Value = Value(kind: kCons, head: h, tl: t)
409 419
420+const ChunkSize* = 32
421+ ## Elements realized per step by a chunked producer. The same batch size
422+ ## Clojure uses: big enough to amortize the per-step allocation, small enough
423+ ## that an infinite seq still costs a bounded amount to look at.
424+
425+proc mkChunk*(xs: sink seq[Value], off: int, rest: Value): Value =
426+ ## Normalizes an exhausted chunk away, so a cursor never sees an empty run.
427+ if off >= xs.len: return (if rest.isNil: NilV else: rest)
428+ Value(kind: kChunk, chunk: xs, coff: off, ctl: rest)
429+
410 proc mkLazy*(f: proc (): Value {.closure.}): Value =430 proc mkLazy*(f: proc (): Value {.closure.}): Value =
411 Value(kind: kLazy, thunk: f, cached: nil, forced: false)431 Value(kind: kLazy, thunk: f, cached: nil, forced: false)
412 432
@@ -423,43 +443,64 @@ proc force*(v: Value): Value =
423 cur443 cur
424 444
425 proc isSeqNode(v: Value): bool =445 proc isSeqNode(v: Value): bool =
426- not v.isNil and v.kind in {kCons, kLazy}446+ not v.isNil and v.kind in {kCons, kChunk, kLazy}
427 447
428 type Cursor* = object448 type Cursor* = object
429- ## Walks any seqable value without materializing it. Cons/lazy chains are449+ ## Walks any seqable value without materializing it. A chunk is indexed
430- ## followed link by link; concrete collections are indexed.450+ ## where it lies -- copying its elements into the cursor would undo the point
431- node: Value451+ ## of chunking -- a concrete collection is indexed through `backing`, and
432- backing: seq[Value]452+ ## cons/lazy links are followed one at a time. `node` is the unrealized rest.
453+ run: Value ## kChunk currently being indexed, else nil
454+ backing: seq[Value] ## concrete collection being indexed, else empty
433 idx: int455 idx: int
434- isNode: bool456+ node: Value
435 457
436 proc cursor*(v: Value): Cursor =458 proc cursor*(v: Value): Cursor =
437- ## Forces nothing: a cons/lazy value is walked link by link, and `hasNext`459+ ## Forces nothing: `hasNext` is the only thing that ever forces, so building
438- ## is the only thing that ever forces. So building `(take 3 (map f xs))`460+ ## `(take 3 (map f xs))` runs `f` zero times until something asks for an
439- ## runs `f` zero times until something asks for an element.461+ ## element.
440- if v.isNil: return Cursor(isNode: false)462+ if v.isNil: return Cursor()
441- if v.kind in {kCons, kLazy, kNil}: Cursor(isNode: true, node: v)463+ if v.kind in {kCons, kChunk, kLazy, kNil}: Cursor(node: v)
442- else: Cursor(isNode: false, backing: toSeq(v))464+ else: Cursor(backing: toSeq(v))
443 465
444 proc hasNext*(c: var Cursor): bool =466 proc hasNext*(c: var Cursor): bool =
445- if not c.isNode: return c.idx < c.backing.len467+ while true:
446- c.node = force(c.node)468+ if not c.run.isNil:
447- if c.node.isNil or c.node.kind == kNil: return false469+ if c.idx < c.run.chunk.len: return true
448- if c.node.kind == kCons: return true470+ c.run = nil
449- # The tail bottomed out in a concrete collection, as `(cons x [1 2])` does.471+ c.idx = 0
450- # Switch to indexing it rather than reporting the seq as finished.472+ elif c.idx < c.backing.len:
451- c.isNode = false473+ return true
452- c.backing = toSeq(c.node)474+ if c.node.isNil: return false
453- c.idx = 0475+ c.node = force(c.node)
454- c.idx < c.backing.len476+ if c.node.isNil or c.node.kind == kNil:
477+ c.node = nil
478+ return false
479+ case c.node.kind
480+ of kCons:
481+ return true # the element is c.node.head
482+ of kChunk:
483+ c.run = c.node
484+ c.idx = c.node.coff
485+ c.backing = @[]
486+ c.node = c.node.ctl
487+ else:
488+ # The tail bottomed out in a concrete collection, as `(cons x [1 2])`
489+ # does. Switch to indexing it rather than reporting the seq as finished.
490+ c.backing = toSeq(c.node)
491+ c.idx = 0
492+ c.node = nil
455 493
456 proc next*(c: var Cursor): Value =494 proc next*(c: var Cursor): Value =
457- if c.isNode:495+ if not c.run.isNil:
458- result = c.node.head496+ result = c.run.chunk[c.idx]
459- c.node = c.node.tl497+ inc c.idx
460- else:498+ elif c.idx < c.backing.len:
461 result = c.backing[c.idx]499 result = c.backing[c.idx]
462 inc c.idx500 inc c.idx
501+ else:
502+ result = c.node.head # valid only just after hasNext saw a cons
503+ c.node = c.node.tl
463 504
464 iterator elems*(v: Value): Value =505 iterator elems*(v: Value): Value =
465 ## The one way to walk a collection in core: works for lists, vectors, maps,506 ## The one way to walk a collection in core: works for lists, vectors, maps,
@@ -472,6 +513,7 @@ proc seqFirst*(v: Value): Value =
472 let f = force(v)513 let f = force(v)
473 if f.isNil: return NilV514 if f.isNil: return NilV
474 if f.kind == kCons: return f.head515 if f.kind == kCons: return f.head
516+ if f.kind == kChunk: return f.chunk[f.coff]
475 var c = cursor(f)517 var c = cursor(f)
476 (if hasNext(c): next(c) else: NilV)518 (if hasNext(c): next(c) else: NilV)
477 519
@@ -481,6 +523,9 @@ proc seqRest*(v: Value): Value =
481 let f = force(v)523 let f = force(v)
482 if f.isNil or f.kind == kNil: return mkList(@[])524 if f.isNil or f.kind == kNil: return mkList(@[])
483 if f.kind == kCons: return (if f.tl.isNil: mkList(@[]) else: f.tl)525 if f.kind == kCons: return (if f.tl.isNil: mkList(@[]) else: f.tl)
526+ if f.kind == kChunk:
527+ let r = mkChunk(f.chunk, f.coff + 1, f.ctl)
528+ return (if r.isNil or r.kind == kNil: mkList(@[]) else: r)
484 let xs = toSeq(f)529 let xs = toSeq(f)
485 (if xs.len <= 1: mkList(@[]) else: mkList(xs[1 .. ^1]))530 (if xs.len <= 1: mkList(@[]) else: mkList(xs[1 .. ^1]))
486 531
@@ -512,7 +557,7 @@ proc hashValue*(v: Value): uint32 =
512 of kStr: mixHash(1'u32, uint32(hash(v.s)))557 of kStr: mixHash(1'u32, uint32(hash(v.s)))
513 of kKeyword: mixHash(2'u32, uint32(hash(v.s)))558 of kKeyword: mixHash(2'u32, uint32(hash(v.s)))
514 of kSymbol: mixHash(3'u32, uint32(hash(v.s)))559 of kSymbol: mixHash(3'u32, uint32(hash(v.s)))
515- of kList, kVector, kCons, kLazy:560+ of kList, kVector, kCons, kChunk, kLazy:
516 # sequentials are `=` when their elements are, so they hash alike561 # sequentials are `=` when their elements are, so they hash alike
517 var h = 7'u32562 var h = 7'u32
518 for x in elems(v): h = mixHash(h, hashValue(x))563 for x in elems(v): h = mixHash(h, hashValue(x))
@@ -540,7 +585,7 @@ proc items*(v: Value): seq[Value] =
540 var r = newSeqOfCap[Value](v.m.cnt)585 var r = newSeqOfCap[Value](v.m.cnt)
541 for e in mapEntries(v.m): r.add e.key586 for e in mapEntries(v.m): r.add e.key
542 r587 r
543- of kCons, kLazy:588+ of kCons, kChunk, kLazy:
544 var r: seq[Value] = @[]589 var r: seq[Value] = @[]
545 var c = cursor(v)590 var c = cursor(v)
546 while hasNext(c): r.add next(c)591 while hasNext(c): r.add next(c)
@@ -560,7 +605,7 @@ proc count*(v: Value): int =
560 of kVector: v.vec.cnt605 of kVector: v.vec.cnt
561 of kMap, kSet: v.m.cnt606 of kMap, kSet: v.m.cnt
562 of kStr: v.s.len607 of kStr: v.s.len
563- of kCons, kLazy:608+ of kCons, kChunk, kLazy:
564 # realizes the whole seq, which is the honest cost of counting one609 # realizes the whole seq, which is the honest cost of counting one
565 var n = 0610 var n = 0
566 var c = cursor(v)611 var c = cursor(v)
@@ -582,7 +627,7 @@ proc equals*(a, b: Value): bool =
582 if a.kind == kInt and b.kind == kFloat: return float64(a.i) == b.f627 if a.kind == kInt and b.kind == kFloat: return float64(a.i) == b.f
583 if a.kind == kFloat and b.kind == kInt: return a.f == float64(b.i)628 if a.kind == kFloat and b.kind == kInt: return a.f == float64(b.i)
584 # every sequential thing is `=` to every other with the same elements629 # every sequential thing is `=` to every other with the same elements
585- const Seqs = {kList, kVector, kCons, kLazy}630+ const Seqs = {kList, kVector, kCons, kChunk, kLazy}
586 if a.kind in Seqs and b.kind in Seqs:631 if a.kind in Seqs and b.kind in Seqs:
587 var ca = cursor(a)632 var ca = cursor(a)
588 var cb = cursor(b)633 var cb = cursor(b)
@@ -610,7 +655,7 @@ proc equals*(a, b: Value): bool =
610 if not equals(e.val, mapGet(b.m, e.key, missing)): return false655 if not equals(e.val, mapGet(b.m, e.key, missing)): return false
611 true656 true
612 of kFn: a == b657 of kFn: a == b
613- of kList, kVector, kCons, kLazy: false # handled above658+ of kList, kVector, kCons, kChunk, kLazy: false # handled above
614 # ---------------------------------------------------------------- printing659 # ---------------------------------------------------------------- printing
615 proc escapeStr(s: string): string =660 proc escapeStr(s: string): string =
616 result = "\""661 result = "\""
@@ -637,7 +682,7 @@ proc toStr*(v: Value, readable: bool): string =
637 of kStr: (if readable: escapeStr(v.s) else: v.s)682 of kStr: (if readable: escapeStr(v.s) else: v.s)
638 of kKeyword: ":" & v.s683 of kKeyword: ":" & v.s
639 of kSymbol: v.s684 of kSymbol: v.s
640- of kList, kCons, kLazy:685+ of kList, kCons, kChunk, kLazy:
641 # printing a lazy seq realizes it, exactly as in Clojure686 # printing a lazy seq realizes it, exactly as in Clojure
642 var parts: seq[string] = @[]687 var parts: seq[string] = @[]
643 for x in elems(v): parts.add toStr(x, readable)688 for x in elems(v): parts.add toStr(x, readable)
@@ -726,7 +771,7 @@ proc toSeq*(v: Value): seq[Value] =
726 if v.isNil: return @[]771 if v.isNil: return @[]
727 case v.kind772 case v.kind
728 of kNil: @[]773 of kNil: @[]
729- of kList, kVector, kSet, kCons, kLazy: v.items774+ of kList, kVector, kSet, kCons, kChunk, kLazy: v.items
730 of kStr:775 of kStr:
731 var r: seq[Value] = @[]776 var r: seq[Value] = @[]
732 for c in v.s: r.add mkStr($c)777 for c in v.s: r.add mkStr($c)
added tests/chunking.expected +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+built: 0
2+(0 1 2) calls: 32
3+(0 1 2) calls: 32
4+(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39) calls: 64
5+(0 2 4 6 8)
6+101
7+(1 2 3)
8+1000 (10 11 12 13)
9+100
10+5000
11+(1 2 0 1)
12+11
13+115
14+(10 21 12)
new file mode 100644
@@ -0,0 +1,14 @@
1+built: 0
2+(0 1 2) calls: 32
3+(0 1 2) calls: 32
4+(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39) calls: 64
5+(0 2 4 6 8)
6+101
7+(1 2 3)
8+1000 (10 11 12 13)
9+100
10+5000
11+(1 2 0 1)
12+11
13+115
14+(10 21 12)
modified tests/lazy.expected +2 -2
@@ -10,8 +10,8 @@
1010 (1 2 0 1 2)
1111 1 1 1000
1212 built, calls so far: 0
13-took (0 1 2) - calls: 3
14-took (0 1 2) - calls: 3 (memoized)
13+took (0 1 2) - calls: 32
14+took (0 1 2) - calls: 32 (memoized)
1515 1330
1616 0 1 (2 3 4)
1717 (2 3 4) (2 4)
@@ -10,8 +10,8 @@
10 (1 2 0 1 2)10 (1 2 0 1 2)
11 1 1 100011 1 1 1000
12 built, calls so far: 012 built, calls so far: 0
13-took (0 1 2) - calls: 313+took (0 1 2) - calls: 32
14-took (0 1 2) - calls: 3 (memoized)14+took (0 1 2) - calls: 32 (memoized)
15 133015 1330
16 0 1 (2 3 4)16 0 1 (2 3 4)
17 (2 3 4) (2 4)17 (2 3 4) (2 4)