Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- `init`, `sync-models`, and `pull` now pair a sibling drafter (gemma4
assistant, DSpark, DFlash) into the model it serves as that model's
`draft_gguf`, which turns speculative decoding on. `sync-models` also adds
the key to a model the config already carries. Before, you added it by hand.

## [0.3.1] - 2026-08-12

### Added
Expand Down
5 changes: 5 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,11 @@ the first existing default config unless `--config` is given. Scanning
recurses by default, because `pull` nests downloads under
`<dir>/<org>__<repo>/`.

A sibling drafter GGUF pairs into the model it serves as that model's
`draft_gguf`, reported as `update:`. This works on entries the config already
carries; an entry with its own `draft_gguf` keeps it. See
[`discover`](server-config.md#discover) for the rules.

With `--from-hf-cache` (or a config already carrying `server.hf_cache: true`)
it also reconciles cache-resident GGUFs, adding new `hf:` entries and dropping
ones that are no longer cached, and flips `server.hf_cache` on for them.
Expand Down
7 changes: 5 additions & 2 deletions docs/server-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -721,8 +721,11 @@ its target id (and profile, if any) must exist. Validated at load.

Opt-in header-only directory scan (architecture + `nextn_predict_layers`
only, zero tensor I/O). Native-head MTP models auto-enable speculative.
Sibling `mmproj*.gguf` pairs into the model it best matches. Assistant
drafters are reported but only wired when a model names one via `draft_gguf`.
Sibling `mmproj*.gguf` pairs into the model it best matches. A sibling
assistant drafter (gemma4 assistant, DSpark, DFlash) pairs in as that model's
`draft_gguf`, which turns speculative on; the arch, the hidden size, and the
filename must all agree. A streamed model gets no drafter, and
`speculative: false` stops the pairing.

```yaml
discover:
Expand Down
29 changes: 29 additions & 0 deletions gmlx/arch_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,41 @@ class UnsupportedArchError(Exception):
})


# model_types whose MTP drafter ships as a companion GGUF, and the arches that
# companion can carry, best container first. The loader reads a row to find the
# sidecar of a target; discovery reads it backwards to pair a drafter it found.
# Extend it together with the loader dispatch.
MTP_DRAFTER_ARCHES = {
# `dflash` is llama.cpp's DSpark container, `deepseek4_mtp_support` the
# legacy nextn one.
"deepseek_v4": ("deepseek4-dspark", "dflash", "deepseek4_mtp_support"),
"muse_glimmer": ("dflash",),
"gemma4_text": ("gemma4_assistant", "gemma4-assistant", "gemma4_mtp"),
}


def mtp_wired(gguf_arch: str | None) -> bool:
"""True iff a native-head MTP GGUF of this arch has a wired target class."""
model_type = config_synth.GGUF_ARCH_TO_MODEL_TYPE.get(gguf_arch or "")
return model_type in MTP_WIRED_MODEL_TYPES


def drafter_arches(model_type: str) -> tuple:
"""The companion-GGUF arches that can draft for ``model_type``, best
container first. ``()`` if the type has no companion drafter."""
return MTP_DRAFTER_ARCHES.get(model_type, ())


def drafter_serves(drafter_arch: str | None, gguf_arch: str | None) -> bool | None:
"""Whether a ``drafter_arch`` drafter can serve a ``gguf_arch`` target.
``None`` when no row lists ``drafter_arch``: the table has no opinion."""
rows = [t for t, arches in MTP_DRAFTER_ARCHES.items()
if drafter_arch in arches]
if not rows:
return None
return config_synth.GGUF_ARCH_TO_MODEL_TYPE.get(gguf_arch or "") in rows


def has_synth(gguf_arch: str) -> bool:
"""True iff ``config_synth`` produces a complete config for this arch."""
return gguf_arch in config_synth.supported_arches()
Expand Down
4 changes: 3 additions & 1 deletion gmlx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -699,12 +699,14 @@ def _deepseek4_mtp_companion(gguf_path: str) -> str | None:
it (auto enable; the loader re-resolves the same path when
draft_gguf_path is not given). Header-cache peeks only."""
try:
from . import arch_table
from .discovery import find_mtp_companion, header_meta

meta = header_meta(gguf_path)
if not meta or meta.get("arch") != "deepseek4":
return None
return find_mtp_companion(gguf_path)
return find_mtp_companion(gguf_path,
arch_table.drafter_arches("deepseek_v4"))
except Exception:
return None

Expand Down
153 changes: 134 additions & 19 deletions gmlx/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
- ``general.architecture == "clip"`` (or a ``mmproj*`` filename) -> **mmproj**:
a VLM companion, not a standalone model.
- arch is an assistant shape (``gemma4_assistant`` / ``gemma4-assistant`` /
``gemma4_mtp``, or carries a target-backbone field) -> **drafter**: only paired
when a model explicitly names it via ``draft_gguf``; never standalone.
``gemma4_mtp``, or carries a target-backbone field) -> **drafter**: a
speculative-decoding companion, never a standalone model. A drafter pairs
into a sibling model as its ``draft_gguf`` (see :func:`_drafter_targets`).
- ``<arch>.nextn_predict_layers > 0`` -> a **native-head MTP** model (the drafter
lives inside the target GGUF; ``speculative: auto`` enables it).
- otherwise -> a plain text **model**.
Expand Down Expand Up @@ -94,6 +95,8 @@ class ClassifiedGguf:
loadable: bool # arch builds a model with no hf override (model kind)
moe: bool = False # routed experts present (model kind only)
name: str | None = None # general.name, for family refinement (model kind)
# hidden size; on a drafter, its target's (see `_drafter_hidden_size`)
n_embd: int | None = None


# Classification
Expand All @@ -115,6 +118,23 @@ def _looks_like_drafter(meta, arch: str | None) -> bool:
return False


def _hidden_size(meta, arch: str | None) -> int | None:
"""The hidden size that ``arch`` declares, or ``None`` if it declares none."""
return read_int(meta, f"{arch}.embedding_length") if arch else None


def _drafter_hidden_size(meta, arch: str | None) -> int | None:
"""The hidden size of the target model for this drafter: its backbone
field, else its own size (a gemma4 assistant declares both, and they
differ; a DSpark sidecar declares neither)."""
if arch:
for suf in _BACKBONE_FIELDS:
v = read_int(meta, f"{arch}.{suf}")
if v:
return v
return _hidden_size(meta, arch)


def _embedding_kind(meta, basename: str, arch: str | None) -> str | None:
"""``"reranker"`` / ``"embedding"`` if this GGUF is an encoder-style retrieval
model rather than a generative chat model, else ``None``. Signals (any one):
Expand Down Expand Up @@ -148,7 +168,8 @@ def _classify_meta(meta, *, basename: str, path: str) -> ClassifiedGguf:
or read_string(meta, "adapter.type") is not None):
return ClassifiedGguf(ap, "adapter", arch, False, quant, False)
if _looks_like_drafter(meta, arch):
return ClassifiedGguf(ap, "drafter", arch, False, quant, False)
return ClassifiedGguf(ap, "drafter", arch, False, quant, False,
n_embd=_drafter_hidden_size(meta, arch))
emb = _embedding_kind(meta, basename, arch)
if emb:
return ClassifiedGguf(ap, emb, arch, False, quant, False)
Expand All @@ -159,7 +180,8 @@ def _classify_meta(meta, *, basename: str, path: str) -> ClassifiedGguf:
loadable = bool(arch) and arch in supported_arches()
return ClassifiedGguf(ap, "model", arch, mtp, quant, loadable,
moe=bool(experts and experts > 0),
name=read_string(meta, "general.name"))
name=read_string(meta, "general.name"),
n_embd=_hidden_size(meta, arch))


def classify_gguf(path: str) -> ClassifiedGguf | None:
Expand Down Expand Up @@ -314,14 +336,19 @@ def header_sampling(path) -> dict:

def find_mtp_companion(
path: str,
drafter_arch: str | tuple = ("deepseek4-dspark", "dflash",
"deepseek4_mtp_support"),
drafter_arch: str | tuple | None = None,
) -> str | None:
"""Path of an MTP drafter GGUF (arch in ``drafter_arch``) sitting in the
same directory as ``path``, or ``None``. Header-only peeks through
same directory as ``path``, or ``None``. ``drafter_arch`` defaults to every
arch in :data:`arch_table.MTP_DRAFTER_ARCHES`; a caller that knows the
target model type passes that row instead (see
:func:`arch_table.drafter_arches`). Header-only peeks through
:func:`header_meta`'s stat-validated cache, so a directory scan costs one
stat per already-seen sibling. Earlier arches in the tuple win over later
ones (dspark over legacy nextn); within an arch, lexically first wins."""
if drafter_arch is None:
drafter_arch = tuple(dict.fromkeys(
a for row in _arch_table.MTP_DRAFTER_ARCHES.values() for a in row))
arches = (drafter_arch,) if isinstance(drafter_arch, str) else tuple(drafter_arch)
ap = os.path.abspath(os.path.expanduser(path))
parent = os.path.dirname(ap)
Expand Down Expand Up @@ -463,24 +490,34 @@ def scan_dirs(
*,
known_ids=frozenset(),
known_paths=frozenset(),
known_models=None,
progress=False,
stats=None,
) -> list[ModelCfg]:
"""Discover servable models from ``specs`` (each a :class:`config.DiscoverSpec`).

A spec with ``dir=None`` scans ``model_dirs``. Native-head MTP models get
``speculative`` per the spec (``auto``/``True`` -> on for MTP; ``False`` -> off);
assistant drafters are reported but never auto-wired (they need an explicit
``draft_gguf``). Sibling mmproj files pair into the model they best match when
``speculative`` per the spec (``auto``/``True`` -> on for MTP; ``False`` -> off).
A sibling assistant drafter pairs into the models it can serve as their
``draft_gguf`` (see :func:`_drafter_targets`), which also turns
``speculative`` on. Sibling mmproj files pair into the model they best match when
``pair_mmproj``. Every id carries its quant codec (see :func:`_assign_ids`).
``known_ids`` /
``known_paths`` (from configured ``models:``) are skipped/deduped against, as
are paths an earlier spec/root in this same call already emitted.
``known_models`` maps a configured model's resolved path to its
:class:`config.ModelCfg`. A drafter pairs into one of those too; the model
stays out of the return list, and the caller writes the key from
``stats["draft_pairs"]``.
``progress`` streams per-file scan feedback to stderr (used by ``init``).
``stats``, if given, is a dict that receives ``skipped``: the count of
.gguf files seen but unreadable as GGUF (so callers can distinguish an
empty dir from a dir of truncated downloads)."""
empty dir from a dir of truncated downloads); and ``draft_pairs``:
``{configured id: drafter path}``."""
known_paths = {os.path.abspath(os.path.expanduser(p)) for p in known_paths}
configured = {os.path.abspath(os.path.expanduser(p)): mc
for p, mc in (known_models or {}).items()}
draft_pairs: dict[str, str] = {}
used_ids = set(known_ids)
out: list[ModelCfg] = []
skipped = 0
Expand All @@ -502,9 +539,11 @@ def scan_dirs(
paths.append(p)
classified = [c for c in _classify_each(paths, progress=progress) if c]
skipped += len(paths) - len(classified)
_emit_dir(classified, spec, used_ids, out)
_emit_dir(classified, spec, used_ids, out,
configured=configured, draft_pairs=draft_pairs)
if stats is not None:
stats["skipped"] = skipped
stats["draft_pairs"] = draft_pairs
return out


Expand Down Expand Up @@ -683,8 +722,15 @@ def _fit_in_memory(mc: ModelCfg, c: ClassifiedGguf) -> None:
f"on its entry: {c.path}", file=sys.stderr)


def _emit_dir(classified, spec, used_ids, out):
"""Build ModelCfgs for one scan, pairing mmproj/draft per directory."""
def _emit_dir(classified, spec, used_ids, out, *, configured=None,
draft_pairs=None):
"""Build ModelCfgs for one scan, pairing mmproj/draft per directory.

A drafter can also pair into a ``configured`` model (resolved path ->
:class:`config.ModelCfg`); ``draft_pairs`` collects those as
``{id: drafter path}``."""
configured = configured or {}
draft_pairs = {} if draft_pairs is None else draft_pairs
by_dir: dict[str, list[ClassifiedGguf]] = {}
for c in classified:
by_dir.setdefault(os.path.dirname(c.path), []).append(c)
Expand All @@ -701,9 +747,6 @@ def _emit_dir(classified, spec, used_ids, out):
if c.kind == "model" and not c.loadable:
print(f"[discover] skip (unsupported arch {c.arch!r}): {c.path}",
file=sys.stderr)
if c.kind == "drafter":
print(f"[discover] assistant drafter (configure via "
f"draft_gguf: on its model): {c.path}", file=sys.stderr)
if c.kind in ("embedding", "reranker"):
wire = ("server.embeddings:" if c.kind == "embedding"
else "the /v1/rerank endpoint")
Expand Down Expand Up @@ -737,6 +780,27 @@ def _emit_dir(classified, spec, used_ids, out):
if tgt is not None and tgt.mmproj is None:
tgt.mmproj = mm.path

drafters = [c for c in group if c.kind == "drafter"]
# The usual case: the target is configured already, so it is not in
# `made` and only this lookup can pair it.
old = _configured_in_dir(_dir, configured) if drafters else []
old_ids = {mc.id for mc, _c in old}
for dr in drafters:
targets = ([] if spec.speculative is False
else _drafter_targets(dr, made + old))
for mc in targets:
mc.draft_gguf = dr.path
mc.speculative = True
if mc.id in old_ids:
draft_pairs[mc.id] = dr.path
if targets:
ids = ", ".join(mc.id for mc in targets)
print(f"[discover] drafter paired (speculative on for {ids}): "
f"{dr.path}", file=sys.stderr)
else:
print(f"[discover] assistant drafter (configure via "
f"draft_gguf: on its model): {dr.path}", file=sys.stderr)


def _assign_ids(models, used_ids: set) -> dict[str, str]:
"""Map each model's path to a unique friendly id, quant tag always included.
Expand Down Expand Up @@ -815,6 +879,49 @@ def _best_mmproj_target(mm: ClassifiedGguf, made):
return None


def _configured_in_dir(dirpath: str, configured) -> list:
"""``(ModelCfg, ClassifiedGguf)`` for each configured model in ``dirpath``.

The scan skips these files, so only this lookup can offer them to a
drafter beside them. One cached header read each."""
out = []
for ap, mc in configured.items():
if os.path.dirname(ap) != dirpath:
continue
c = classify_gguf(ap)
if c is not None and c.kind == "model" and c.loadable:
out.append((mc, c))
return out


def _drafter_targets(dr: ClassifiedGguf, made) -> list:
"""The models the sibling drafter ``dr`` can serve; empty leaves it unpaired.

A candidate must have a wired MTP class, a resident placement (MTP needs a
resident base), an arch that :data:`arch_table.MTP_DRAFTER_ARCHES` lists
for this drafter, and an equal hidden size where both files declare one.
The size is the only test that separates the two ``dflash`` drafters, the
DSpark sidecar and the Muse Glimmer one.

The filename then selects among the candidates, on the
:func:`_best_mmproj_target` rule; a lone candidate pairs without it. Two
quants of one target both pair on purpose."""
cands = [(mc, c) for mc, c in made
if _arch_table.mtp_wired(c.arch) and not mc.stream
and mc.draft_gguf is None
and _arch_table.drafter_serves(dr.arch, c.arch) is not False
and not (dr.n_embd and c.n_embd and dr.n_embd != c.n_embd)]
if not cands:
return []
core, _ = derive_id(os.path.basename(dr.path)) # markers (dflash/draft) stripped
hits = [mc for mc, _c in cands
if core and core != "model"
and _common_prefix_len(core, mc.id) >= max(6, int(0.7 * len(core)))]
if hits:
return hits
return [cands[0][0]] if len(cands) == 1 else []


def _common_prefix_len(a: str, b: str) -> int:
n = 0
for x, y in zip(a, b):
Expand Down Expand Up @@ -845,7 +952,11 @@ def model_to_entry(mc: ModelCfg, model_dirs) -> dict:
entry: dict = {"path": _rel(mc.path, model_dirs)}
if mc.mmproj:
entry["mmproj"] = _rel(mc.mmproj, model_dirs)
if mc.speculative:
if mc.draft_gguf:
entry["draft_gguf"] = _rel(mc.draft_gguf, model_dirs)
# A `draft_gguf` turns speculative decoding on at config load, thus a
# second `speculative: true` key adds nothing.
if mc.speculative and not mc.draft_gguf:
entry["speculative"] = True
if mc.stream:
entry["stream"] = mc.stream
Expand Down Expand Up @@ -1208,7 +1319,11 @@ def _scaffold_models_block(models, dirs) -> list[str]:
lines.append(f" profile: {mc.profile}")
if mc.mmproj:
lines.append(f" mmproj: {_rel(mc.mmproj, dirs)}")
if mc.speculative:
if mc.draft_gguf:
lines.append(" # a companion drafter sits next to the model; "
"this key turns speculative decoding on")
lines.append(f" draft_gguf: {_rel(mc.draft_gguf, dirs)}")
elif mc.speculative:
lines.append(" # native-head MTP (drafter inside the "
"target GGUF)")
lines.append(" speculative: true")
Expand Down
8 changes: 6 additions & 2 deletions gmlx/mtp_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -1191,9 +1191,11 @@ def load_mtp_model(
# deepseek4_mtp_support), never as in-GGUF nextn tensors; the
# native-head extraction below is qwen-shaped and cannot serve it,
# even though the V4 metadata advertises mtp_num_hidden_layers.
from . import arch_table
from .discovery import find_mtp_companion

draft_gguf_path = find_mtp_companion(gguf_path)
draft_gguf_path = find_mtp_companion(
gguf_path, arch_table.drafter_arches("deepseek_v4"))
if draft_gguf_path is None:
raise ValueError(
"deepseek_v4 MTP needs its companion drafter GGUF (arch "
Expand All @@ -1206,9 +1208,11 @@ def load_mtp_model(
if not assistant and config_dict.get("model_type") == "muse_glimmer":
# Muse Glimmer's drafter is likewise a companion GGUF (arch dflash),
# never an in-file nextn block.
from . import arch_table
from .discovery import find_mtp_companion

draft_gguf_path = find_mtp_companion(gguf_path, ("dflash",))
draft_gguf_path = find_mtp_companion(
gguf_path, arch_table.drafter_arches("muse_glimmer"))
if draft_gguf_path is None:
raise ValueError(
"muse_glimmer MTP needs its companion DFlash drafter GGUF "
Expand Down
Loading