Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Bugfixes

- Fixed acoustic inference session being aborted on macOS when stats were enabled: hardened parent/child memory tracking against `psutil.AccessDenied`, and replaced the two tracked semaphores with a wrapper that mirrors the count into shared memory so `get_value()` works on macOS (#39)

## [0.2.15] - 2026-05-02

### Bugfixes
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/consistency_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

from birdnet.acoustic.models.v2_4.model import AcousticModelV2_4
from birdnet.core.backends import litert_installed
from birdnet.utils.local_data import get_package_version
from birdnet.model_loader import load
from birdnet.utils.local_data import get_package_version


def _check_tf_gpu() -> bool:
Expand Down
51 changes: 37 additions & 14 deletions src/birdnet/acoustic/inference/core/perf_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@
from collections.abc import Callable
from dataclasses import dataclass
from multiprocessing import Queue, shared_memory
from multiprocessing.synchronize import Event, Semaphore
from multiprocessing.synchronize import Event
from queue import Empty

import numpy as np
import psutil

import birdnet.acoustic.inference.core.logs as bn_logging
from birdnet.acoustic.inference.core.shm import RingField
from birdnet.acoustic.inference.core.sync import CountedSemaphore
from birdnet.globals import READABLE_FLAG, READING_FLAG, WRITABLE_FLAG


Expand Down Expand Up @@ -124,8 +125,8 @@ def __init__(
logging_queue: Queue,
logging_level: int,
perf_res: Queue,
sem_active_workers: Semaphore,
sem_filled_slots: Semaphore,
sem_active_workers: CountedSemaphore,
sem_filled_slots: CountedSemaphore,
segment_size_s: float,
parent_process_id: int,
rf_flags: RingField,
Expand Down Expand Up @@ -275,20 +276,42 @@ def reset(self) -> None:
self._prd_speed_xrt_tracker.reset()
self._prd_speed_seg_per_s_tracker.reset()

@staticmethod
def _safe_proc_memory(proc: psutil.Process) -> float | None:
try:
return float(proc.memory_full_info().uss)
except (psutil.AccessDenied, PermissionError):
pass
except psutil.NoSuchProcess:
return None
try:
return float(proc.memory_info().rss)
except (psutil.NoSuchProcess, psutil.AccessDenied, PermissionError):
return None

def _track_memory_usage(self) -> None:
if self._parent_process is None:
self._parent_process = psutil.Process(self._parent_process_id)
memory_usage: float = self._parent_process.memory_full_info().uss
for child in self._parent_process.children(recursive=True):
try:
memory_usage += child.memory_full_info().uss
except psutil.NoSuchProcess:
continue
except psutil.AccessDenied:
continue

mem_usage_MiB = memory_usage / 1024**2
self._memory_usage_MiB_tracker.add_value(mem_usage_MiB)
self._parent_process = psutil.Process(self._parent_process_id)
except (psutil.NoSuchProcess, psutil.AccessDenied, PermissionError):
return

parent_mem = self._safe_proc_memory(self._parent_process)
if parent_mem is None:
return

total = parent_mem
try:
children = self._parent_process.children(recursive=True)
except (psutil.AccessDenied, PermissionError, psutil.NoSuchProcess):
children = []

for child in children:
child_mem = self._safe_proc_memory(child)
if child_mem is not None:
total += child_mem

self._memory_usage_MiB_tracker.add_value(total / 1024**2)

@property
def wall_time(self) -> float:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import os
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING

import numpy as np
from ordered_set import OrderedSet
Expand Down
3 changes: 2 additions & 1 deletion src/birdnet/acoustic/inference/core/producer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import birdnet.acoustic.inference.core.logs as bn_logging
from birdnet.acoustic.inference.core.shm import RingField
from birdnet.acoustic.inference.core.sync import CountedSemaphore
from birdnet.globals import (
READABLE_FLAG,
READING_FLAG,
Expand Down Expand Up @@ -54,7 +55,7 @@ def __init__(
rf_batch_sizes: RingField,
rf_flags: RingField,
sem_free_slots: Semaphore,
sem_filled_slots: Semaphore,
sem_filled_slots: CountedSemaphore,
max_segment_idx_ptr: ctypes.c_uint8
| ctypes.c_uint16
| ctypes.c_uint32
Expand Down
42 changes: 42 additions & 0 deletions src/birdnet/acoustic/inference/core/sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

import multiprocessing as mp
from multiprocessing.sharedctypes import Synchronized
from types import TracebackType


class CountedSemaphore:
"""
Drop-in replacement for ``mp.Semaphore`` whose ``get_value()`` works on
macOS by mirroring acquire/release into a shared counter.
"""

def __init__(self, initial: int = 0) -> None:
self._sem = mp.Semaphore(initial)
self._counter: Synchronized = mp.Value("i", initial)

def acquire(self, block: bool = True, timeout: float | None = None) -> bool:
acquired = self._sem.acquire(block, timeout)
if acquired:
with self._counter.get_lock():
self._counter.value -= 1
return acquired

def release(self) -> None:
with self._counter.get_lock():
self._counter.value += 1
self._sem.release()

def get_value(self) -> int:
return self._counter.value

def __enter__(self) -> bool:
return self.acquire()

def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
self.release()
5 changes: 3 additions & 2 deletions src/birdnet/acoustic/inference/core/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import birdnet.acoustic.inference.core.logs as bn_logging
from birdnet.acoustic.inference.core.shm import RingField
from birdnet.acoustic.inference.core.sync import CountedSemaphore
from birdnet.core.backends import BackendLoader, BatchT, VersionedBackendProtocol
from birdnet.globals import (
READABLE_FLAG,
Expand Down Expand Up @@ -43,8 +44,8 @@ def __init__(
out_q: Queue,
wkr_ring_access_lock: multiprocessing.synchronize.Lock,
sem_free: Semaphore,
sem_fill: Semaphore,
sem_active_workers: Semaphore | None,
sem_fill: CountedSemaphore,
sem_active_workers: CountedSemaphore | None,
half_precision: bool,
wkr_stats_queue: Queue | None,
logging_queue: Queue,
Expand Down
9 changes: 5 additions & 4 deletions src/birdnet/acoustic/inference/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
PerformanceTrackingResult,
)
from birdnet.acoustic.inference.core.shm import RingField, create_shm_ring
from birdnet.acoustic.inference.core.sync import CountedSemaphore
from birdnet.core.backends import BackendLoader
from birdnet.core.base import get_session_id_hash
from birdnet.globals import MODEL_TYPE_ACOUSTIC, PKG_NAME, WRITABLE_FLAG
Expand Down Expand Up @@ -106,7 +107,7 @@ class RingBufferResources:
rf_batch_sizes: RingField
rf_flags: RingField
sem_free_slots: multiprocessing.synchronize.Semaphore
sem_filled_slots: multiprocessing.synchronize.Semaphore
sem_filled_slots: CountedSemaphore

_rf_flags_memory: shared_memory.SharedMemory | None = None

Expand Down Expand Up @@ -172,7 +173,7 @@ def _create(
rf_batch_sizes=rf_batch_sizes,
rf_flags=rf_flags,
sem_free_slots=mp.Semaphore(n_slots),
sem_filled_slots=mp.Semaphore(0),
sem_filled_slots=CountedSemaphore(0),
)

@classmethod
Expand Down Expand Up @@ -478,7 +479,7 @@ def start_iso_time(self) -> str:
track_performance: bool
wkr_stats_queue: Queue | None
prd_stats_queue: Queue | None
sem_active_workers: multiprocessing.synchronize.Semaphore | None
sem_active_workers: CountedSemaphore | None
perf_res_queue: Queue | None
perf_res_start_signal: multiprocessing.synchronize.Event | None
perf_res_finish_signal: multiprocessing.synchronize.Event | None
Expand Down Expand Up @@ -539,7 +540,7 @@ def create(
perf_res_finish_signal = mp.Event()
wkr_stats_queue = Queue()
prd_stats_queue = Queue()
sem_active_workers = mp.Semaphore(0)
sem_active_workers = CountedSemaphore(0)

callback_start_signal = None
callback_finish_signal = None
Expand Down
2 changes: 0 additions & 2 deletions src/birdnet_benchmark/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json
import logging
import math
import multiprocessing
import os
import platform
Expand All @@ -23,7 +22,6 @@
AcousticPredictionResultBase,
)
from birdnet.acoustic.models.base import AcousticModelBase
from birdnet.acoustic.models.v2_4.model import AcousticModelV2_4
from birdnet.core.backends import litert_installed, tf_installed
from birdnet.globals import (
ACOUSTIC_MODEL_VERSION_V2_4,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import pytest

from birdnet.acoustic.models.v2_4.tf import AcousticTFDownloaderV2_4
from birdnet.utils.local_data import get_lang_dir, get_model_path
from birdnet.model_loader import load_custom
from birdnet.utils.local_data import get_lang_dir, get_model_path
from birdnet_tests.helper import ensure_litert_or_skip
from birdnet_tests.test_files import (
TEST_FILE_SHORT,
Expand Down