Skip to main content

qts/pipeline/
backend.rs

1use std::cmp::max;
2use std::collections::BTreeMap;
3use std::ffi::CStr;
4use std::ptr::NonNull;
5use std::sync::{Arc, Mutex, MutexGuard};
6
7use qts_ggml::sys;
8
9use crate::Qwen3TtsError;
10
11/// `ggml_backend_reg_by_name` argument for the Metal backend (device names are `MTL0`, … — not `"Metal"`).
12#[cfg(all(feature = "metal", target_vendor = "apple"))]
13fn ggml_reg_mtl() -> &'static CStr {
14    c"MTL"
15}
16
17/// `ggml_backend_reg_by_name` argument for the Vulkan backend (per-device names are `Vulkan0`, …).
18#[cfg(feature = "vulkan")]
19fn ggml_reg_vulkan() -> &'static CStr {
20    c"Vulkan"
21}
22
23#[cfg(any(all(feature = "metal", target_vendor = "apple"), feature = "vulkan"))]
24fn gpu_device_index() -> Result<usize, Qwen3TtsError> {
25    let Ok(s) = std::env::var("QWEN3_TTS_GPU_DEVICE") else {
26        return Ok(0);
27    };
28    let s = s.trim();
29    if s.is_empty() {
30        return Ok(0);
31    }
32    s.parse().map_err(|_| {
33        Qwen3TtsError::InvalidInput(format!(
34            "QWEN3_TTS_GPU_DEVICE must be a non-negative integer, got {s:?}"
35        ))
36    })
37}
38
39/// Parsed `QWEN3_TTS_BACKEND` — which GGML primary backend to use (independent of which backends
40/// were linked into the binary).
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42enum BackendChoice {
43    Auto,
44    Explicit(BackendPreference),
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48enum BackendPreference {
49    Cpu,
50    Metal,
51    Vulkan,
52}
53
54impl BackendPreference {
55    fn as_env_str(self) -> &'static str {
56        match self {
57            Self::Cpu => "cpu",
58            Self::Metal => "metal",
59            Self::Vulkan => "vulkan",
60        }
61    }
62}
63
64fn parse_qts_backend() -> Result<BackendChoice, Qwen3TtsError> {
65    let var = match std::env::var("QWEN3_TTS_BACKEND") {
66        Ok(s) if !s.trim().is_empty() => s,
67        _ => return Ok(BackendChoice::Auto),
68    };
69    match var.trim().to_ascii_lowercase().as_str() {
70        "auto" => Ok(BackendChoice::Auto),
71        "cpu" => Ok(BackendChoice::Explicit(BackendPreference::Cpu)),
72        "metal" => Ok(BackendChoice::Explicit(BackendPreference::Metal)),
73        "vulkan" => Ok(BackendChoice::Explicit(BackendPreference::Vulkan)),
74        other => Err(Qwen3TtsError::InvalidInput(format!(
75            "QWEN3_TTS_BACKEND: unknown value '{other}' (expected auto, cpu, metal, vulkan)"
76        ))),
77    }
78}
79
80fn default_auto_backend_order() -> Vec<BackendPreference> {
81    #[cfg(target_vendor = "apple")]
82    {
83        vec![
84            BackendPreference::Metal,
85            BackendPreference::Vulkan,
86            BackendPreference::Cpu,
87        ]
88    }
89    #[cfg(not(target_vendor = "apple"))]
90    {
91        vec![BackendPreference::Vulkan, BackendPreference::Cpu]
92    }
93}
94
95fn parse_auto_backend_order() -> Result<Vec<BackendPreference>, Qwen3TtsError> {
96    let var = match std::env::var("QWEN3_TTS_BACKEND_FALLBACK") {
97        Ok(s) if !s.trim().is_empty() => s,
98        _ => return Ok(default_auto_backend_order()),
99    };
100    let mut order = Vec::new();
101    for token in var.split(',') {
102        let value = token.trim().to_ascii_lowercase();
103        if value.is_empty() {
104            continue;
105        }
106        let pref = match value.as_str() {
107            "cpu" => BackendPreference::Cpu,
108            "metal" => BackendPreference::Metal,
109            "vulkan" => BackendPreference::Vulkan,
110            other => {
111                return Err(Qwen3TtsError::InvalidInput(format!(
112                    "QWEN3_TTS_BACKEND_FALLBACK: unknown backend '{other}' (expected cpu, metal, vulkan)"
113                )));
114            }
115        };
116        if !order.contains(&pref) {
117            order.push(pref);
118        }
119    }
120    if order.is_empty() {
121        return Err(Qwen3TtsError::InvalidInput(
122            "QWEN3_TTS_BACKEND_FALLBACK must contain at least one backend".into(),
123        ));
124    }
125    Ok(order)
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum BackendKind {
130    Cpu,
131    #[cfg(all(feature = "metal", target_vendor = "apple"))]
132    Metal,
133    #[cfg(feature = "vulkan")]
134    Vulkan,
135}
136
137impl BackendKind {
138    pub fn as_str(self) -> &'static str {
139        match self {
140            Self::Cpu => "CPU",
141            #[cfg(all(feature = "metal", target_vendor = "apple"))]
142            Self::Metal => "Metal",
143            #[cfg(feature = "vulkan")]
144            Self::Vulkan => "Vulkan",
145        }
146    }
147}
148
149#[derive(Clone)]
150pub(crate) struct BackendSet(Arc<BackendSetInner>);
151
152// SAFETY: BackendSet wraps ggml backends whose weight data is immutable after loading.
153// Each graph execution creates its own allocation via the Mutex-protected gallocr.
154// The CPU backend's thread pool is per-graph-execution (set_n_threads is called before compute).
155unsafe impl Send for BackendSet {}
156unsafe impl Sync for BackendSet {}
157
158struct BackendSetInner {
159    primary: OwnedBackend,
160    primary_kind: BackendKind,
161    cpu_fallback: Option<OwnedBackend>,
162    primary_galloc: Mutex<OwnedGallocr>,
163}
164
165impl BackendSet {
166    pub(crate) fn new() -> Result<Self, Qwen3TtsError> {
167        unsafe {
168            sys::ggml_backend_load_all();
169            sys::ggml_cpu_init();
170        }
171
172        match parse_qts_backend()? {
173            BackendChoice::Auto => Self::auto_select_backend(&parse_auto_backend_order()?),
174            BackendChoice::Explicit(choice) => Self::require_backend(choice),
175        }
176    }
177
178    fn auto_select_backend(order: &[BackendPreference]) -> Result<Self, Qwen3TtsError> {
179        if backend_debug_enabled() {
180            let chain = order
181                .iter()
182                .map(|choice| choice.as_env_str())
183                .collect::<Vec<_>>()
184                .join(" -> ");
185            eprintln!("[backend-debug] auto fallback chain: {chain}");
186        }
187        for choice in order {
188            if let Some(backends) = Self::try_backend(*choice)? {
189                return Ok(backends);
190            }
191        }
192        Err(Qwen3TtsError::InvalidInput(
193            "QWEN3_TTS_BACKEND_FALLBACK did not contain a usable backend".into(),
194        ))
195    }
196
197    fn require_backend(choice: BackendPreference) -> Result<Self, Qwen3TtsError> {
198        match choice {
199            BackendPreference::Cpu => {
200                let primary = OwnedBackend::cpu()?;
201                if backend_debug_enabled() {
202                    eprintln!("[backend-debug] selected {}", BackendKind::Cpu.as_str());
203                }
204                Self::with_primary(primary, BackendKind::Cpu, None)
205            }
206            BackendPreference::Metal => {
207                #[cfg(all(feature = "metal", target_vendor = "apple"))]
208                {
209                    Self::require_reg_backend(ggml_reg_mtl(), BackendKind::Metal)
210                }
211                #[cfg(not(all(feature = "metal", target_vendor = "apple")))]
212                {
213                    Err(Qwen3TtsError::InvalidInput(
214                        "QWEN3_TTS_BACKEND=metal is only valid on Apple targets with the `metal` feature"
215                            .into(),
216                    ))
217                }
218            }
219            BackendPreference::Vulkan => {
220                #[cfg(feature = "vulkan")]
221                {
222                    Self::require_reg_backend(ggml_reg_vulkan(), BackendKind::Vulkan)
223                }
224                #[cfg(not(feature = "vulkan"))]
225                {
226                    Err(Qwen3TtsError::InvalidInput(
227                        "QWEN3_TTS_BACKEND=vulkan requires building with --features vulkan".into(),
228                    ))
229                }
230            }
231        }
232    }
233
234    fn try_backend(choice: BackendPreference) -> Result<Option<Self>, Qwen3TtsError> {
235        match choice {
236            BackendPreference::Cpu => {
237                let primary = OwnedBackend::cpu()?;
238                if backend_debug_enabled() {
239                    eprintln!("[backend-debug] selected {}", BackendKind::Cpu.as_str());
240                }
241                Ok(Some(Self::with_primary(primary, BackendKind::Cpu, None)?))
242            }
243            BackendPreference::Metal => {
244                #[cfg(all(feature = "metal", target_vendor = "apple"))]
245                {
246                    Self::try_optional_reg(ggml_reg_mtl(), BackendKind::Metal)
247                }
248                #[cfg(not(all(feature = "metal", target_vendor = "apple")))]
249                {
250                    if backend_debug_enabled() {
251                        eprintln!(
252                            "[backend-debug] skipping metal (not supported by this build/target)"
253                        );
254                    }
255                    Ok(None)
256                }
257            }
258            BackendPreference::Vulkan => {
259                #[cfg(feature = "vulkan")]
260                {
261                    Self::try_optional_reg(ggml_reg_vulkan(), BackendKind::Vulkan)
262                }
263                #[cfg(not(feature = "vulkan"))]
264                {
265                    if backend_debug_enabled() {
266                        eprintln!("[backend-debug] skipping vulkan (not enabled in this build)");
267                    }
268                    Ok(None)
269                }
270            }
271        }
272    }
273
274    #[cfg(any(all(feature = "metal", target_vendor = "apple"), feature = "vulkan"))]
275    fn try_optional_reg(reg: &CStr, kind: BackendKind) -> Result<Option<Self>, Qwen3TtsError> {
276        let label = kind.as_str();
277        if let Some(primary) = OwnedBackend::init_from_reg(reg, label, false)? {
278            if backend_debug_enabled() {
279                eprintln!("[backend-debug] selected {label}");
280            }
281            return Ok(Some(Self::with_primary(
282                primary,
283                kind,
284                Some(OwnedBackend::cpu()?),
285            )?));
286        }
287
288        if backend_debug_enabled() {
289            eprintln!("[backend-debug] {label} unavailable, falling back");
290        }
291        Ok(None)
292    }
293
294    #[cfg(any(all(feature = "metal", target_vendor = "apple"), feature = "vulkan"))]
295    fn require_reg_backend(reg: &CStr, kind: BackendKind) -> Result<Self, Qwen3TtsError> {
296        let label = kind.as_str();
297        let primary = OwnedBackend::init_from_reg(reg, label, true)?.ok_or_else(|| {
298            Qwen3TtsError::InvalidInput(format!(
299                "QWEN3_TTS_BACKEND requested {label}, but GPU backend init failed (see QWEN3_TTS_GPU_DEVICE, drivers, or SDK)"
300            ))
301        })?;
302        if backend_debug_enabled() {
303            eprintln!("[backend-debug] selected {label} (QWEN3_TTS_BACKEND)");
304        }
305        Self::with_primary(primary, kind, Some(OwnedBackend::cpu()?))
306    }
307
308    #[allow(clippy::arc_with_non_send_sync)]
309    fn with_primary(
310        primary: OwnedBackend,
311        primary_kind: BackendKind,
312        cpu_fallback: Option<OwnedBackend>,
313    ) -> Result<Self, Qwen3TtsError> {
314        let primary_galloc = Mutex::new(OwnedGallocr::new(primary.as_ptr())?);
315        Ok(Self(Arc::new(BackendSetInner {
316            primary,
317            primary_kind,
318            cpu_fallback,
319            primary_galloc,
320        })))
321    }
322
323    pub(crate) fn primary_ptr(&self) -> sys::ggml_backend_t {
324        self.0.primary.as_ptr()
325    }
326
327    pub(crate) fn primary_kind(&self) -> BackendKind {
328        self.0.primary_kind
329    }
330
331    pub(crate) fn configure_threads(&self, thread_count: usize) {
332        self.0.primary.set_threads(thread_count);
333        if let Some(cpu_fallback) = &self.0.cpu_fallback {
334            cpu_fallback.set_threads(thread_count);
335        }
336    }
337
338    fn primary_galloc(&self) -> MutexGuard<'_, OwnedGallocr> {
339        self.0.primary_galloc.lock().unwrap()
340    }
341}
342
343struct OwnedBackend {
344    raw: NonNull<sys::ggml_backend>,
345    is_cpu: bool,
346}
347
348impl OwnedBackend {
349    fn cpu() -> Result<Self, Qwen3TtsError> {
350        let raw = unsafe { sys::ggml_backend_cpu_init() };
351        let raw = NonNull::new(raw).ok_or_else(|| {
352            Qwen3TtsError::InvalidInput("failed to initialize ggml CPU backend".into())
353        })?;
354        Ok(Self { raw, is_cpu: true })
355    }
356
357    /// Initialize via `ggml_backend_reg_by_name` + `ggml_backend_dev_init`.
358    ///
359    /// `ggml_backend_init_by_name` matches **per-device** ids (`Vulkan0`, `MTL0`, …), not registry
360    /// names (`Vulkan`, `MTL`), so we use the registry API instead.
361    #[cfg(any(all(feature = "metal", target_vendor = "apple"), feature = "vulkan"))]
362    fn init_from_reg(
363        reg: &CStr,
364        label: &str,
365        require: bool,
366    ) -> Result<Option<Self>, Qwen3TtsError> {
367        let device_index = gpu_device_index()?;
368
369        let reg_ptr = unsafe { sys::ggml_backend_reg_by_name(reg.as_ptr()) };
370        if reg_ptr.is_null() {
371            return Ok(None);
372        }
373
374        let n = unsafe { sys::ggml_backend_reg_dev_count(reg_ptr) };
375        if n == 0 {
376            return Ok(None);
377        }
378
379        if device_index >= n {
380            return Err(Qwen3TtsError::InvalidInput(format!(
381                "QWEN3_TTS_GPU_DEVICE={device_index} is out of range for {label} (available: 0..{})",
382                n.saturating_sub(1)
383            )));
384        }
385
386        let dev = unsafe { sys::ggml_backend_reg_dev_get(reg_ptr, device_index) };
387        if dev.is_null() {
388            return Ok(None);
389        }
390        let raw = unsafe { sys::ggml_backend_dev_init(dev, std::ptr::null()) };
391        let Some(raw) = NonNull::new(raw) else {
392            if require {
393                return Err(Qwen3TtsError::InvalidInput(format!(
394                    "QWEN3_TTS_BACKEND requested {label}, but ggml_backend_dev_init failed for device index {device_index}"
395                )));
396            }
397            return Ok(None);
398        };
399
400        Ok(Some(Self { raw, is_cpu: false }))
401    }
402
403    fn as_ptr(&self) -> sys::ggml_backend_t {
404        self.raw.as_ptr()
405    }
406
407    fn set_threads(&self, thread_count: usize) {
408        if self.is_cpu {
409            unsafe {
410                sys::ggml_backend_cpu_set_n_threads(
411                    self.raw.as_ptr(),
412                    normalize_threads(thread_count),
413                );
414            }
415        }
416    }
417}
418
419impl Drop for OwnedBackend {
420    fn drop(&mut self) {
421        unsafe {
422            sys::ggml_backend_free(self.raw.as_ptr());
423        }
424    }
425}
426
427struct OwnedGallocr {
428    raw: NonNull<sys::ggml_gallocr>,
429}
430
431impl OwnedGallocr {
432    fn new(backend: sys::ggml_backend_t) -> Result<Self, Qwen3TtsError> {
433        let raw =
434            unsafe { sys::ggml_gallocr_new(sys::ggml_backend_get_default_buffer_type(backend)) };
435        let raw = NonNull::new(raw).ok_or_else(|| {
436            Qwen3TtsError::InvalidInput("failed to initialize ggml graph allocator".into())
437        })?;
438        Ok(Self { raw })
439    }
440}
441
442impl Drop for OwnedGallocr {
443    fn drop(&mut self) {
444        unsafe {
445            sys::ggml_gallocr_free(self.raw.as_ptr());
446        }
447    }
448}
449
450pub(crate) struct TensorUpload<'a> {
451    pub(crate) tensor: *mut sys::ggml_tensor,
452    pub(crate) bytes: &'a [u8],
453}
454
455pub(crate) struct TensorDownload<'a> {
456    pub(crate) tensor: *mut sys::ggml_tensor,
457    pub(crate) bytes: &'a mut [u8],
458}
459
460pub(crate) struct OwnedBuffer {
461    raw: NonNull<sys::ggml_backend_buffer>,
462}
463
464impl OwnedBuffer {
465    pub(crate) fn alloc(
466        ctx: *mut sys::ggml_context,
467        backend: sys::ggml_backend_t,
468    ) -> Result<Self, Qwen3TtsError> {
469        let raw = unsafe { sys::ggml_backend_alloc_ctx_tensors(ctx, backend) };
470        let raw = NonNull::new(raw).ok_or_else(|| {
471            Qwen3TtsError::InvalidInput("failed to allocate ggml backend tensor buffer".into())
472        })?;
473        Ok(Self { raw })
474    }
475}
476
477impl Drop for OwnedBuffer {
478    fn drop(&mut self) {
479        unsafe {
480            sys::ggml_backend_buffer_free(self.raw.as_ptr());
481        }
482    }
483}
484
485pub(crate) fn graph_metadata_mem_size(max_nodes: usize) -> usize {
486    let tensor_overhead = unsafe { sys::ggml_tensor_overhead() };
487    let graph_overhead = unsafe { sys::ggml_graph_overhead_custom(max_nodes, false) };
488    max(
489        1024 * 1024,
490        graph_overhead + tensor_overhead * max_nodes * 16,
491    )
492}
493
494pub(crate) fn slice_as_bytes<T>(slice: &[T]) -> &[u8] {
495    unsafe { std::slice::from_raw_parts(slice.as_ptr().cast::<u8>(), std::mem::size_of_val(slice)) }
496}
497
498pub(crate) fn slice_as_bytes_mut<T>(slice: &mut [T]) -> &mut [u8] {
499    unsafe {
500        std::slice::from_raw_parts_mut(
501            slice.as_mut_ptr().cast::<u8>(),
502            std::mem::size_of_val(slice),
503        )
504    }
505}
506
507/// F32 bias for [`sys::ggml_soft_max_ext`], matching GGML CPU `ggml_compute_forward_diag_mask_f32` / `ggml_diag_mask_inf`.
508/// Metal does not implement `GGML_OP_DIAG_MASK_INF`; fusing into softmax avoids that op.
509pub(crate) fn packed_softmax_diag_mask_f32(ne: [i64; 4], n_past: i32) -> Vec<f32> {
510    let n0 = ne[0].max(0) as usize;
511    let n1 = ne[1].max(0) as usize;
512    let n2 = ne[2].max(0) as usize;
513    let n3 = ne[3].max(0) as usize;
514    let total = n0.saturating_mul(n1).saturating_mul(n2).saturating_mul(n3);
515    let mut out = vec![0.0f32; total];
516    let np = n_past.max(0) as usize;
517    for i3 in 0..n3 {
518        for i2 in 0..n2 {
519            for j in 0..n1 {
520                for i in 0..n0 {
521                    let v = if i >= np && i > np.saturating_add(j) {
522                        f32::NEG_INFINITY
523                    } else {
524                        0.0
525                    };
526                    let idx = i + n0 * (j + n1 * (i2 + n2 * i3));
527                    if let Some(slot) = out.get_mut(idx) {
528                        *slot = v;
529                    }
530                }
531            }
532        }
533    }
534    out
535}
536
537/// One shared mask leaf per graph (same `kq` layout for every layer). Caller must [`TensorUpload`] the packed bytes.
538pub(crate) unsafe fn ggml_soft_max_ext_with_diag_mask_cache(
539    ctx: *mut sys::ggml_context,
540    kq: *mut sys::ggml_tensor,
541    n_past: i32,
542    cache: &mut Option<(*mut sys::ggml_tensor, Vec<f32>)>,
543) -> *mut sys::ggml_tensor {
544    let mask_ptr = match cache {
545        None => {
546            let ne = (*kq).ne;
547            let data = packed_softmax_diag_mask_f32(ne, n_past);
548            let t = sys::ggml_new_tensor_4d(
549                ctx,
550                sys::ggml_type_GGML_TYPE_F32,
551                ne[0],
552                ne[1],
553                ne[2],
554                ne[3],
555            );
556            assert!(
557                !t.is_null(),
558                "ggml_new_tensor_4d failed for softmax causal mask"
559            );
560            *cache = Some((t, data));
561            t
562        }
563        Some((mask_t, _)) => *mask_t,
564    };
565    sys::ggml_soft_max_ext(ctx, kq, mask_ptr, 1.0, 0.0)
566}
567
568pub(crate) fn execute_graph(
569    backends: &BackendSet,
570    graph: NonNull<sys::ggml_cgraph>,
571    uploads: &[TensorUpload<'_>],
572    downloads: &mut [TensorDownload<'_>],
573    thread_count: usize,
574    error_message: &str,
575) -> Result<(), Qwen3TtsError> {
576    maybe_log_backend_support(backends, graph, error_message);
577    backends.configure_threads(thread_count);
578    let galloc = backends.primary_galloc();
579    let allocated = unsafe { sys::ggml_gallocr_alloc_graph(galloc.raw.as_ptr(), graph.as_ptr()) };
580    if !allocated {
581        return Err(Qwen3TtsError::InvalidInput(format!(
582            "failed to allocate backend graph for {error_message}"
583        )));
584    }
585    run_graph_impl(backends, graph, uploads, downloads, error_message)
586}
587
588fn run_graph_impl(
589    backends: &BackendSet,
590    graph: NonNull<sys::ggml_cgraph>,
591    uploads: &[TensorUpload<'_>],
592    downloads: &mut [TensorDownload<'_>],
593    error_message: &str,
594) -> Result<(), Qwen3TtsError> {
595    let _t0 = std::time::Instant::now();
596    for upload in uploads {
597        unsafe {
598            sys::ggml_backend_tensor_set(
599                upload.tensor,
600                upload.bytes.as_ptr().cast(),
601                0,
602                upload.bytes.len(),
603            );
604        }
605    }
606    let _t1 = std::time::Instant::now();
607    let status = unsafe { sys::ggml_backend_graph_compute(backends.primary_ptr(), graph.as_ptr()) };
608    if status != sys::ggml_status_GGML_STATUS_SUCCESS {
609        return Err(Qwen3TtsError::InvalidInput(error_message.into()));
610    }
611    let _t2 = std::time::Instant::now();
612    for download in downloads {
613        unsafe {
614            sys::ggml_backend_tensor_get(
615                download.tensor,
616                download.bytes.as_mut_ptr().cast(),
617                0,
618                download.bytes.len(),
619            );
620        }
621    }
622    let _t3 = std::time::Instant::now();
623    if backend_debug_enabled() {
624        eprintln!(
625            "[graph_impl] upload={:.2}ms  compute={:.2}ms  download={:.2}ms  ({error_message})",
626            (_t1 - _t0).as_secs_f64() * 1000.0,
627            (_t2 - _t1).as_secs_f64() * 1000.0,
628            (_t3 - _t2).as_secs_f64() * 1000.0,
629        );
630    }
631    Ok(())
632}
633
634fn maybe_log_backend_support(backends: &BackendSet, graph: NonNull<sys::ggml_cgraph>, label: &str) {
635    if !backend_debug_enabled() {
636        return;
637    }
638
639    let n_nodes = unsafe { sys::ggml_graph_n_nodes(graph.as_ptr()) };
640    let mut supported = 0usize;
641    let mut offloaded = 0usize;
642    let mut unsupported_ops = BTreeMap::<String, usize>::new();
643    for idx in 0..n_nodes {
644        let node = unsafe { sys::ggml_graph_node(graph.as_ptr(), idx) };
645        if node.is_null() {
646            continue;
647        }
648        let is_supported = unsafe { sys::ggml_backend_supports_op(backends.primary_ptr(), node) };
649        let is_offloaded = unsafe { sys::ggml_backend_offload_op(backends.primary_ptr(), node) };
650        if is_supported {
651            supported += 1;
652        } else {
653            let op = unsafe {
654                let desc = sys::ggml_op_desc(node);
655                if desc.is_null() {
656                    "<unknown>".to_string()
657                } else {
658                    CStr::from_ptr(desc).to_string_lossy().into_owned()
659                }
660            };
661            *unsupported_ops.entry(op).or_default() += 1;
662        }
663        if is_offloaded {
664            offloaded += 1;
665        }
666    }
667
668    eprintln!(
669        "[backend-debug] {label}: nodes={n_nodes} supported={supported} offloaded={offloaded}"
670    );
671    for (op, count) in unsupported_ops.into_iter().take(12) {
672        eprintln!("[backend-debug] unsupported {op}: {count}");
673    }
674}
675
676fn backend_debug_enabled() -> bool {
677    std::env::var_os("QWEN3_TTS_DEBUG_BACKEND").is_some()
678}
679
680fn normalize_threads(thread_count: usize) -> i32 {
681    max(1, thread_count) as i32
682}