Skip to content

Metal build segfaults on inference (Apple M4/macOS 26.6): NULL buffer in ggml_metal_get_buffer_id via CPU-resident host-read tensor on the fast gallocr path #3

Description

@Jinshin2534

Summary

A Metal build (-DDA_GGML_METAL=ON) segfaults deterministically during the first depth inference on Apple Silicon (M4, macOS 26.6). The exact same commit, model, and image run correctly with a CPU-only build (-DDA_GGML_METAL=OFF). The crash is a NULL-pointer dereference inside ggml's Metal backend, triggered by a graph tensor that reaches the Metal op-encoder without a Metal buffer.

Environment

depth-anything.cpp f4e17de
bundled ggml 3af5f576 (v0.15.1)
OS macOS 26.6 (25G5043d)
Chip Apple M4
Toolchain cmake 4.4.0, Apple clang 17.0.0
Model depth-anything-base-q4_k.gguf (from mudler/depth-anything.cpp-gguf)

Reproduction

cmake -B build -DDA_BUILD_CLI=ON -DDA_GGML_METAL=ON
cmake --build build -j
hf download mudler/depth-anything.cpp-gguf depth-anything-base-q4_k.gguf --local-dir models
build/examples/cli/da3-cli depth \
  --model models/depth-anything-base-q4_k.gguf \
  --input assets/samples/desk.jpg --pfm depth.pfm --png depth.png
# -> exit 139 (SIGSEGV)

info --model loads fine (the model reads and offloads: offload_weights: 275 weights -> MTL0 (4 host-read tensors kept on CPU)). The crash is at compute time, on the first forward.

CPU build works and produces a correct depth map:

cmake -B build-cpu -DDA_BUILD_CLI=ON -DDA_GGML_METAL=OFF && cmake --build build-cpu -j
build-cpu/examples/cli/da3-cli depth --model models/depth-anything-base-q4_k.gguf \
  --input assets/samples/desk.jpg --pfm depth.pfm --png depth.png
# -> depth 504x336 min=0.7395 max=2.4074  (OK)

Crash backtrace (lldb)

thread #1, queue = 'ggml-metal', stop reason = EXC_BAD_ACCESS (code=1, address=0x60)
  * frame #0: ggml_metal_op_norm + 948
    frame #1: ggml_metal_op_encode + 1592
    frame #2: __ggml_metal_set_n_cb_block_invoke + 188
    ...
    frame #9: ggml_metal_graph_compute + 724
    frame #10: ggml_backend_graph_compute + 32
    frame #11: da::Backend::compute(...) + 872
    frame #12: da::Engine::depth_native_fused(...) + 436
    frame #13: da::Engine::depth_native(...)
    frame #14: main

Crashing instruction: ldr x0, [x9, #0x60] with x9 = NULL → faulting address 0x60.

Root cause

address = 0x60 is NULL + offsetof(ggml_backend_buffer, context). The fault is in ggml_metal_get_buffer_id (third_party/ggml/src/ggml-metal/ggml-metal-ops.cpp:16):

static ggml_metal_buffer_id ggml_metal_get_buffer_id(const ggml_tensor * t) {
    if (!t) {
        return { nullptr, 0 };
    }
    ggml_backend_buffer_t buffer = t->view_src ? t->view_src->buffer : t->buffer;
    ggml_metal_buffer_t ctx = (ggml_metal_buffer_t) buffer->context;   // <-- buffer == NULL here
    return ggml_metal_buffer_get_id(ctx, t);
}

It guards t == NULL but not buffer == NULL, so a graph tensor whose ->buffer was never assigned dereferences NULL at buffer->context.

Why does such a tensor reach the Metal encoder? The backtrace (da::Backend::computeggml_backend_graph_compute, not ggml_backend_sched_graph_compute) shows this is the fast single-backend gallocr path, not the scheduler path. In src/backend.cpp the scheduler ({GPU, CPU}, op_offload=true) is only engaged when some op is unsupported by the GPU backend:

// src/backend.cpp:266
bool need_sched = false;
if (impl_->use_sched) {
    for (int i = 0; i < n_nodes; ++i) {
        if (!ggml_backend_supports_op(impl_->backend, ggml_graph_node(gf, i))) {
            need_sched = true; break;
        }
    }
}

Metal supports every op in the DA3 depth graph (norm, bin/add, mul_mm, flash-attn, …), so need_sched stays false and the whole graph runs on Metal via ggml_gallocr. But the is_host_read_tensor weights (the "4 host-read tensors kept on CPU" from ModelLoader::offload_weights, src/model_loader.cpp) intentionally stay on the CPU context. When a Metal NORM/ADD op reads one of these CPU-resident (or unallocated) src tensors, ggml_metal_get_buffer_id hits the NULL/non-Metal buffer and segfaults.

In other words: the trigger is a mixed-residency tensor, but the fast-path decision only checks for unsupported ops — so the cross-backend case is never routed through the scheduler that would handle it.

Additional evidence

  • GGML_METAL_FUSION_DISABLE=1 does not fix it — it only moves the crash from ggml_metal_op_norm to ggml_metal_op_bin, same ldr [reg, #0x60] NULL deref. So this is not a fusion bug.
frame #0: ggml_metal_op_bin + 404      (with GGML_METAL_FUSION_DISABLE=1)

Possible directions (for maintainer to decide)

  • da3 side: ensure host-read / CPU-resident tensors are either mirrored onto the Metal buffer, or force the scheduler path when the graph contains any tensor not resident on the active GPU backend (extend the need_sched scan beyond ggml_backend_supports_op to also check src-tensor residency).
  • ggml side: a defensive buffer == NULL guard in ggml_metal_get_buffer_id would turn the segfault into a clean assert, but the real fix is making sure such tensors don't reach the encoder.

Workaround

Use a CPU-only build (-DDA_GGML_METAL=OFF) — it works and is still fast (Accelerate BLAS + tinyBLAS).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions