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})"