Skip to content

Commit 87ec4f3

Browse files
stefantaubertJosef-Hauptkahstfegue
authored
Dev (#44)
* Fix issue 32 (#35) * fix table * fix issue 32 * rm accident * bump version * Fixing mac issues (#40) * Fixing mac issues * fix issue --------- Co-authored-by: Stefan Kahl <kahst@hrz.tu-chemnitz.de> Co-authored-by: Stefan Taubert <23339395+stefantaubert@users.noreply.github.com> * Upcast timing columns to float32 in Arrow export to avoid halffloat interop issues (#43) * add fix for issue 38 * add comment for current limitation --------- Co-authored-by: Stefan Taubert <23339395+stefantaubert@users.noreply.github.com> --------- Co-authored-by: Josef Haupt <josef_haupt@gmx.net> Co-authored-by: Stefan Kahl <kahst@hrz.tu-chemnitz.de> Co-authored-by: Felix Günther <guenther@oekofor.de>
1 parent 85f788b commit 87ec4f3

16 files changed

Lines changed: 522 additions & 40 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Bugfixes
11+
12+
- 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)
13+
- Fixed float16 quantization of segment timestamps in prediction results, which caused up to ±0.05 s drift in CSV/DataFrame/Parquet output (#38, #42). Also closed an analogous hole in encoding results where a hop duration that is exactly representable in float16 (e.g. hop=1.5) could still produce drifting accumulated timestamps. Timestamps are now always materialized at >= float32 precision at the source.
14+
1015
## [0.2.15] - 2026-05-02
1116

1217
### Bugfixes

‎benchmarks/consistency_test.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99

1010
from birdnet.acoustic.models.v2_4.model import AcousticModelV2_4
1111
from birdnet.core.backends import litert_installed
12-
from birdnet.utils.local_data import get_package_version
1312
from birdnet.model_loader import load
13+
from birdnet.utils.local_data import get_package_version
1414

1515

1616
def _check_tf_gpu() -> bool:

‎docs/general.rst‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,25 @@ A *Producer* loads only as much audio as the buffer can hold, keeping RAM usage
2626
* **Buffer Size** – By default, the buffer is set to twice the *Worker* count, ensuring that every *Worker* always has a pre-loaded batch to process and thus avoids idle time.
2727
* **Model Backends** – Each worker loads its own instance of the inference model. On the CPU, both **TFLite** and **Protocol Buffers** (Protobuf) models can be used; Protobuf models can optionally run on the GPU.
2828
* **Best Practice for CPU Inference** – For CPU-only execution on Linux, the number of *Worker* processes should not exceed the number of physical cores, as oversubscription typically leads to reduced performance. When running TFLite, keep the batch size to one (1); larger batches offer no throughput benefit.
29+
30+
Known limitations
31+
----
32+
33+
**End-time precision on the last segment of short files (≤ ~34 minutes).**
34+
For memory efficiency, per-file durations are stored in the smallest float
35+
dtype that covers their magnitude: ``float16`` for files up to 2\ :sup:`11` ≈
36+
2048 s, ``float32`` for files up to 2\ :sup:`24` s (~194 days), ``float64``
37+
beyond. The stored duration is used as the upper clamp when computing the
38+
``end_time`` of the *last* segment of each file. Inside the float16 range
39+
this rounding is visible: the largest representable float16 below ``X`` may
40+
differ from ``X`` by up to one ULP — about 0.06 s near 128 s, 0.25 s near
41+
1024 s, and 0.5 s near 2048 s. The error appears only on the very last
42+
segment per file and only when the actual file duration is not exactly
43+
representable in float16 (integer-second durations up to 2048 s are
44+
exact). For files of one hour or longer the storage dtype is float32, where
45+
the equivalent ULP is below 4 ms even at 12 h, so the effect is not
46+
observable in practice.
47+
48+
All other timestamps (``start_time`` and ``end_time`` of every segment that
49+
does not hit the clamp) are computed at ≥ float32 precision regardless of
50+
file length.

‎src/birdnet/acoustic/inference/core/encoding/encoding_result.py‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
format_input_for_csv,
2222
get_uint_dtype,
2323
hms_centis_fast,
24-
upgrade_float_dtype_for_value,
2524
)
2625

2726
if TYPE_CHECKING:
@@ -135,11 +134,11 @@ def to_structured_array(self) -> np.ndarray:
135134
embeddings_selected = self.embeddings[valid_file_idx, valid_seg_idx]
136135

137136
hop_duration_s = self.hop_duration_s
138-
# Upgrade the storage dtype for the output if it cannot represent hop
139-
# exactly, otherwise rounding accumulates across segments.
140-
time_dtype = upgrade_float_dtype_for_value(
141-
self._input_durations.dtype, hop_duration_s
142-
)
137+
# Force at least float32 for timing columns. The bulk _input_durations
138+
# array is stored in a magnitude-based dtype (float16 for files <= 2**11 s),
139+
# which is too coarse for accumulated i*hop products and would also produce
140+
# Arrow halffloat that some implementations (e.g. R) cannot read.
141+
time_dtype = np.result_type(self._input_durations.dtype, np.float32)
143142

144143
dtype = [
145144
(VAR_INPUT, self._input_dtype),

‎src/birdnet/acoustic/inference/core/perf_tracker.py‎

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
from collections.abc import Callable
1111
from dataclasses import dataclass
1212
from multiprocessing import Queue, shared_memory
13-
from multiprocessing.synchronize import Event, Semaphore
13+
from multiprocessing.synchronize import Event
1414
from queue import Empty
1515

1616
import numpy as np
1717
import psutil
1818

1919
import birdnet.acoustic.inference.core.logs as bn_logging
2020
from birdnet.acoustic.inference.core.shm import RingField
21+
from birdnet.acoustic.inference.core.sync import CountedSemaphore
2122
from birdnet.globals import READABLE_FLAG, READING_FLAG, WRITABLE_FLAG
2223

2324

@@ -124,8 +125,8 @@ def __init__(
124125
logging_queue: Queue,
125126
logging_level: int,
126127
perf_res: Queue,
127-
sem_active_workers: Semaphore,
128-
sem_filled_slots: Semaphore,
128+
sem_active_workers: CountedSemaphore,
129+
sem_filled_slots: CountedSemaphore,
129130
segment_size_s: float,
130131
parent_process_id: int,
131132
rf_flags: RingField,
@@ -275,20 +276,42 @@ def reset(self) -> None:
275276
self._prd_speed_xrt_tracker.reset()
276277
self._prd_speed_seg_per_s_tracker.reset()
277278

279+
@staticmethod
280+
def _safe_proc_memory(proc: psutil.Process) -> float | None:
281+
try:
282+
return float(proc.memory_full_info().uss)
283+
except (psutil.AccessDenied, PermissionError):
284+
pass
285+
except psutil.NoSuchProcess:
286+
return None
287+
try:
288+
return float(proc.memory_info().rss)
289+
except (psutil.NoSuchProcess, psutil.AccessDenied, PermissionError):
290+
return None
291+
278292
def _track_memory_usage(self) -> None:
279293
if self._parent_process is None:
280-
self._parent_process = psutil.Process(self._parent_process_id)
281-
memory_usage: float = self._parent_process.memory_full_info().uss
282-
for child in self._parent_process.children(recursive=True):
283294
try:
284-
memory_usage += child.memory_full_info().uss
285-
except psutil.NoSuchProcess:
286-
continue
287-
except psutil.AccessDenied:
288-
continue
289-
290-
mem_usage_MiB = memory_usage / 1024**2
291-
self._memory_usage_MiB_tracker.add_value(mem_usage_MiB)
295+
self._parent_process = psutil.Process(self._parent_process_id)
296+
except (psutil.NoSuchProcess, psutil.AccessDenied, PermissionError):
297+
return
298+
299+
parent_mem = self._safe_proc_memory(self._parent_process)
300+
if parent_mem is None:
301+
return
302+
303+
total = parent_mem
304+
try:
305+
children = self._parent_process.children(recursive=True)
306+
except (psutil.AccessDenied, PermissionError, psutil.NoSuchProcess):
307+
children = []
308+
309+
for child in children:
310+
child_mem = self._safe_proc_memory(child)
311+
if child_mem is not None:
312+
total += child_mem
313+
314+
self._memory_usage_MiB_tracker.add_value(total / 1024**2)
292315

293316
@property
294317
def wall_time(self) -> float:

‎src/birdnet/acoustic/inference/core/prediction/prediction_result.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import os
44
from pathlib import Path
5-
from typing import TYPE_CHECKING, Any
5+
from typing import TYPE_CHECKING
66

77
import numpy as np
88
from ordered_set import OrderedSet
@@ -153,10 +153,15 @@ def to_structured_array(self) -> np.ndarray:
153153
del valid_mask
154154

155155
n_predictions = len(valid_indices[0])
156+
# Force at least float32 for timing columns. The bulk _input_durations
157+
# array is stored in a magnitude-based dtype (float16 for files <= 2**11 s),
158+
# which is too coarse for accumulated i*hop products and would also produce
159+
# Arrow halffloat that some implementations (e.g. R) cannot read.
160+
time_dtype = np.result_type(self._input_durations.dtype, np.float32)
156161
dtype = [
157162
(VAR_INPUT, self._input_dtype),
158-
(VAR_START_TIME, self._input_durations.dtype),
159-
(VAR_END_TIME, self._input_durations.dtype),
163+
(VAR_START_TIME, time_dtype),
164+
(VAR_END_TIME, time_dtype),
160165
(VAR_SPECIES_NAME, object),
161166
(VAR_CONFIDENCE, self._species_probs.dtype),
162167
]
@@ -191,15 +196,15 @@ def to_structured_array(self) -> np.ndarray:
191196
del sort_indices
192197

193198
hop_duration_s = self.hop_duration_s
194-
start_times = chunk_idx_flat.astype(self._input_durations.dtype) * hop_duration_s
199+
start_times = chunk_idx_flat.astype(time_dtype) * hop_duration_s
195200
del hop_duration_s
196201
del chunk_idx_flat
197202

198203
structured_array[VAR_START_TIME] = start_times
199204
structured_array[VAR_END_TIME] = np.minimum(
200205
start_times
201206
+ apply_speed_to_duration(self._segment_duration_s[0], self._speed[0]),
202-
self._input_durations[file_idx_flat],
207+
self._input_durations[file_idx_flat].astype(time_dtype),
203208
)
204209
del start_times
205210
structured_array[VAR_INPUT] = self._inputs[file_idx_flat]

‎src/birdnet/acoustic/inference/core/producer.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import birdnet.acoustic.inference.core.logs as bn_logging
2121
from birdnet.acoustic.inference.core.shm import RingField
22+
from birdnet.acoustic.inference.core.sync import CountedSemaphore
2223
from birdnet.globals import (
2324
READABLE_FLAG,
2425
READING_FLAG,
@@ -54,7 +55,7 @@ def __init__(
5455
rf_batch_sizes: RingField,
5556
rf_flags: RingField,
5657
sem_free_slots: Semaphore,
57-
sem_filled_slots: Semaphore,
58+
sem_filled_slots: CountedSemaphore,
5859
max_segment_idx_ptr: ctypes.c_uint8
5960
| ctypes.c_uint16
6061
| ctypes.c_uint32
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
import multiprocessing as mp
4+
from multiprocessing.sharedctypes import Synchronized
5+
from types import TracebackType
6+
7+
8+
class CountedSemaphore:
9+
"""
10+
Drop-in replacement for ``mp.Semaphore`` whose ``get_value()`` works on
11+
macOS by mirroring acquire/release into a shared counter.
12+
"""
13+
14+
def __init__(self, initial: int = 0) -> None:
15+
self._sem = mp.Semaphore(initial)
16+
self._counter: Synchronized = mp.Value("i", initial)
17+
18+
def acquire(self, block: bool = True, timeout: float | None = None) -> bool:
19+
acquired = self._sem.acquire(block, timeout)
20+
if acquired:
21+
with self._counter.get_lock():
22+
self._counter.value -= 1
23+
return acquired
24+
25+
def release(self) -> None:
26+
with self._counter.get_lock():
27+
self._counter.value += 1
28+
self._sem.release()
29+
30+
def get_value(self) -> int:
31+
return self._counter.value
32+
33+
def __enter__(self) -> bool:
34+
return self.acquire()
35+
36+
def __exit__(
37+
self,
38+
exc_type: type[BaseException] | None,
39+
exc: BaseException | None,
40+
tb: TracebackType | None,
41+
) -> None:
42+
self.release()

‎src/birdnet/acoustic/inference/core/worker.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import birdnet.acoustic.inference.core.logs as bn_logging
1616
from birdnet.acoustic.inference.core.shm import RingField
17+
from birdnet.acoustic.inference.core.sync import CountedSemaphore
1718
from birdnet.core.backends import BackendLoader, BatchT, VersionedBackendProtocol
1819
from birdnet.globals import (
1920
READABLE_FLAG,
@@ -43,8 +44,8 @@ def __init__(
4344
out_q: Queue,
4445
wkr_ring_access_lock: multiprocessing.synchronize.Lock,
4546
sem_free: Semaphore,
46-
sem_fill: Semaphore,
47-
sem_active_workers: Semaphore | None,
47+
sem_fill: CountedSemaphore,
48+
sem_active_workers: CountedSemaphore | None,
4849
half_precision: bool,
4950
wkr_stats_queue: Queue | None,
5051
logging_queue: Queue,

‎src/birdnet/acoustic/inference/resources.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
PerformanceTrackingResult,
2727
)
2828
from birdnet.acoustic.inference.core.shm import RingField, create_shm_ring
29+
from birdnet.acoustic.inference.core.sync import CountedSemaphore
2930
from birdnet.core.backends import BackendLoader
3031
from birdnet.core.base import get_session_id_hash
3132
from birdnet.globals import MODEL_TYPE_ACOUSTIC, PKG_NAME, WRITABLE_FLAG
@@ -106,7 +107,7 @@ class RingBufferResources:
106107
rf_batch_sizes: RingField
107108
rf_flags: RingField
108109
sem_free_slots: multiprocessing.synchronize.Semaphore
109-
sem_filled_slots: multiprocessing.synchronize.Semaphore
110+
sem_filled_slots: CountedSemaphore
110111

111112
_rf_flags_memory: shared_memory.SharedMemory | None = None
112113

@@ -172,7 +173,7 @@ def _create(
172173
rf_batch_sizes=rf_batch_sizes,
173174
rf_flags=rf_flags,
174175
sem_free_slots=mp.Semaphore(n_slots),
175-
sem_filled_slots=mp.Semaphore(0),
176+
sem_filled_slots=CountedSemaphore(0),
176177
)
177178

178179
@classmethod
@@ -478,7 +479,7 @@ def start_iso_time(self) -> str:
478479
track_performance: bool
479480
wkr_stats_queue: Queue | None
480481
prd_stats_queue: Queue | None
481-
sem_active_workers: multiprocessing.synchronize.Semaphore | None
482+
sem_active_workers: CountedSemaphore | None
482483
perf_res_queue: Queue | None
483484
perf_res_start_signal: multiprocessing.synchronize.Event | None
484485
perf_res_finish_signal: multiprocessing.synchronize.Event | None
@@ -539,7 +540,7 @@ def create(
539540
perf_res_finish_signal = mp.Event()
540541
wkr_stats_queue = Queue()
541542
prd_stats_queue = Queue()
542-
sem_active_workers = mp.Semaphore(0)
543+
sem_active_workers = CountedSemaphore(0)
543544

544545
callback_start_signal = None
545546
callback_finish_signal = None

0 commit comments

Comments
 (0)