Skip to content

Add item-count and TTL bounds to the compile-closure cache - #140

Open
Chapaman wants to merge 3 commits into
elixir-nx:mainfrom
Chapaman:compile-cache-bounds
Open

Add item-count and TTL bounds to the compile-closure cache#140
Chapaman wants to merge 3 commits into
elixir-nx:mainfrom
Chapaman:compile-cache-bounds

Conversation

@Chapaman

@Chapaman Chapaman commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

closes #139

#137's :emlx_compile_closures table grew without a cap. This adds compile_cache_max_items and compile_cache_ttl (both :infinity by default) so a long-running serving process can opt in without changing today's unbounded behaviour.

…#139)

elixir-nx#137's table grew without a cap. compile_cache_max_items and
compile_cache_ttl both default to :infinity so serving can opt in
without changing the unbounded default. Overflow is FIFO; TTL is
from insert time. No byte cap — Erlang term size wouldn't track
MLX native memory.

@polvalente polvalente left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looking good, but I think we can improve performance!

Comment thread emlx/config/config.exs Outdated
Comment thread emlx/config/dev.exs Outdated
Comment thread emlx/lib/emlx/compile_cache.ex Outdated
Comment on lines +16 to +17
Evicting a closure does **not** free the compiled MLX program held in
`:emlx_native_dispatch_cache`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it should indeed evict it

@Chapaman Chapaman Sep 2, 2026

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.

Couldn't drop the native dispatch row here — it's shared across compile-closure keys. Any suggestions?

Comment on lines +50 to +68
@doc false
def fetch(table, key, opts) do
ttl = bound!(opts, :ttl)
# Validate both knobs on fetch and put so a bad config fails at first use.
_max_items = bound!(opts, :max_items)

case :ets.lookup(table, key) do
[{^key, fun, inserted_ms}] ->
if expired?(inserted_ms, ttl) do
:ets.delete(table, key)
:miss
else
{:ok, fun}
end

[] ->
:miss
end
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this module should become a process, such that the TTL expiration logic lives inside a loop of Process.send_after(self(), :expire_entries, div(ttl, 2)) + its corresponding handle_info

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Cache reads would only do the lookup with {:ok, result} vs {:error, :cache_miss}

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.

CompileCache is now a GenServer under EMLX.Application. Reads are a plain ETS lookup ({:ok, fun} / {:error, :cache_miss}). TTL lives in handle_info(:expire_entries) on send_after(div(ttl, 2)), skipped when ttl is :infinity at start (a running sweeper re-reads app env each tick).

Comment thread emlx/lib/emlx/compile_cache.ex Outdated
Comment on lines +105 to +118
defp evict_overflow(table, max_items) do
overflow = :ets.info(table, :size) - max_items

if overflow > 0 do
oldest_keys =
:ets.foldl(fn {key, _fun, ms}, acc -> [{ms, key} | acc] end, [], table)
|> Enum.sort_by(&elem(&1, 0))
|> Enum.take(overflow)
|> Enum.map(&elem(&1, 1))

Enum.each(oldest_keys, &:ets.delete(table, &1))
else
:ok
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think :ets.select_count could be used to find the amount of items we need to evict. Also if we keep a counter that we insert in the table, we can easily select for the items that should be deleted by matching on index <= current_counter - max_items to prune

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.

Switched to an insert index via :ets.update_counter and :ets.select_delete of index <= current - max_items.

Comment thread emlx/lib/emlx.ex Outdated
Comment on lines +165 to +186
other compilers, and it works identically here. EMLX additionally keeps a
persistent, structural (shape/op-based, not object-identity) dispatch-key
cache across calls (see `dispatch_key/3` in the source), which is what makes
`Nx.Defn.Graph.run/3`'s per-call re-tracing and structurally-identical-but-
distinct call sites (e.g. many copies of the same layer in a model) cheap
too — but a caller-held `Nx.Defn.compile/3` closure is always cheaper still.

### Compile-closure cache bounds

`:emlx_compile_closures` is unbounded by default. Long-running serving can
cap it at runtime (not `compile_env` — no recompile needed):

config :emlx, compile_cache_max_items: 1024
config :emlx, compile_cache_ttl: :timer.minutes(30)

Both accept `:infinity` (the default) or a positive integer. TTL is in
milliseconds, counted from insert time — a hit does not refresh it.
Overflow evicts the oldest inserts (FIFO). Two concurrent misses on the
same key both compile; the later insert wins.

Evicting a closure does **not** free the compiled MLX program in
`:emlx_native_dispatch_cache`. See `EMLX.CompileCache`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This docstring seems a bit too verbose, especially since it repeats a lot of what's described in the CompileCache moduledoc

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.

Dropped the bounds subsection from the EMLX moduledoc; it now points at EMLX.CompileCache.

Chapaman and others added 2 commits September 2, 2026 20:43
Co-authored-by: Paulo Valente <16843419+polvalente@users.noreply.github.com>
TTL now lives in handle_info(:expire_entries) so fetches are a plain
ETS lookup. Overflow uses an insert counter and select_delete instead
of folding the table. The dispatch-program table stays unbounded —
its keys are structural and shared across compile-closure entries.

@polvalente polvalente left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Almost there! I think CompileCache should lean more into it's GenServer nature

Comment thread emlx/lib/emlx.ex
Comment on lines 1858 to 1861
@doc false
def init do
case :ets.whereis(@compile_closure_table) do
:undefined ->
:ets.new(@compile_closure_table, [
:named_table,
:public,
:set,
read_concurrency: true,
write_concurrency: true
])

_ ->
:ok
end
EMLX.CompileCache.ensure_table()
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We don't need to ensure_table here, we can just trust the default CompileCache init.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In fact I think we don't even need this init function

Comment thread emlx/lib/emlx.ex
eval_fn =
case :ets.lookup(@compile_closure_table, cache_key) do
[{^cache_key, cached}] ->
case EMLX.CompileCache.fetch(EMLX.CompileCache.table(), cache_key) do

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

fetch shouldn't be table-aware, but process aware, defaulting to the global named process

Comment thread emlx/lib/emlx.ex
Comment on lines +1906 to +1911
EMLX.CompileCache.put(
EMLX.CompileCache.table(),
cache_key,
built,
EMLX.CompileCache.opts()
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same for here, we should just be passing cache_key and built

Comment thread emlx/README.md
Comment on lines +63 to +74
EMLX caches jit/compile closures in an ETS table so repeated calls of the same
function and shapes skip retrace. The table is unbounded by default. For a
long-running serving process you can cap it:

```elixir
config :emlx, compile_cache_max_items: 1024 # or :infinity
config :emlx, compile_cache_ttl: :timer.minutes(30) # milliseconds, or :infinity
```

Overflow drops the oldest inserts. TTL is counted from insert time, not last
access. See `EMLX.CompileCache`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's remove this from here as it's too much detail for the readme. We can have the moduledoc for EMLX reference EMLX.CompileCache, and the moduledoc from it has the nitty-gritty details

Comment on lines +155 to +156
defp env_key(:max_items), do: :compile_cache_max_items
defp env_key(:ttl), do: :compile_cache_ttl

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

how about we use config :emlx, EMLX.CompileCache, [....] as the config path?

Comment on lines +41 to +42
:ets.new(table, [
:named_table,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The table should not be named, but saved in the process state, and all interactions with it pass through the genserver interface. This serializes access to it, but the process is very lightweight so this isn't really a penalty

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.

Add bounds to the EMLX compiler cache table

2 participants