Skip to content

Commit 26faf73

Browse files
authored
Merge pull request #182 from ausimian/feat/expr-compiler-memory-test-isolation
Isolate program memory tests as async: false
2 parents e130611 + 7e59644 commit 26faf73

2 files changed

Lines changed: 111 additions & 76 deletions

File tree

test/emily/program_memory_test.exs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
defmodule Emily.ProgramMemoryTest do
2+
@moduledoc """
3+
Memory leak-detection for the program replay engine — split out of
4+
`Emily.ProgramTest` so it can run `async: false`.
5+
6+
These assert *tiny* deltas (64 KB / 4 MB) on the **process-global** MLX
7+
active-memory metric (`Native.get_active_memory/0`), measured across two
8+
points seconds apart. That metric is shared across the whole VM, so a
9+
parallel test that loads model weights (the `:conformance` / `*_full`
10+
lanes in a full `--include` run) inflates the reading mid-measurement and
11+
the deltas blow up — a false positive, not a real leak. `async: false`
12+
gives these tests the exclusive run the global metric requires; the rest
13+
of `Emily.ProgramTest` stays `async: true`.
14+
"""
15+
use ExUnit.Case, async: false
16+
17+
import Emily.TensorHelpers
18+
19+
alias Emily.{IR, Native, Program}
20+
21+
describe "memory" do
22+
test "repeated replay does not grow active memory with iteration count" do
23+
input = f32([1.0, 2.0, 3.0], [3])
24+
bias = f32([1.0, 1.0, 1.0], [3])
25+
prog = Program.compile(add_chain_ir(50, bias))
26+
27+
replay = fn n ->
28+
for _ <- 1..n do
29+
[out] = Program.eval(worker(), prog, [input])
30+
_ = to_f32_list(out)
31+
end
32+
33+
:erlang.garbage_collect()
34+
Native.clear_cache()
35+
Native.get_active_memory()
36+
end
37+
38+
# Warm up so the allocator reaches steady state.
39+
_ = replay.(50)
40+
after_100 = replay.(100)
41+
after_400 = replay.(400)
42+
43+
# A genuine per-replay leak would make active memory scale with the
44+
# 4x iteration count; assert it stays flat (small allocator slack).
45+
assert after_400 - after_100 <= 64 * 1024,
46+
"active memory grew #{after_400 - after_100} bytes over 4x more replays"
47+
end
48+
49+
test "compiled-mode programs release their mx::compile cache on GC" do
50+
# Each distinct Program evaled in :compiled mode installs an entry in
51+
# the worker's *thread-local* mx::compile cache that pins copies of
52+
# its captured weights. Program::~Program must drop that entry on the
53+
# worker thread; if it instead erased the GC thread's cache (the bug
54+
# this guards), the weights would stay live and active memory would
55+
# scale with the number of compiled programs.
56+
n = div(512 * 1024, 4)
57+
zero = f32(List.duplicate(0.0, n), [n])
58+
59+
make_and_run = fn k ->
60+
# A fresh capture per program -> a distinct cache entry / fun_id.
61+
weight = f32(List.duplicate(k * 1.0, n), [n])
62+
prog = Program.compile(add_chain_ir(4, weight))
63+
[out] = Program.eval(worker(), prog, [zero], mode: :compiled)
64+
_ = to_f32_list(out)
65+
:ok
66+
end
67+
68+
flush = fn ->
69+
:erlang.garbage_collect()
70+
# Teardown is posted to the worker queue during resource GC; a sync
71+
# op after it (FIFO) guarantees every posted teardown has run before
72+
# we read memory.
73+
[out] = Program.eval(worker(), Program.compile(add_chain_ir(1, zero)), [zero])
74+
_ = to_f32_list(out)
75+
Native.clear_cache()
76+
Native.get_active_memory()
77+
end
78+
79+
run_n = fn count ->
80+
for k <- 1..count, do: make_and_run.(k)
81+
flush.()
82+
end
83+
84+
_ = run_n.(20)
85+
after_40 = run_n.(40)
86+
after_80 = run_n.(80)
87+
88+
# A leaked cache entry pins ~512 KiB per program; 2x more programs
89+
# would add tens of MiB. Assert it stays flat (generous slack).
90+
assert after_80 - after_40 <= 4 * 1024 * 1024,
91+
"compiled-mode cache leaked #{after_80 - after_40} bytes over 2x more programs"
92+
end
93+
end
94+
95+
# Local copy of `Emily.ProgramTest`'s IR builder — a chain of `n` adds of
96+
# `bias_ref` onto input 0. Kept here (rather than shared) so this module
97+
# stands alone; `Emily.ProgramTest` still uses its own copy.
98+
defp add_chain_ir(n, bias_ref) when n > 0 do
99+
instrs =
100+
for k <- 0..(n - 1) do
101+
left = if k == 0, do: {:input, 0}, else: {:instr, k - 1}
102+
%{opcode: :add, operands: [left, {:capture, 0}]}
103+
end
104+
105+
%IR{n_inputs: 1, captures: [bias_ref], instrs: instrs, outputs: [{:instr, n - 1}]}
106+
end
107+
end

