gguf-provider
1# frozen_string_literal: true
2
3# Builds the vendored llama.cpp (shared libs) and our thin C shim at gem install
4# time, installing the shared objects into lib/elelem/native/ so the :gguf provider
5# can Fiddle.dlopen them and run a model in-process. llama.cpp is a CMake project
6# (not an mkmf extension), so we drive cmake ourselves and then emit a stub Makefile
7# to satisfy RubyGems' `make && make install` step.
8#
9# The GPU backend is chosen automatically from the host's toolchain (see
10# detect_backend): NVIDIA/CUDA, then Vulkan (covers AMD/Intel/NVIDIA), else CPU. A
11# GPU build that fails to configure/compile falls back to CPU so a bare `gem install`
12# never hard-fails. Override with ELELEM_LLAMA_BACKEND=auto|cpu|vulkan|cuda.
13require "fileutils"
14# NB: we deliberately do NOT `require "mkmf"` -- its at_exit hook aborts unless
15# create_makefile ran, which conflicts with the stub Makefile we emit below. A
16# small PATH probe (which) covers the toolchain detection we need.
17
18EXT_DIR = __dir__
19GEM_ROOT = File.expand_path("../..", EXT_DIR)
20VENDOR = File.join(GEM_ROOT, "vendor", "llama.cpp")
21BUILD_DIR = File.join(EXT_DIR, "build")
22NATIVE = File.join(GEM_ROOT, "lib", "elelem", "native")
23STAMP = File.join(BUILD_DIR, ".elelem_backend") # last backend built here
24
25# macOS/Metal is deferred: it needs .dylib naming, @loader_path rpath, an embedded
26# .metallib, and extra cmake targets -- and a Mac to test on. Bail clearly rather
27# than emit a broken build. (Detection + build path will slot in here later.)
28if RbConfig::CONFIG["host_os"] =~ /darwin/
29 abort "elelem: macOS/Metal is not supported yet -- Linux (CPU/CUDA/Vulkan) only."
30end
31
32class BuildError < StandardError; end
33
34def run(*cmd)
35 warn "elelem: + #{cmd.join(' ')}"
36 system(*cmd) || raise(BuildError, "build step failed: #{cmd.join(' ')}")
37end
38
39# Is `cmd` an executable on PATH? (stdlib stand-in for mkmf's find_executable.)
40def which(cmd)
41 ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |dir|
42 path = File.join(dir, cmd)
43 File.executable?(path) && !File.directory?(path)
44 end
45end
46
47# A Vulkan build links libvulkan at runtime, so glslc (shader compiler) alone is not
48# enough -- probe for the loader too, or we'd build a lib that fails at dlopen.
49def vulkan_loader?
50 return true if which("vulkaninfo")
51 %w[/usr/lib64 /usr/lib /usr/local/lib /lib64 /lib].any? do |dir|
52 !Dir.glob(File.join(dir, "libvulkan.so*")).empty?
53 end
54end
55
56VALID_BACKENDS = %w[cpu vulkan cuda].freeze
57
58# Pick the backend from an explicit override, then the host toolchain, else CPU.
59def detect_backend
60 forced = ENV["ELELEM_LLAMA_BACKEND"].to_s.strip.downcase
61 unless forced.empty? || forced == "auto" || VALID_BACKENDS.include?(forced)
62 abort "elelem: ELELEM_LLAMA_BACKEND=#{forced.inspect} invalid; use auto|#{VALID_BACKENDS.join('|')}"
63 end
64 return forced unless forced.empty? || forced == "auto"
65 # Legacy opt-in still honored.
66 return "vulkan" if %w[1 on true yes].include?(ENV["ELELEM_GGML_VULKAN"].to_s.strip.downcase)
67
68 return "cuda" if which("nvcc")
69 return "vulkan" if which("glslc") && vulkan_loader?
70 "cpu"
71end
72
73GPU_FLAGS = {
74 "cpu" => [], # GGML_NATIVE is ON by default -> host CPU SIMD
75 "vulkan" => ["-DGGML_VULKAN=ON"],
76 "cuda" => ["-DGGML_CUDA=ON"]
77}.freeze
78
79# Configure + build only libllama + libllama-common (chat templates + tool-call
80# parsing). Examples/tests/tools/server off keeps the build fast. $ORIGIN rpath lets
81# the co-located libs in lib/elelem/native find their ggml siblings at runtime.
82def configure_and_build(backend)
83 flags = GPU_FLAGS.fetch(backend) { abort "elelem: unknown backend #{backend.inspect}" }
84 run("cmake", "-S", VENDOR, "-B", BUILD_DIR,
85 "-DCMAKE_BUILD_TYPE=Release",
86 "-DBUILD_SHARED_LIBS=ON",
87 "-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON",
88 "-DCMAKE_INSTALL_RPATH=$ORIGIN",
89 "-DLLAMA_CURL=OFF",
90 "-DLLAMA_BUILD_COMMON=ON",
91 "-DLLAMA_BUILD_TESTS=OFF",
92 "-DLLAMA_BUILD_EXAMPLES=OFF",
93 "-DLLAMA_BUILD_TOOLS=OFF",
94 "-DLLAMA_BUILD_SERVER=OFF",
95 *flags)
96 run("cmake", "--build", BUILD_DIR, "--target", "llama", "llama-common", "--parallel")
97 File.write(STAMP, backend)
98end
99
100unless File.exist?(File.join(VENDOR, "CMakeLists.txt"))
101 abort "elelem: vendored llama.cpp missing at #{VENDOR}\n" \
102 " run: git submodule update --init --recursive"
103end
104
105backend = detect_backend
106
107# Toggling backends flips cached CMake vars, so a re-run targeting a different
108# backend needs a clean build dir.
109if File.exist?(STAMP) && File.read(STAMP).strip != backend
110 warn "elelem: backend changed -> wiping #{BUILD_DIR}"
111 FileUtils.rm_rf(BUILD_DIR)
112end
113
114# 1. Build llama.cpp for the chosen backend; on GPU failure, fall back to CPU so a
115# bare install never hard-fails on a half-present GPU toolchain.
116begin
117 warn "elelem: building llama.cpp backend=#{backend}"
118 configure_and_build(backend)
119rescue BuildError => e
120 raise if backend == "cpu"
121 warn "elelem: #{backend} build failed (#{e.message}); falling back to CPU"
122 FileUtils.rm_rf(BUILD_DIR)
123 backend = "cpu"
124 configure_and_build(backend)
125end
126
127# 2. Copy the shared objects (preserving SONAME symlinks) next to where the shim
128# will live, so the shim's $ORIGIN rpath resolves libllama/libggml at runtime.
129# Clear stale libs first: switching backends (e.g. vulkan -> cpu) must not leave a
130# previous backend's libggml-*.so behind for ggml to pick up at runtime.
131FileUtils.mkdir_p(NATIVE)
132FileUtils.rm_f(Dir.glob(File.join(NATIVE, "*.so*")))
133libdir = File.join(BUILD_DIR, "bin")
134run("sh", "-c", "cp -a #{libdir}/*.so* #{NATIVE}/")
135
136# 3. Compile the shim against the vendored headers, link libllama, rpath $ORIGIN.
137cxx = ENV["CXX"] || "c++"
138run(cxx, "-std=c++17", "-O2", "-Wall", "-Wextra", "-shared", "-fPIC",
139 "-I", File.join(VENDOR, "include"),
140 "-I", File.join(VENDOR, "ggml", "include"),
141 "-I", File.join(VENDOR, "common"), # chat.h (common_chat)
142 "-I", File.join(VENDOR, "vendor"), # nlohmann/json.hpp
143 File.join(EXT_DIR, "elelem_llama.cpp"),
144 "-o", File.join(NATIVE, "libelelem_llama.so"),
145 "-L", libdir, "-lllama-common", "-lllama",
146 "-Wl,-rpath,$ORIGIN")
147
148# 4. Record the backend the runtime actually got, so the :gguf provider can default
149# n_gpu_layers appropriately (see lib/elelem/plugins/gguf.rb).
150File.write(File.join(NATIVE, "backend"), backend)
151
152# 5. Stub Makefile so `make` / `make install` succeed (work is already done).
153File.write(File.join(EXT_DIR, "Makefile"), <<~MAKE)
154 all:
155 clean:
156 install:
157 .PHONY: all clean install
158MAKE
159
160warn "elelem: built llama.cpp + shim into #{NATIVE} (backend=#{backend})"