Skip to content

improvement: memoize Comparable's type-pair dispatch in a release - #2819

Merged
zachdaniel merged 2 commits into
ash-project:mainfrom
matt-beanland:improvement/comp-memoise-dispatch
Aug 4, 2026
Merged

improvement: memoize Comparable's type-pair dispatch in a release#2819
zachdaniel merged 2 commits into
ash-project:mainfrom
matt-beanland:improvement/comp-memoise-dispatch

Conversation

@matt-beanland

Copy link
Copy Markdown
Contributor

Contributor checklist

Leave anything that you believe does not apply unchecked.

  • I accept the AI Policy, or AI was not used in the creation of this PR.
  • Bug fixes include regression tests
  • Chores
  • Documentation changes
  • Features include unit/acceptance tests
  • Refactoring
  • Update dependencies

Summary

Comp.new/2 rebuilds the dispatch module on every comparison. For any pair with no specific comparator (the common case) that is a raised and caught ArgumentError. This memoises the resolution, a pure function of the two type atoms, in the process dictionary. No semantics change.

It shows up in runtime in-filters. A MapSet miss falls back to Enum.any?(set, &Comp.equal?(&1, left)), so every non-matching record scans the set and each element pays that dispatch twice over. The change is to the vendored Comp. The O(n) miss scan is left as is.

Evidence

In.evaluate/1 over a miss (binary left, MapSet of binaries) is the per-record shape of a runtime in-filter. Elixir 1.20 / OTP 27.

build before after speedup
dev (unconsolidated protocols) 289.6 µs 102.0 µs 2.8x
prod (consolidated protocols) 266.0 µs 24.0 µs ~11x

Module.safe_concat/1 for one 421-element miss: 842 to 0.

Comment thread lib/comparable/comp.ex Outdated
defp dispatch(left_type, right_type) do
key = {__MODULE__, :dispatch, left_type, right_type}

case Process.get(key) do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should use persistent term here actually to memoizenit forever?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was my first thought too. I'm trying to avoid subtle bugs if/when comparators are changed during development or runtime synthesis. At least a process is restarted in development and can be easily restarted on hot code swap, whereas persistent_term lodges these in the VM where they will survive tests, etc. I've not had lots of experience with either approach so I'm happy to be guided by your decision.

With persistent_term I briefly considered using :erase in defcomparable, but it is a convenience macro so no guarantee that it would be used, another footgun.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm...thats a fair point, but I feel like we could work around it. We could use a sentinel like build_embedded or something like that to not do this in development environments 🤔

@matt-beanland
matt-beanland force-pushed the improvement/comp-memoise-dispatch branch from 7128900 to 105376c Compare August 4, 2026 00:57
@zachdaniel

Copy link
Copy Markdown
Contributor

I think there may be a simpler way to do this. I think what we can do is use something like @ref make_ref() at the top, and use that as the persistent_term key. Then if the module is ever recompiled, that @ref will change. It will be recompiled on the target types being recompiled since the protocol has a compile time dependency on them. Not sure we need a toggle for turning this off either?

@matt-beanland matt-beanland changed the title improvement: memoise Comparable's type-pair dispatch instead of rebuilding it per comparison improvement: memoize Comparable's type-pair dispatch in a release Aug 4, 2026
@matt-beanland

Copy link
Copy Markdown
Contributor Author

I was drafting an detailed explanation, but you've already read the code.

In my view we should provide an option to control the memoization irrespective of the environment. So can memoize or not (benchmarks, etc), and can reset if necessary at runtime rather than restart a VM.

I don't think we can rely on a Comparator implementing anything other than the protocol.

@matt-beanland

matt-beanland commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

I've tried the @ref idea and it compiles if you just use an integer token.
But with@ref in the Type, Comp won't be recompiled with it unless in the same source file. A protocol implementation is a module of its own.

And being compile-time, it wouldn't reach the case I'm most concerned about anyway: a comparator
synthesised into a running release, where nothing is recompiled at all.

So I think the two functions on Comp are necessary.

@matt-beanland

Copy link
Copy Markdown
Contributor Author

#2840 should be reviewed/merged first, it deals with a fairly unlikely hazard regarding Comp.compare dispatch which is compounded with memoization.

@zachdaniel

Copy link
Copy Markdown
Contributor

will merge once conflicts are resolved 👍

Interleave and repeat several distinct operand type pairs through
In.evaluate/1 so a later change memoising the type-pair -> comparator
resolution cannot let one pair's cached comparator decide another. Passes
against the current per-comparison resolution.
`Comp.new/2` rebuilds a `Comparable.Type.<L>.To.<R>` module name on every
comparison. For any pair with no specific comparator — the common case — that
is a `Module.safe_concat/1` raising `ArgumentError`, caught, and a second name
built for the `Any.To.Any` fallback. Resolving a pair is a pure function of
the two type atoms, so it can be memoized.

Where it is safe to keep the answer depends on whether code can still change.
`Module.safe_concat/1` resolves on atom existence, and atoms only ever appear,
so the one way a cached answer goes stale is a pair resolving to the fallback
before a specific comparator's atom exists. In `:embedded` mode — a release —
every module is loaded at boot and none arrives later, so the resolution is
fixed for the life of the VM and `:persistent_term` can hold it once for every
process. Under `mix`, `iex` and tests the mode is `:interactive`, code is
loaded lazily and recompiled in place, and nothing is cached.

`memoize_dispatch/1` overrides the mode at runtime and clears on the way
through, so a switch cannot leave a stale answer behind; `reset_dispatch/0`
discards what is held. Both are needed by anything that defines a comparator
at runtime, and turning it on is how a benchmark or a test reaches the cached
path at all, since both run interactively.

Being off means nothing is written, so a read is a `:persistent_term` miss and
the answer is resolved as before. The flag is therefore only consulted when a
pair is first resolved, never on a cached read, and the switch costs nothing
on the hot path.

`In.evaluate/1` over a 421-element `MapSet` miss, the per-record shape of a
runtime `in`-filter:

| build | off | on | |
| --- | ---: | ---: | ---: |
| dev, unconsolidated | 343.9 µs | 107.2 µs | 3.2x |
| prod, consolidated | 236.4 µs | 24.8 µs | 9.5x |
@matt-beanland
matt-beanland force-pushed the improvement/comp-memoise-dispatch branch from 105376c to 8ac3831 Compare August 4, 2026 15:06
@zachdaniel
zachdaniel merged commit 71747a0 into ash-project:main Aug 4, 2026
51 checks passed
@zachdaniel

Copy link
Copy Markdown
Contributor

🚀 Thank you for your contribution! 🚀

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.

2 participants