Commit 7640081

2026-09-11 02:11:00
perf: replace hand-tuned arena sizing with ggml_gallocr + no_alloc
The compute context per forward pass used to be a fixed-size malloc'd arena, sized by a hand-guessed formula (data size + tensor-overhead constant, doubled, plus 16MB slack, floored at 256MB). That floor meant every single-token decode step on a 656K-param model allocated and freed 256MB, and the guess had already aborted the process twice on a 7B model earlier in this work. Graph construction is now split into el_build_graph (pure graph shape, no tensor data) built in a no_alloc=true context, allocated by a persistent ggml_gallocr_t on the handle, with inputs written via ggml_backend_tensor_set/get after allocation instead of direct ->data access. The KV cache (still a plain malloc'd context) needed no change -- verified by isolated probe that gallocr leaves already- allocated tensors (views into it) untouched. Actual buffer sizes measured post-change: 9,216 bytes for the 656K TinyStories model (was 256MB), ~5.4MB for the 7B model, flat across prefill and every decode step. EL_TENSORS_PER_LAYER changed meaning in the same pass: it used to be one term inside a padded estimate, where over-guessing was free. It's now a hard cap on both context bytes and graph node count with no slack, and overflow aborts the process (GGML_ASSERT). Bumped 40->64 after measuring per-layer usage came in at ~38-40 nodes on both test architectures -- comfortable margin now, at a cost of ~200KB context on a 24-layer model. Dropped the redundant ggml_gallocr_reserve call per forward pass: alloc_graph already reallocates on demand, confirmed by measurement (buffer size was flat across 20 TinyStories decode steps and 8 7B steps despite prefill/decode shape changes). Verified byte-identical greedy-decode token IDs against the Qwen2 golden (three prompts) and the TinyStories regression canary; 7B still runs (~1.66s for prefill+16 tokens). A persistent threadpool was considered and dropped: promoting the compute work buffer to the handle (removing a per-token malloc/free) measured no throughput change, so thread-spawn-per-token isn't costing enough here to justify the added lifecycle.
1 parent 408739c
Changed files (1)
ext
elelem
ext/elelem/ggml/elelem.c
@@ -4,12 +4,12 @@
 #include <string.h>
 
 #include "ggml.h"
+#include "ggml-alloc.h"
+#include "ggml-backend.h"
 #include "ggml-cpu.h"
 #include "gguf.h"
 
