Add item-count and TTL bounds to the compile-closure cache - #140
Add item-count and TTL bounds to the compile-closure cache#140Chapaman wants to merge 3 commits into
Conversation
…#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
left a comment
There was a problem hiding this comment.
Looking good, but I think we can improve performance!
| Evicting a closure does **not** free the compiled MLX program held in | ||
| `:emlx_native_dispatch_cache`. |
There was a problem hiding this comment.
I think it should indeed evict it
There was a problem hiding this comment.
Couldn't drop the native dispatch row here — it's shared across compile-closure keys. Any suggestions?
| @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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Cache reads would only do the lookup with {:ok, result} vs {:error, :cache_miss}
There was a problem hiding this comment.
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).
| 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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Switched to an insert index via :ets.update_counter and :ets.select_delete of index <= current - max_items.
| 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`. |
There was a problem hiding this comment.
This docstring seems a bit too verbose, especially since it repeats a lot of what's described in the CompileCache moduledoc
There was a problem hiding this comment.
Dropped the bounds subsection from the EMLX moduledoc; it now points at EMLX.CompileCache.
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
left a comment
There was a problem hiding this comment.
Almost there! I think CompileCache should lean more into it's GenServer nature
| @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 |
There was a problem hiding this comment.
We don't need to ensure_table here, we can just trust the default CompileCache init.
There was a problem hiding this comment.
In fact I think we don't even need this init function
| eval_fn = | ||
| case :ets.lookup(@compile_closure_table, cache_key) do | ||
| [{^cache_key, cached}] -> | ||
| case EMLX.CompileCache.fetch(EMLX.CompileCache.table(), cache_key) do |
There was a problem hiding this comment.
fetch shouldn't be table-aware, but process aware, defaulting to the global named process
| EMLX.CompileCache.put( | ||
| EMLX.CompileCache.table(), | ||
| cache_key, | ||
| built, | ||
| EMLX.CompileCache.opts() | ||
| ) |
There was a problem hiding this comment.
Same for here, we should just be passing cache_key and built
| 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`. | ||
|
|
There was a problem hiding this comment.
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
| defp env_key(:max_items), do: :compile_cache_max_items | ||
| defp env_key(:ttl), do: :compile_cache_ttl |
There was a problem hiding this comment.
how about we use config :emlx, EMLX.CompileCache, [....] as the config path?
| :ets.new(table, [ | ||
| :named_table, |
There was a problem hiding this comment.
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
closes #139
#137's
:emlx_compile_closurestable grew without a cap. This addscompile_cache_max_itemsandcompile_cache_ttl(both:infinityby default) so a long-running serving process can opt in without changing today's unbounded behaviour.