From 91096c02ca99542c0f615c31a853d0c6d0eefdd4 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 31 Aug 2026 14:07:35 +0200 Subject: [PATCH] Add GPU support --- AGENTS.md | 8 ++ birdnet_analyzer/analyze/core.py | 10 ++ birdnet_analyzer/cli.py | 17 +++ birdnet_analyzer/gui/analysis.py | 3 + birdnet_analyzer/gui/embeddings.py | 4 +- birdnet_analyzer/gui/multi_file.py | 7 +- birdnet_analyzer/gui/presets.py | 7 + birdnet_analyzer/gui/utils.py | 60 +++++++- birdnet_analyzer/lang/de.json | 3 + birdnet_analyzer/lang/en.json | 3 + birdnet_analyzer/lang/fi.json | 3 + birdnet_analyzer/lang/fr.json | 3 + birdnet_analyzer/lang/id.json | 3 + birdnet_analyzer/lang/pt-br.json | 3 + birdnet_analyzer/lang/ru.json | 3 + birdnet_analyzer/lang/se.json | 3 + birdnet_analyzer/lang/tlh.json | 3 + birdnet_analyzer/lang/zh_CN.json | 3 + birdnet_analyzer/lang/zh_TW.json | 3 + birdnet_analyzer/model_utils.py | 135 ++++++++++++++++++ birdnet_analyzer/params.py | 1 + docs/implementation-details.rst | 1 + docs/implementation-details/gpu-inference.rst | 81 +++++++++++ tests/test_model_utils.py | 59 ++++++++ tests/test_params.py | 8 ++ 25 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 docs/implementation-details/gpu-inference.rst diff --git a/AGENTS.md b/AGENTS.md index bb04c5d6a..d856a6dd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,14 @@ that directory is the way to test a first-run experience. of what the next line does or how the code was arrived at — that belongs in commit messages and the changelog. The same bar applies to test comments: the test name and its assertions are the explanation. +- **GPU inference is BirdNET 3.0 only.** `--device GPU` reaches the birdnet session, + but only the ONNX backend 3.0 runs on has a device to dispatch to: 2.4 and custom + classifiers are asserted to CPU by the TensorFlow Lite backend, and Perch's + TensorFlow backend has no GPU on native Windows. `model_utils.effective_device` + downgrades anything else to CPU up front, because the library would otherwise only + fail inside the worker subprocesses. See + `docs/implementation-details/gpu-inference.rst`, which also records why DirectML is + not accepted as a GPU. - **Don't widen the ruff config to make a fix pass.** The `select`/`ignore` lists in `pyproject.toml` are deliberate. - **Packaging is an allow-list, not a deny-list.** What ships is decided by the diff --git a/birdnet_analyzer/analyze/core.py b/birdnet_analyzer/analyze/core.py index 623647f04..afceb9f3c 100644 --- a/birdnet_analyzer/analyze/core.py +++ b/birdnet_analyzer/analyze/core.py @@ -39,6 +39,7 @@ def analyze( batch_size: int = 1, n_workers: int | None = None, n_producers: int = 1, + device: str = "CPU", rtype: RESULT_TYPES | list[RESULT_TYPES] = "table", sf_thresh: float = 0.03, top_n: int | None = None, @@ -79,6 +80,10 @@ def analyze( audio_speed (float, optional): Speed factor for audio playback during analysis. Defaults to 1.0. batch_size (int, optional): Batch size for processing. Defaults to 1. + device (str, optional): Device to run inference on: "CPU", "GPU" or + "GPU:". Only BirdNET 3.0 can use the GPU, and only with a + GPU-capable ONNX Runtime installed; any other combination falls back to + the CPU with a warning. Defaults to "CPU". rtype (Literal["table", "audacity", "kaleidoscope", "csv", "parquet"] | List[Literal["table", "audacity", "kaleidoscope", "csv", "parquet"]], optional): Output format(s) for results. Defaults to "table". @@ -117,6 +122,7 @@ def analyze( import birdnet_analyzer.config as cfg from birdnet_analyzer.analyze.resume import ResumeJournal, RunMetadata from birdnet_analyzer.model_utils import ( + effective_device, effective_sensitivity, run_geomodel, run_inference, @@ -125,6 +131,8 @@ def analyze( # Settled before the params file, result columns and resume fingerprint see it. sensitivity = effective_sensitivity(sensitivity, model, birdnet, classifier) + # Deliberately not part of the resume fingerprint: it does not change results. + device = effective_device(device, model, birdnet, classifier) species_list_file = slist if isinstance(slist, (str, Path)) else "" rtypes: list[RESULT_TYPES] = [rtype] if isinstance(rtype, str) else rtype @@ -209,6 +217,7 @@ def analyze( callback=on_update, n_workers=n_workers, n_producers=n_producers, + device=device, on_file_complete=journal.on_file_complete if journal else None, ) @@ -323,6 +332,7 @@ def analyze( "Batch size": batch_size, "Number of workers": n_workers or "", "Number of producers": n_producers, + "Device": device, "Result type(s)": ", ".join(rtypes), "Additional columns": ", ".join(additional_columns) if additional_columns diff --git a/birdnet_analyzer/cli.py b/birdnet_analyzer/cli.py index c5ace58a6..218b5c9b9 100644 --- a/birdnet_analyzer/cli.py +++ b/birdnet_analyzer/cli.py @@ -412,6 +412,22 @@ def computing_resources_args(): return p +def device_args(): + """Argument parser for the inference device (--device).""" + p = argparse.ArgumentParser(add_help=False) + + p.add_argument( + "--device", + type=str, + default="CPU", + help="Device to run inference on: 'CPU', 'GPU' or 'GPU:'. Only BirdNET " + "3.0 supports the GPU, and only with a GPU-capable ONNX Runtime installed; " + "anything else falls back to the CPU with a warning.", + ) + + return p + + def db_args(): """Argument parser for the database path (-db/--database).""" p = argparse.ArgumentParser(add_help=False) @@ -442,6 +458,7 @@ def analyzer_parser(): locale_args(), bs_args(), computing_resources_args(), + device_args(), load_params_args("analysis", "birdnet.analyze-params.csv"), verbosity_args(), ] diff --git a/birdnet_analyzer/gui/analysis.py b/birdnet_analyzer/gui/analysis.py index cbd318766..387b690af 100644 --- a/birdnet_analyzer/gui/analysis.py +++ b/birdnet_analyzer/gui/analysis.py @@ -64,6 +64,7 @@ def run_analysis( n_producers, n_workers, progress: gr.Progress | None, + device: str = "CPU", split_tables: bool = False, ): """Starts the analysis. @@ -93,6 +94,7 @@ def run_analysis( batch_size: The number of samples in a batch. n_producers: The number of producer threads to be used. n_workers: The number of worker threads to be used. + device: The device to run inference on ("CPU", "GPU" or "GPU:"). input_dir: The input directory. progress: The gradio progress bar. split_tables: Whether to split the output into separate tables per input file. @@ -160,6 +162,7 @@ def run_analysis( save_params=save_params, n_producers=n_producers, n_workers=n_workers, + device=device, split_tables=split_tables, _return_only=bool(input_path), # only for single file tab ) diff --git a/birdnet_analyzer/gui/embeddings.py b/birdnet_analyzer/gui/embeddings.py index 546c1e633..5a32bcce2 100644 --- a/birdnet_analyzer/gui/embeddings.py +++ b/birdnet_analyzer/gui/embeddings.py @@ -246,7 +246,9 @@ def on_cb_click(status, current, db_dir): info=loc.localize("embedding-settings-audio-speed-slider-info"), ) - bs_number, producers_number, workers_number = gu.computing_settings(state) + bs_number, producers_number, workers_number, _ = gu.computing_settings( + state + ) fmin_number, fmax_number = gu.bandpass_settings(state) diff --git a/birdnet_analyzer/gui/multi_file.py b/birdnet_analyzer/gui/multi_file.py index 0e334dca4..723a3ccd8 100644 --- a/birdnet_analyzer/gui/multi_file.py +++ b/birdnet_analyzer/gui/multi_file.py @@ -63,6 +63,7 @@ def run_batch_analysis( batch_size, producers_number, workers_number, + device, input_dir, progress=gr.Progress(), ): @@ -103,6 +104,7 @@ def run_batch_analysis( progress=progress, n_producers=producers_number, n_workers=workers_number, + device=device, split_tables=split_tables_checkbox, ) except RuntimeError: @@ -342,7 +344,9 @@ def select_directory_wrapper(): info=loc.localize("multi-tab-split-table-checkbox-info"), ) - bs_number, producers_number, workers_number = gu.computing_settings(state) + bs_number, producers_number, workers_number, device_radio = ( + gu.computing_settings(state, with_device=True) + ) resume_status_md = gr.Markdown(visible=False) with gr.Row(equal_height=True): @@ -386,6 +390,7 @@ def select_directory_wrapper(): bs_number, producers_number, workers_number, + device_radio, input_directory_state, ] diff --git a/birdnet_analyzer/gui/presets.py b/birdnet_analyzer/gui/presets.py index 5e67c2804..9546c3f19 100644 --- a/birdnet_analyzer/gui/presets.py +++ b/birdnet_analyzer/gui/presets.py @@ -326,6 +326,7 @@ def load_analysis_params(path: str) -> dict[str, Any]: "batch_size": "batch_size_number", "n_producers": "producers_number", "n_workers": "workers_number", + "device": "device_radio", "top_n": "top_n_input", "lat": "lat_number", "lon": "lon_number", @@ -340,6 +341,12 @@ def load_analysis_params(path: str) -> dict[str, Any]: if "audio_speed" in kwargs: values["audio_speed_slider"] = _speed_to_slider(kwargs["audio_speed"]) + if "device_radio" in values: + import birdnet_analyzer.gui.utils as gu + + device = str(values["device_radio"]).strip().upper().partition(":")[0] + values["device_radio"] = device if device in gu.analysis_devices() else "CPU" + values["use_top_n_checkbox"] = "top_n" in kwargs # With top N in use the analysis ran without a confidence threshold and stored 0, diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index 7b1dfb6d0..34529ed35 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -1578,9 +1578,32 @@ def _get_win_drives(): return [f"{drive}:\\" for drive in UPPER_CASE] + _get_network_shortcuts() -def computing_settings(state: TabState): +# Measured several times faster than the batch size of 1 the CPU defaults to. +GPU_BATCH_SIZE = 16 + + +def analysis_devices() -> list[str]: + """The device choices to offer, newest-model first. + + ``GPU`` is only offered when an ONNX Runtime that can actually run BirdNET 3.0 on + it is installed, so the choice never silently degrades to CPU mid-analysis. + """ + from birdnet_analyzer.model_utils import gpu_available + + return ["CPU", "GPU"] if gpu_available() else ["CPU"] + + +def computing_settings(state: TabState, with_device: bool = False): + """Build the shared computing settings row. + + Always returns four components; ``device_radio`` is None unless ``with_device``, + which only the analysis tab sets - the other tabs run models that are CPU-only. + """ import psutil + device_radio = None + cpu_workers = psutil.cpu_count(logical=True) or 1 + with gr.Row(): bs_number = state.persist( "batch_size_number", @@ -1605,12 +1628,43 @@ def computing_settings(state: TabState): gr.Number, precision=1, label=loc.localize("computing-settings-workers-number-label"), - value=psutil.cpu_count(logical=True) or 1, + value=cpu_workers, info=loc.localize("computing-settings-workers-number-info"), minimum=1, ) - return bs_number, producers_number, workers_number + if with_device: + devices = analysis_devices() + device_radio = state.persist( + "device_radio", + gr.Radio, + choices=devices, + value="CPU", + label=loc.localize("computing-settings-device-radio-label"), + info=loc.localize("computing-settings-device-radio-info") + if len(devices) > 1 + else loc.localize("computing-settings-device-radio-unavailable-info"), + interactive=len(devices) > 1, + ) + + if device_radio is not None: + + def settings_for_device(device): + on_gpu = device != "CPU" + + return gr.update(value=1 if on_gpu else cpu_workers), gr.update( + value=GPU_BATCH_SIZE if on_gpu else 1 + ) + + device_radio.input( + settings_for_device, + inputs=device_radio, + outputs=[workers_number, bs_number], + show_progress="hidden", + queue=False, + ) + + return bs_number, producers_number, workers_number, device_radio def info_box(description: str, title="Info") -> gr.Accordion: diff --git a/birdnet_analyzer/lang/de.json b/birdnet_analyzer/lang/de.json index a7afa37b0..72a02d590 100644 --- a/birdnet_analyzer/lang/de.json +++ b/birdnet_analyzer/lang/de.json @@ -4,6 +4,9 @@ "analyze-start-button-label": "Analysieren", "computing-settings-batchsize-number-info": "Anzahl der gleichzeitig verarbeiteten Proben. Lassen Sie diesen Wert unverändert, wenn Sie nicht genau wissen, was Sie tun.", "computing-settings-batchsize-number-label": "Batch-Größe", + "computing-settings-device-radio-info": "Hardware, auf der die Inferenz läuft. Die GPU steht nur für BirdNET 3.0 zur Verfügung.", + "computing-settings-device-radio-label": "Gerät", + "computing-settings-device-radio-unavailable-info": "Die Inferenz läuft auf der CPU: Es ist keine GPU-fähige ONNX Runtime installiert.", "computing-settings-producers-number-info": "Anzahl der Producer-Prozesse, die die Inferenz-Warteschlange befüllen. Lassen Sie diesen Wert unverändert, wenn Sie nicht genau wissen, was Sie tun.", "computing-settings-producers-number-label": "Produzenten", "computing-settings-workers-number-info": "Maximale Anzahl an Worker-Threads für die Inferenz. Lassen Sie diesen Wert unverändert, wenn Sie nicht genau wissen, was Sie tun.", diff --git a/birdnet_analyzer/lang/en.json b/birdnet_analyzer/lang/en.json index 70d357f8a..ff440f269 100644 --- a/birdnet_analyzer/lang/en.json +++ b/birdnet_analyzer/lang/en.json @@ -4,6 +4,9 @@ "analyze-start-button-label": "Analyze", "computing-settings-batchsize-number-info": "Number of samples processed simultaneously. Leave this value at its default unless you know what you're doing.", "computing-settings-batchsize-number-label": "Batch size", + "computing-settings-device-radio-info": "Hardware used for inference. The GPU is only available for BirdNET 3.0.", + "computing-settings-device-radio-label": "Device", + "computing-settings-device-radio-unavailable-info": "Inference runs on the CPU: no GPU-capable ONNX Runtime is installed.", "computing-settings-producers-number-info": "Number of producer processes feeding the inference queue. Leave this value at its default unless you know what you're doing.", "computing-settings-producers-number-label": "Producers", "computing-settings-workers-number-info": "Maximum number of worker threads used for inference. Leave this value at its default unless you know what you're doing.", diff --git a/birdnet_analyzer/lang/fi.json b/birdnet_analyzer/lang/fi.json index d7c62df5e..9c274bb3e 100644 --- a/birdnet_analyzer/lang/fi.json +++ b/birdnet_analyzer/lang/fi.json @@ -4,6 +4,9 @@ "analyze-start-button-label": "Analysoi", "computing-settings-batchsize-number-info": "Samanaikaisesti käsiteltävien näytteiden määrä. Jätä oletusarvoon, ellet tiedä tarkalleen mitä teet.", "computing-settings-batchsize-number-label": "Eräkoko", + "computing-settings-device-radio-info": "Laitteisto, jolla inferenssi suoritetaan. GPU on käytettävissä vain BirdNET 3.0:lle.", + "computing-settings-device-radio-label": "Laite", + "computing-settings-device-radio-unavailable-info": "Inferenssi suoritetaan suorittimella: GPU-yhteensopivaa ONNX Runtimea ei ole asennettu.", "computing-settings-producers-number-info": "Inferenssijonoa täyttävien tuottajaprosessien määrä. Jätä oletusarvoon, ellet tiedä tarkalleen mitä teet.", "computing-settings-producers-number-label": "Tuottajat", "computing-settings-workers-number-info": "Inferenssiin käytettävien työntekijäsäikeiden enimmäismäärä. Jätä oletusarvoon, ellet tiedä tarkalleen mitä teet.", diff --git a/birdnet_analyzer/lang/fr.json b/birdnet_analyzer/lang/fr.json index 60335e869..cb49b7516 100644 --- a/birdnet_analyzer/lang/fr.json +++ b/birdnet_analyzer/lang/fr.json @@ -4,6 +4,9 @@ "analyze-start-button-label": "Analyser", "computing-settings-batchsize-number-info": "Nombre d'échantillons traités simultanément. Laissez cette valeur par défaut sauf si vous savez exactement ce que vous faites.", "computing-settings-batchsize-number-label": "Taille du lot", + "computing-settings-device-radio-info": "Matériel utilisé pour l'inférence. Le GPU n'est disponible que pour BirdNET 3.0.", + "computing-settings-device-radio-label": "Périphérique", + "computing-settings-device-radio-unavailable-info": "L'inférence s'exécute sur le CPU : aucun ONNX Runtime compatible GPU n'est installé.", "computing-settings-producers-number-info": "Nombre de processus producteurs alimentant la file d'inférence. Laissez cette valeur par défaut sauf si vous savez exactement ce que vous faites.", "computing-settings-producers-number-label": "Producteurs", "computing-settings-workers-number-info": "Nombre maximal de threads de travail utilisés pour l'inférence. Laissez cette valeur par défaut sauf si vous savez exactement ce que vous faites.", diff --git a/birdnet_analyzer/lang/id.json b/birdnet_analyzer/lang/id.json index 8fe001ae5..89394d145 100644 --- a/birdnet_analyzer/lang/id.json +++ b/birdnet_analyzer/lang/id.json @@ -4,6 +4,9 @@ "analyze-start-button-label": "Analisis", "computing-settings-batchsize-number-info": "Jumlah sampel yang diproses secara bersamaan. Biarkan pada nilai bawaan kecuali Anda benar-benar tahu apa yang Anda lakukan.", "computing-settings-batchsize-number-label": "Ukuran batch", + "computing-settings-device-radio-info": "Perangkat keras yang digunakan untuk inferensi. GPU hanya tersedia untuk BirdNET 3.0.", + "computing-settings-device-radio-label": "Perangkat", + "computing-settings-device-radio-unavailable-info": "Inferensi berjalan di CPU: tidak ada ONNX Runtime yang mendukung GPU terpasang.", "computing-settings-producers-number-info": "Jumlah proses produser yang mengisi antrean inferensi. Biarkan pada nilai bawaan kecuali Anda benar-benar tahu apa yang Anda lakukan.", "computing-settings-producers-number-label": "Produser", "computing-settings-workers-number-info": "Jumlah maksimum thread worker yang digunakan untuk inferensi. Biarkan pada nilai bawaan kecuali Anda benar-benar tahu apa yang Anda lakukan.", diff --git a/birdnet_analyzer/lang/pt-br.json b/birdnet_analyzer/lang/pt-br.json index fd92e5c2e..20b4745a2 100644 --- a/birdnet_analyzer/lang/pt-br.json +++ b/birdnet_analyzer/lang/pt-br.json @@ -4,6 +4,9 @@ "analyze-start-button-label": "Analisar", "computing-settings-batchsize-number-info": "Quantidade de amostras processadas simultaneamente. Deixe o valor padrão a menos que saiba exatamente o que está fazendo.", "computing-settings-batchsize-number-label": "Tamanho do lote", + "computing-settings-device-radio-info": "Hardware usado na inferência. A GPU só está disponível para o BirdNET 3.0.", + "computing-settings-device-radio-label": "Dispositivo", + "computing-settings-device-radio-unavailable-info": "A inferência é executada na CPU: nenhum ONNX Runtime com suporte a GPU está instalado.", "computing-settings-producers-number-info": "Número de processos produtores que alimentam a fila de inferência. Deixe o valor padrão a menos que saiba exatamente o que está fazendo.", "computing-settings-producers-number-label": "Produtores", "computing-settings-workers-number-info": "Número máximo de threads de trabalho usados na inferência. Deixe o valor padrão a menos que saiba exatamente o que está fazendo.", diff --git a/birdnet_analyzer/lang/ru.json b/birdnet_analyzer/lang/ru.json index e52e09b6f..b27a94e1d 100644 --- a/birdnet_analyzer/lang/ru.json +++ b/birdnet_analyzer/lang/ru.json @@ -4,6 +4,9 @@ "analyze-start-button-label": "Анализировать", "computing-settings-batchsize-number-info": "Количество образцов, обрабатываемых одновременно. Оставьте значение по умолчанию, если не уверены в своих действиях.", "computing-settings-batchsize-number-label": "Размер пакета", + "computing-settings-device-radio-info": "Оборудование, используемое для инференса. Графический процессор доступен только для BirdNET 3.0.", + "computing-settings-device-radio-label": "Устройство", + "computing-settings-device-radio-unavailable-info": "Инференс выполняется на процессоре: ONNX Runtime с поддержкой GPU не установлен.", "computing-settings-producers-number-info": "Число процессов-производителей, наполняющих очередь инференса. Оставьте значение по умолчанию, если не уверены в своих действиях.", "computing-settings-producers-number-label": "Производители", "computing-settings-workers-number-info": "Максимальное число рабочих потоков, используемых для инференса. Оставьте значение по умолчанию, если не уверены в своих действиях.", diff --git a/birdnet_analyzer/lang/se.json b/birdnet_analyzer/lang/se.json index aee5d7c4b..8f7cd8315 100644 --- a/birdnet_analyzer/lang/se.json +++ b/birdnet_analyzer/lang/se.json @@ -4,6 +4,9 @@ "analyze-start-button-label": "Analysera", "computing-settings-batchsize-number-info": "Antal sampel som bearbetas samtidigt. Låt standardvärdet stå om du inte vet exakt vad du gör.", "computing-settings-batchsize-number-label": "Batch-storlek", + "computing-settings-device-radio-info": "Hårdvara som används för inferens. GPU är endast tillgängligt för BirdNET 3.0.", + "computing-settings-device-radio-label": "Enhet", + "computing-settings-device-radio-unavailable-info": "Inferensen körs på CPU:n: ingen ONNX Runtime med GPU-stöd är installerad.", "computing-settings-producers-number-info": "Antal producentprocesser som fyller inferenskön. Låt standardvärdet stå om du inte vet exakt vad du gör.", "computing-settings-producers-number-label": "Producenter", "computing-settings-workers-number-info": "Maximalt antal arbetstrådar som används för inferens. Låt standardvärdet stå om du inte vet exakt vad du gör.", diff --git a/birdnet_analyzer/lang/tlh.json b/birdnet_analyzer/lang/tlh.json index 2b2f44899..c88fcfc1a 100644 --- a/birdnet_analyzer/lang/tlh.json +++ b/birdnet_analyzer/lang/tlh.json @@ -4,6 +4,9 @@ "analyze-start-button-label": "poj yItagh", "computing-settings-batchsize-number-info": "wa'logh Qapbogh wavmey mI'. Doch Dachovbejbe'chugh, motlh patlh yISeHQo'.", "computing-settings-batchsize-number-label": "batch qechmey", + "computing-settings-device-radio-info": "poj lo'lu'bogh jan. BirdNET 3.0 neH GPU lo'laH.", + "computing-settings-device-radio-label": "jan", + "computing-settings-device-radio-unavailable-info": "CPU lo'lu'. GPU lo'laHbogh ONNX Runtime tu'lu'be'.", "computing-settings-producers-number-info": "poj HIvwI'meyvaD De' lInobbogh chenmoHwI' mIw mI'. Doch Dachovbejbe'chugh, motlh patlh yISeHQo'.", "computing-settings-producers-number-label": "chenmoHwI'pu'", "computing-settings-workers-number-info": "poj lo'lu'bogh vumwI' SIrgh nIv mI'. Doch Dachovbejbe'chugh, motlh patlh yISeHQo'.", diff --git a/birdnet_analyzer/lang/zh_CN.json b/birdnet_analyzer/lang/zh_CN.json index d7e434edc..3ad9f151d 100644 --- a/birdnet_analyzer/lang/zh_CN.json +++ b/birdnet_analyzer/lang/zh_CN.json @@ -4,6 +4,9 @@ "analyze-start-button-label": "分析", "computing-settings-batchsize-number-info": "同时处理的样本数量。除非您清楚自己在做什么,否则请保持默认值。", "computing-settings-batchsize-number-label": "批大小", + "computing-settings-device-radio-info": "用于推理的硬件。仅 BirdNET 3.0 支持 GPU。", + "computing-settings-device-radio-label": "设备", + "computing-settings-device-radio-unavailable-info": "推理将在 CPU 上运行:未安装支持 GPU 的 ONNX Runtime。", "computing-settings-producers-number-info": "向推理队列提供数据的生产者进程数量。除非您清楚自己在做什么,否则请保持默认值。", "computing-settings-producers-number-label": "生产者", "computing-settings-workers-number-info": "用于推理的工作线程最大数量。除非您清楚自己在做什么,否则请保持默认值。", diff --git a/birdnet_analyzer/lang/zh_TW.json b/birdnet_analyzer/lang/zh_TW.json index fec203acb..77136e382 100644 --- a/birdnet_analyzer/lang/zh_TW.json +++ b/birdnet_analyzer/lang/zh_TW.json @@ -4,6 +4,9 @@ "analyze-start-button-label": "開始分析", "computing-settings-batchsize-number-info": "同時處理的樣本數量。除非非常清楚要調整什麼,否則請保持預設值。", "computing-settings-batchsize-number-label": "批次大小", + "computing-settings-device-radio-info": "用於推論的硬體。僅 BirdNET 3.0 支援 GPU。", + "computing-settings-device-radio-label": "裝置", + "computing-settings-device-radio-unavailable-info": "推論將在 CPU 上執行:未安裝支援 GPU 的 ONNX Runtime。", "computing-settings-producers-number-info": "輸入推論佇列的產生者程序數量。除非非常清楚要調整什麼,否則請保持預設值。", "computing-settings-producers-number-label": "產生者", "computing-settings-workers-number-info": "用於推論的工作執行緒最大數量。除非非常清楚要調整什麼,否則請保持預設值。", diff --git a/birdnet_analyzer/model_utils.py b/birdnet_analyzer/model_utils.py index 545d65971..c9cbf88d3 100644 --- a/birdnet_analyzer/model_utils.py +++ b/birdnet_analyzer/model_utils.py @@ -3,6 +3,7 @@ import logging import threading from contextlib import suppress +from functools import cache from typing import TYPE_CHECKING, cast import birdnet @@ -309,6 +310,126 @@ def _language_for_version(language: MODEL_LANGUAGES, version: str) -> MODEL_LANG return MODEL_LANGUAGE_EN_US +# Accepted as a GPU. DirectML is excluded on purpose, and a provider only belongs +# here once birdnet selects it; see docs/implementation-details/gpu-inference.rst. +GPU_EXECUTION_PROVIDERS = ("CUDAExecutionProvider",) + + +def gpu_available() -> bool: + """Whether an ONNX Runtime that can run BirdNET 3.0 on a GPU is installed. + + Reports what the installed build *can* do, not whether its libraries resolve at + runtime - a mismatched CUDA runtime makes ONNX Runtime fall back to the CPU + provider silently. The providers that count are :data:`GPU_EXECUTION_PROVIDERS`. + """ + from importlib.util import find_spec + + if find_spec("onnxruntime") is None: + return False + + try: + import onnxruntime as ort + + available = set(ort.get_available_providers()) + return any(provider in available for provider in GPU_EXECUTION_PROVIDERS) + except Exception: + return False + + +@cache +def _add_cuda_libraries_to_path() -> None: + """Put the CUDA libraries of the ``nvidia-*`` pip packages on PATH (Windows). + + Without this ONNX Runtime does not find them and falls back to the CPU provider. + PATH rather than ``onnxruntime.preload_dlls()`` because inference runs in worker + subprocesses, which inherit the environment but not a DLL directory added here. + """ + import os + + if os.name != "nt": + return + + from importlib.util import find_spec + from pathlib import Path + + spec = find_spec("nvidia") + roots = list(spec.submodule_search_locations) if spec else [] + dirs = [ + str(path) + for root in roots + for path in sorted(Path(root).glob("*/bin")) + if path.is_dir() + ] + + if dirs: + os.environ["PATH"] = os.pathsep.join([*dirs, os.environ.get("PATH", "")]) + + +def supports_gpu( + model: str, version: str = "3.0", classifier: str | None = None +) -> bool: + """Whether the selected model has a backend that can run on the GPU. + + Only BirdNET 3.0 qualifies - it is the one model loaded on the ONNX backend. See + docs/implementation-details/gpu-inference.rst. + """ + if classifier: + return False + + return model == "birdnet" and version == "3.0" + + +def effective_device( + device: str, + model: str = "birdnet", + version: str = "3.0", + classifier: str | None = None, +) -> str: + """The device the analysis will use; warns and falls back to CPU. + + ``device`` is ``"CPU"``, ``"GPU"`` or ``"GPU:"``, in any case. A GPU that + the model or the installation cannot deliver is reported and downgraded here, in + the main process: the library would otherwise only fail once the worker + subprocesses load the model, mid-analysis. + """ + device = device.strip().upper() + name, _, index = device.partition(":") + + if name not in ("CPU", "GPU"): + raise ValueError( + f"Unknown device: {device!r}. Use 'CPU', 'GPU' or 'GPU:'." + ) + + if index and not index.isdigit(): + raise ValueError( + f"Unknown device: {device!r}. The device index must be a number." + ) + + if name == "CPU": + return "CPU" + + if not supports_gpu(model, version, classifier): + logger.warning( + "GPU inference is not available for %s; running on the CPU instead.", + "a custom classifier" + if classifier + else ("Perch" if model == "perch" else f"BirdNET {version}"), + ) + return "CPU" + + if not gpu_available(): + logger.warning( + "No GPU-capable ONNX Runtime found; running on the CPU instead. Install " + "the GPU build with 'pip install onnxruntime-gpu' (and a matching CUDA " + "runtime) to use the GPU.", + ) + return "CPU" + + _add_cuda_libraries_to_path() + + return device + + def supports_sensitivity( model: str, version: str = "3.0", classifier: str | None = None ) -> bool: @@ -364,9 +485,22 @@ def run_inference( classifier: str | None = None, cc_species_list: str | None = None, strict_species_list: bool = False, + device: str = "CPU", callback: Callable[[AcousticProgressStats], None] | None = None, on_file_complete: Callable[[AcousticFilePredictionResult], None] | None = None, ) -> AcousticFilePredictionResult: + device = effective_device(device, model, version, classifier) + + if device.startswith("GPU"): + if n_workers is None: + n_workers = 1 + + if batch_size == 1: + logger.info( + "Running on the GPU with batch size 1. A larger batch size is " + "typically several times faster." + ) + if classifier: if not cc_species_list: cc_species_list = classifier.replace(".tflite", "_Labels.txt", 1) @@ -434,6 +568,7 @@ def run_inference( n_producers=n_producers, apply_sigmoid=model != "perch", max_n_files=len(input_files), + device=device, on_file_complete=on_file_complete, ) as session: _register_session(session) diff --git a/birdnet_analyzer/params.py b/birdnet_analyzer/params.py index 5225e67c1..af9ecda2f 100644 --- a/birdnet_analyzer/params.py +++ b/birdnet_analyzer/params.py @@ -118,6 +118,7 @@ def load_analysis_params(path: str) -> dict[str, Any]: parse("batch_size", _to_int, "Batch size") parse("n_producers", _to_int, "Number of producers") parse("n_workers", _to_int, "Number of workers") + parse("device", str, "Device") parse("top_n", _to_int, "Top N") parse("lat", float, "Latitude") parse("lon", float, "Longitude") diff --git a/docs/implementation-details.rst b/docs/implementation-details.rst index c0e42726e..07db6f758 100644 --- a/docs/implementation-details.rst +++ b/docs/implementation-details.rst @@ -9,3 +9,4 @@ Implementation details implementation-details/training-hyperparameters implementation-details/segment-collection-mode implementation-details/sensitivity + implementation-details/gpu-inference diff --git a/docs/implementation-details/gpu-inference.rst b/docs/implementation-details/gpu-inference.rst new file mode 100644 index 000000000..72194fda4 --- /dev/null +++ b/docs/implementation-details/gpu-inference.rst @@ -0,0 +1,81 @@ +GPU inference +=============================== + +Analysis can run on an NVIDIA GPU. Select the device with ``--device`` on the command +line, with the *Device* setting under the computing settings of the *Multi-file +analysis* tab, or with ``analyze(..., device="GPU")`` in the Python API. Accepted +values are ``CPU``, ``GPU`` and ``GPU:`` for a specific card. + +The device is checked before an analysis starts. If the GPU cannot be used, the run +continues on the CPU and reports why in the log instead of failing. + +Which models support it +-------------------------------------------------------- + +Only **BirdNET 3.0**. It is the one model this package runs on ONNX Runtime, which is +the backend that can dispatch to a GPU. BirdNET 2.4, custom classifiers (which run on +the 2.4 base) and Perch are always analyzed on the CPU, and requesting a GPU for them +logs a warning and falls back. + +Installing a GPU-capable ONNX Runtime +-------------------------------------------------------- + +The ``onnxruntime`` wheel installed with BirdNET-Analyzer is a CPU-only build. GPU +inference needs the CUDA build in its place: + +.. code-block:: bash + + pip uninstall onnxruntime + pip install onnxruntime-gpu + +``onnxruntime-gpu`` does not bundle CUDA, and **which CUDA it needs depends on the +release**: 1.29 is built against CUDA 13, releases up to 1.24 against CUDA 12. The +`ONNX Runtime CUDA requirements `_ +page lists the pairing per release. Without a system-wide CUDA installation, the +``nvidia-*`` pip packages provide the libraries. + +That pairing also decides which cards can be used: CUDA 13 dropped Maxwell, Pascal and +Volta, so a card older than Turing (compute capability below 7.5) needs a CUDA 12 +release of ONNX Runtime and a cuDNN built for it: + +.. code-block:: bash + + pip install "onnxruntime-gpu==1.24.4" "nvidia-cudnn-cu12==9.8.0.87" nvidia-cublas-cu12 + +Mismatches show up in two ways. A CUDA runtime that does not match the ONNX Runtime +release fails to load the provider +(``Error loading onnxruntime_providers_cuda.dll which depends on cublasLt64_NN.dll``) +and leaves the session on the CPU provider. A cuDNN too new for the card creates the +session but fails during inference with +``CUDNN failure 5003: CUDNN_STATUS_EXECUTION_FAILED_CUDART``. + +On Windows the ``nvidia-*`` packages put their libraries in ``site-packages/nvidia`` +rather than on PATH, where ONNX Runtime looks for them. BirdNET-Analyzer adds those +directories to PATH itself when a GPU is requested, so no manual setup is needed. + +.. warning:: + DirectML (``onnxruntime-directml``) is not a working alternative: the BirdNET 3.0 + acoustic model fails on it at every batch size and precision. Only the CUDA + execution provider is accepted as a GPU, so a DirectML-only installation reports no + GPU and runs on the CPU rather than failing mid-analysis. + +Checking that the GPU is being used +-------------------------------------------------------- + +``--device GPU`` being accepted only means the installed ONNX Runtime is a CUDA build. +If its libraries do not load, ONNX Runtime falls back to the CPU provider on its own. +``nvidia-smi`` during a run is the reliable check: a GPU analysis shows the worker +process holding video memory. Debug logging (``-v``) additionally reports the provider +the model was loaded with. + +Batch size and workers +-------------------------------------------------------- + +**Raise the batch size for GPU runs.** At the default of 1 a GPU run is no faster than +the CPU. The GUI switches the batch size to 16 together with the device; on the +command line, set ``--batch_size`` explicitly. An analysis started on a GPU with a +batch size of 1 logs a reminder. + +The worker default is **1** on a GPU, against one per core on the CPU, because each +worker is a separate process holding its own copy of the model in video memory. +Raising ``--n_workers`` can still improve throughput on a card with enough memory. diff --git a/tests/test_model_utils.py b/tests/test_model_utils.py index 9dd98fd24..632d42811 100644 --- a/tests/test_model_utils.py +++ b/tests/test_model_utils.py @@ -1,5 +1,7 @@ """Tests for analysis session pause/cancel behavior in model_utils.""" +import pytest + from birdnet_analyzer import model_utils @@ -98,3 +100,60 @@ def fake_predict_session(**kwargs): assert result == "result" assert seen["sigmoid_sensitivity"] == 1.0 + + +def test_only_birdnet_3_0_can_use_the_gpu(): + assert model_utils.supports_gpu("birdnet", "3.0") + assert not model_utils.supports_gpu("birdnet", "2.4") + assert not model_utils.supports_gpu("perch", "3.0") + assert not model_utils.supports_gpu("birdnet", "3.0", classifier="custom.tflite") + + +def test_effective_device_normalizes_and_rejects_unknown_devices(): + assert model_utils.effective_device("cpu") == "CPU" + assert model_utils.effective_device(" CPU ") == "CPU" + + for unknown in ("TPU", "cuda", "GPU:x", ""): + with pytest.raises(ValueError, match="Unknown device"): + model_utils.effective_device(unknown) + + +def test_effective_device_keeps_the_gpu_when_it_can_be_delivered(monkeypatch): + monkeypatch.setattr(model_utils, "gpu_available", lambda: True) + + assert model_utils.effective_device("GPU", "birdnet", "3.0") == "GPU" + assert model_utils.effective_device("gpu:1", "birdnet", "3.0") == "GPU:1" + + +def test_effective_device_falls_back_to_cpu_instead_of_failing_in_a_worker(monkeypatch): + monkeypatch.setattr(model_utils, "gpu_available", lambda: True) + + assert model_utils.effective_device("GPU", "birdnet", "2.4") == "CPU" + assert model_utils.effective_device("GPU", "perch") == "CPU" + assert model_utils.effective_device("GPU", classifier="custom.tflite") == "CPU" + + monkeypatch.setattr(model_utils, "gpu_available", lambda: False) + assert model_utils.effective_device("GPU", "birdnet", "3.0") == "CPU" + + +def test_gpu_is_only_offered_for_a_cuda_capable_onnxruntime(monkeypatch): + import onnxruntime as ort + + monkeypatch.setattr( + ort, "get_available_providers", lambda: ["CPUExecutionProvider"] + ) + assert not model_utils.gpu_available() + + monkeypatch.setattr( + ort, + "get_available_providers", + lambda: ["DmlExecutionProvider", "CPUExecutionProvider"], + ) + assert not model_utils.gpu_available() + + monkeypatch.setattr( + ort, + "get_available_providers", + lambda: ["CUDAExecutionProvider", "CPUExecutionProvider"], + ) + assert model_utils.gpu_available() diff --git a/tests/test_params.py b/tests/test_params.py index 1b0f92917..742400703 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -162,6 +162,14 @@ def test_the_values_are_returned_exactly_as_the_analysis_ran_with_them(tmp_path) assert kwargs["min_conf"] == 0 +def test_the_device_of_an_analysis_is_read_back(tmp_path): + file = tall_file(tmp_path, analysis_values(Device="GPU:1")) + older = tall_file(tmp_path, analysis_values(), name="older.analyze-params.csv") + + assert params.load_analysis_params(file)["device"] == "GPU:1" + assert "device" not in params.load_analysis_params(older) + + def test_files_of_an_analysis_are_read_back(tmp_path): file = tall_file( tmp_path,