Commit 00658c6

mo khan <mo@mokhan.ca>
2026-08-28 13:29:33
feat(gguf): auto-detect GPU backend at install, with CPU fallback
extconf.rb now picks the llama.cpp backend from the host toolchain (nvcc -> cuda; glslc + a real Vulkan loader -> vulkan; else cpu) instead of the manual ELELEM_GGML_VULKAN=ON opt-in. A GPU configure/build failure wipes the build dir and rebuilds CPU-only so a bare `gem install` never hard-fails. Override via ELELEM_LLAMA_BACKEND=auto|cpu|vulkan|cuda (validated up front; legacy ELELEM_GGML_VULKAN still honored). The chosen backend is stamped to lib/elelem/native/backend, and stale libs are cleared before copying so switching backends can't leave an old libggml-*.so behind. Runtime now uses what was built: Net::GGUF.backend reads the marker and plugins/gguf.rb defaults GGUF_N_GPU_LAYERS to 999 on a GPU build (else 0) -- it was hardcoded to 0, so the compiled GPU backend went unused. On Strix Halo (7B Q4) full offload measured ~2x faster than CPU. The shim gains temp/seed params (temp<=0 = greedy) so runs can be made deterministic; used by a new Evals.gguf client (EVALS_PROVIDER=gguf) that verifies tool-calling across models via `rake evals`. Deliberately avoids `require "mkmf"` (its at_exit fights the stub Makefile); a tiny stdlib PATH probe covers toolchain detection. macOS/Metal is deferred (extconf aborts clearly on darwin). Verified via `gem install --local` into a throwaway GEM_HOME (marker + libs land where the installed gguf.rb looks) and bin/run. Suite 316/0. Claude-Session: https://claude.ai/code/session_01UDKgb5gaG9Xmn3DViHRnJ7
Changed files (5)
ext
lib
elelem
spec
evals
support
ext/elelem_llama/elelem_llama.cpp
@@ -20,11 +20,15 @@ struct el_handle {
     common_chat_templates_ptr tmpls;
     int n_ctx;
     int n_threads;
+    float temp;       // <= 0 => greedy (deterministic); used by evals
+    uint32_t seed;
 };
 
 static bool g_backend = false;
 
