| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-04-17 |
| Deciders | Project author |
| Context | ADR-0020 mandates engine-owned embedding via bge-m3 GGUF. The only maintained C/C++ inference path for bge-m3 (XLMRoberta architecture) is llama.cpp. Adding llama.cpp alongside the existing whisper.cpp creates a ggml symbol collision (533+ duplicate symbols) that must be resolved structurally. |
| Related | ADR-0009 (C++ build + whisper.cpp), ADR-0020 (engine owns inference), ADR-0010 (ModelBudget — bge-m3 adds ~438 MB) |
Three facts force this ADR:
-
bge-m3 requires llama.cpp. The XLMRoberta architecture (SentencePiece tokenizer, encoder-only BERT graph, CLS/mean pooling) is only supported in llama.cpp's
llama_encode()+llama_get_embeddings_seq()path. The standalonebert.cppproject is abandoned ("merged into llama.cpp"). Raw ggml graph construction is possible but would take weeks to reimplement what llama.cpp already provides. -
whisper.cpp and llama.cpp both bundle ggml. Both are from
ggml-organd both vendor the ggml tensor library in their source trees. Linking both into one binary produces 533+ duplicate symbol errors. This is a known upstream issue (llama.cpp #9267, whisper.cpp #1887, ggml #1148) with no upstream fix beyond theUSE_SYSTEM_GGMLescape hatch. -
Static linking is mandatory. The engine is a single binary (ADR-0020 "one binary, one ggml runtime"). Dynamic linking with
RTLD_LOCALcould theoretically isolate the two ggml copies, but it contradicts the static-link-everything Bazel hermetic build model and introduces runtime linker dependencies. In static linking, duplicate symbols are a hard linker error — there is no workaround.
Build ggml exactly once from the standalone ggml-org/ggml
repository. Both whisper.cpp and llama.cpp link against this
single ggml build via their USE_SYSTEM_GGML=ON CMake flags.
All three repositories are pinned at ggml v0.9.8 compatibility:
| Component | Version / Tag | ggml version | System ggml flag |
|---|---|---|---|
| ggml standalone | v0.9.8 | 0.9.8 | N/A (is the source) |
| whisper.cpp | v1.8.4 | 0.9.8 | WHISPER_USE_SYSTEM_GGML=ON |
| llama.cpp | b8595 | 0.9.8 | LLAMA_USE_SYSTEM_GGML=ON |
http_archive("ggml", v0.9.8)
└─ cmake() → libggml.a, libggml-base.a, libggml-cpu.a
http_archive("whisper_cpp", v1.8.4)
└─ cmake(WHISPER_USE_SYSTEM_GGML=ON, deps=[ggml])
→ libwhisper.a
http_archive("llama_cpp", b8595)
└─ cmake(LLAMA_USE_SYSTEM_GGML=ON, deps=[ggml])
→ libllama.a
cc_binary("engine")
└─ deps: whisper_cpp, llama_cpp, ggml (one copy of each symbol)
These three deps form a version-coupled triple. Independent upgrades break the build. Four protection mechanisms are required:
# ╔══════════════════════════════════════════════════════════════╗
# ║ VERSION-COUPLED TRIPLE — ggml / whisper.cpp / llama.cpp ║
# ║ ║
# ║ These three deps share one ggml runtime (ADR-0021). ║
# ║ They MUST be upgraded together. Never bump one without ║
# ║ verifying the other two are compatible. ║
# ║ ║
# ║ Upgrade SOP: docs/CONTRIBUTING.md §Upgrading ggml triple ║
# ╚══════════════════════════════════════════════════════════════╝Add to .github/dependabot.yml:
# ggml / whisper.cpp / llama.cpp are a version-coupled triple
# (ADR-0021). Automated bumps would break the build because
# Dependabot bumps deps independently. Manual upgrade only.
ignore:
- dependency-name: "ggml"
- dependency-name: "whisper_cpp"
- dependency-name: "llama_cpp"A CI job that extracts the ggml version from all three deps and
fails if they diverge. Implementation: grep for
GGML_VERSION_MAJOR/MINOR/PATCH in each dep's source tree and
assert equality. Runs on every PR that touches MODULE.bazel.
Documented procedure:
- Check ggml-org release notes for the latest ggml version.
- Find a whisper.cpp tag that bundles or supports that ggml
version (check
ggml/CMakeLists.txtin the whisper.cpp tree for theGGML_VERSIONstring). - Find a llama.cpp tag that bundles or supports the same ggml
version (check
ggml/CMakeLists.txtin the llama.cpp tree). - Update all three
http_archiveentries inMODULE.bazelin a single commit. - Run the full test suite:
bazel test //engine_cpp/... - PR title must include
deps(ggml-triple):to signal the coupled upgrade.
Typical effort: ~30 minutes to find the compatible triple + test. Not automatable by Dependabot because the compatibility check requires cross-repo version inspection.
objcopy --prefix-symbols=whisper_ggml_ would prefix all ggml
symbols in whisper.cpp's copy, avoiding collision. Rejected:
- Every ggml API call site in whisper.cpp would need to use the prefixed names, requiring patches to whisper.cpp source.
- Every dep bump re-applies those patches — maintenance drag proportional to ggml's API surface (~200 public symbols).
- Fragile in Bazel:
objcopyruns after compilation, so the C++ source still references unprefixed names; a two-pass build is needed.
Running the embedder in a second process (IPC via Unix socket or gRPC) eliminates the link collision entirely. Rejected per ADR-0020: "one binary, one Metal/SIMD context, one memory budget." A second process doubles the memory-mapped model weight cost (no shared mmap across process boundaries) and adds ~5 ms IPC latency per embed call.
dlopen("libllama.so", RTLD_LOCAL) would isolate llama.cpp's
ggml symbols. Rejected:
- Contradicts the Bazel hermetic static-link model.
- Introduces runtime linker dependencies (
ld.sobehavior). - macOS's
dlopendoes not supportRTLD_LOCALfor Mach-O — symbols leak into the flat namespace regardless.
Both whisper.cpp and llama.cpp have USE_SYSTEM_GGML flags
that expect find_package(ggml). The standalone ggml-org/ggml
repo produces exactly that CMake package. Using llama.cpp's
bundled ggml would create a dependency of whisper.cpp on
llama.cpp, which is the wrong direction (they are peers, not
parent-child).
This ADR's shared-ggml approach is the right answer for a portfolio repository demonstrating single-binary model co-location. A production deployment under different constraints would likely choose differently.
If the engine were split into two microservices:
┌─────────────────────┐ ┌──────────────────────┐
│ ASR Service │ │ Embedding Service │
│ whisper.cpp + ggml │ │ llama.cpp + ggml │
│ (own version) │ │ (own version) │
└─────────┬───────────┘ └──────────┬────────────┘
│ gRPC │ gRPC
└───────────┬───────────────┘
▼
Go Gateway (BFF)
Each service owns its own ggml version. The diamond dependency vanishes — upgrades are fully independent. The tradeoff:
| Dimension | Shared ggml (this ADR) | Microservice split |
|---|---|---|
| Diamond dependency | P1–P4 mitigations | Eliminated |
| Upgrade coupling | 3 repos in lockstep | Independent |
| Memory | One ggml, shared Metal/SIMD context | Two copies (~2–3 MB each, negligible) |
| IPC latency | Zero (same process) | ~1–5 ms per call |
| Operational complexity | One binary, one deploy | Two services, two deploy pipelines |
| Portfolio signal | "I can co-locate models in one process" | "I can decompose services cleanly" |
| Local mode (16 GB ceiling) | One process, simpler | Two processes, slightly higher fixed overhead |
Why this ADR chooses shared ggml anyway:
- ADR-0020's "one binary" thesis is a deliberate portfolio claim about model co-location and memory budgeting. Splitting into microservices would demonstrate a different (and more conventional) skill.
- Local mode benefits from a single process —
app_localspawns one child (the engine), not two. - The diamond dependency is manageable at portfolio scale (upgrades happen monthly, not hourly).
When to reconsider: If this repository transitions from portfolio to production product, and the upgrade coordination cost of P1–P4 exceeds the operational cost of running two services, the microservice split becomes the better engineering answer. That decision belongs in a future ADR that revises ADR-0020's "one binary" constraint.
- Zero duplicate symbols. One ggml, one address space, one Metal/SIMD context. Exactly what ADR-0020 prescribes.
- Model co-location works. whisper + bge-m3 (+ future LLM) share one ggml backend, one Metal context, one memory arena. Context switches between models are cache-friendly.
- Upstream-blessed pattern. whisper.cpp's
talk-llamaexample does exactly this. TheUSE_SYSTEM_GGMLflags exist for this purpose. - Container image stays small. One copy of libggml (~2–3 MB static) instead of two.
- Diamond dependency. Three repos must be upgraded in lockstep. Mitigated by P1–P4 above, but manual coordination is inherent.
- ggml compatibility window is narrow. A given ggml version is typically compatible with whisper.cpp and llama.cpp releases spanning ~2–3 weeks. Outside that window, API drift causes build failures. This forces frequent, small upgrades rather than infrequent large jumps.
- Bazel
rules_foreign_cccomplexity. WiringCMAKE_PREFIX_PATHacross threecmake()rules is non-trivial. The initial Bazel plumbing is ~1–2 days. - Dependabot cannot help. The three deps must be excluded from automated bumping. This is a permanent maintenance obligation.
- ggml-org breaks
USE_SYSTEM_GGML. The flag is not heavily tested upstream (most users build ggml from the vendored copy). If a future release breaks the flag, we must file upstream or patch locally. Mitigation: the flag is a simplefind_package+ alias; patches are small. - ggml-org merges the repos. If ggml-org eventually ships llama.cpp and whisper.cpp with a single shared ggml (their stated direction), this ADR becomes unnecessary and the three-dep pin simplifies to two or one. That is a positive outcome, not a risk.
- Add
ggml-org/ggmlashttp_archiveinMODULE.bazelwith SHA256. Initial pin v0.9.8; bumped to v0.9.9 in Phase 3b Slice 4 to cover llama.cpp b8595'sgguf_*_ptrsymbols (incident-10). - Add
ggml-org/llama.cppb8595 ashttp_archiveinMODULE.bazelwith SHA256 - Write
engine_cpp/third_party/ggml/ggml.BUILD— cmake target producinglibggml.a+ friends - Write
engine_cpp/third_party/llama_cpp/llama_cpp.BUILD— cmake target depending on@ggml//:ggml_cmake - Update
engine_cpp/third_party/whisper_cpp/whisper_cpp.BUILD— depend on@ggml//:ggml_cmakeso the bundled ggml is not rebuilt separately - Verify
bazel build //engine_cpp/cmd/engine:enginelinks without duplicate symbols - Verify existing whisper tests still pass (against the shared ggml runtime, and across the v0.9.8 → v0.9.9 bump)
- Add coupling comment block to
MODULE.bazel(P1) - Add Dependabot ignore rules (P2) —
.github/dependabot.yml - Add CI ggml-version-match check (P3) — implemented as a
two-layer check:
tools/scripts/check_ggml_versions.sh(grepGGML_VERSION_*from each archive, fail on wrong-direction drift) +bazel build //engine_cpp/tests/integration/...in CI (authoritative link-compatibility gate, catches same-number / divergent-source drift per incident-10) - Add upgrade SOP to
CONTRIBUTING.md(P4) — §Upgrading the ggml triple -
GGMLEmbedderimplementation using llama.cpp C API - Update
models/manifest.jsonwith bge-m3 Q4_K_M entry (source:lm-kit/bge-m3-gguf, 438 MB, SHA256) — manifest-driven rather thanthird_party/bge_m3/http_archive, consistent with the Phase 1 runtime-model pattern used for whisper weights
- ADR-0009 C++ Build and Toolchain (whisper.cpp via Bazel)
- ADR-0020 Engine owns inference (one binary, unified runtime)
- ADR-0010 §Revision (ModelBudget — bge-m3 adds ~438 MB)
- whisper.cpp
talk-llamaexample (canonical two-consumer reference) - llama.cpp #9267 (533 duplicate ggml symbols)
- whisper.cpp #1887 (Xcode build conflict)
- ggml #1148 (shared library collision)