Skip to content

perf(pure/Map, pure/Set): remove closures, tuple allocations and no-op path copies - #563

Draft
timohanke wants to merge 28 commits into
caffeinelabs:mainfrom
research-ag:perf/pure-map-remove
Draft

timohanke wants to merge 28 commits into
caffeinelabs:mainfrom
research-ag:perf/pure-map-remove

Conversation

@timohanke

Copy link
Copy Markdown
Contributor

Summary

pure/Map and pure/Set allocated much more than the path copying of a persistent red-black tree requires. Four patterns, all removed here:

  1. Switching on a tuple expression allocates the tuple. lbalance/rbalance (every insertion) and balLeft/balRight/append (every removal) did switch (left, right), once per rebalanced level. They now switch on one subtree at a time: lbalance/rbalance need only one, balLeft/balRight use two switches in a row (the first returns early), and only append, whose cases depend on both subtrees, nests them. The same applies once per element or node to pure/Map.equal/compare and pure/Set.equal/isSubset/compare, now written flat with let-else and let (…) = switch … (neither allocates).
  2. Closures and captured variables. The recursions in insert/swap, remove/delete and pure/Set.contains were local closures capturing the key (plus a var box for the result and, in pure/Map.swap, an onClash closure and a { old; new } record per clash). They are now module-level functions returning (Tree, result).
  3. Projecting or switching on a returned tuple (f(…).0, switch (f(…)) { case (t, null) … }) allocates it; a let (t, y) = f(…) pattern doesn't.
  4. No-op updates rebuilt the path. Removing an absent key (Map and Set) and adding a present element (Set) descended to the end and rebuilt every node on the path before the result was thrown away. They now return the unchanged subtree as soon as the recursion reports that nothing changed.

The algorithms and the resulting trees are unchanged; no API or stable-type change.

Commits: one step each, benchmark first

Each bench file is added in its own commit before any code change, so every later commit can be checked out and benched against it (mops bench PureMap, mops bench PureSet):

  • pure/Map remove: bench (18116a8) → early exit for absent keys → module-level del/delNode → let pattern in remove/delete/take.
  • pure/Map add: bench row add (existing) (c0825c8) → lbalance/rbalance → module-level ins → let patterns in add/swap/insert → balLeft/balRight/append.
  • pure/Set: bench (0612fb3) → contains without closure → lbalance/rbalance → module-level ins → early exit for present elements → module-level del → early exit for absent elements → balLeft/balRight/append → let patterns.
  • Comparisons: bench rows equal/compare (and isSubset for Set) (035b5e6) → pure/Map.equal/compare → pure/Set.equal/isSubset/compare.
  • Readability, same performance: balLeft/balRight as two switches in a row instead of nested; equal/compare/isSubset with let-else instead of nested switches.

Measurements

moc 1.16.0, 64-bit heap, n = 10000, with [optimize] (wasm-opt 133, added locally, not committed); each row applies the operation to all n keys (remove: every second key; misses: n absent keys, each against the full structure).

pure/Map

main this PR change
Instructions: add 97_871_724 69_538_960 −29%
Instructions: add (existing) 94_122_928 63_867_308 −32%
Instructions: remove 66_369_373 51_283_851 −23%
Instructions: remove (misses) 125_048_783 25_521_765 −80%
Heap: add 18.39 MiB 12.65 MiB −31%
Heap: add (existing) 17.05 MiB 11.07 MiB −35%
Heap: remove 13.21 MiB 10.12 MiB −23%
Heap: remove (misses) 25.18 MiB 80 B 0 B per call
Instructions: equal 32_956_133 31_625_994 −4.0%
Instructions: compare 35_716_286 34_116_155 −4.5%
Heap: equal 5.65 MiB 5.26 MiB −40 B per element
Heap: compare 6.03 MiB 5.65 MiB −40 B per element

get and map are unchanged. equal/compare compare with an equal copy built separately. add (existing) now allocates ≈1160 B per call, the copied path itself.

pure/Set

main this PR change
Instructions: add 92_694_757 68_550_713 −26%
Instructions: add (existing) 84_026_233 19_271_702 −77%
Instructions: contains (hits) 19_677_093 17_985_239 −8.6%
Instructions: remove 64_426_944 49_128_669 −24%
Instructions: remove (misses) 121_829_629 22_521_765 −82%
Heap: add 16.44 MiB 11.54 MiB −30%
Heap: add (existing) 14.48 MiB 80 B 0 B per call
Heap: contains 390.7 KiB 80 B 0 B per call
Heap: remove 12.31 MiB 9.22 MiB −25%
Heap: remove (misses) 23.37 MiB 80 B 0 B per call
Instructions: equal / isSubset 5_357_785 2_527_385 −53%
Instructions: compare 29_818_081 28_095_881 −5.8%
Heap: equal / isSubset 781.37 KiB 80 B 0 B per call
Heap: compare 4.88 MiB 4.5 MiB −40 B per element

80 B is the bench's constant per-cell overhead. Without wasm-opt (the repo default) the changes are similar, and the Heap values are identical.

Not changed

  • The iterators (entries, keys, values, …, used by pure/Map.equal/compare and pure/Set.compare) allocate about 275 B per element: three list cells and three variant objects per node. That is the remaining heap of the equal/compare rows; a stack-based iterator would be a separate change.

🤖 Generated with Claude Code

timohanke and others added 27 commits October 19, 2026 10:27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recursion is now two module-level functions that return the
removed value alongside the subtree, instead of local closures that
capture the key and write the value into a captured variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Switching on a tuple returned from a call allocates it; binding it with
a let pattern doesn't. take() of an absent key now returns the map itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Switching on a tuple expression allocates the tuple.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The insertion is now a module-level function that returns the previous
value alongside the subtree, instead of local closures, a captured
variable and a clash record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Switching on a tuple expression allocates the tuple.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g the path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tor result at a time

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ow instead of nested

The first switch returns early, so the code stays flat without
switching on a tuple.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stead of nested switches

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@timohanke

Copy link
Copy Markdown
Contributor Author

This PR makes the code less readable in places: it rewrites switch statements over tuples by hand, for example switch (left, right) into one switch per subtree or into let-else, and in append into nested switches. The only reason is that a switch on a tuple allocates the tuple, while binding it with let (a, b) = … doesn't.

That's why we keep this PR as a draft for now. We hope to get caffeinelabs/motoko#6462 resolved first. Then the code in this PR can be made prettier again, back to switching on tuples, while keeping the same performance.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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