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::compute → ggml_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).
Summary
A Metal build (
-DDA_GGML_METAL=ON) segfaults deterministically during the firstdepthinference 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
f4e17de3af5f576(v0.15.1)depth-anything-base-q4_k.gguf(frommudler/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 --modelloads 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:
Crash backtrace (lldb)
Crashing instruction:
ldr x0, [x9, #0x60]withx9 = NULL→ faulting address0x60.Root cause
address = 0x60isNULL + offsetof(ggml_backend_buffer, context). The fault is inggml_metal_get_buffer_id(third_party/ggml/src/ggml-metal/ggml-metal-ops.cpp:16):It guards
t == NULLbut notbuffer == NULL, so a graph tensor whose->bufferwas never assigned dereferences NULL atbuffer->context.Why does such a tensor reach the Metal encoder? The backtrace (
da::Backend::compute→ggml_backend_graph_compute, notggml_backend_sched_graph_compute) shows this is the fast single-backend gallocr path, not the scheduler path. Insrc/backend.cppthe scheduler ({GPU, CPU},op_offload=true) is only engaged when some op is unsupported by the GPU backend:Metal supports every op in the DA3 depth graph (norm, bin/add, mul_mm, flash-attn, …), so
need_schedstaysfalseand the whole graph runs on Metal viaggml_gallocr. But theis_host_read_tensorweights (the "4 host-read tensors kept on CPU" fromModelLoader::offload_weights,src/model_loader.cpp) intentionally stay on the CPU context. When a MetalNORM/ADDop reads one of these CPU-resident (or unallocated) src tensors,ggml_metal_get_buffer_idhits 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=1does not fix it — it only moves the crash fromggml_metal_op_normtoggml_metal_op_bin, sameldr [reg, #0x60]NULL deref. So this is not a fusion bug.Possible directions (for maintainer to decide)
need_schedscan beyondggml_backend_supports_opto also check src-tensor residency).buffer == NULLguard inggml_metal_get_buffer_idwould 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).