11defmodule Emily.Soak.MemoryTest do
22 @ moduledoc """
3- Memory-stability soak test. Tagged `:soak` so it's excluded from the
4- default `mix test` run. Execute with `mix test --only soak`.
5-
6- Allocates and drops many tensors in a tight loop, evaluating each,
7- then asserts MLX's active-memory figure returns to the baseline once
8- the BEAM garbage-collects our Tensor refs and we call
9- `clear_cache/0`. A leak here would show up as a growing delta over
10- iterations.
3+ Memory-stability soak test. Tagged `:soak` for discoverability, but
4+ runs as part of the default suite — the check completes in ~1 s.
5+
6+ Design (the *why* matters — the first cut was effectively a no-op
7+ and the second couldn't tell a leak from Metal arena drift):
8+
9+ * Each iteration allocates a 1 MB tensor, evaluates it, drops it.
10+ * We run many iterations (2000) so MLX's constant arena overhead
11+ (roughly 2 tensors' worth persisted past `clear_cache`) becomes
12+ negligible versus any per-iteration leak — a per-iter leak
13+ accumulates linearly, arena drift does not.
14+ * Baseline is sampled *after* a warm-up loop so MLX's one-time
15+ allocator setup (kernel caches, Metal libraries, lookup tables)
16+ doesn't show up as phantom growth.
17+ * Tolerance is sized so the observed ~2 MB of Metal arena drift
18+ passes, but the test fails once the cumulative leak exceeds
19+ ~2 retained tensors total — i.e. a leak rate above ~0.1 %.
20+
21+ A tight tolerance matters — a generous one makes the test pass even
22+ when refcounts are broken.
1123 """
1224
1325 use ExUnit.Case , async: false
1426
15- import Emily.TensorHelpers
16-
1727 alias Emily.Native
1828
1929 @ moduletag :soak
2030
21- @ iters 5_000
22- # Allow MLX allocator book-keeping to fluctuate by this many bytes
23- # before we call it a leak. Empirically an M-series MLX allocator
24- # sits at tens of KB of small persistent buffers.
25- @ tolerance_bytes 1_024 * 1_024
31+ # 256 Ki × f32 = 1 MB per iter. Small so the workload fits in ~1 s
32+ # of wall time even at high iteration counts.
33+ @ tensor_elems 256 * 1024
34+ @ iters 2_000
35+ @ warmup 20
36+
37+ # Measured drift under the full suite is ~2 MB (Metal pool keeps
38+ # ~2 tensors live past clear_cache). 4 MB tolerance absorbs that
39+ # without masking a leak of more than a couple of retained tensors;
40+ # a 1 % per-iter leak rate would blow through it by ~5×.
41+ @ tolerance_bytes 4 * 1024 * 1024
42+
43+ defp workload ( data ) do
44+ a = Native . from_binary ( data , [ @ tensor_elems ] , { :f , 32 } )
45+ b = Native . multiply ( a , a )
46+ _ = Native . to_binary ( b )
47+ :ok
48+ end
49+
50+ test "repeated 1 MB allocate/eval/drop returns to baseline" do
51+ # Build the source binary once; we're exercising MLX's allocator,
52+ # not binary copy churn on the BEAM side.
53+ data = for _ <- 1 .. @ tensor_elems , into: << >> , do: << 1.0 :: float - 32 - native >>
54+
55+ for _ <- 1 .. @ warmup , do: workload ( data )
2656
27- test "repeated allocate/evaluate/drop returns to baseline" do
28- # Drain any per-test scaffolding before taking the baseline.
2957 :erlang . garbage_collect ( )
3058 Native . clear_cache ( )
3159 baseline = Native . get_active_memory ( )
32-
3360 Native . reset_peak_memory ( )
3461
35- for _ <- 1 .. @ iters do
36- a = f32 ( [ 1.0 , 2.0 , 3.0 , 4.0 , 5.0 , 6.0 , 7.0 , 8.0 ] , [ 8 ] )
37- b = Native . multiply ( a , a )
38- _ = Native . to_binary ( b )
39- :ok
40- end
62+ for _ <- 1 .. @ iters , do: workload ( data )
4163
4264 :erlang . garbage_collect ( )
4365 Native . clear_cache ( )
@@ -49,10 +71,13 @@ defmodule Emily.Soak.MemoryTest do
4971 assert delta <= @ tolerance_bytes ,
5072 """
5173 active-memory delta #{ delta } bytes exceeds tolerance #{ @ tolerance_bytes }
52- baseline: #{ baseline }
53- final: #{ final }
54- peak: #{ peak }
55- iters: #{ @ iters }
74+ baseline: #{ baseline }
75+ final: #{ final }
76+ peak: #{ peak }
77+ iters: #{ @ iters }
78+ bytes/tensor: #{ @ tensor_elems * 4 }
79+ A single retained tensor per iteration would grow delta by
80+ #{ @ iters * @ tensor_elems * 4 } bytes (~#{ div ( @ iters * @ tensor_elems * 4 , 1024 * 1024 ) } MB).
5681 """
5782 end
5883end
0 commit comments