-#define EL_COMPUTE_MEM_FLOOR (256 * 1024 * 1024)
-#define EL_TENSOR_OVERHEAD (sizeof(struct ggml_tensor) + 256)
-#define EL_TENSORS_PER_LAYER 40
+#define EL_TENSORS_PER_LAYER 64
 #define EL_DEFAULT_N_CTX 4096
 
 struct el_hparams {
@@ -36,11 +36,20 @@ struct el_handle {
   struct ggml_context *weights;
   struct el_hparams hparams;
   struct el_kv_cache kv;
+  ggml_gallocr_t galloc;
   int32_t n_ctx;
   int n_threads;
   char version[256];
 };
 
+struct el_graph {
+  struct ggml_cgraph *gf;
+  struct ggml_tensor *inp_tokens;
+  struct ggml_tensor *inp_pos;
+  struct ggml_tensor *kq_mask;
+  struct ggml_tensor *logits;
+};
+
 static int32_t el_kv_i32(struct gguf_context *gguf, const char *key, int32_t fallback) {
   int64_t id = gguf_find_key(gguf, key);
   if (id < 0) return fallback;
@@ -176,6 +185,15 @@ void *el_open(const char *model_path, int n_gpu_layers, int n_ctx, int n_threads
     return NULL;
   }
 
+  h->galloc = ggml_gallocr_new(ggml_backend_cpu_buffer_type());
+  if (!h->galloc) {
+    el_kv_cache_free(&h->kv);
+    ggml_free(weights);
+    gguf_free(gguf);
+    free(h);
+    return NULL;
+  }
+
   snprintf(h->version, sizeof(h->version),
            "elelem-ggml arch=%s n_layer=%d n_embd=%d n_head=%d n_head_kv=%d n_ff=%d n_vocab=%d",
            h->hparams.arch, h->hparams.n_layer, h->hparams.n_embd,
@@ -207,57 +225,33 @@ static struct ggml_tensor *el_rms_norm(struct ggml_context *ctx, struct ggml_ten
   return cur;
 }
 
-static int32_t el_forward_argmax(struct el_handle *h, const int32_t *new_tokens, int32_t n_new, int32_t n_past) {
-  struct el_hparams *hp = &h->hparams;
-
-  if (n_past + n_new > h->n_ctx) return -1;
+static size_t el_graph_ctx_size(int32_t n_layer) {
+  size_t n_nodes = (size_t) n_layer * EL_TENSORS_PER_LAYER + 16;
+  return ggml_tensor_overhead() * n_nodes + ggml_graph_overhead_custom(n_nodes, false);
+}
 
+static int el_build_graph(struct el_handle *h, struct ggml_context *ctx, struct el_graph *g,
+                           int32_t n_new, int32_t n_past) {
+  struct el_hparams *hp = &h->hparams;
   const int64_t n_embd_head = hp->n_embd / hp->n_head;
   const int32_t n_kv = n_past + n_new;
 
-  size_t data_per_layer = (size_t) n_new * (hp->n_embd * 4 + hp->n_ff * 3) * sizeof(float);
-  size_t overhead_per_layer = EL_TENSORS_PER_LAYER * (size_t) EL_TENSOR_OVERHEAD;
-  size_t attn_term = (size_t) n_new * n_kv * hp->n_head * sizeof(float) * 2;
-  size_t output_term = (size_t) hp->n_vocab * sizeof(float) + EL_TENSOR_OVERHEAD * 8;
-  size_t estimate = (size_t) hp->n_layer * (data_per_layer + overhead_per_layer + attn_term) + output_term;
-  size_t mem_size = estimate * 2 + (16 * 1024 * 1024);
-  if (mem_size < EL_COMPUTE_MEM_FLOOR) mem_size = EL_COMPUTE_MEM_FLOOR;
-
-  struct ggml_context *ctx = ggml_init((struct ggml_init_params) {
-    .mem_size = mem_size,
-    .mem_buffer = NULL,
-    .no_alloc = false,
-  });
-  if (!ctx) return -1;
-
   struct ggml_tensor *tok_embd = el_weight(h->weights, "token_embd.weight", -1);
   struct ggml_tensor *output_norm = el_weight(h->weights, "output_norm.weight", -1);
   struct ggml_tensor *output = el_weight(h->weights, "output.weight", -1);
   if (!output) output = tok_embd;
-  if (!tok_embd || !output_norm || !output) {
-    ggml_free(ctx);
-    return -1;
-  }
+  if (!tok_embd || !output_norm || !output) return -1;
 
   struct ggml_tensor *inp_tokens = ggml_new_tensor_1d(ctx, GGML_TYPE_I32, n_new);
-  memcpy(inp_tokens->data, new_tokens, n_new * sizeof(int32_t));
-
   struct ggml_tensor *inp_pos = ggml_new_tensor_1d(ctx, GGML_TYPE_I32, n_new);
-  for (int32_t i = 0; i < n_new; i++) {
-    ((int32_t *) inp_pos->data)[i] = n_past + i;
-  }
-
   struct ggml_tensor *kq_mask = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, n_kv, n_new);
-  float *mask = (float *) kq_mask->data;
-  for (int32_t q = 0; q < n_new; q++) {
-    for (int32_t k = 0; k < n_kv; k++) {
-      mask[q * n_kv + k] = k <= n_past + q ? 0.0f : -INFINITY;
-    }
-  }
+  ggml_set_input(inp_tokens);
+  ggml_set_input(inp_pos);
+  ggml_set_input(kq_mask);
 
   struct ggml_tensor *cur = ggml_get_rows(ctx, tok_embd, inp_tokens);
 
-  struct ggml_cgraph *gf = ggml_new_graph(ctx);
+  struct ggml_cgraph *gf = ggml_new_graph_custom(ctx, (size_t) hp->n_layer * EL_TENSORS_PER_LAYER + 16, false);
 
   for (int32_t il = 0; il < hp->n_layer; il++) {
     struct ggml_tensor *inp_sa = cur;
@@ -354,9 +348,69 @@ static int32_t el_forward_argmax(struct el_handle *h, const int32_t *new_tokens,
 
   ggml_build_forward_expand(gf, cur);
 
-  ggml_graph_compute_with_ctx(ctx, gf, h->n_threads);
+  g->gf = gf;
+  g->inp_tokens = inp_tokens;
+  g->inp_pos = inp_pos;
+  g->kq_mask = kq_mask;
+  g->logits = cur;
+
+  return 0;
+}
+
+static int32_t el_forward_argmax(struct el_handle *h, const int32_t *new_tokens, int32_t n_new, int32_t n_past) {
+  struct el_hparams *hp = &h->hparams;
+
+  if (n_past + n_new > h->n_ctx) return -1;
+
+  const int32_t n_kv = n_past + n_new;
+  size_t ctx_size = el_graph_ctx_size(hp->n_layer);
+
+  struct ggml_context *ctx = ggml_init((struct ggml_init_params) {
+    .mem_size = ctx_size,
+    .mem_buffer = NULL,
+    .no_alloc = true,
+  });
+  if (!ctx) return -1;
+
+  struct el_graph g;
+  if (el_build_graph(h, ctx, &g, n_new, n_past) != 0) {
+    ggml_free(ctx);
+    return -1;
+  }
+
+  if (!ggml_gallocr_alloc_graph(h->galloc, g.gf)) {
+    ggml_free(ctx);
+    return -1;
+  }
+
+  ggml_backend_tensor_set(g.inp_tokens, new_tokens, 0, n_new * sizeof(int32_t));
+
+  int32_t *pos = malloc(n_new * sizeof(int32_t));
+  for (int32_t i = 0; i < n_new; i++) pos[i] = n_past + i;
+  ggml_backend_tensor_set(g.inp_pos, pos, 0, n_new * sizeof(int32_t));
+  free(pos);
+
+  float *mask = malloc((size_t) n_new * n_kv * sizeof(float));
+  for (int32_t q = 0; q < n_new; q++) {
+    for (int32_t k = 0; k < n_kv; k++) {
+      mask[q * n_kv + k] = k <= n_past + q ? 0.0f : -INFINITY;
+    }
+  }
+  ggml_backend_tensor_set(g.kq_mask, mask, 0, (size_t) n_new * n_kv * sizeof(float));
+  free(mask);
+
+  struct ggml_cplan plan = ggml_graph_plan(g.gf, h->n_threads, NULL);
+  void *work_data = plan.work_size > 0 ? malloc(plan.work_size) : NULL;
+  plan.work_data = work_data;
+  enum ggml_status status = ggml_graph_compute(g.gf, &plan);
+  free(work_data);
+  if (status != GGML_STATUS_SUCCESS) {
+    ggml_free(ctx);
+    return -1;
+  }
 
-  const float *logits = (const float *) cur->data;
+  float *logits = malloc((size_t) hp->n_vocab * sizeof(float));
+  ggml_backend_tensor_get(g.logits, logits, 0, (size_t) hp->n_vocab * sizeof(float));
 
   int32_t best_id = 0;
   float best_logit = logits[0];
@@ -367,6 +421,7 @@ static int32_t el_forward_argmax(struct el_handle *h, const int32_t *new_tokens,
     }
   }
 
+  free(logits);
   ggml_free(ctx);
   return best_id;
 }
@@ -403,6 +458,7 @@ int32_t el_generate(void *handle, const int32_t *prompt, int32_t n_prompt,
 void el_close(void *handle) {
   if (!handle) return;
   struct el_handle *h = handle;
+  if (h->galloc) ggml_gallocr_free(h->galloc);
   el_kv_cache_free(&h->kv);
   if (h->weights) ggml_free(h->weights);
   if (h->gguf) gguf_free(h->gguf);