Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ waste$(EXE): cli/main.o libwaste.a
# the two failures tests/run.sh was written to catch, so a binary that
# `test` builds and `clean` forgets defeats the check meant to notice it.
TESTNAMES := test_kda test_container test_forward test_tokenizer test_k3parts \
test_state test_vision test_image test_memory test_cpus sweep
test_state test_vision test_image test_memory test_cpus test_lock sweep
TESTBINS := $(addsuffix $(EXE),$(TESTNAMES))

test: $(TESTBINS)
Expand Down Expand Up @@ -274,6 +274,9 @@ test_memory$(EXE): tests/test_memory.o src/memory.o
test_cpus$(EXE): tests/test_cpus.o
$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)

test_lock$(EXE): tests/test_lock.o libwaste.a
$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)

%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<

Expand Down
6 changes: 5 additions & 1 deletion cli/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ typedef struct {
uint64_t budget;
uint32_t ctx, max_tokens;
float temperature, top_p;
int top_k, threads, quiet, learn, json, no_echo;
int top_k, threads, quiet, learn, json, no_echo, allow_concurrent;
int media_inlined; /* the media block is already in the
prompt string, inside the user turn */
uint64_t seed;
Expand Down Expand Up @@ -190,6 +190,7 @@ static int parse_opts(int argc, char **argv, int from, opts *o)
else if (!strcmp(a, "--json")) o->json = 1;
else if (!strcmp(a, "--raw")) o->raw = 1;
else if (!strcmp(a, "--verify")) o->verify = 1;
else if (!strcmp(a, "--allow-concurrent-open")) o->allow_concurrent = 1;
else if (!strcmp(a, "-")) { /* explicit stdin */
if (o->n_pos >= MAX_POS) { fprintf(stderr, "too many arguments\n"); return -1; }
o->pos[o->n_pos++] = "-";
Expand Down Expand Up @@ -273,6 +274,7 @@ static waste_status open_model(const char *path, const opts *o, waste_ctx **ctx)
* Kimi-Linear. Worth it for a container that was copied or downloaded
* and has not been read since. */
cfg.verify_records = o->verify;
cfg.allow_concurrent_open = o->allow_concurrent;
const waste_status st = waste_open(path, &cfg, ctx);
/* Two statuses that say nothing useful on their own when --cpus is
* what produced them, and it usually is: nothing else here can be
Expand Down Expand Up @@ -1220,6 +1222,7 @@ int main(int argc, char **argv)
"options: --budget 8G --ctx N -n N --temp F --top-p F\n"
" --top-k N --seed N --threads N --cpus LIST\n"
" --stop STR --file F --json -q --learn --verify\n"
" --allow-concurrent-open\n"
" --stop ends generation when the text appears\n"
" --json machine-readable output for eval, tokenize, plan,\n"
" info and bench\n"
Expand All @@ -1231,6 +1234,7 @@ int main(int argc, char **argv)
" the cores differ: on a two-die Ryzen, six threads on one die\n"
" measured 16-25%% faster than six split across both. Linux and\n"
" Windows; the default is to leave placement to the OS\n"
" --allow-concurrent-open opts out of the POSIX container lock\n"
" --verify checks each expert record's checksum as it is read,\n"
" for a container you have not read since copying it. Costs ~5%%\n"
" on Kimi-Linear, ~1%% on K3; off otherwise\n",
Expand Down
21 changes: 21 additions & 0 deletions docs/ENGINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@ state save/load, model introspection and aggregate stats.
Deliberately *not* in the API: logging to stdout, signal handlers, config
files, argument parsing. Those belong to the host — the CLI included.

### Container ownership

On POSIX hosts, the first `waste_open` takes a non-blocking advisory lock on
the container directory before memory planning or model-sized allocation. A
different process opening the same container receives `WASTE_E_BUSY`; it does
not wait while both processes allocate the model and discover the collision
through memory pressure. Paths are matched by device and inode, so aliases of
one directory do not evade the check.

Contexts in one process remain independent as documented: they share a
reference-counted ownership entry, and the last `waste_close` releases it.
Failures during planning, budget validation, or partial model loading release
it as well. Lock descriptors are close-on-exec, and a forked child is treated
as a different process rather than inheriting the parent's registry.

An embedding host that deliberately accepts competing model loads can set
`waste_cfg.allow_concurrent_open`. The CLI and server expose the same opt-out
as `--allow-concurrent-open`. This is an advisory lock between cooperating
WASTE processes and depends on the filesystem's `flock` support; Windows keeps
its existing lifecycle behavior and ignores the setting.

## 2. CLI as a first-class client

`cli/` links the library and adds only host concerns: argv parsing, a
Expand Down
2 changes: 2 additions & 0 deletions docs/SERVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ python3 -m serve MODEL [options]
--vision load the vision tower
--verify check every expert record's crc32 as it is read
--usage PATH learned hotlist (default <model>/usage.waste)
--allow-concurrent-open
opt out of the POSIX per-container process lock
--max-tokens N default cap when a request does not set one (4096)
--no-thinking answer without the think channel unless asked
--allow-local-images
Expand Down
5 changes: 4 additions & 1 deletion serve/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ def main(argv=None) -> int:
"since. Costs ~5%% on Kimi-Linear, ~1%% on K3")
g.add_argument("--usage", default=None, metavar="PATH",
help="learned hotlist (default <model>/usage.waste)")
g.add_argument("--allow-concurrent-open", action="store_true",
help="permit another process to load the same container")

s = ap.add_argument_group("serving")
s.add_argument("--max-tokens", type=bounded_int(1, (1 << 32) - 1),
Expand Down Expand Up @@ -177,7 +179,8 @@ def main(argv=None) -> int:
direct_io=not args.no_direct_io,
vision=args.vision,
verify_records=args.verify,
usage_path=args.usage)
usage_path=args.usage,
allow_concurrent_open=args.allow_concurrent_open)
except EngineError as e:
print(f"{e}", file=sys.stderr)
# Two statuses that say nothing useful on their own when --cpus is
Expand Down
8 changes: 6 additions & 2 deletions serve/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
WASTE_E_ARG = -5
WASTE_E_UNSUPPORTED = -6
WASTE_E_CANCELLED = -7
WASTE_E_BUSY = -8

# waste.h's waste_cache_policy. There is no third: a "pinned" policy was
# listed there and never implemented, so it selected LFRU like everything
Expand Down Expand Up @@ -87,7 +88,8 @@ class Cfg(C.Structure):
("use_direct_io", C.c_int),
("vision", C.c_int),
("verify_records", C.c_int),
("usage_path", C.c_char_p)]
("usage_path", C.c_char_p),
("allow_concurrent_open", C.c_int)]


class GenParams(C.Structure):
Expand Down Expand Up @@ -378,7 +380,8 @@ def __init__(self, model_path: str, *,
direct_io: bool = True,
vision: bool = False,
verify_records: bool = False,
usage_path: Optional[str] = None):
usage_path: Optional[str] = None,
allow_concurrent_open: bool = False):
ram_budget_bytes = _bounded_int(
"ram_budget_bytes", ram_budget_bytes, 0, (1 << 64) - 1)
ctx_tokens = _bounded_int("ctx_tokens", ctx_tokens, 0, (1 << 32) - 1)
Expand Down Expand Up @@ -408,6 +411,7 @@ def __init__(self, model_path: str, *,
# and a temporary would be freed before waste_open reads it.
self._usage = usage_path.encode() if usage_path else None
cfg.usage_path = self._usage
cfg.allow_concurrent_open = 1 if allow_concurrent_open else 0

st = self.lib.waste_open(self.model_path.encode(), C.byref(cfg),
C.byref(self._ctx))
Expand Down
167 changes: 165 additions & 2 deletions src/waste.c
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

#include "waste.h"

#include <errno.h>
#include <limits.h>
#include <math.h>
#include <stdio.h>
Expand All @@ -21,6 +22,12 @@
#include <sys/sysctl.h>
#endif
#include <time.h>
#if !defined(_WIN32)
#include <fcntl.h>
#include <pthread.h>
#include <sys/file.h>
#include <sys/stat.h>
#endif

#include "json.h"
#include "memory.h"
Expand All @@ -29,6 +36,8 @@
#include "tokenizer.h"
#include "waste_backend.h"

typedef struct waste_model_lock waste_model_lock;

struct waste_ctx {
waste_model m;
waste_tok *tok;
Expand All @@ -41,6 +50,7 @@ struct waste_ctx {
char quant[64]; /* composed at open, reported by get_info */
char detail[128]; /* which record failed, for waste_error_detail */
waste_stats stats;
waste_model_lock *model_lock;

/* Queued image embeddings, concatenated: img_each[] is how many rows
* each queued image contributed, which is what expand needs to know
Expand All @@ -51,6 +61,143 @@ struct waste_ctx {
int img_n;
};

#if !defined(_WIN32)
struct waste_model_lock {
dev_t dev;
ino_t ino;
int fd;
unsigned refs;
pid_t owner;
waste_model_lock *next;
};

static pthread_mutex_t model_lock_mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_once_t model_lock_once = PTHREAD_ONCE_INIT;
static waste_model_lock *model_locks;

/* A forked child is a competing process, not another context in its parent.
* Close its inherited copies before it can consult the copied registry. The
* entries themselves remain allocated in the child: free is not async-signal
* safe, and inherited contexts ignore entries owned by a different pid. */
static void model_lock_atfork_prepare(void)
{
pthread_mutex_lock(&model_lock_mu);
}

static void model_lock_atfork_parent(void)
{
pthread_mutex_unlock(&model_lock_mu);
}

static void model_lock_atfork_child(void)
{
for (waste_model_lock *p = model_locks; p; p = p->next) close(p->fd);
model_locks = NULL;
pthread_mutex_unlock(&model_lock_mu);
}

static void model_lock_init(void)
{
(void)pthread_atfork(model_lock_atfork_prepare, model_lock_atfork_parent,
model_lock_atfork_child);
}

/* One OS lock per container and process. Device/inode identity means aliases
* of the same directory share an entry. The registry supplies the reference
* semantics flock does not: closing one context must not release ownership
* while another context in this process still uses the container. */
static waste_model_lock *model_lock_acquire(const char *path, int allow,
waste_status *status)
{
*status = WASTE_OK;
if (allow) return NULL;

pthread_once(&model_lock_once, model_lock_init);
int flags = O_RDONLY;
#ifdef O_CLOEXEC
flags |= O_CLOEXEC;
#endif
const int fd = open(path, flags);
if (fd < 0) { *status = WASTE_E_IO; return NULL; }
#ifndef O_CLOEXEC
const int fdflags = fcntl(fd, F_GETFD);
if (fdflags < 0 || fcntl(fd, F_SETFD, fdflags | FD_CLOEXEC)) {
close(fd);
*status = WASTE_E_IO;
return NULL;
}
#endif
struct stat st;
if (fstat(fd, &st)) { close(fd); *status = WASTE_E_IO; return NULL; }

pthread_mutex_lock(&model_lock_mu);
for (waste_model_lock *p = model_locks; p; p = p->next) {
if (p->dev == st.st_dev && p->ino == st.st_ino) {
p->refs++;
pthread_mutex_unlock(&model_lock_mu);
close(fd);
return p;
}
}

int rc;
do rc = flock(fd, LOCK_EX | LOCK_NB); while (rc && errno == EINTR);
if (rc) {
const int busy = errno == EWOULDBLOCK || errno == EAGAIN;
pthread_mutex_unlock(&model_lock_mu);
close(fd);
*status = busy ? WASTE_E_BUSY : WASTE_E_IO;
return NULL;
}

waste_model_lock *p = (waste_model_lock *)calloc(1, sizeof *p);
if (!p) {
(void)flock(fd, LOCK_UN);
pthread_mutex_unlock(&model_lock_mu);
close(fd);
*status = WASTE_E_OOM;
return NULL;
}
p->dev = st.st_dev;
p->ino = st.st_ino;
p->fd = fd;
p->refs = 1;
p->owner = getpid();
p->next = model_locks;
model_locks = p;
pthread_mutex_unlock(&model_lock_mu);
return p;
}

static void model_lock_release(waste_model_lock *entry)
{
if (!entry || entry->owner != getpid()) return;
pthread_mutex_lock(&model_lock_mu);
if (--entry->refs == 0) {
waste_model_lock **pp = &model_locks;
while (*pp && *pp != entry) pp = &(*pp)->next;
if (*pp) *pp = entry->next;
(void)flock(entry->fd, LOCK_UN);
close(entry->fd);
free(entry);
}
pthread_mutex_unlock(&model_lock_mu);
}
#else
/* Keep non-POSIX lifecycle behavior unchanged. The public opt-out is ignored
* on hosts where this advisory ownership lock is not implemented. */
struct waste_model_lock { int unused; };
static waste_model_lock *model_lock_acquire(const char *path, int allow,
waste_status *status)
{
(void)path;
(void)allow;
*status = WASTE_OK;
return NULL;
}
static void model_lock_release(waste_model_lock *entry) { (void)entry; }
#endif



/* What the container is actually stored as, composed once at open. It used
Expand Down Expand Up @@ -87,6 +234,7 @@ const char *waste_strerror(waste_status s)
case WASTE_E_ARG: return "invalid argument";
case WASTE_E_UNSUPPORTED: return "unsupported";
case WASTE_E_CANCELLED: return "cancelled by callback";
case WASTE_E_BUSY: return "container is already open in another process";
}
return "unknown error";
}
Expand Down Expand Up @@ -408,8 +556,17 @@ waste_status waste_open(const char *model_path, const waste_cfg *cfg_in,
c->cfg = cfg;
snprintf(c->path, sizeof c->path, "%s", model_path);

waste_status lock_status = WASTE_OK;
c->model_lock = model_lock_acquire(model_path, cfg.allow_concurrent_open,
&lock_status);
if (lock_status != WASTE_OK) { free(c); return lock_status; }

waste_status st = waste_plan_memory(model_path, cfg.ctx_tokens, &c->plan);
if (st != WASTE_OK) { free(c); return st; }
if (st != WASTE_OK) {
model_lock_release(c->model_lock);
free(c);
return st;
}

/* Optional vision weights, decode buffers, tower activations and queued
* embeddings are real memory, so all of them enter the floor. */
Expand Down Expand Up @@ -457,7 +614,11 @@ waste_status waste_open(const char *model_path, const waste_cfg *cfg_in,
if (!cap || b <= cap) { budget = b; break; }
}
}
if (budget < c->plan.floor_bytes) { free(c); return WASTE_E_RAM_BUDGET; }
if (budget < c->plan.floor_bytes) {
model_lock_release(c->model_lock);
free(c);
return WASTE_E_RAM_BUDGET;
}

/* A budget close to physical RAM backfires: the OS starts paging out
* the engine's own expert cache, and a "hit" then costs a page fault
Expand Down Expand Up @@ -495,6 +656,7 @@ waste_status waste_open(const char *model_path, const waste_cfg *cfg_in,
* fail, and freeing only the context left all of it behind —
* on K3 that is tens of gigabytes lost to one bad manifest. */
waste_model_free(&c->m);
model_lock_release(c->model_lock);
free(c);
return rc == -2 ? WASTE_E_FORMAT : WASTE_E_IO;
}
Expand Down Expand Up @@ -524,6 +686,7 @@ void waste_close(waste_ctx *c)
waste_model_free(&c->m);
waste_tok_free(c->tok);
free(c->img);
model_lock_release(c->model_lock);
free(c);
}

Expand Down
Loading