Commit 00658c6
2026-08-28 13:29:33
1 parent
3ce6dd3
Changed files (5)
ext
elelem_llama
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