Skip to content

Commit 3d56142

Browse files
committed
Make soak test actually detect leaks
The original soak test allocated [8] f32 tensors (64 bytes/iter) and checked delta <= 1 MB after 5000 iters. Maximum possible leak was ~320 KB — well under the tolerance — so even a total refcount breakage would pass the test. New design: - 1 Mi-element f32 tensors (4 MB each), 500 iters. - Warm-up loop of 20 iters before sampling the baseline so MLX's one-time allocator setup (kernel cache, Metal libraries) doesn't look like phantom growth. - Tolerance dropped to 2 MB — half of one iteration's allocation, so a single retained tensor per cycle would trip the test. Validated by forcing a deliberate leak (retaining every tensor in a list): delta measured at 407 MB vs 2 MB tolerance, test correctly fails. The clean path still passes.
1 parent 410e92f commit 3d56142

1 file changed

Lines changed: 53 additions & 28 deletions

File tree

test/soak/memory_test.exs

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,65 @@
11
defmodule 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
5883
end

0 commit comments

Comments
 (0)