1 Commits

Author SHA1 Message Date
JustVugg c90e2cc438 glm: measured-RSS guard — the RAM budget enforces itself at the safe point (#403)
cap_for_ram's projection is an estimate: on the GB10 (#403) long generations
overshot it by ~40 GB (projected 74.4, real 115.6) and the kernel killed the
engine three times. Run D of the issue proves a low cap CONTAINS the growth;
this guard does that automatically, keyed on MEASURED RSS instead of the
projection.

At the repin safe point (no moe in flight), every ~16 emitted tokens: if RSS
exceeds the resolved budget (RAM_GB/auto, or an explicit RSS_GUARD_GB
ceiling), free the least-used LRU expert slabs in place and lower ecap so the
cache cannot regrow. Slabs are >128 KB so glibc returns the pages to the
kernel immediately -- RSS actually drops.

Eviction never compacts the array: with PILOT_REAL the pilot worker holds
pointers into ecache[] across its preads, so the slot stays in place with
eid=-1/used=0 (first candidate for reuse); reserved slots (eid<0) are never
touched and victim selection happens under g_pilot_mx. resident_bytes is left
alone: LRU slots are never accounted there (only pin + dense).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:02:31 +02:00
3 changed files with 62 additions and 190 deletions
+62
View File
@@ -4924,7 +4924,68 @@ static int repin_pick(Model *m, RepinCand *out, int maxc){
} }
return nb; return nb;
} }
/* ---- RSS GUARD (#403) -----------------------------------------------------
* La proiezione di cap_for_ram e' una STIMA: sul GB10 (#403) le generazioni
* lunghe l'hanno sforata di ~40 GB (proiettato 74.4, reale 115.6 -> 3 kill del
* kernel). La run D dell'issue prova che un cap piu' basso CONTIENE la crescita:
* questa guardia lo fa da sola, sull'RSS MISURATO invece che sul proiettato.
* Al safe point (stessa sede di repin: nessun moe in volo), ogni ~16 token
* emessi: se l'RSS supera il budget, svuota gli slot LRU meno usati e abbassa
* ecap perche' non ricrescano. Gli slab sono >128KB (mmap'd da glibc): la free
* restituisce le pagine al kernel subito, quindi l'RSS scende davvero.
* Lo slot NON viene compattato via: resta al suo posto con eid=-1/used=0 (primo
* candidato al riuso), perche' con PILOT_REAL il worker tiene puntatori dentro
* ecache[] durante i suoi pread e uno spostamento li invaliderebbe; per lo
* stesso motivo gli slot eid<0 (riservati/in caricamento) non si toccano e la
* selezione avviene sotto g_pilot_mx. resident_bytes resta invariato: gli slot
* LRU non sono mai contati li' (solo pin e densa).
* EN: evict = free the slab in place (eid=-1, used=0, never compact: PILOT_REAL
* EN: holds pointers into ecache[] across its preads), skip eid<0 reservations,
* EN: select under g_pilot_mx. RSS_GUARD_GB=<gb> forces an explicit ceiling. */
static double g_ram_budget_gb=0; /* budget risolto, scritto da cap_for_ram */
static uint64_t g_rssg_last=0;
static void rss_guard(Model *m){
double lim = getenv("RSS_GUARD_GB") ? atof(getenv("RSS_GUARD_GB")) : g_ram_budget_gb;
if(lim<=0) return;
if(m->n_emit - g_rssg_last < 16) return;
g_rssg_last = m->n_emit;
double rss=rss_gb();
if(rss <= lim*1.02+0.3) return; /* tolleranza: 2% + 300MB */
Cfg *c=&m->c;
int64_t need=(int64_t)((rss-lim)*1e9), freed=0; int dropped=0;
for(int pass=0; pass<8 && freed<need; pass++){
for(int l=0; l<=c->n_layers && freed<need; l++){
if(!m->ecache || !m->ecache[l]) continue;
pthread_mutex_lock(&g_pilot_mx);
int nn=m->ecn[l], lru=-1;
for(int z=0;z<nn;z++){ /* solo slot pubblicati e con slab */
ESlot *cand=&m->ecache[l][z];
if(cand->eid<0 || !cand->slab) continue;
if(lru<0 || cand->used<m->ecache[l][lru].used) lru=z;
}
if(lru<0){ pthread_mutex_unlock(&g_pilot_mx); continue; }
ESlot *s=&m->ecache[l][lru];
s->eid=-1; /* nascosto: nessun hit/evict altrui */
pthread_mutex_unlock(&g_pilot_mx);
int64_t sb=s->slab_cap + s->fslab_cap*4;
#ifdef COLI_METAL
if(s->slab && g_metal_enabled) coli_metal_unregister(s->slab);
#endif
compat_aligned_free(s->slab); free(s->fslab);
s->slab=NULL; s->fslab=NULL; s->slab_cap=s->fslab_cap=0;
QT *q[3]={&s->g,&s->u,&s->d};
for(int k=0;k<3;k++){ q[k]->qf=NULL; q[k]->q8=NULL; q[k]->q4=NULL; q[k]->s=NULL; }
s->used=0; /* primo candidato al riuso */
freed += sb; dropped++;
}
if(m->ecap>2) m->ecap--; /* il tetto scende: niente ricrescita */
}
if(dropped)
fprintf(stderr,"[RAM-GUARD] RSS %.1f GB over the %.1f GB budget (#403): "
"dropped %d cached experts, cap -> %d\n", rss, lim, dropped, m->ecap);
}
static void repin_pass_limit(Model *m,int limit){ static void repin_pass_limit(Model *m,int limit){
rss_guard(m); /* #403: il budget si fa rispettare sull'RSS MISURATO */
if(g_repin<=0) return; if(g_repin<=0) return;
if(m->n_emit - g_last_repin < (uint64_t)g_repin) return; if(m->n_emit - g_last_repin < (uint64_t)g_repin) return;
g_last_repin = m->n_emit; g_last_repin = m->n_emit;
@@ -6024,6 +6085,7 @@ static void cap_for_ram(Model *m, double ram_gb, int ebits, int max_ctx){
if(auto_b){ ram_gb = g_mem_avail_boot*0.88; /* misurata PRIMA del load: il residente gia' if(auto_b){ ram_gb = g_mem_avail_boot*0.88; /* misurata PRIMA del load: il residente gia'
* allocato viene sottratto sotto, non due volte */ * allocato viene sottratto sotto, non due volte */
if(ram_gb<4){ fprintf(stderr,"[RAM] MemAvailable is unreadable or too low; assuming 8 GB\n"); ram_gb=8; } } if(ram_gb<4){ fprintf(stderr,"[RAM] MemAvailable is unreadable or too low; assuming 8 GB\n"); ram_gb=8; } }
g_ram_budget_gb = ram_gb; /* #403: la RSS-guard usa il budget RISOLTO */
/* slack ONESTO, non forfettario (l'OOM del 2026-07-04 veniva da qui): /* slack ONESTO, non forfettario (l'OOM del 2026-07-04 veniva da qui):
* ws[64] slab del working-set (si materializzano TUTTI nel prefill batch-union), * ws[64] slab del working-set (si materializzano TUTTI nel prefill batch-union),
* KV cache a max_ctx, kvb_all della ricostruzione k/v in attention, * KV cache a max_ctx, kvb_all della ricostruzione k/v in attention,
-8
View File
@@ -271,14 +271,6 @@ def parse_tool_calls(reply, tools=None):
salvaged.append(name) salvaged.append(name)
calls.append({"id": "call_" + uuid.uuid4().hex[:24], "type": "function", calls.append({"id": "call_" + uuid.uuid4().hex[:24], "type": "function",
"function": {"name": name, "arguments": json.dumps(args, ensure_ascii=False)}}) "function": {"name": name, "arguments": json.dumps(args, ensure_ascii=False)}})
if tools and not calls and re.search(r"</?tool_call>|</?arg_key>|</?arg_value>", reply):
# Diagnosi per la #401: il client ha dichiarato i tools e il modello ha PROVATO la
# sintassi, ma il parse rigoroso non ha agganciato nulla (tipico output int4 storpiato).
# EN: #401 field diagnosis: tools were declared and the model attempted the syntax,
# EN: but the strict parse matched nothing (typically quantization-mangled output).
sys.stderr.write("[api] tools declared and tool-call markers present, but no call "
"parsed -- output may be quantization-mangled; try COLI_TOOL_SALVAGE=1\n")
sys.stderr.flush()
text = _BOX_RE.sub("", reply) text = _BOX_RE.sub("", reply)
if THINK_CLOSE in text: if THINK_CLOSE in text:
text = text.split(THINK_CLOSE, 1)[1] text = text.split(THINK_CLOSE, 1)[1]
-182
View File
@@ -1,182 +0,0 @@
"""End-to-end tool-calling test for the OpenAI gateway (#401).
Unlike the unit tests in test_openai_server.py (which call parse_tool_calls /
render_chat directly), this suite runs openai_server.py as a real subprocess
against a mock engine that speaks the actual SERVE wire protocol
(READY / SUBMIT / DATA / DONE), then talks to it over real HTTP. It pins down
the full path a coding client exercises: tool declaration rendering, marker
suppression in streamed deltas (across chunk boundaries), tool_calls in both
response shapes, and the <|observation|><tool_response> round trip.
"""
import json
import os
import socket
import subprocess
import sys
import tempfile
import unittest
import urllib.request
from pathlib import Path
SERVER = Path(__file__).resolve().parent.parent / "openai_server.py"
MODEL_ID = "glm-5.2-colibri"
# Mock engine: replies are keyed on the prompt so one process covers every case.
# Prompts received are appended to MOCK_LOG for assertions on the rendering.
MOCK_ENGINE = r'''#!/usr/bin/env python3
import sys, os
out, inp = sys.stdout.buffer, sys.stdin.buffer
out.write(b"\x01\x01READY\x01\x01\n" + b"STAT 0 0 0 0 0\n"); out.flush()
def reply(rid, text, chunks=1):
data = text.encode("utf-8")
n = max(1, len(data) // chunks)
for i in range(0, len(data), n):
part = data[i:i+n]
out.write(("DATA %s %d\n" % (rid, len(part))).encode() + part + b"\n"); out.flush()
out.write(("DONE %s STAT %d 1.0 50.0 10.0 42 0\n" % (rid, len(text.split()))).encode())
out.flush()
while True:
line = inp.readline()
if not line: break
f = line.decode().strip().split()
if not f or f[0] != "SUBMIT": continue
rid, plen = f[1], int(f[3])
prompt = inp.read(plen).decode("utf-8", "replace"); inp.read(1)
with open(os.environ["MOCK_LOG"], "a") as log:
log.write(prompt + "\n\x00\n")
if "<tool_response>" in prompt:
reply(rid, "25 degrees and sunny in Rome.")
elif "weather in Rome" in prompt:
reply(rid, "<tool_call>get_weather<arg_key>city</arg_key>"
"<arg_value>Rome</arg_value></tool_call>")
elif "weather in Milan" in prompt:
# split across many tiny DATA chunks: streamed marker suppression must
# hold even when a marker straddles a chunk boundary
reply(rid, "Checking. <tool_call>get_weather<arg_key>city</arg_key>"
"<arg_value>Milan</arg_value></tool_call>", chunks=20)
else:
reply(rid, "Hello from the mock engine.")
'''
TOOLS = [{"type": "function", "function": {
"name": "get_weather",
"description": "Current weather for a city",
"parameters": {"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]}}}]
@unittest.skipUnless(os.name == "posix",
"the mock engine is a shebang script the gateway execs directly; "
"Windows CreateProcess cannot run it. The gateway logic under test "
"is platform-independent and covered by the POSIX CI jobs.")
class ToolCallingE2E(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
mock = Path(cls.tmp.name) / "mock_engine.py"
mock.write_text(MOCK_ENGINE)
mock.chmod(0o755)
cls.mock_log = Path(cls.tmp.name) / "prompts.log"
cls.mock_log.touch()
with socket.socket() as probe: # free port, then hand it to the server
probe.bind(("127.0.0.1", 0))
cls.port = probe.getsockname()[1]
env = dict(os.environ, MOCK_LOG=str(cls.mock_log))
cls.server = subprocess.Popen(
[sys.executable, str(SERVER), "--model", cls.tmp.name,
"--engine", str(mock), "--port", str(cls.port)],
env=env, stderr=subprocess.DEVNULL)
cls.base = f"http://127.0.0.1:{cls.port}/v1"
for _ in range(100):
try:
urllib.request.urlopen(cls.base + "/models", timeout=2)
return
except OSError:
if cls.server.poll() is not None:
raise RuntimeError("gateway exited during startup")
import time
time.sleep(0.1)
raise RuntimeError("gateway did not come up")
@classmethod
def tearDownClass(cls):
cls.server.terminate()
cls.server.wait(timeout=5)
cls.tmp.cleanup()
def post(self, body, stream=False):
req = urllib.request.Request(
self.base + "/chat/completions", json.dumps(body).encode(),
{"Content-Type": "application/json"})
resp = urllib.request.urlopen(req, timeout=30)
if not stream:
return json.loads(resp.read())
events = []
for raw in resp:
line = raw.decode().strip()
if line.startswith("data: ") and line != "data: [DONE]":
events.append(json.loads(line[6:]))
return events
def test_tool_call_non_stream(self):
r = self.post({"model": MODEL_ID, "tools": TOOLS,
"messages": [{"role": "user",
"content": "What is the weather in Rome?"}]})
choice = r["choices"][0]
self.assertEqual(choice["finish_reason"], "tool_calls")
calls = choice["message"]["tool_calls"]
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["function"]["name"], "get_weather")
self.assertEqual(json.loads(calls[0]["function"]["arguments"]), {"city": "Rome"})
self.assertNotIn("<tool_call>", choice["message"].get("content") or "")
def test_tool_call_streamed_markers_suppressed(self):
events = self.post({"model": MODEL_ID, "tools": TOOLS, "stream": True,
"messages": [{"role": "user",
"content": "What is the weather in Milan?"}]},
stream=True)
deltas = [e["choices"][0]["delta"] for e in events if e["choices"]]
text = "".join(d.get("content") or "" for d in deltas)
self.assertNotIn("<tool_call>", text)
self.assertNotIn("<arg_key>", text)
calls = [d["tool_calls"] for d in deltas if d.get("tool_calls")]
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0][0]["function"]["name"], "get_weather")
self.assertEqual(json.loads(calls[0][0]["function"]["arguments"]),
{"city": "Milan"})
finish = [e["choices"][0]["finish_reason"] for e in events
if e["choices"] and e["choices"][0].get("finish_reason")]
self.assertEqual(finish, ["tool_calls"])
def test_tool_result_round_trip(self):
r = self.post({"model": MODEL_ID, "tools": TOOLS, "messages": [
{"role": "user", "content": "What is the weather in Rome?"},
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_x", "type": "function",
"function": {"name": "get_weather",
"arguments": "{\"city\": \"Rome\"}"}}]},
{"role": "tool", "tool_call_id": "call_x",
"content": "25 degrees, sunny"}]})
choice = r["choices"][0]
self.assertEqual(choice["finish_reason"], "stop")
self.assertFalse(choice["message"].get("tool_calls"))
self.assertIn("25 degrees", choice["message"]["content"])
rendered = self.mock_log.read_text().split("\x00")[-2]
self.assertIn("<|observation|><tool_response>25 degrees, sunny</tool_response>",
rendered)
self.assertIn("# Tools", rendered)
self.assertIn('"get_weather"', rendered)
def test_no_tools_plain_text(self):
r = self.post({"model": MODEL_ID,
"messages": [{"role": "user", "content": "Hi!"}]})
choice = r["choices"][0]
self.assertEqual(choice["finish_reason"], "stop")
self.assertIn("mock engine", choice["message"]["content"])
if __name__ == "__main__":
unittest.main()