-void *el_open(const char *path, int n_gpu_layers, int n_ctx, int n_threads) {
+// temp <= 0 selects greedy/deterministic sampling; seed is the RNG seed for the
+// sampled path (both surfaced so callers -- notably the eval harness -- can pin them).
+void *el_open(const char *path, int n_gpu_layers, int n_ctx, int n_threads, float temp, int seed) {
     if (!g_backend) { llama_backend_init(); g_backend = true; }
 
     llama_model_params mp = llama_model_default_params();
@@ -32,7 +36,8 @@ void *el_open(const char *path, int n_gpu_layers, int n_ctx, int n_threads) {
     llama_model *model = llama_model_load_from_file(path, mp);
     if (!model) return nullptr;
 
-    return new el_handle{model, common_chat_templates_init(model, ""), n_ctx, n_threads};
+    return new el_handle{model, common_chat_templates_init(model, ""), n_ctx, n_threads,
+                         temp, (uint32_t) seed};
 }
 
 // Tool-call arguments arrive as either a JSON string or an object; llama.cpp's
@@ -105,10 +110,14 @@ const char *el_generate(void *handle, const char *messages_json, const char *too
     if (!ctx) { buf = result.dump(); return buf.c_str(); }
 
     llama_sampler *smpl = llama_sampler_chain_init(llama_sampler_chain_default_params());
-    llama_sampler_chain_add(smpl, llama_sampler_init_top_k(40));
-    llama_sampler_chain_add(smpl, llama_sampler_init_top_p(0.95f, 1));
-    llama_sampler_chain_add(smpl, llama_sampler_init_temp(0.7f));
-    llama_sampler_chain_add(smpl, llama_sampler_init_dist(LLAMA_DEFAULT_SEED));
+    if (h->temp <= 0.0f) {
+        llama_sampler_chain_add(smpl, llama_sampler_init_greedy());
+    } else {
+        llama_sampler_chain_add(smpl, llama_sampler_init_top_k(40));
+        llama_sampler_chain_add(smpl, llama_sampler_init_top_p(0.95f, 1));
+        llama_sampler_chain_add(smpl, llama_sampler_init_temp(h->temp));
+        llama_sampler_chain_add(smpl, llama_sampler_init_dist(h->seed));
+    }
 
     const std::string &prompt = cparams.prompt;
     int n_prompt = -llama_tokenize(vocab, prompt.c_str(), (int32_t) prompt.size(), nullptr, 0, true, true);
ext/elelem_llama/extconf.rb
@@ -1,27 +1,100 @@
 # frozen_string_literal: true
 
-# Builds the vendored llama.cpp (CPU shared libs) and our thin C shim at gem
-# install time, installing the shared objects into lib/elelem/native/ so the
-# :gguf provider can Fiddle.dlopen them and run a model in-process. llama.cpp is
-# a CMake project (not an mkmf extension), so we drive cmake ourselves and then
-# emit a stub Makefile to satisfy RubyGems' `make && make install` step.
+# Builds the vendored llama.cpp (shared libs) and our thin C shim at gem install
+# time, installing the shared objects into lib/elelem/native/ so the :gguf provider
+# can Fiddle.dlopen them and run a model in-process. llama.cpp is a CMake project
+# (not an mkmf extension), so we drive cmake ourselves and then emit a stub Makefile
+# to satisfy RubyGems' `make && make install` step.
+#
+# The GPU backend is chosen automatically from the host's toolchain (see
+# detect_backend): NVIDIA/CUDA, then Vulkan (covers AMD/Intel/NVIDIA), else CPU. A
+# GPU build that fails to configure/compile falls back to CPU so a bare `gem install`
+# never hard-fails. Override with ELELEM_LLAMA_BACKEND=auto|cpu|vulkan|cuda.
 require "fileutils"
+# NB: we deliberately do NOT `require "mkmf"` -- its at_exit hook aborts unless
+# create_makefile ran, which conflicts with the stub Makefile we emit below. A
+# small PATH probe (which) covers the toolchain detection we need.
 
 EXT_DIR   = __dir__
 GEM_ROOT  = File.expand_path("../..", EXT_DIR)
 VENDOR    = File.join(GEM_ROOT, "vendor", "llama.cpp")
 BUILD_DIR = File.join(EXT_DIR, "build")
 NATIVE    = File.join(GEM_ROOT, "lib", "elelem", "native")
+STAMP     = File.join(BUILD_DIR, ".elelem_backend") # last backend built here
 
-# GPU offload is opt-in: ELELEM_GGML_VULKAN=ON builds the Vulkan backend (needs
-# vulkan-headers, vulkan-loader-devel, and glslc). CPU-only stays the default so a
-# bare `gem install` never requires GPU toolchain. Toggling this flips a cached
-# CMake var, so switching modes wants a clean build dir (rm -rf ext/*/build).
-VULKAN = %w[1 on true yes].include?(ENV["ELELEM_GGML_VULKAN"].to_s.strip.downcase)
+# macOS/Metal is deferred: it needs .dylib naming, @loader_path rpath, an embedded
+# .metallib, and extra cmake targets -- and a Mac to test on. Bail clearly rather
+# than emit a broken build. (Detection + build path will slot in here later.)
+if RbConfig::CONFIG["host_os"] =~ /darwin/
+  abort "elelem: macOS/Metal is not supported yet -- Linux (CPU/CUDA/Vulkan) only."
+end
+
+class BuildError < StandardError; end
 
 def run(*cmd)
   warn "elelem: + #{cmd.join(' ')}"
-  system(*cmd) || abort("elelem: build step failed: #{cmd.join(' ')}")
+  system(*cmd) || raise(BuildError, "build step failed: #{cmd.join(' ')}")
+end
+
+# Is `cmd` an executable on PATH? (stdlib stand-in for mkmf's find_executable.)
+def which(cmd)
+  ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |dir|
+    path = File.join(dir, cmd)
+    File.executable?(path) && !File.directory?(path)
+  end
+end
+
+# A Vulkan build links libvulkan at runtime, so glslc (shader compiler) alone is not
+# enough -- probe for the loader too, or we'd build a lib that fails at dlopen.
+def vulkan_loader?
+  return true if which("vulkaninfo")
+  %w[/usr/lib64 /usr/lib /usr/local/lib /lib64 /lib].any? do |dir|
+    !Dir.glob(File.join(dir, "libvulkan.so*")).empty?
+  end
+end
+
+VALID_BACKENDS = %w[cpu vulkan cuda].freeze
+
+# Pick the backend from an explicit override, then the host toolchain, else CPU.
+def detect_backend
+  forced = ENV["ELELEM_LLAMA_BACKEND"].to_s.strip.downcase
+  unless forced.empty? || forced == "auto" || VALID_BACKENDS.include?(forced)
+    abort "elelem: ELELEM_LLAMA_BACKEND=#{forced.inspect} invalid; use auto|#{VALID_BACKENDS.join('|')}"
+  end
+  return forced unless forced.empty? || forced == "auto"
+  # Legacy opt-in still honored.
+  return "vulkan" if %w[1 on true yes].include?(ENV["ELELEM_GGML_VULKAN"].to_s.strip.downcase)
+
+  return "cuda" if which("nvcc")
+  return "vulkan" if which("glslc") && vulkan_loader?
+  "cpu"
+end
+
+GPU_FLAGS = {
+  "cpu"    => [],                     # GGML_NATIVE is ON by default -> host CPU SIMD
+  "vulkan" => ["-DGGML_VULKAN=ON"],
+  "cuda"   => ["-DGGML_CUDA=ON"]
+}.freeze
+
+# Configure + build only libllama + libllama-common (chat templates + tool-call
+# parsing). Examples/tests/tools/server off keeps the build fast. $ORIGIN rpath lets
+# the co-located libs in lib/elelem/native find their ggml siblings at runtime.
+def configure_and_build(backend)
+  flags = GPU_FLAGS.fetch(backend) { abort "elelem: unknown backend #{backend.inspect}" }
+  run("cmake", "-S", VENDOR, "-B", BUILD_DIR,
+      "-DCMAKE_BUILD_TYPE=Release",
+      "-DBUILD_SHARED_LIBS=ON",
+      "-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON",
+      "-DCMAKE_INSTALL_RPATH=$ORIGIN",
+      "-DLLAMA_CURL=OFF",
+      "-DLLAMA_BUILD_COMMON=ON",
+      "-DLLAMA_BUILD_TESTS=OFF",
+      "-DLLAMA_BUILD_EXAMPLES=OFF",
+      "-DLLAMA_BUILD_TOOLS=OFF",
+      "-DLLAMA_BUILD_SERVER=OFF",
+      *flags)
+  run("cmake", "--build", BUILD_DIR, "--target", "llama", "llama-common", "--parallel")
+  File.write(STAMP, backend)
 end
 
 unless File.exist?(File.join(VENDOR, "CMakeLists.txt"))
@@ -29,27 +102,34 @@ unless File.exist?(File.join(VENDOR, "CMakeLists.txt"))
         "        run: git submodule update --init --recursive"
 end
 
-# 1. Configure + build only libllama (CPU). Examples/tests/tools/server off keeps
-#    the build fast; GPU backends come later.
-run("cmake", "-S", VENDOR, "-B", BUILD_DIR,
-    "-DCMAKE_BUILD_TYPE=Release",
-    "-DBUILD_SHARED_LIBS=ON",
-    # Bake $ORIGIN rpath into the libs so, once co-located in lib/elelem/native,
-    # libllama finds its ggml siblings there -- not in this throwaway build dir.
-    "-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON",
-    "-DCMAKE_INSTALL_RPATH=$ORIGIN",
-    "-DGGML_VULKAN=#{VULKAN ? 'ON' : 'OFF'}",
-    "-DLLAMA_CURL=OFF",
-    "-DLLAMA_BUILD_COMMON=ON", # common_chat: chat templates + tool-call parsing
-    "-DLLAMA_BUILD_TESTS=OFF",
-    "-DLLAMA_BUILD_EXAMPLES=OFF",
-    "-DLLAMA_BUILD_TOOLS=OFF",
-    "-DLLAMA_BUILD_SERVER=OFF")
-run("cmake", "--build", BUILD_DIR, "--target", "llama", "llama-common", "--parallel")
+backend = detect_backend
+
+# Toggling backends flips cached CMake vars, so a re-run targeting a different
+# backend needs a clean build dir.
+if File.exist?(STAMP) && File.read(STAMP).strip != backend
+  warn "elelem: backend changed -> wiping #{BUILD_DIR}"
+  FileUtils.rm_rf(BUILD_DIR)
+end
+
+# 1. Build llama.cpp for the chosen backend; on GPU failure, fall back to CPU so a
+#    bare install never hard-fails on a half-present GPU toolchain.
+begin
+  warn "elelem: building llama.cpp backend=#{backend}"
+  configure_and_build(backend)
+rescue BuildError => e
+  raise if backend == "cpu"
+  warn "elelem: #{backend} build failed (#{e.message}); falling back to CPU"
+  FileUtils.rm_rf(BUILD_DIR)
+  backend = "cpu"
+  configure_and_build(backend)
+end
 
 # 2. Copy the shared objects (preserving SONAME symlinks) next to where the shim
 #    will live, so the shim's $ORIGIN rpath resolves libllama/libggml at runtime.
+#    Clear stale libs first: switching backends (e.g. vulkan -> cpu) must not leave a
+#    previous backend's libggml-*.so behind for ggml to pick up at runtime.
 FileUtils.mkdir_p(NATIVE)
+FileUtils.rm_f(Dir.glob(File.join(NATIVE, "*.so*")))
 libdir = File.join(BUILD_DIR, "bin")
 run("sh", "-c", "cp -a #{libdir}/*.so* #{NATIVE}/")
 
@@ -65,7 +145,11 @@ run(cxx, "-std=c++17", "-O2", "-shared", "-fPIC",
     "-L", libdir, "-lllama-common", "-lllama",
     "-Wl,-rpath,$ORIGIN")
 
-# 4. Stub Makefile so `make` / `make install` succeed (work is already done).
+# 4. Record the backend the runtime actually got, so the :gguf provider can default
+#    n_gpu_layers appropriately (see lib/elelem/plugins/gguf.rb).
+File.write(File.join(NATIVE, "backend"), backend)
+
+# 5. Stub Makefile so `make` / `make install` succeed (work is already done).
 File.write(File.join(EXT_DIR, "Makefile"), <<~MAKE)
   all:
   clean:
@@ -73,4 +157,4 @@ File.write(File.join(EXT_DIR, "Makefile"), <<~MAKE)
   .PHONY: all clean install
 MAKE
 
-warn "elelem: built llama.cpp + shim into #{NATIVE} (#{VULKAN ? 'Vulkan/GPU' : 'CPU'})"
+warn "elelem: built llama.cpp + shim into #{NATIVE} (backend=#{backend})"
lib/elelem/net/gguf.rb
@@ -12,9 +12,19 @@ module Elelem
     # JSON, so the model participates in the normal agent tool loop. No subprocess,
     # no HTTP server, no third-party gem.
     class GGUF
-      SHIM = File.expand_path("../native/libelelem_llama.so", __dir__)
+      NATIVE = File.expand_path("../native", __dir__)
+      SHIM = File.join(NATIVE, "libelelem_llama.so")
       V = Fiddle::TYPE_VOIDP
       I = Fiddle::TYPE_INT
+      F = Fiddle::TYPE_FLOAT
+
+      # The GPU backend the extension compiled (extconf.rb stamps this at install);
+      # "cpu" when absent. Lets the provider default GPU offload to what was built.
+      def self.backend
+        File.read(File.join(NATIVE, "backend")).strip
+      rescue SystemCallError
+        "cpu"
+      end
 
       # dlopen + bindings are process-wide resources -- memoize once, like Net.http.
       def self.functions
@@ -24,15 +34,17 @@ module Elelem
           end
           lib = Fiddle.dlopen(SHIM)
           {
-            open: Fiddle::Function.new(lib["el_open"], [V, I, I, I], V),
+            open: Fiddle::Function.new(lib["el_open"], [V, I, I, I, F, I], V),
             generate: Fiddle::Function.new(lib["el_generate"], [V, V, V, I], V)
           }
         end
       end
 
-      def initialize(model_path:, n_ctx: 4096, n_threads: 16, max_tokens: 512, n_gpu_layers: 0)
+      # temp <= 0 => greedy/deterministic; seed -1 keeps llama.cpp's default seed.
+      def initialize(model_path:, n_ctx: 4096, n_threads: 16, max_tokens: 512,
+                     n_gpu_layers: 0, temp: 0.7, seed: -1)
         @max_tokens = max_tokens
-        @handle = self.class.functions[:open].call(model_path, n_gpu_layers, n_ctx, n_threads)
+        @handle = self.class.functions[:open].call(model_path, n_gpu_layers, n_ctx, n_threads, temp, seed)
         raise "gguf: failed to load model at #{model_path}" if @handle.null?
       end
 
lib/elelem/plugins/gguf.rb
@@ -2,11 +2,17 @@
 
 # In-process GGUF provider (see Elelem::Net::GGUF in lib/elelem/net/gguf.rb).
 Elelem::Providers.register(:gguf) do
+  # Offload all layers by default when a GPU backend was compiled (extconf.rb stamps
+  # it); CPU builds stay at 0. GGUF_N_GPU_LAYERS overrides either way.
+  gpu = %w[vulkan cuda metal].include?(Elelem::Net::GGUF.backend)
+
   Elelem::Net::GGUF.new(
     model_path: ENV.fetch("GGUF_MODEL", File.expand_path("~/models/Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf")),
     n_ctx: Integer(ENV.fetch("GGUF_N_CTX", "8192")), # room for system prompt + tool results
     n_threads: Integer(ENV.fetch("GGUF_THREADS", "16")),
     max_tokens: Integer(ENV.fetch("GGUF_MAX_TOKENS", "512")),
-    n_gpu_layers: Integer(ENV.fetch("GGUF_N_GPU_LAYERS", "0")) # 0 = CPU; 999 = offload all layers
+    n_gpu_layers: Integer(ENV.fetch("GGUF_N_GPU_LAYERS", gpu ? "999" : "0")), # 999 = offload all
+    temp: Float(ENV.fetch("GGUF_TEMP", "0.7")),                               # 0 = greedy
+    seed: Integer(ENV.fetch("GGUF_SEED", "-1"))                              # -1 = llama default
   )
 end
spec/evals/support/client.rb
@@ -10,7 +10,26 @@ module Elelem
       Elelem::Net::Ollama.new(model: model, host: host, keep_alive: "30m", **params)
     end
 
+    # In-process GGUF model, run greedy (temp 0) for deterministic eval scoring.
+    # Memoized: the runner asks for a client per case, but generation is stateless
+    # (fresh context each call), so one resident model serves every case -- loading
+    # a fresh 4GB+ model per case would exhaust GPU/unified memory.
+    def self.gguf(model_path: ENV.fetch("GGUF_MODEL"))
+      @gguf ||= begin
+        gpu = %w[vulkan cuda metal].include?(Elelem::Net::GGUF.backend)
+        Elelem::Net::GGUF.new(
+          model_path: File.expand_path(model_path),
+          n_ctx: Integer(ENV.fetch("GGUF_N_CTX", "8192")),
+          n_gpu_layers: Integer(ENV.fetch("GGUF_N_GPU_LAYERS", gpu ? "999" : "0")),
+          temp: 0.0, seed: 42
+        )
+      end
+    end
+
+    # EVALS_PROVIDER=gguf points the runner at the local model instead of Ollama.
     def self.client(model: MODEL)
+      return gguf if ENV["EVALS_PROVIDER"] == "gguf"
+
       ollama(model: model, options: { temperature: 0, seed: 42 })
     end
   end