Commit 9bf1b90

mo khan <mo@mokhan.ca>
2026-08-31 01:55:36
perf: persist the llama_context across generate calls
Every el_generate call allocated and freed a fresh llama_context, paying ~115ms of context-alloc/graph-reserve overhead per turn (and per tool round-trip within a turn) for no benefit the interactive chat path needs -- only the eval harness relies on isolation. The context is now created once in el_open and reused, with its KV cache cleared at the start of each call to keep output identical. Also adds timing instrumentation (ms_reset/ms_prefill/ms_decode/ n_prompt/n_decoded) to el_generate_impl's result, logged at DEBUG via gguf.rb, so future latency work has real numbers instead of guesses -- this is what surfaced the persist-context win (111ms -> 24ms/call) and, incidentally, an unrelated masked native-error bug (see next commit). Claude-Session: https://claude.ai/code/session_01FpbgyAMtPEkDbo2kx78qR6
Changed files (2)
ext
elelem_llama
lib
elelem
ext/elelem_llama/elelem_llama.cpp
@@ -9,6 +9,7 @@
 #include <nlohmann/json.hpp>
 #include <algorithm>
 #include <cctype>
+#include <chrono>
 #include <cstdlib>
 #include <cstring>
 #include <string>
@@ -21,6 +22,9 @@ extern "C" {
 struct el_handle {
     llama_model *model;
     common_chat_templates_ptr tmpls;
+    llama_context *ctx; // persistent across el_generate calls; memory is
+                         // cleared at the start of each call so behavior
+                         // stays identical to a fresh context per call
     int n_ctx;
     int n_threads;
     float temp;       // <= 0 => greedy (deterministic); used by evals
@@ -67,7 +71,16 @@ void *el_open(const char *path, int n_gpu_layers, int n_ctx, int n_threads, floa
     // returning null, which would otherwise abort the whole process (see
     // el_generate's comment on the FFI boundary).
     try {
-        return new el_handle{model, common_chat_templates_init(model, ""), n_ctx, n_threads, temp, (uint32_t) seed};
+        auto tmpls = common_chat_templates_init(model, "");
+
+        llama_context_params cp = llama_context_default_params();
+        cp.n_ctx = (uint32_t) n_ctx;
+        cp.n_threads = n_threads;
+        cp.n_threads_batch = n_threads;
+        llama_context *ctx = llama_init_from_model(model, cp);
+        if (!ctx) { llama_model_free(model); return nullptr; }
+
+        return new el_handle{model, std::move(tmpls), ctx, n_ctx, n_threads, temp, (uint32_t) seed};
     } catch (const std::exception &) {
         llama_model_free(model);
         return nullptr;
@@ -149,12 +162,13 @@ static const char *el_generate_impl(void *handle, const char *messages_json, con
     result["content"] = "";
     result["tool_calls"] = json::array();
 
-    llama_context_params cp = llama_context_default_params();
-    cp.n_ctx = (uint32_t) h->n_ctx;
-    cp.n_threads = h->n_threads;
-    cp.n_threads_batch = h->n_threads;
-    llama_context *ctx = llama_init_from_model(h->model, cp);
-    if (!ctx) { buf = result.dump(); return buf.c_str(); }
+    // ctx is persistent on the handle (see el_open); clear its KV cache so
+    // each call is still stateless from the model's point of view, just
+    // without paying context-alloc/graph-reserve cost every time.
+    llama_context *ctx = h->ctx;
+    auto t0 = std::chrono::steady_clock::now();
+    llama_memory_clear(llama_get_memory(ctx), true);
+    auto t1 = std::chrono::steady_clock::now();
 
     llama_sampler *smpl = llama_sampler_chain_init(llama_sampler_chain_default_params());
     if (h->temp <= 0.0f) {
@@ -178,21 +192,31 @@ static const char *el_generate_impl(void *handle, const char *messages_json, con
         int n = std::min(n_batch, n_prompt - i);
         if (llama_decode(ctx, llama_batch_get_one(tokens.data() + i, n)) != 0) break;
     }
+    auto t2 = std::chrono::steady_clock::now();
 
     std::string output;
     char piece[512];
+    int n_decoded = 0;
     for (int t = 0; t < max_tokens; t++) {
         llama_token id = llama_sampler_sample(smpl, ctx, -1);
         if (llama_vocab_is_eog(vocab, id)) break;
         int np = llama_token_to_piece(vocab, id, piece, (int32_t) sizeof(piece), 0, true);
         if (np > 0) output.append(piece, np);
+        n_decoded++;
         // Advance the KV cache so we can sample the next token. Skip it on the last
         // planned iteration -- that forward pass would never be sampled from.
         if (t + 1 < max_tokens && llama_decode(ctx, llama_batch_get_one(&id, 1)) != 0) break;
     }
+    auto t3 = std::chrono::steady_clock::now();
 
     llama_sampler_free(smpl);
-    llama_free(ctx);
+
+    auto ms = [](auto a, auto b) { return std::chrono::duration<double, std::milli>(b - a).count(); };
+    result["ms_reset"] = ms(t0, t1);
+    result["ms_prefill"] = ms(t1, t2);
+    result["ms_decode"] = ms(t2, t3);
+    result["n_prompt"] = n_prompt;
+    result["n_decoded"] = n_decoded;
 
     // The parse rules live in a PEG arena that templates_apply serialized into
     // cparams.parser; common_chat_parse forwards params.parser to the PEG engine,
@@ -336,6 +360,7 @@ const char *el_generate(void *handle, const char *messages_json, const char *too
 void el_close(void *handle) {
     auto *h = (el_handle *) handle;
     if (!h) return;
+    llama_free(h->ctx);
     llama_model_free(h->model);
     delete h;
 }
lib/elelem/net/gguf.rb
@@ -40,10 +40,16 @@ module Elelem
 
         Elelem.logger.debug("gguf: tool-call fallback used") if result["fallback"]
         Elelem.logger.debug("gguf: harmony tag fallback used") if result["harmony_tag_fallback"]
+        Elelem.logger.warn("gguf: native generate error: #{result["error"]}") if result["error"]
+        if result["ms_decode"]
+          Elelem.logger.debug(format(
+            "gguf: reset=%.0fms prefill=%.0fms (n=%d) decode=%.0fms (n=%d)",
+            result["ms_reset"], result["ms_prefill"], result["n_prompt"], result["ms_decode"], result["n_decoded"]
+          ))
+        end
         if result["tool_calls"].to_a.empty? && !tools.empty? && result["content"].to_s.include?("\"name\"")
           Elelem.logger.debug("gguf: no tool calls parsed, tools offered")
         end
-        Elelem.logger.warn("gguf: native generate error: #{result["error"]}") if result["error"]
         Elelem.logger.debug("gguf: reasoning: #{result["reasoning"]}") if result["reasoning"].to_s != ""
 
         content = result["content"].to_s