Skip to content

Commit cfb784b

Browse files
authored
improved memory management and diagnostics (#36)
1 parent 3ba1ecd commit cfb784b

3 files changed

Lines changed: 194 additions & 7 deletions

File tree

Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ RUN pip install spikeinterface==0.104.1
3030
RUN pip install spikeinterface-gui==0.13.1
3131

3232
ENV PYTHONUNBUFFERED=1
33+
# Limit glibc malloc arenas to reduce per-thread heap fragmentation.
34+
# Default is 8 * NCPU; with numpy/zarr large alloc + free patterns this
35+
# leaves freed pages stranded across many arenas, inflating RSS.
36+
ENV MALLOC_ARENA_MAX=2
3337

3438
EXPOSE 8000
3539
ENTRYPOINT ["python", "entrypoint.py", "--address", "0.0.0.0", "--port", "8000"]

entrypoint.py

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
1+
import gc
12
import os
23
import shutil
34
import threading
5+
import tracemalloc
46
from argparse import ArgumentParser
7+
from collections import Counter
58

69
import numpy as np
710
import psutil
811
import panel as pn
912
from tornado.web import RequestHandler
1013

14+
# Start tracemalloc as early as possible so allocation tracebacks include the
15+
# session-loading code paths. Frame depth 10 is enough to identify the call site
16+
# inside spikeinterface/zarr/fsspec without blowing up memory overhead.
17+
tracemalloc.start(10)
18+
_tracemalloc_baseline = tracemalloc.take_snapshot()
19+
1120
# 1. Run setup (replaces --setup flag)
12-
from aind_ephys_portal.setup import * # noqa: F401,F403
13-
from aind_ephys_portal.panel.logging import (
21+
from aind_ephys_portal.setup import * # noqa: F401,F403,E402
22+
from aind_ephys_portal.panel.logging import ( # noqa: E402
1423
list_gui_sessions,
1524
get_max_number_of_gui_sessions,
1625
get_container_total_memory,
@@ -23,6 +32,13 @@
2332
TARGET_CLEAR_TMP_ARR_SECONDS = 180
2433
TARGET_INFLATE_DELAY_SECONDS = 90
2534

35+
# Recycle signal: when a task is sitting idle (0 GUI sessions) but its RAM
36+
# stays above this percent, /health returns 503 so the ALB deregisters it and
37+
# the ECS service scheduler replaces it. Baseline (no sessions, fresh start)
38+
# is ~10%, so 40% means tolerating ~30 points of accumulated residue before
39+
# recycling. Only triggers with 0 sessions, so user sessions are never cut.
40+
RECYCLE_RAM_PERCENT_WHEN_IDLE = 40
41+
2642

2743
if LOG_DIR.is_dir():
2844
print(f"Cleaning up old log files in {LOG_DIR}...")
@@ -76,6 +92,16 @@ def get(self):
7692
task_id = get_ecs_task_id()
7793
max_gui_sessions = get_max_number_of_gui_sessions()
7894

95+
# Idle-but-bloated → ask ALB to deregister so ECS replaces us.
96+
# GUI-level enforcement protects against admitting too many sessions,
97+
# but only the load balancer can take a leaky task out of rotation.
98+
if len(gui_sessions) == 0 and mem_percent > RECYCLE_RAM_PERCENT_WHEN_IDLE:
99+
self.set_status(503)
100+
self.write(
101+
f"Unhealthy (recycle): Memory at {mem_percent:.1f}% with 0 sessions - Task ID: {task_id}"
102+
)
103+
return
104+
79105
if mem_percent > TARGET_MEMORY_TRIGGER_PERCENT:
80106
self.set_status(200)
81107
self.write(
@@ -124,6 +150,99 @@ def get(self):
124150
self.redirect("/ephys_portal_app")
125151

126152

153+
class DebugMemoryHandler(RequestHandler):
154+
"""Live introspection for diagnosing memory retention on a bloated task.
155+
156+
Returns top object counts (via gc.get_objects), top allocation sites
157+
(tracemalloc snapshot diff vs. boot baseline), and fsspec/s3fs instance
158+
cache sizes. Gated by DEBUG_MEMORY_TOKEN env var to avoid exposing
159+
process internals publicly.
160+
"""
161+
162+
def get(self):
163+
token = os.environ.get("DEBUG_MEMORY_TOKEN")
164+
if token and self.request.headers.get("X-Debug-Token") != token:
165+
self.set_status(401)
166+
self.write("Unauthorized: missing or wrong X-Debug-Token header")
167+
return
168+
169+
task_id = get_ecs_task_id()
170+
total_memory = get_container_total_memory()
171+
used_memory = get_container_used_memory()
172+
mem_percent = used_memory / total_memory * 100
173+
gui_sessions = list_gui_sessions()
174+
175+
# Force a collection so we don't count obviously-dead objects.
176+
gc.collect()
177+
gc.collect()
178+
179+
# 1) Object counts by type (top 30 by count)
180+
type_counts = Counter()
181+
for obj in gc.get_objects():
182+
type_counts[type(obj).__name__] += 1
183+
top_types = type_counts.most_common(30)
184+
185+
# 2) numpy/zarr/pandas big buffers — sum bytes by type
186+
big_buffers = []
187+
try:
188+
import numpy as _np
189+
190+
np_bytes = 0
191+
np_count = 0
192+
for obj in gc.get_objects():
193+
if isinstance(obj, _np.ndarray):
194+
np_bytes += obj.nbytes
195+
np_count += 1
196+
big_buffers.append(("numpy.ndarray", np_count, np_bytes))
197+
except Exception as e:
198+
big_buffers.append(("numpy.ndarray", -1, -1))
199+
200+
# 3) fsspec/s3fs cache state
201+
fsspec_info = []
202+
try:
203+
from fsspec import AbstractFileSystem
204+
205+
fsspec_info.append(
206+
f"fsspec AbstractFileSystem._cache size: {len(AbstractFileSystem._cache)}"
207+
)
208+
for key, fs in list(AbstractFileSystem._cache.items())[:10]:
209+
fsspec_info.append(f" - {type(fs).__name__}: protocol={getattr(fs, 'protocol', '?')}")
210+
except Exception as e:
211+
fsspec_info.append(f"fsspec inspection failed: {e}")
212+
213+
# 4) tracemalloc top allocators since boot baseline
214+
tm_lines = []
215+
try:
216+
current = tracemalloc.take_snapshot()
217+
stats = current.compare_to(_tracemalloc_baseline, "lineno")[:25]
218+
for stat in stats:
219+
tm_lines.append(
220+
f" +{stat.size_diff/1024/1024:7.2f} MiB ({stat.count_diff:+d} blocks) {stat.traceback[0]}"
221+
)
222+
except Exception as e:
223+
tm_lines.append(f"tracemalloc unavailable: {e}")
224+
225+
self.set_header("Content-Type", "text/plain; charset=utf-8")
226+
out = [
227+
f"=== Task {task_id} ===",
228+
f"Memory: {mem_percent:.1f}% ({used_memory/1024**3:.2f} / {total_memory/1024**3:.2f} GB)",
229+
f"GUI sessions: {len(gui_sessions)}",
230+
"",
231+
"--- Top object types by count ---",
232+
]
233+
for name, count in top_types:
234+
out.append(f" {count:>10d} {name}")
235+
out.extend(["", "--- Buffer bytes ---"])
236+
for name, count, nbytes in big_buffers:
237+
mib = nbytes / 1024 / 1024 if nbytes >= 0 else -1
238+
out.append(f" {name}: {count} objects, {mib:.1f} MiB")
239+
out.extend(["", "--- fsspec ---"])
240+
out.extend(fsspec_info)
241+
out.extend(["", "--- tracemalloc (top 25 since boot) ---"])
242+
out.extend(tm_lines)
243+
self.write("\n".join(out))
244+
245+
127246
# 3. App file paths — Panel will exec these per-session with a proper context
128247
APP_DIR = "src/aind_ephys_portal"
129248
apps = {
@@ -182,7 +301,11 @@ def get(self):
182301
port=port,
183302
allow_websocket_origin=allow_ws,
184303
static_dirs={"images": os.path.join(APP_DIR, "images")},
185-
extra_patterns=[(r"/health", HealthHandler), (r"/", IndexRedirectHandler)],
304+
extra_patterns=[
305+
(r"/health", HealthHandler),
306+
(r"/debug/memory", DebugMemoryHandler),
307+
(r"/", IndexRedirectHandler),
308+
],
186309
check_unused_sessions=2000,
187310
unused_session_lifetime=5000,
188311
show=False,

src/aind_ephys_portal/panel/ephys_gui.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,19 @@
2323
get_max_number_of_gui_sessions,
2424
list_gui_sessions,
2525
get_ecs_task_id,
26+
get_container_total_memory,
27+
get_container_used_memory,
2628
remove_session,
2729
)
2830
from aind_ephys_portal.panel.utils import PostMessageListener, FullscreenResizeHandler
2931

32+
# Refuse to admit a new session if the task's RAM is already above this percent,
33+
# regardless of how many sessions are active. Protects "poisoned" tasks
34+
# (residue from prior sessions) from being pushed into OOM by the count-based
35+
# admission rule. Picked below the /health 503 recycle threshold so the ALB
36+
# pulls the task out of rotation before the GUI starts rejecting.
37+
MAX_RAM_PERCENT_FOR_NEW_SESSION = 55
38+
3039
displayed_unit_properties = [
3140
"decoder_label",
3241
"default_qc",
@@ -78,6 +87,47 @@ def _malloc_trim():
7887
pass
7988

8089

90+
def _clear_fsspec_instance_caches():
91+
"""Drop cached fsspec filesystem instances and their internal block buffers.
92+
93+
fsspec keeps every filesystem ever constructed in AbstractFileSystem._cache,
94+
so per-instance invalidate_cache() (dir listings) doesn't release the FS
95+
itself or its dircache/block buffers. We only drop instances with no other
96+
referrers to avoid stealing FS objects from concurrent sessions.
97+
"""
98+
try:
99+
import sys
100+
from fsspec import AbstractFileSystem
101+
except Exception:
102+
return
103+
104+
cache = getattr(AbstractFileSystem, "_cache", None)
105+
if not cache:
106+
return
107+
108+
# 2 = the local var here + sys.getrefcount's own arg ref → no external holders.
109+
# If anything else has a handle, leave it alone.
110+
removed = 0
111+
for key in list(cache.keys()):
112+
fs = cache.get(key)
113+
if fs is None:
114+
continue
115+
if sys.getrefcount(fs) <= 3: # cache dict + local + getrefcount arg
116+
try:
117+
# Drop any caches the FS exposes before dropping the FS.
118+
for attr in ("dircache", "_intrans", "_open_files"):
119+
try:
120+
getattr(fs, attr, {}).clear()
121+
except Exception:
122+
pass
123+
del cache[key]
124+
removed += 1
125+
except Exception:
126+
pass
127+
if removed:
128+
print(f"Dropped {removed} idle fsspec filesystem instance(s) from cache.")
129+
130+
81131
class EphysGuiView(param.Parameterized):
82132

83133
def __init__(
@@ -126,20 +176,26 @@ def __init__(
126176

127177
num_gui_sessions = len(list_gui_sessions())
128178
max_sessions = get_max_number_of_gui_sessions()
129-
if num_gui_sessions > max_sessions:
179+
ram_percent = get_container_used_memory() / get_container_total_memory() * 100
180+
too_many_sessions = num_gui_sessions > max_sessions
181+
too_much_ram = ram_percent > MAX_RAM_PERCENT_FOR_NEW_SESSION
182+
if too_many_sessions or too_much_ram:
130183
# Remove this session from the count — it is being rejected
131184
doc = pn.state.curdoc
132185
route = getattr(doc, "_log_route", None)
133186
session_id = getattr(doc, "_log_session_id", None)
134187
if route and session_id:
135188
remove_session(route, session_id)
136-
print(
137-
f"Current number of GUI sessions: {num_gui_sessions}. Max allowed per worker: {max_sessions}. Task ID: {task_id}"
189+
reason = (
190+
f"too many sessions ({num_gui_sessions}/{max_sessions})"
191+
if too_many_sessions
192+
else f"task RAM already at {ram_percent:.1f}% (limit {MAX_RAM_PERCENT_FOR_NEW_SESSION}%)"
138193
)
194+
print(f"Rejecting new session: {reason}. Task ID: {task_id}")
139195
self.layout = pn.Column(
140196
header,
141197
pn.pane.Markdown(
142-
f"⚠️ Too many active GUI sessions ({num_gui_sessions}). Max allowed per worker is {max_sessions}. "
198+
f"⚠️ Cannot start a new GUI session: {reason}. "
143199
f"Please try again in a few minutes or open a new tab (Task ID: `{task_id}`).",
144200
sizing_mode="stretch_both",
145201
),
@@ -543,6 +599,8 @@ def cleanup(self):
543599
def _deferred_gc():
544600
gc.collect()
545601
gc.collect()
602+
_clear_fsspec_instance_caches()
603+
gc.collect()
546604
_malloc_trim()
547605
final_mem = psutil.virtual_memory()
548606
used = final_mem.used / (1024**3)
@@ -554,6 +612,8 @@ def _deferred_gc():
554612
# Fallback: run immediately if IOLoop is unavailable
555613
gc.collect()
556614
gc.collect()
615+
_clear_fsspec_instance_caches()
616+
gc.collect()
557617
_malloc_trim()
558618
final_mem = psutil.virtual_memory()
559619
used = final_mem.used / (1024**3)

0 commit comments

Comments
 (0)