test/emily/program_test.exs

Lines changed: 4 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ defmodule Emily.ProgramTest do
77
replay must produce **bit-identical** results to a fold of `Native.add`
88
on the same inputs. Plus: captures keep weights alive across source GC,
99
handles are reusable, malformed IR is rejected cleanly (worker
10-
survives), async parity holds, and repeated replay leaves no memory
11-
drift. The dispatch-collapse speedup itself is measured by
10+
survives), and async parity holds. Memory-leak detection (replay drift,
11+
mx::compile cache release) lives in `Emily.ProgramMemoryTest` — split out
12+
as `async: false` because it reads the process-global memory metric. The
13+
dispatch-collapse speedup itself is measured by
1214
`bench/program_dispatch.exs`.
1315
"""
1416
use ExUnit.Case, async: true
@@ -368,80 +370,6 @@ defmodule Emily.ProgramTest do
368370
end
369371
end
370372

371-
describe "memory" do
372-
test "repeated replay does not grow active memory with iteration count" do
373-
input = f32([1.0, 2.0, 3.0], [3])
374-
bias = f32([1.0, 1.0, 1.0], [3])
375-
prog = Program.compile(add_chain_ir(50, bias))
376-
377-
replay = fn n ->
378-
for _ <- 1..n do
379-
[out] = Program.eval(worker(), prog, [input])
380-
_ = to_f32_list(out)
381-
end
382-
383-
:erlang.garbage_collect()
384-
Native.clear_cache()
385-
Native.get_active_memory()
386-
end
387-
388-
# Warm up so the allocator reaches steady state.
389-
_ = replay.(50)
390-
after_100 = replay.(100)
391-
after_400 = replay.(400)
392-
393-
# A genuine per-replay leak would make active memory scale with the
394-
# 4x iteration count; assert it stays flat (small allocator slack).
395-
assert after_400 - after_100 <= 64 * 1024,
396-
"active memory grew #{after_400 - after_100} bytes over 4x more replays"
397-
end
398-
399-
test "compiled-mode programs release their mx::compile cache on GC" do
400-
# Each distinct Program evaled in :compiled mode installs an entry in
401-
# the worker's *thread-local* mx::compile cache that pins copies of
402-
# its captured weights. Program::~Program must drop that entry on the
403-
# worker thread; if it instead erased the GC thread's cache (the bug
404-
# this guards), the weights would stay live and active memory would
405-
# scale with the number of compiled programs.
406-
n = div(512 * 1024, 4)
407-
zero = f32(List.duplicate(0.0, n), [n])
408-
409-
make_and_run = fn k ->
410-
# A fresh capture per program -> a distinct cache entry / fun_id.
411-
weight = f32(List.duplicate(k * 1.0, n), [n])
412-
prog = Program.compile(add_chain_ir(4, weight))
413-
[out] = Program.eval(worker(), prog, [zero], mode: :compiled)
414-
_ = to_f32_list(out)
415-
:ok
416-
end
417-
418-
flush = fn ->
419-
:erlang.garbage_collect()
420-
# Teardown is posted to the worker queue during resource GC; a sync
421-
# op after it (FIFO) guarantees every posted teardown has run before
422-
# we read memory.
423-
[out] = Program.eval(worker(), Program.compile(add_chain_ir(1, zero)), [zero])
424-
_ = to_f32_list(out)
425-
Native.clear_cache()
426-
Native.get_active_memory()
427-
end
428-
429-
run_n = fn count ->
430-
for k <- 1..count, do: make_and_run.(k)
431-
flush.()
432-
end
433-
434-
_ = run_n.(20)
435-
after_40 = run_n.(40)
436-
after_80 = run_n.(80)
437-
438-
# A leaked cache entry pins ~512 KiB per program; 2x more programs
439-
# would add tens of MiB. Assert it stays flat (generous slack).
440-
assert after_80 - after_40 <= 4 * 1024 * 1024,
441-
"compiled-mode cache leaked #{after_80 - after_40} bytes over 2x more programs"
442-
end
443-
end
444-
445373
# --- helpers ---
446374

447375
defp eval_add(a, b) do

0 commit comments

Comments
 (0)