Spaces:
Running on Zero
Running on Zero
File size: 10,828 Bytes
98acb70 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | -- backend/local.lua - Drives the pure-Lua model from repo 1.
--
-- The backend the README leads with: no API key, no network, the whole stack
-- readable from the agent loop down to the matmul.
--
-- It binds to lua-llama's exported forward pass rather than its generate()
-- loop, because the entire point is to intervene BETWEEN logits and
-- sampling -- that is where grammar.lua masks. The seam:
--
-- gen.forward(model, token, pos, state, key_cache, value_cache, att_buf)
-- -> logits[1..vocab_size] (1-indexed array; token ids are 0-indexed)
--
-- No change to repo 1 is needed; forward() is already exported.
--
--
-- HOW A STEP IS GENERATED
--
-- The harness writes the scaffolding and the model fills the slots:
--
-- prompt ... "\nTHOUGHT: " <- harness
-- "the sun was very big" <- model, UNCONSTRAINED, one line
-- "\nCALL: " <- harness
-- {"tool":"answer","args":... <- model, CONSTRAINED by grammar.lua
--
-- Stated plainly because it matters for reading the result: the model is not
-- deciding whether to call a tool. It is deciding WHICH tool and with what
-- arguments. A 15M checkpoint left to choose freely emits neither, which is
-- the 0% arm of the ablation and is measured in eval/ablate.lua rather than
-- assumed.
--
--
-- HONEST LIMITATION
--
-- Pure-Lua inference runs ~3-4 tok/s at 15M, so ~0.05 tok/s at 1B. This
-- backend demonstrates the MECHANISM. backend/http.lua demonstrates the
-- CAPABILITY. The split is the point, not an apology.
local grammar = require('grammar')
local protocol = require('protocol')
local backend = {}
local Local = {}
Local.__index = Local
-- opts:
-- llama_path path to the lua-llama checkout, default "../lua_llama"
-- checkpoint default <llama_path>/stories15M.bin
-- tokenizer default <llama_path>/tokenizer.bin
-- tools registry, required when constrain is true
-- constrain default true; false reproduces the 0% arm of the ablation
-- temperature default 0.0 (greedy, so runs are reproducible)
-- max_thought default 24 tokens
-- max_call default 96 tokens
-- quiet suppress repo 1's load-time prints
function backend.new(opts)
opts = opts or {}
local root = opts.llama_path or "../lua_llama"
-- Repo 1's modules require() each other by bare name, so its directory
-- has to be on the path as well as ours.
package.path = root .. "/?.lua;" .. package.path
local Model = require('model')
local Tokenizer = require('tokenizer')
local gen = require('generate')
-- model.lua and tokenizer.lua print as they load. Useful in repo 1's CLI,
-- noise here.
local real_print = print
if opts.quiet ~= false then _G.print = function() end end
local ok, model = pcall(Model.new, opts.checkpoint or (root .. "/stories15M.bin"))
local tok
if ok then
ok, tok = pcall(Tokenizer.new,
opts.tokenizer or (root .. "/tokenizer.bin"), model.config.vocab_size)
end
_G.print = real_print
if not ok then
error("backend/local: " .. tostring(model or tok) ..
"\n looked in " .. root .. " -- set --llama-path if lua-llama is elsewhere", 0)
end
local self = setmetatable({
model = model,
tok = tok,
gen = gen,
cfg = model.config,
constrain = opts.constrain ~= false,
temperature = opts.temperature or 0.0,
max_thought = opts.max_thought or 24,
max_call = opts.max_call or 96,
stats = { calls = 0, parsed = 0, tokens = 0 },
}, Local)
if self.constrain then
if not opts.tools then error("backend/local: constrained mode needs the tool registry") end
self.grammar = grammar.compile(opts.tools, tok, self.cfg.vocab_size)
end
self:reset()
return self
end
-- Swap the tool set without reloading the model.
--
-- The grammar bakes the tool names and argument keys into its paths, so a
-- different tool set needs a different grammar -- but loading a checkpoint
-- takes ~17s and the eval runs one task per tool set. Recompiling is
-- milliseconds; reloading is not.
function Local:set_tools(reg)
if self.constrain then
self.grammar = grammar.compile(reg, self.tok, self.cfg.vocab_size)
end
end
-- KV cache lifecycle.
--
-- The agent re-prompts with a growing transcript every step, so the naive
-- implementation refills the cache from scratch each time -- O(n^2) over a
-- run, and the dominant cost at 3 tok/s. Instead the cache persists and only
-- the tokens the prompt grew by are forwarded.
--
-- The invariant that catches bugs here is the one repo 1 used for the
-- speculative draft path: a warm run and a cold run must produce identical
-- output at temperature 0. eval/ablate.lua checks it.
function Local:reset()
local c = self.cfg
-- Allocate once. Re-zeroing on every reset is pure waste: the cache is
-- position-indexed, and every slot below `pos` is written before it is
-- ever read again -- the same invariant `feed` relies on when it rewinds
-- after a prefix divergence. For the 42M checkpoint this is 8.4M Lua
-- numbers per reset, and the eval calls reset once per task.
if not self.key_cache then
self.state = self.model:create_run_state()
self.key_cache, self.value_cache = {}, {}
for i = 1, c.n_layers * c.seq_len * c.kv_dim do
self.key_cache[i], self.value_cache[i] = 0.0, 0.0
end
self.att_buf = {}
for i = 1, c.seq_len do self.att_buf[i] = 0.0 end
end
self.pos = 0
self.cached = {} -- token ids currently represented in the cache
end
-- Forward `tokens` starting at the current position, reusing the cache for
-- any shared prefix. Returns the logits after the last token.
function Local:feed(tokens)
local shared = 0
while shared < #self.cached and shared < #tokens
and self.cached[shared + 1] == tokens[shared + 1] do
shared = shared + 1
end
-- Divergence means the cache past `shared` is stale. The KV cache is
-- position-indexed, so rewinding is just moving pos back: entries beyond
-- it are overwritten before they are ever read again.
if shared < #self.cached then
self.pos = shared
for i = #self.cached, shared + 1, -1 do self.cached[i] = nil end
end
local logits
for i = shared + 1, #tokens do
if self.pos >= self.cfg.seq_len then
error(string.format(
"prompt exceeds the model's %d-token context", self.cfg.seq_len), 0)
end
logits = self.gen.forward(self.model, tokens[i], self.pos, self.state,
self.key_cache, self.value_cache, self.att_buf)
self.cached[i] = tokens[i]
self.pos = self.pos + 1
end
return logits
end
local function sample(logits, n, temperature)
if temperature < 0.01 then
local best, best_v = 1, -math.huge
for i = 1, n do
if logits[i] > best_v then best, best_v = i, logits[i] end
end
return best - 1
end
-- Softmax over the masked distribution. -inf entries drop out naturally.
local max_v = -math.huge
for i = 1, n do if logits[i] > max_v then max_v = logits[i] end end
local sum = 0
local probs = {}
for i = 1, n do
probs[i] = math.exp((logits[i] - max_v) / temperature)
sum = sum + probs[i]
end
local r, cdf = math.random() * sum, 0
for i = 1, n do
cdf = cdf + probs[i]
if r < cdf then return i - 1 end
end
return n - 1
end
-- Generate freely until a stop string appears or the ceiling is hit.
function Local:generate_free(tokens, max_new, stops)
local out, n = {}, self.cfg.vocab_size
local logits = self:feed(tokens)
for _ = 1, max_new do
local id = sample(logits, n, self.temperature)
if id == 2 then break end -- EOS
local piece = self.tok:decode(id)
out[#out + 1] = piece
local text = table.concat(out)
for _, s in ipairs(stops or {}) do
if text:find(s, 1, true) then
return text:sub(1, text:find(s, 1, true) - 1), #out
end
end
tokens[#tokens + 1] = id
logits = self:feed(tokens)
end
return table.concat(out), #out
end
-- Generate under the grammar. Every sampled token is legal by construction,
-- so the result parses by construction.
function Local:generate_constrained(tokens)
local m = self.grammar:start()
local n = self.cfg.vocab_size
local min_legal = math.huge
local produced = 0
local logits = self:feed(tokens)
for _ = 1, self.max_call do
if m:is_done() then break end
local legal = m:apply(logits, n)
if legal < min_legal then min_legal = legal end
local id = sample(logits, n, self.temperature)
m:advance(id)
produced = produced + 1
tokens[#tokens + 1] = id
if m:is_done() then break end
logits = self:feed(tokens)
end
-- Hit the ceiling mid-call: finishing it costs a few characters and
-- turns a wasted step into a usable one.
local text = m:text()
local forced_finish = not m:is_done()
if forced_finish then text = text .. m:finish() end
return text, produced, min_legal, forced_finish
end
function Local:complete(prompt, opts)
opts = opts or {}
-- The thought is free-form and one line long. It is not load-bearing for
-- a 15M model -- it will be a fragment of a children's story -- but it is
-- in the transcript, and seeing it next to the constrained call is what
-- makes the syntax/semantics split legible.
local thought_prompt = prompt .. "\n" .. protocol.THOUGHT_PREFIX
local thought = self:generate_free(
self.tok:encode(thought_prompt), self.max_thought, { "\n" })
thought = thought:gsub("%s+$", ""):gsub("\n.*", "")
local call_prompt = thought_prompt .. thought .. "\n" .. protocol.CALL_PREFIX
local tokens = self.tok:encode(call_prompt)
local call, produced, min_legal, forced_finish
if self.constrain then
call, produced, min_legal, forced_finish = self:generate_constrained(tokens)
else
call, produced = self:generate_free(tokens, self.max_call, { "\n" })
call = call:gsub("^%s+", "")
end
self.stats.calls = self.stats.calls + 1
self.stats.tokens = self.stats.tokens + produced
if grammar.parses(call) then self.stats.parsed = self.stats.parsed + 1 end
local text = protocol.THOUGHT_PREFIX .. thought .. "\n" .. protocol.CALL_PREFIX .. call
return text, {
tokens = produced,
min_legal = min_legal ~= math.huge and min_legal or nil,
constrained = self.constrain,
forced_finish = forced_finish,
}
end
return backend
|