Skip to content

Tree traversal fixes and design notes - #3261

Merged
harendra-kumar merged 8 commits into
masterfrom
tree-traversal
Oct 18, 2026
Merged

harendra-kumar merged 8 commits into
masterfrom
tree-traversal

Conversation

@harendra-kumar

Copy link
Copy Markdown
Member
  • altBfsConcatIterate is renamed to wideDfsConcatIterateRev, added wideDfsConcatIterate.
  • The unfold BFS traversals no longer keep a stream open for every pending element.
  • unfoldIterate is 5-17x faster. Leaves are marked with b -> Maybe a, so they are neither held nor unfolded.
  • Breaking: parConcatIterate now takes a -> Maybe (Stream m a), like concatIterate.
  • Moved the tree combinators to Streamly.Internal.Data.Stream.Tree.
  • Added design notes in docs/Developer/tree-traversal.md.

…catIterate

altBfsConcatIterate was documented as a breadth first traversal emitting each
level in reverse order, but it is a depth first traversal that emits all the
children of a node together, then expands each of them, the last child first.
For the tree 1->[2,3], 2->[4], 3->[5], 4->[7], 5->[6] it emits
[1,2,3,5,6,4,7]. Each stream is run to completion before the next one is
started, so only one stream is open at a time: a directory tree traversal
keeps one directory open, whereas concatIterate keeps one open for each level
of the current path.

Rename it to wideDfsConcatIterateRev, keeping altBfsConcatIterate and
concatIterateBfsRev as deprecated aliases. Add wideDfsConcatIterate, which
expands the children in order, [1,2,3,4,7,5,6], the order of ls -R.

Document the defining characteristic and the resource use of concatIterate,
bfsConcatIterate and wideDfsConcatIterate. Run the concat based BFS directory
benchmarks on the big tree and add benchmarks and tests for
wideDfsConcatIterate. Rename the DirIO ConcatBfsRev, ChunkBfsRev and
ChunkFoldBfsRev benchmarks and tests to WideDfsRev.
Hide the stack of suspended unfold states from SpecConstr using a
NoSpecConstr newtype. SpecConstr specialized the loop on every stack
shape: with -fspec-constr-recursive=16 compilation ran out of memory,
with the default limit the loop fell back to boxed state beyond a stack
depth of two. On an in-memory tree unfoldIterate is now 5-17x faster
than concatIterate.

bfsUnfoldIterate and altBfsUnfoldIterate injected every element as it
was emitted, keeping a stream (e.g. a directory) open for every pending
element. They now keep the elements and inject them when their turn
comes. Rename altBfsUnfoldIterate to wideDfsUnfoldIterateRev and add
wideDfsUnfoldIterate.

Enable the iterate tests that were disabled for the compile blowup, run
the unfold BFS DirIO benchmarks and tests on the big tree, and add
in-memory tree traversal benchmarks for all the iterate combinators.
Rename the DirIO UnfoldBfsRev benchmark and test to UnfoldWideDfsRev.
These BFS variants start the streams of a level in the reverse of the
order in which their parents were emitted, avoiding the reverse of the
pending list at each level. bfsUnfoldIterateRev is about 2x faster than
bfsUnfoldIterate on an in-memory tree; bfsConcatIterateRev is 30% faster
on a binary tree of 10^6 nodes and within noise on smaller trees.
…ules

Stream.Type.Iterate was at the 500 MB GHC heap limit, so a new benchmark
could not be added to it. Move the concatIterate traversals to
Stream.Type.ConcatIterate and the unfoldIterate traversals to
Stream.Type.UnfoldIterate. The benchmark names are unchanged.
The unfold traversals took an Unfold m a a and could not tell a leaf
from a node with children, so the BFS and wide DFS traversals held every
pending element, e.g. the paths of the files as well as those of the
subdirectories of a directory tree.

All unfold traversals now take a function b -> Maybe a and an
Unfold m a b. An element for which the function returns Just a is
expanded by unfolding the seed a, an element for which it returns
Nothing is a leaf: it is not held, and no unfold is started for it.

Add unfoldIterateMaybe and bfsUnfoldIterateMaybe, and deprecate the
released unfoldIterate, bfsUnfoldIterate and altBfsUnfoldIterate in
favour of unfoldIterateMaybe, bfsUnfoldIterateMaybe and
wideDfsUnfoldIterateRev, implementing them as xMaybe Just. The
unreleased wideDfsUnfoldIterate, wideDfsUnfoldIterateRev and
bfsUnfoldIterateRev change their signature.

Summing the nodes of complete trees of 10^6 nodes held in memory,
bfsUnfoldIterate vs bfsUnfoldIterateMaybe:

  tree      time              peak memory
  binary    95 ms -> 46 ms    57 MB -> 33 MB
  10-ary    145 ms -> 8.8 ms  104 MB -> 12 MB
  1000-ary  134 ms -> 4.2 ms  129 MB -> 6 MB

and the other traversals, old vs new (ms):

                                binary       10-ary       1000-ary
  unfoldIterate(Maybe)          9.00 -> 5.76 8.67 -> 2.86 8.40 -> 2.01
  wideDfsUnfoldIterate          12.9 -> 9.37 12.3 -> 4.99 13.6 -> 3.78
  wideDfsUnfoldIterateRev       5.64 -> 5.78 6.68 -> 4.66 6.96 -> 4.06
  bfsUnfoldIterateRev           61.5 -> 31.5 94.0 -> 7.06 78.8 -> 4.32
The notes in docs/Developer/tree-traversal.md start from the primitive
iterate form and cover the traversal orders and their resource use,
marking the leaves with Maybe or Either, the concat and unfold forms,
the StreamK forms, chunked elements, stateful and graph traversal,
concurrent traversal and bottom-up reduction. They also list the
current API, its naming, the gaps in what it can express, implementation
notes and benchmark results.
parConcatIterate took a -> Stream m a, while concatIterate takes
a -> Maybe (Stream m a). It now takes the same function as
concatIterate: Nothing marks a leaf, which is emitted with K.fromPure
instead of iterating an empty stream through chanConcatMapK. This is a
breaking change; parConcatIterate cfg (Just . f) keeps the old
behaviour.

In-memory trees of 100,001 Int nodes, -N1, old code path vs Nothing:

  binary, default:       35.2 ms, 119 MB -> 34.3 ms, 111 MB
  binary, ordered True:  40.9 ms, 148 MB -> 37.4 ms, 135 MB
  10-ary, default:       31.5 ms, 112 MB -> 30.3 ms,  94 MB
  10-ary, ordered True:  37.5 ms, 138 MB -> 33.1 ms, 113 MB

The FileSystem.DirIO benchmark shows no difference, its tree has no
files and hence no leaves.

Update the DirIO bench/test helpers, the tutorial example, the
changelog and the design notes.
Move the unfoldIterate, concatIterate and foldIterate combinators and
their tests from Stream.Type to a new Streamly.Internal.Data.Stream.Tree
module, re-exported from Streamly.Internal.Data.Stream.
@harendra-kumar
harendra-kumar merged commit d3dec79 into master Oct 18, 2026
18 of 19 checks passed
@harendra-kumar
harendra-kumar deleted the tree-traversal branch October 21, 2026 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant