Compare commits

...
Author SHA1 Message Date
Aleksander Grygier 6a5c985904 ui : models discover dialog
Add the Models Discover explorer behind a new sidebar action and full-screen
dialog: a searchable HuggingFace GGUF list (org avatars, capability icons,
catalog size ranges) and a detail pane with header, metadata chips, the
sanitized model-card readme and the download area - per-quant action chips
grouped by bit depth, sidecar queuing and a scrollable serve-command preview
with dynamic quant selects. Searchable dropdowns gain sticky headers/footers.

Assisted-by: pi:GLM-5.3-Flash
2026-09-07 16:08:16 +02:00
Aleksander Grygier 81ef2a3dd3 ui : shared model display primitives
Extract ModelCapabilityIcons (canonical Tools/Reasoning/Vision/Video/Audio
order) out of ModelId and reuse it there, add the shared DialogConfirmDownload
for destructive download actions, the discover org avatar with dark-mode
inversion and the thin download progress bar, and rework ModelId badges to take
thinking/tool-use support directly.

Assisted-by: pi:GLM-5.3-Flash
2026-09-07 16:08:16 +02:00
Aleksander Grygier 2498e9ae76 ui : optional sanitized raw HTML in markdown
Add an allowHtml prop to MarkdownContent: raw HTML found in the markdown is
rendered after DOMPurify sanitization instead of being escaped as literal
text. Default stays escaped.

Assisted-by: pi:GLM-5.3-Flash
2026-09-07 16:08:16 +02:00
Aleksander Grygier b774d2c807 ui : model download pipeline
Track HuggingFace downloads end to end: the server download/cancel endpoints,
a status manager fed by the /models/sse download progress events, and a
models-discover store holding the catalog and detail state for the discover
view. Downloaded and in-flight entries are excluded from the loadable model
list.

Assisted-by: pi:GLM-5.3-Flash
2026-09-07 16:08:16 +02:00
Aleksander Grygier affdf585c1 ui : model memory-fit estimation
Replace the raw runtime-memory estimate with the app's compatibility check:
the smallest real Mac memory tier that fits a model file, budgeted as
RAM x 0.75 minus fixed overhead with headroom on the file size. The constants
move to lib; the unused runtime-memory estimate is dropped. browser-info's
OS detection is exported for reuse.

Assisted-by: pi:GLM-5.3-Flash
2026-09-07 16:08:16 +02:00
Aleksander Grygier b9bf09af84 ui : Hugging Face Hub data layer
Add HuggingFaceService and its constants/enums/types: GGUF repo search, file
tree and model detail fetching, quant/sidecar filename analysis, shard-set
collapsing and the llama.app catalog feed, plus an orgOf() helper on the model
name utils.

Assisted-by: pi:GLM-5.3-Flash
2026-09-07 16:08:16 +02:00
Aleksander Grygier a693dd45c2 ui : model id grammar for sidecars, quants and capability parsing
Extend the shared model id parser with sidecar tokens (draft variants and
auxiliary imatrix/mmproj files), weight-file and custom-quant regexes, and add
the tools capability to ModelCapabilities; the selector option row picks it up
from the model's declared capabilities.

Assisted-by: pi:GLM-5.3-Flash
2026-09-07 16:08:16 +02:00
Aleksander Grygier 8635b58aae ui : type-safe API types, fetch helpers and download-ready models store plumbing
Assisted-by: pi:GLM-5.3-Flash
2026-09-07 16:08:15 +02:00
Aleksander Grygier 296b0f8881 server : fix deadlock when removing a finished download
The download monitor thread acquires the mutex on its way out, so joining
it while holding the lock in server_models::remove deadlocks once the
status has flipped to DOWNLOADED. Join outside the lock, same pattern as
load_models().

Assisted-by: pi:zai-org/GLM-5.3
2026-09-07 16:08:15 +02:00
Aleksander Grygier b14462c0c9 common : resolve <quant>-<sidecar> download tags and list cached sidecars
A Q4_0-mtp style tag now resolves the sidecar file when no model file matches it, so a solo draft or mmproj download actually pulls the file. Cached sidecar files list as their own entries so the state survives a restart, and removing such a tag deletes only the sidecar.

Assisted-by: pi:zai-org/GLM-5.3
2026-09-07 16:08:15 +02:00
Zhaolun YinandGitHub ccc3646c63 nix : update deprecated expressions (#28145)
* fixed warnings

* fixed nixfmt warning
2026-09-07 15:59:45 +02:00
PascalandGitHub c0b1871bc7 webgpu: format the GET_ROWS case block (#28542)
Brace on its own line and body indented one level, matching the
surrounding cases, so the webgpu clang-format check passes.
2026-09-07 15:55:14 +02:00
160bd031b2 server: fix LRU hang on multiple requests same model (#28539)
* server: fix LRU hang on multiple requests same model

* server: keep a queued model out of the victim pool until its waiters leave

A waiter that gave up while its model was still loading left the
model idle with no request behind it, and nothing recounted the free
slots, so a second request queued behind it stayed queued forever.
tick() was only driven by requests: join, claim and the end of a
proxied request.

Keep the queue entry alive after a successful claim so the model
coming up is never picked as a victim before its waiters use it, and
recount the slots on every status change and whenever a waiter
abandons the queue. The model is then evicted as soon as it comes up
with nobody left to serve.

---------

Co-authored-by: Pascal <admin@serveurperso.com>
2026-09-07 15:50:46 +02:00
TitaniumtownandGitHub dbeb37548e sycl: add a batched L2_NORM kernel (#28222)
* sycl: add a batched L2_NORM kernel

* sycl: batch consecutive L2_NORM siblings in the graph dispatch

Measured on Intel Arc Pro B70 (Battlemage), Qwen3.6-27B Q4_K_M, f16 KV,
npp=128 ntg=128 npl=2, GGML_SYCL profiler:

    L2_NORM dispatches       12480 -> 6240
    L2_NORM device time      68.77 -> 39.14 ms   (-43%)
    total device time        6782 -> 6748 ms     (-0.5%)
    wall decode t/s          flat

* tests: add L2_NORM_BATCH coverage
2026-09-07 15:24:14 +02:00
7a333e7240 vulkan: add DeepSeek-V4 hyper-connection fused ops (DSV4_HC_COMB/PRE/POST) (#26578)
* vulkan: add DeepSeek-V4 hyper-connection fused ops (DSV4_HC_COMB/PRE/POST)

CUDA has these ops from the DeepSeek-V4 merge and Metal gained them in
PR 26459. Vulkan was the last major backend running the unfused primitive
chain. On DeepSeek-V4-Flash the unfused Sinkhorn comb chain alone takes
about 32% of decode op time on gfx1151 (Strix Halo), spread over roughly
16k dispatches per token.

dsv4_hc_comb runs the full 20-iteration Sinkhorn in registers. A token's
4x4 comb matrix lives in 16 consecutive subgroup lanes, with idst in bits
0-1 and isrc in bits 2-3 to match the CPU reference layout, so
subgroupShuffleXor by 1|2 reduces rows and by 4|8 reduces columns. One
dispatch replaces about 137 strictly ordered node executions per site.
The shuffle masks never cross a 16-lane boundary, so a subgroup of size
64 packs 4 independent tokens.

dsv4_hc_pre and dsv4_hc_post handle the elementwise stream collapse and
fan-out, with per-token coefficients staged in shared memory.

GGML_VK_DISABLE_DSV4_HC disables all three ops. The _COMB, _PRE and
_POST variants gate each op independently so a single kernel can be
bisected against the unfused graph.

Adds eval cases at the production n_iter=20 across batch sizes that
cross subgroup and workgroup boundaries.

* vulkan: dsv4 hc review fixes

Drop the per-op env-var disables and device flags, the stride divisibility
check (ggml guarantees it) and the workgroup-count fallback in supports_op.
Trim the comb shader comments to the lane layout.

---------

Co-authored-by: Kevin Hopper <no-reply@maestro.press>
2026-09-07 15:24:03 +02:00
0c963452ea CUDA: size routed MoE MMQ N-tiles from typical expert width on RDNA3 (#24546)
* adjust ncols_picker for routed MoE in mul_mat_q_case function

* Adding CDNA, RDNA2 and RDNA4

* fix: update mmq_use_routed_moe_ncols_picker to include NVIDIA + Volta support

* feat: enhance mmq configuration for various architectures with moe_ncols_min_cc support

* refactor: replace moe_ncols_min_cc with use_typical_moe_ncols in mmq configuration files

* HIP: mmq: enable typical moe ncols on RDNA4

---------

Co-authored-by: Carl Philipp Klemm <carl@uvos.xyz>
2026-09-07 15:22:42 +02:00
AuroraRASandGitHub 4735997382 ggml: add gfx90c HIP support (#26454)
* ggml: add gfx90c HIP support

* ggml: make gfx90c HIP support compliant with specifications
2026-09-07 15:21:42 +02:00
d23c47f2a9 convert : refactor Hy4-preview conversion - move HC tensor mapping to the global map (#28451)
Co-authored-by: Stanisław Szymczyk <sszymczy@gmail.com>
2026-09-07 15:20:58 +02:00
73ab7599b5 CUDA: branchless Q4_K/Q5_K unpack to speed up mmvq, L2 prefetch on DGX Spark (#26705)
* Update Q4_K and Q5_K to use branchless computation, which stops the scale unpack being re-executed for every column in mmvq, improving perf at batch sizes > 1

* Gating the change off from DGX Spark due to no gain

* Adding prefetch gated to Spark, making branchless change in Q4_K and Q5_K general and modifying switch points based on latest perf data

* Guard the mmvq L2 prefetch against MUSA as well as HIP

* Define the mmvq L2 prefetch only under the Spark guard

* Update switch point for Q4_K to accommodate more models

* Remove stale comments

* Add block_size to ggml_cuda_type_traits and create a separate mmvq_should_prefetch function

* Rename block_size to bs for cleaner indentation

* Fix build error on non-Spark CUDA arch with appropriate conditional around new function added

---------

Co-authored-by: praneshgo <227579474+praneshgo@users.noreply.github.com>
2026-09-07 19:36:58 +08:00
0cae43063c vulkan: support type-aligned GET_ROWS (#28253)
* vulkan: fall back to CPU for GET_ROWS with misaligned offsets

The Vulkan GET_ROWS shader asserts when a tensor's backing-buffer offset
plus view_offs is misaligned w.r.t. minStorageBufferOffsetAlignment
(see init_pushconst_tensor_offsets). Previously this caused a hard crash
on models using ggml_view + ggml_get_rows (e.g. Qwen3-TTS, Qwen3-VL).

Return false from supports_op() in the misaligned case so the scheduler
falls back to CPU, matching the existing pattern for PAD_REFLECT_1D and
other unsupported op/shape combinations.

Repro: llama-tts -m Qwen3-TTS-*.gguf -mm mmproj-*.gguf -ngl 99
Crash: GGML_ASSERT(dst->op != GGML_OP_GET_ROWS || (a_offset == 0 && ...)) failed

* vulkan: trim comment for GET_ROWS misalign fallback

* vulkan: fix file corruption in gated_linear_attn struct

* vulkan: properly handle misaligned offsets in GET_ROWS quantized path

- get_rows_quant.comp was missing get_aoffset()/get_boffset()/get_doffset()
  calls that are already present in get_rows.comp, causing GGML_ASSERT crashes
  when GET_ROWS operates on views with non-zero view_offs, as produced by
  KV cache slices in Qwen3-TTS and Qwen3-VL.
- Remove the defensive misalignment GGML_ASSERT in init_pushconst_tensor_offsets
  for the binary push-constants specialization, since both get_rows.comp and
  get_rows_quant.comp now correctly apply per-tensor base offsets.
- Remove the workaround CPU fallback in supports_op() for GET_ROWS, since the
  Vulkan backend now handles misaligned offsets natively (no more bailout).
- Add backend test coverage with view_src0=true (ggml_view_4d into a padded
  tensor) for F32, F16, Q4_0, Q4_K, Q8_0, and I32 types, exercising both the
  non-quantized (get_rows.comp) and quantized (get_rows_quant.comp) paths
  with non-zero view_offs that reproduce the original Qwen3-TTS crash.

* tests: trim redundant comments in test_get_rows vs0 region

* tests: trim redundant comments in test_get_rows vs0 region (follow-up)

* vulkan: bind tensor base for binary ops, pass full view_offs via push constants

For ops using vk_op_binary_push_constants (GET_ROWS, ADD, SUB, MUL, etc.),
bind the view_src base and pass the full view_offs divided by type_size via
push constant misalign_offsets. This avoids truncation when misalign_bytes is
not a multiple of quantized block size.

ggml_vk_tensor_subbuffer gains a use_view_offs parameter. When false, the
binding points to vk_tensor_offset (base) and size includes view_offs.
init_pushconst_tensor_offsets<binary> computes a/b/d_offset directly from
tensor->view_offs, which is always row-aligned and therefore exact.

Added non-zero view offset (offset_rows=3) backend tests for GET_ROWS across
all_types with be1={1,7}, v={false,true}, skipping gradient setup for view
tensors (GGML_OP_VIEW fails ggml_set_param).

All 223 GET_ROWS tests pass on Vulkan (NVIDIA RTX 5060 Ti).

* vulkan: bind aligned offset for binary ops, pass adjusted misalign via push constants

For ops using vk_op_binary_push_constants (GET_ROWS, ADD, SUB, etc.), bind
the buffer to an aligned position near the view offset (not the tensor base)
and pass the adjusted misalignment via push constants.

ggml_vk_get_adjusted_misalign finds the smallest misalign that is both a
multiple of minStorageBufferOffsetAlignment and type_size, ensuring
misalign/type_size is exact (no truncation for quantized block types).

ggml_vk_tensor_subbuffer gains use_view_offs parameter. When false, binds
to (target - adjusted_misalign) instead of the view_src base, keeping the
offset small enough for 16-bit/8-bit push constant fields.

Added non-zero view offset (offset_rows=3) backend tests for GET_ROWS across
all_types with be1={1,7}, v={false,true}, skipping gradient setup for view
tensors (GGML_OP_VIEW fails ggml_set_param).

All 223 GET_ROWS tests pass on Vulkan (NVIDIA RTX 5060 Ti).

* vulkan: bind aligned offset for binary ops, fix UMA offset mismatch

For ops using vk_op_binary_push_constants (GET_ROWS, ADD, SUB, etc.), bind
the buffer to an aligned position near the view offset (not the tensor base)
and pass the adjusted misalignment via push constants.

Added ggml_vk_tensor_physical_offset to unify physical offset lookup across
UMA and non-UMA devices. On UMA, resolves via ggml_vk_host_get(tensor->data);
otherwise uses vk_tensor_offset(t) + t->view_offs. Both get_misalign_bytes and
the new ggml_vk_get_adjusted_misalign helper build on top of this function,
so buffer bindings and push constant offsets are always consistent regardless
of device memory model.

ggml_vk_get_adjusted_misalign finds the smallest misalign that is both a
multiple of minStorageBufferOffsetAlignment and type_size, ensuring
misalign/type_size is exact (no truncation for quantized block types) while
remaining small enough for 16-bit/8-bit push constant fields
(adjusted_misalign < lcm(align, type_size)).

ggml_vk_tensor_subbuffer gains use_view_offs parameter. When false, binds
to (physical_offset - adjusted_misalign) on both UMA and discrete GPUs,
fixing a bug where the UMA host_get path previously skipped the adjusted
misalign binding and returned the target offset directly.

Added non-zero view offset (offset_rows=3) backend tests for GET_ROWS across
all_types with be1={1,7}, v={false,true}, skipping gradient setup for view
tensors (GGML_OP_VIEW fails ggml_set_param).

All 223 GET_ROWS tests pass on Vulkan (NVIDIA GeForce RTX 5060 Ti).

* finish misalignment fix

* supports_op changes for openvino/webgpu

---------

Co-authored-by: AiChiTuDouPian <15327701848@qq.com>
2026-09-07 12:22:10 +02:00
Daniel BeveniusandGitHub 1173700b9c examples : print ggml_version and ggml_commit in test-cmake [no ci] (#28538)
This commit adds the printing of the ggml version and commit to the
test-cmake example.

The motivation is just to be able to quickly verify that the correct
version of ggml is being used.

Example output:
```console
test-cmake] llama.cpp version: 0.4.0-dev, build: 10837 (5202104b5)
[test-cmake] ggml version: 0.23.0, commit: 5202104b5
[test-cmake] Initializing backend...
...
```
2026-09-07 12:11:40 +02:00
Sigbjørn SkjæretandGitHub 5202104b59 caps : recheck typed content if template checks for string (#28511) 2026-09-07 09:14:32 +02:00
9a7570587c convert : write explicit recurrent_layers for Qwen3-Next / Qwen3.5 (#28208)
Problem
- Loader prefers `<arch>.attention.recurrent_layers`, falls back to `full_attention_interval` if missing
- Converter only ever writes the interval. gguf-py has no constant/writer for the array
- Interval can only describe evenly spaced full-attention layers. Any non-uniform `layer_types` gets reconstructed wrong
- No error, no warning. Model loads, runs, wrong layers get wrong ops. Full-attn layers marked recurrent lose their KV cache
- Every published Qwen3.5 checkpoint is uniform so nobody's hit it yet

Repro
12 layers, periods 4/3/5:

    layer:  0 1 2 3 4 5 6 7 8 9 10 11
    actual: L L L F L L F L L L  L  F
    loader: L L L F L L L F L L  L  F
                        ^ ^

Layer 6 is full attn, loaded as recurrent. Layer 7 the reverse.
52-layer non-uniform stack: 15/52 mis-typed.

Fix
- `constants.py`: add `Keys.Attention.RECURRENT_LAYERS` (name already registered in llama-arch.cpp)
- `gguf_writer.py`: add `add_recurrent_layers()`, same shape as `add_rope_pattern()`
- `conversion/qwen.py`: emit array from `layer_types` in `Qwen3NextModel.set_gguf_parameters` (covers 3-Next, 3.5, 3.5-MoE)

Notes
- Array is padded with `false` for MTP blocks. `get_key_or_arr` checks length against `n_layer_all`, which includes MTP. Matches the fallback's `i < n_layer()` guard
- Interval is still written. Old builds only understand the interval
- `layer_types` length != `num_hidden_layers` now raises in converter instead of producing a GGUF that fails at load

Tested
- End-to-end on a 62-layer non-uniform Qwen3.8-27B (2 linear layers removed). Loader reads the array, 62 blocks, 0 mismatches. Without fix: interval fallback, mis-typed
- MTP padding NOT tested on a real MTP model. Reasoned from qwen35.cpp + get_key_or_arr. Would appreciate a check

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:12:50 +03:00
Siavash NorouziandGitHub b74f590eaf ggml-cuda: fix divergent barrier in f16 flash attention (#27870)
* ggml-cuda: fix divergent barrier in f16 flash attention

* ggml-cuda: avoid duplicate metadata pointer setup
2026-09-07 09:23:21 +03:00
Aman GuptaandGitHub 992cb503cd ggml: allow backend inputs to not create another split (#28387) 2026-09-07 09:10:40 +03:00
125 changed files with 7193 additions and 732 deletions
+6 -6
View File
@@ -31,7 +31,7 @@
]
&& blas.meta.available,
useCuda ? config.cudaSupport,
useMetalKit ? stdenv.isAarch64 && stdenv.isDarwin,
useMetalKit ? stdenv.hostPlatform.isAarch64 && stdenv.hostPlatform.isDarwin,
# Increases the runtime closure size by ~700M
useMpi ? false,
useRocm ? config.rocmSupport,
@@ -92,7 +92,7 @@ let
cudaBuildInputs = with cudaPackages; [
cuda_cudart
cuda_cccl # <nv/target>
cccl # <nv/target>
libcublas
];
@@ -166,7 +166,7 @@ effectiveStdenv.mkDerivation (finalAttrs: {
# `xcrun` is used find the path of the Metal compiler, which is varible
# and not on $PATH
# see https://github.com/ggml-org/llama.cpp/pull/6118 for discussion
__noChroot = effectiveStdenv.isDarwin && useMetalKit && precompileMetalShaders;
__noChroot = effectiveStdenv.hostPlatform.isDarwin && useMetalKit && precompileMetalShaders;
nativeBuildInputs =
[
@@ -181,10 +181,10 @@ effectiveStdenv.mkDerivation (finalAttrs: {
autoAddDriverRunpath
]
++ optionals (effectiveStdenv.hostPlatform.isGnu && enableStatic) [ glibc.static ]
++ optionals (effectiveStdenv.isDarwin && useMetalKit && precompileMetalShaders) [ xcrunHost ];
++ optionals (effectiveStdenv.hostPlatform.isDarwin && useMetalKit && precompileMetalShaders) [ xcrunHost ];
buildInputs =
optionals effectiveStdenv.isDarwin darwinBuildInputs
optionals effectiveStdenv.hostPlatform.isDarwin darwinBuildInputs
++ optionals useCuda cudaBuildInputs
++ optionals useMpi [ mpi ]
++ optionals useRocm rocmBuildInputs
@@ -245,7 +245,7 @@ effectiveStdenv.mkDerivation (finalAttrs: {
# Configurations that are known to result in build failures. Can be
# overridden by importing Nixpkgs with `allowBroken = true`.
broken = (useMetalKit && !effectiveStdenv.isDarwin);
broken = (useMetalKit && !effectiveStdenv.hostPlatform.isDarwin);
description = "Inference of LLaMA model in pure C/C++${descriptionSuffix}";
homepage = "https://github.com/ggml-org/llama.cpp/";
+221 -58
View File
@@ -8,6 +8,7 @@
#include "json.h"
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <future>
@@ -534,15 +535,18 @@ static gguf_split_info get_gguf_split_info(const std::string & path) {
}
// Q4_0 -> 4, F16 -> 16, NVFP4 -> 4, Q8_K_M -> 8, etc
static int extract_quant_bits(const std::string & filename) {
auto split = get_gguf_split_info(filename);
static int quant_bits_from_tag(const std::string & tag) {
auto pos = tag.find_first_of("0123456789");
auto pos = split.tag.find_first_of("0123456789");
if (pos == std::string::npos) {
return 0;
}
return std::stoi(split.tag.substr(pos));
return std::stoi(tag.substr(pos));
}
static int extract_quant_bits(const std::string & filename) {
return quant_bits_from_tag(get_gguf_split_info(filename).tag);
}
static hf_cache::hf_files get_split_files(const hf_cache::hf_files & files,
@@ -563,12 +567,127 @@ static hf_cache::hf_files get_split_files(const hf_cache::hf_files & files,
return result;
}
// pick the best sibling GGUF whose filename contains `keyword` (e.g. "mmproj" / "mtp"),
// sidecar filename tokens, as used in `<quant>-<sidecar>` download tags,
// e.g. `Q4_0-mtp` for `mtp-Model-Q4_0.gguf`, `BF16-mmproj` for `mmproj-BF16.gguf`
static const std::vector<std::string> sidecar_tokens = {
"mtp", "eagle3", "dflash", "dspark", "mmproj", "imatrix",
};
static bool iequals(const std::string & a, const std::string & b) {
return a.size() == b.size() &&
std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) {
return std::tolower((unsigned char) x) == std::tolower((unsigned char) y);
});
}
// split a `<quant>-<sidecar>` tag into its parts, e.g. `Q4_0-mtp` -> {`Q4_0`, `mtp`};
// a bare sidecar tag (`mtp`) yields an empty quant; no sidecar yields an empty token
static std::pair<std::string, std::string> split_sidecar_tag(const std::string & tag) {
for (const auto & t : sidecar_tokens) {
if (tag.size() > t.size() + 1 && iequals(tag.substr(tag.size() - t.size() - 1), "-" + t)) {
return { tag.substr(0, tag.size() - t.size() - 1), t };
}
if (iequals(tag, t)) {
return { "", t };
}
}
return { tag, "" };
}
// filename with directory and extension removed, e.g. `sub/mtp-Model-Q4_0.gguf` -> `mtp-Model-Q4_0`
static std::string stem_of(const std::string & path) {
std::string base = path;
if (auto pos = base.rfind('/'); pos != std::string::npos) {
base = base.substr(pos + 1);
}
string_remove_suffix(base, ".gguf");
return base;
}
// a sidecar file carries its token as a name segment in any position and case,
// e.g. `mmproj-Model-F16.gguf`, `Model-mtp-Q4_0.gguf`, `Model-Q4_0-mtp.gguf`
// or the short `mmproj-F16.gguf`; the token must be a whole segment, so an
// unrelated name that merely contains it (`smtp-Model.gguf`) is a plain model
static std::string sidecar_token_of(const std::string & path) {
std::string base = stem_of(path);
for (char & c : base) {
c = (char) std::tolower((unsigned char) c);
}
if (base.empty()) {
return {};
}
for (const auto & t : sidecar_tokens) {
if (base == t) {
return t; // the sidecar file itself, e.g. `imatrix.gguf`
}
if (base.rfind(t + "-", 0) == 0) {
return t; // `mtp-Model-Q4_0.gguf`
}
if (base.find("-" + t + "-") != std::string::npos) {
return t; // `Model-mtp-Q4_0.gguf`
}
// `Model-Q4_0-mtp.gguf`, optionally with a `-draft` tail
if (string_ends_with(base, "-" + t) || string_ends_with(base, "-" + t + "-draft")) {
return t;
}
}
return {};
}
// name with the sidecar token segment removed, lowercased for tag parsing,
// e.g. `Model-MTP-Q4_0` -> `model-q4_0`
static std::string strip_sidecar_token(const std::string & base, const std::string & token) {
std::string lower = base;
for (char & c : lower) {
c = (char) std::tolower((unsigned char) c);
}
if (lower == token) {
return {};
}
if (lower.rfind(token + "-", 0) == 0) {
return lower.substr(token.size() + 1);
}
const std::string seg = "-" + token + "-";
if (auto pos = lower.find(seg); pos != std::string::npos) {
return lower.substr(0, pos) + "-" + lower.substr(pos + seg.size());
}
if (string_ends_with(lower, "-" + token + "-draft")) {
return lower.substr(0, lower.size() - token.size() - 7);
}
if (string_ends_with(lower, "-" + token)) {
return lower.substr(0, lower.size() - token.size() - 1);
}
return lower;
}
// the quant a sidecar file belongs to, from its name with the token stripped;
// a short-form name (`mmproj-F16.gguf`) leaves the bare quant, which has no
// `-` separator for the tag regex, so it becomes the tag directly
static std::string sidecar_quant(const std::string & path, const std::string & token) {
std::string name = strip_sidecar_token(stem_of(path), token);
std::string tag = get_gguf_split_info(name + ".gguf").tag;
if (tag.empty() && name.find('-') == std::string::npos) {
for (char & c : name) {
c = (char) std::toupper((unsigned char) c);
}
tag = name;
}
return tag;
}
// pick the best sibling GGUF carrying the sidecar `token` (e.g. "mmproj" / "mtp"),
// preferring deeper shared directory prefix with the model, then exact `tag` match,
// then closest quantization to the tag when given, or to the model otherwise
// an empty `model` skips the directory constraint: the sidecar is matched by tag alone
static hf_cache::hf_file find_best_sibling(const hf_cache::hf_files & files,
const std::string & model,
const std::string & keyword,
const std::string & token,
const std::string & tag = "") {
hf_cache::hf_file best;
size_t best_depth = 0;
@@ -589,32 +708,32 @@ static hf_cache::hf_file find_best_sibling(const hf_cache::hf_files & files,
model_bits = extract_quant_bits(model);
}
auto model_parts = string_split<std::string>(model, '/');
auto model_dir = model_parts.end() - 1;
for (const auto & f : files) {
if (!string_ends_with(f.path, ".gguf") ||
f.path.find(keyword) == std::string::npos) {
if (sidecar_token_of(f.path) != token) {
continue;
}
auto sib_parts = string_split<std::string>(f.path, '/');
auto sib_dir = sib_parts.end() - 1;
auto [_, dir] = std::mismatch(model_parts.begin(), model_dir,
sib_parts.begin(), sib_dir);
if (dir != sib_dir) {
continue;
size_t depth = 0;
if (!model.empty()) {
auto model_dir = model_parts.end() - 1;
auto [_, dir] = std::mismatch(model_parts.begin(), model_dir,
sib_parts.begin(), sib_dir);
if (dir != sib_dir) {
continue;
}
depth = dir - sib_parts.begin();
}
size_t depth = dir - sib_parts.begin();
auto bits = extract_quant_bits(f.path);
auto diff = std::abs(bits - model_bits);
std::string path_upper = f.path;
for (char & c : path_upper) {
c = (char) std::toupper((unsigned char) c);
}
bool exact = !tag_upper.empty() && path_upper.find("-" + tag_upper + ".") != std::string::npos;
// rank by the quant the sidecar belongs to, with the token segment
// stripped from its name
auto tag = sidecar_quant(f.path, token);
auto bits = quant_bits_from_tag(tag);
auto diff = std::abs(bits - model_bits);
bool exact = !tag_upper.empty() && tag == tag_upper;
if (!found || depth > best_depth ||
(depth == best_depth && exact && !best_exact) ||
@@ -637,43 +756,31 @@ static hf_cache::hf_file find_best_mmproj(const hf_cache::hf_files & files,
static hf_cache::hf_file find_best_mtp(const hf_cache::hf_files & files,
const std::string & model,
const std::string & tag = "") {
return find_best_sibling(files, model, "mtp-", tag);
return find_best_sibling(files, model, "mtp", tag);
}
static hf_cache::hf_file find_best_eagle3(const hf_cache::hf_files & files,
const std::string & model,
const std::string & tag = "") {
return find_best_sibling(files, model, "eagle3-", tag);
return find_best_sibling(files, model, "eagle3", tag);
}
static hf_cache::hf_file find_best_dflash(const hf_cache::hf_files & files,
const std::string & model,
const std::string & tag = "") {
return find_best_sibling(files, model, "dflash-", tag);
return find_best_sibling(files, model, "dflash", tag);
}
static hf_cache::hf_file find_best_dspark(const hf_cache::hf_files & files,
const std::string & model,
const std::string & tag = "") {
return find_best_sibling(files, model, "dspark-", tag);
return find_best_sibling(files, model, "dspark", tag);
}
// a plain model file: a GGUF whose name carries no sidecar token segment,
// so `smtp-Model.gguf` counts and every sidecar form does not
static bool gguf_filename_is_model(const std::string & filepath) {
if (!string_ends_with(filepath, ".gguf")) {
return false;
}
std::string filename = filepath;
if (auto pos = filename.rfind('/'); pos != std::string::npos) {
filename = filename.substr(pos + 1);
}
return filename.find("mmproj") == std::string::npos &&
filename.find("imatrix") == std::string::npos &&
filename.find("mtp-") == std::string::npos &&
filename.find("eagle3-") == std::string::npos &&
filename.find("dflash-") == std::string::npos &&
filename.find("dspark-") == std::string::npos;
return string_ends_with(filepath, ".gguf") && sidecar_token_of(filepath).empty();
}
static hf_cache::hf_file find_best_model(const hf_cache::hf_files & files,
@@ -765,8 +872,27 @@ common_download_hf_plan common_download_get_hf_plan(const common_params_model &
}
} else {
primary = find_best_model(all, tag);
// a `<quant>-<sidecar>` tag (e.g. `Q4_0-mtp`) requests that sidecar alone;
// every token-bearing file is a sidecar (find_best_model skips them),
// so the sidecar resolves here whenever the tag carries one
auto [base_tag, sidecar] = split_sidecar_tag(tag);
if (primary.path.empty() && !sidecar.empty()) {
auto found = find_best_sibling(all, "", sidecar, base_tag);
if (!found.path.empty()) {
if (sidecar == "mtp") plan.mtp = found;
else if (sidecar == "eagle3") plan.eagle3 = found;
else if (sidecar == "dflash") plan.dflash = found;
else if (sidecar == "dspark") plan.dspark = found;
else plan.mmproj = found;
}
}
// a requested sidecar can resolve on its own, without a full model of the same tag
if (primary.path.empty() && !opts.download_mtp && !opts.download_dflash && !opts.download_eagle3 && !opts.download_dspark) {
if (primary.path.empty() && sidecar.empty() &&
!opts.download_mtp && !opts.download_dflash && !opts.download_eagle3 && !opts.download_dspark) {
LOG_ERR("%s: no GGUF files found in repository %s\n", __func__, repo.c_str());
list_available_gguf_files(all);
return plan;
@@ -794,7 +920,7 @@ common_download_hf_plan common_download_get_hf_plan(const common_params_model &
plan.dspark = find_best_dspark(all, primary.path, tag);
}
if (primary.path.empty() &&
if (primary.path.empty() && plan.mmproj.local_path.empty() &&
plan.mtp.local_path.empty() && plan.dflash.local_path.empty() && plan.eagle3.local_path.empty() && plan.dspark.local_path.empty()) {
LOG_ERR("%s: no GGUF files found in repository %s\n", __func__, repo.c_str());
list_available_gguf_files(all);
@@ -968,17 +1094,34 @@ std::vector<common_cached_model_info> common_list_cached_models() {
auto files = hf_cache::get_cached_files();
for (const auto & f : files) {
auto split = get_gguf_split_info(f.path);
if (split.index != 1 || split.tag.empty() ||
split.prefix.find("mmproj") != std::string::npos ||
split.prefix.find("mtp-") != std::string::npos ||
split.prefix.find("eagle3-") != std::string::npos ||
split.prefix.find("dflash-") != std::string::npos ||
split.prefix.find("dspark-") != std::string::npos) {
continue;
// a sidecar file is listed under its own `<quant>-<sidecar>` tag, so a
// cached `mtp-Model-Q4_0.gguf`, `Model-mtp-Q4_0.gguf`, `Model-Q4_0-mtp.gguf`
// or short `mmproj-F16.gguf` shows up as `<repo>:Q4_0-mtp` / `<repo>:F16-mmproj`;
// files whose name carries no token stay loadable models
auto token = sidecar_token_of(f.path);
std::string tag;
if (token.empty()) {
auto split = get_gguf_split_info(f.path);
if (split.index != 1 || split.tag.empty()) {
continue;
}
tag = split.tag;
} else {
tag = sidecar_quant(f.path, token);
// a bare sidecar file has no tag to request it by, stay hidden
if (tag.empty()) {
continue;
}
tag += "-" + token;
}
if (seen.insert(f.repo_id + ":" + split.tag).second) {
result.push_back({f.repo_id, split.tag});
if (seen.insert(f.repo_id + ":" + tag).second) {
result.push_back({f.repo_id, tag});
}
}
@@ -1014,7 +1157,15 @@ bool common_download_remove(const std::string & hf_repo_with_tag) {
return hf_cache::remove_cached_repo(repo_id);
}
std::string tag_upper = tag;
// a `<quant>-<sidecar>` tag (`Q4_0-mtp`) targets that sidecar alone; a bare
// sidecar tag (`mtp`) is ambiguous across quants and is rejected
auto [base_tag, sidecar] = split_sidecar_tag(tag);
if (!sidecar.empty() && base_tag.empty()) {
LOG_ERR("%s: bare sidecar tag '%s': use `<quant>-<sidecar>`\n", __func__, tag.c_str());
return false;
}
std::string tag_upper = sidecar.empty() ? tag : base_tag;
for (char & c : tag_upper) {
c = (char) std::toupper((unsigned char) c);
}
@@ -1024,13 +1175,25 @@ bool common_download_remove(const std::string & hf_repo_with_tag) {
return false;
}
// collect snapshot entries whose tag matches
// collect the snapshot entries the tag selects; sidecar files keep their
// own tags, so a plain quant tag never removes them
std::vector<fs::path> to_remove;
for (const auto & f : files) {
auto split = get_gguf_split_info(f.path);
if (split.tag == tag_upper) {
to_remove.emplace_back(f.local_path);
auto token = sidecar_token_of(f.path);
if (sidecar.empty()) {
if (!token.empty()) {
continue;
}
if (get_gguf_split_info(f.path).tag != tag_upper) {
continue;
}
} else {
if (token != sidecar || sidecar_quant(f.path, sidecar) != tag_upper) {
continue;
}
}
to_remove.emplace_back(f.local_path);
}
if (to_remove.empty()) {
+32
View File
@@ -117,6 +117,7 @@ caps caps_get(jinja::program & prog) {
JJ_DEBUG("%s\n", ">>> Running capability check: typed content");
bool checks_for_string = false;
static const std::string content_marker = "STRING_MARKER";
// case: typed content support
@@ -136,6 +137,10 @@ caps caps_get(jinja::program & prog) {
[&](context &, bool success, value & messages, value &, const std::string & rendered) {
auto & content = messages->at(0)->at("content");
caps_print_stats(content, "messages[0].content");
if (has_op(content, "test_is_string")) {
// checked if content is string
checks_for_string = true;
}
bool used_as_array = has_op(content, "selectattr") || has_op(content, "array_access");
if (used_as_array) {
// accessed as an array
@@ -151,6 +156,33 @@ caps caps_get(jinja::program & prog) {
}
);
if (checks_for_string) {
caps_try_execute(
prog,
[&]() {
// messages
return json::array({
{
{"role", "user"},
{"content", json::array({
})}
}
});
},
nullptr, // ctx_fn
nullptr, // tools_fn
[&](context &, bool success, value & messages, value &, const std::string &) {
auto & content = messages->at(0)->at("content");
caps_print_stats(content, "messages[0].content");
bool used_as_array = has_op(content, "selectattr") || has_op(content, "array_access");
if (used_as_array && success) {
// accessed as an array
result.supports_typed_content = true;
}
}
);
}
JJ_DEBUG("%s\n", ">>> Running capability check: system prompt");
// case: system prompt support
+8 -2
View File
@@ -412,12 +412,18 @@ value test_expression::execute_impl(context & ctx) {
throw std::runtime_error("Invalid test expression");
}
auto it = builtins.find("test_is_" + test_id);
JJ_DEBUG("Test expression %s '%s' %s (using function 'test_is_%s')", operand->type().c_str(), test_id.c_str(), negate ? "(negate)" : "", test_id.c_str());
const std::string test_name = "test_is_" + test_id;
auto it = builtins.find(test_name);
JJ_DEBUG("Test expression %s '%s' %s (using function '%s')", operand->type().c_str(), test_id.c_str(), negate ? "(negate)" : "", test_name.c_str());
if (it == builtins.end()) {
throw std::runtime_error("Unknown test '" + test_id + "'");
}
if (ctx.is_get_stats) {
value_t::stats_t::mark_used(input);
input->stats.ops.insert(test_name);
}
auto res = it->second(args);
if (negate) {
+22 -89
View File
@@ -9,20 +9,6 @@ from .base import ModelBase, gguf, logger
from .deepseek import DeepseekV2Model
def split_kv_b_proj(weight: torch.Tensor, n_head: int, qk_nope: int, v_head_dim: int):
"""Split kv_b_proj into k_b (transposed) and v_b, matching DeepSeek MLA absorption.
weight: [n_head*(qk_nope+v_head_dim), kv_lora_rank].
Returns (k_b, v_b): k_b [n_head, kv_lora_rank, qk_nope], v_b [n_head, v_head_dim, kv_lora_rank].
"""
kv_lora = weight.shape[-1]
assert weight.shape[0] == n_head * (qk_nope + v_head_dim)
kv_b = weight.view(n_head, qk_nope + v_head_dim, kv_lora)
k_b, v_b = torch.split(kv_b, [qk_nope, v_head_dim], dim=1)
k_b = k_b.transpose(1, 2).contiguous() # [n_head, kv_lora, qk_nope]
return k_b, v_b.contiguous()
def split_gate_up(weight: torch.Tensor, moe_intermediate_size: int):
"""Split a fused stacked gate_up expert tensor into (gate, up).
@@ -36,6 +22,7 @@ def split_gate_up(weight: torch.Tensor, moe_intermediate_size: int):
@ModelBase.register("HYV4ForCausalLM")
@ModelBase.example("tencent/Hy4-preview")
class HYV4Model(DeepseekV2Model):
"""HY_V4: DeepSeek-V3 style MLA + MoE with iHC, a gated MLA output and a learnable sink.
@@ -54,6 +41,8 @@ class HYV4Model(DeepseekV2Model):
model_arch = gguf.MODEL_ARCH.HY_V4
merge_expert = False
# tensors a "full" indexer layer must carry
INDEXER_SUFFIXES = frozenset({
"self_attn.indexer.wq_b.weight",
@@ -186,6 +175,10 @@ class HYV4Model(DeepseekV2Model):
)
def prepare_tensors(self):
# Hy4-preview for some reason has num_key_value_heads equal to 8, so override it here
# without this conversion/deepseek.py fails on assert
self.hparams["num_key_value_heads"] = self.hparams["num_attention_heads"]
# validate before the base materializes tensors, so a mismatch fails early
is_full = self.indexer_is_full()
if is_full is not None:
@@ -227,85 +220,25 @@ class HYV4Model(DeepseekV2Model):
def modify_tensors(self, data_torch: torch.Tensor, name: str, bid: int | None) -> Iterable[tuple[str, torch.Tensor]]:
hparams = self.hparams
n_head = hparams["num_attention_heads"]
qk_nope = hparams["qk_nope_head_dim"]
v_head_dim = hparams["v_head_dim"]
moe_inter = hparams["moe_intermediate_size"]
tn = self.format_tensor_name
# ---- global (non per-layer) ----
if name == "model.embed_tokens.weight":
return [(tn(gguf.MODEL_TENSOR.TOKEN_EMBD), data_torch)]
if name == "model.norm.weight":
return [(tn(gguf.MODEL_TENSOR.OUTPUT_NORM), data_torch)]
if name == "lm_head.weight":
return [(tn(gguf.MODEL_TENSOR.OUTPUT), data_torch)]
if name == "model.hc_head.hc_head_fn":
return [(tn(gguf.MODEL_TENSOR.HC_HEAD_FN), data_torch)]
if name == "model.hc_head.hc_head_base":
return [(tn(gguf.MODEL_TENSOR.HC_HEAD_BASE), data_torch)]
if name == "model.hc_head.hc_head_scale":
return [(tn(gguf.MODEL_TENSOR.HC_HEAD_SCALE), data_torch)]
assert bid is not None, f"expected a per-layer tensor, got {name!r}"
# ---- per-layer, keyed by suffix after 'model.layers.{bid}.' ----
suffix = name.split(f"model.layers.{bid}.", 1)[-1]
# note: q_b_proj and kv_a_proj_with_mqa are mapped straight through (no RoPE permute),
# the graph rotates consecutive pairs so the rows need no reordering
simple = {
"input_layernorm.weight": (gguf.MODEL_TENSOR.ATTN_NORM, ".weight"),
"post_attention_layernorm.weight": (gguf.MODEL_TENSOR.FFN_NORM, ".weight"),
"self_attn.q_a_proj.weight": (gguf.MODEL_TENSOR.ATTN_Q_A, ".weight"),
"self_attn.q_a_layernorm.weight": (gguf.MODEL_TENSOR.ATTN_Q_A_NORM, ".weight"),
"self_attn.q_b_proj.weight": (gguf.MODEL_TENSOR.ATTN_Q_B, ".weight"),
"self_attn.kv_a_proj_with_mqa.weight": (gguf.MODEL_TENSOR.ATTN_KV_A_MQA, ".weight"),
"self_attn.kv_a_layernorm.weight": (gguf.MODEL_TENSOR.ATTN_KV_A_NORM, ".weight"),
"self_attn.o_proj.weight": (gguf.MODEL_TENSOR.ATTN_OUT, ".weight"),
"self_attn.linear_gate.weight": (gguf.MODEL_TENSOR.ATTN_GATE, ".weight"),
"self_attn.learnable_sink_param": (gguf.MODEL_TENSOR.ATTN_SINKS, ".weight"),
"self_attn.indexer.wq_b.weight": (gguf.MODEL_TENSOR.INDEXER_ATTN_Q_B, ".weight"),
"self_attn.indexer.wk.weight": (gguf.MODEL_TENSOR.INDEXER_ATTN_K, ".weight"),
"self_attn.indexer.k_norm.weight": (gguf.MODEL_TENSOR.INDEXER_K_NORM, ".weight"),
"self_attn.indexer.k_norm.bias": (gguf.MODEL_TENSOR.INDEXER_K_NORM, ".bias"),
"self_attn.indexer.weights_proj.weight": (gguf.MODEL_TENSOR.INDEXER_PROJ, ".weight"),
"hc_attn_layer.hc_pre.hc_fn": (gguf.MODEL_TENSOR.HC_ATTN_FN, ".weight"),
"hc_attn_layer.hc_pre.hc_base": (gguf.MODEL_TENSOR.HC_ATTN_BASE, ".weight"),
"hc_attn_layer.hc_pre.hc_scale": (gguf.MODEL_TENSOR.HC_ATTN_SCALE, ".weight"),
"hc_mlp_layer.hc_pre.hc_fn": (gguf.MODEL_TENSOR.HC_FFN_FN, ".weight"),
"hc_mlp_layer.hc_pre.hc_base": (gguf.MODEL_TENSOR.HC_FFN_BASE, ".weight"),
"hc_mlp_layer.hc_pre.hc_scale": (gguf.MODEL_TENSOR.HC_FFN_SCALE, ".weight"),
"mlp.gate.weight": (gguf.MODEL_TENSOR.FFN_GATE_INP, ".weight"),
"mlp.gate.e_score_correction.bias":(gguf.MODEL_TENSOR.FFN_EXP_PROBS_B, ".bias"),
"mlp.gate_proj.weight": (gguf.MODEL_TENSOR.FFN_GATE, ".weight"),
"mlp.up_proj.weight": (gguf.MODEL_TENSOR.FFN_UP, ".weight"),
"mlp.down_proj.weight": (gguf.MODEL_TENSOR.FFN_DOWN, ".weight"),
"mlp.shared_experts.gate_proj.weight": (gguf.MODEL_TENSOR.FFN_GATE_SHEXP, ".weight"),
"mlp.shared_experts.up_proj.weight": (gguf.MODEL_TENSOR.FFN_UP_SHEXP, ".weight"),
"mlp.shared_experts.down_proj.weight": (gguf.MODEL_TENSOR.FFN_DOWN_SHEXP, ".weight"),
}
if suffix in simple:
key, sfx = simple[suffix]
return [(tn(key, bid, sfx), data_torch)]
# kv_b_proj: split into k_b (transposed) and v_b
if suffix == "self_attn.kv_b_proj.weight":
k_b, v_b = split_kv_b_proj(data_torch, n_head, qk_nope, v_head_dim)
return [
(tn(gguf.MODEL_TENSOR.ATTN_K_B, bid), k_b),
(tn(gguf.MODEL_TENSOR.ATTN_V_B, bid), v_b),
]
# fused stacked experts: split gate_up into gate/up
if suffix == "mlp.experts.gate_up_proj":
if name.endswith("mlp.experts.gate_up_proj"):
gate, up = split_gate_up(data_torch, moe_inter)
return [
(tn(gguf.MODEL_TENSOR.FFN_GATE_EXP, bid), gate),
(tn(gguf.MODEL_TENSOR.FFN_UP_EXP, bid), up),
]
if suffix == "mlp.experts.down_proj":
return [(tn(gguf.MODEL_TENSOR.FFN_DOWN_EXP, bid), data_torch)]
yield from super().modify_tensors(gate, tn(gguf.MODEL_TENSOR.FFN_GATE_EXP, bid), bid)
yield from super().modify_tensors(up, tn(gguf.MODEL_TENSOR.FFN_UP_EXP, bid), bid)
return
raise ValueError(f"Unsupported HY_V4 tensor {name!r} (suffix {suffix!r})")
# add .weight suffixes
if name.endswith("mlp.experts.down_proj") or name.endswith(".self_attn.learnable_sink_param"):
name += ".weight"
if re.search(r"\.hc_head\.hc_head_(?:fn|base|scale)$", name):
name += ".weight"
if re.search(r"\.hc_(?:attn|mlp)_layer\.hc_pre\.hc_(?:fn|base|scale)$", name):
name += ".weight"
yield from super().modify_tensors(data_torch, name, bid)
+7
View File
@@ -379,6 +379,13 @@ class Qwen3NextModel(_QwenMtpMixin, Qwen2MoeModel):
self.gguf_writer.add_ssm_group_count(self.hparams["linear_num_key_heads"])
self.gguf_writer.add_ssm_time_step_rank(self.hparams["linear_num_value_heads"])
self.gguf_writer.add_ssm_inner_size(self.hparams["linear_value_head_dim"] * self.hparams["linear_num_value_heads"])
if (layer_types := self.hparams.get("layer_types")) is not None:
n_layer = self.hparams["num_hidden_layers"]
if len(layer_types) != n_layer:
raise ValueError(f"layer_types has {len(layer_types)} entries, expected num_hidden_layers ({n_layer})")
recurrent = [t == "linear_attention" for t in layer_types]
recurrent += [False] * (self.block_count - n_layer)
self.gguf_writer.add_recurrent_layers(recurrent)
self.gguf_writer.add_full_attention_interval(self.hparams.get("full_attention_interval", 4))
if (rope_dim := self.hparams.get("head_dim")) is None:
rope_dim = self.hparams["hidden_size"] // self.hparams["num_attention_heads"]
+2 -1
View File
@@ -2,8 +2,9 @@
#include <cstdio>
int main(void) {
printf("[test-cmake] version: %s, build: %d (%s)\n",
printf("[test-cmake] llama.cpp version: %s, build: %d (%s)\n",
llama_version(), LLAMA_BUILD_NUMBER, LLAMA_BUILD_COMMIT);
printf("[test-cmake] ggml version: %s, commit: %s\n", ggml_version(), ggml_commit());
printf("[test-cmake] Initializing backend...\n");
llama_backend_init();
printf("[test-cmake] Backend initialized.\n");
+2 -2
View File
@@ -128,7 +128,7 @@
}:
{
# For standardised reproducible formatting with `nix fmt`
formatter = pkgs.nixfmt-rfc-style;
formatter = pkgs.nixfmt;
# Unlike `.#packages`, legacyPackages may contain values of
# arbitrary types (including nested attrsets) and may even throw
@@ -156,7 +156,7 @@
windows = config.legacyPackages.llamaPackagesWindows.llama-cpp;
python-scripts = config.legacyPackages.llamaPackages.python-scripts;
}
// lib.optionalAttrs pkgs.stdenv.isLinux {
// lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
cuda = config.legacyPackages.llamaPackagesCuda.llama-cpp;
mpi-cpu = config.packages.default.override { useMpi = true; };
+2 -13
View File
@@ -849,7 +849,7 @@ static void ggml_backend_sched_split_inputs_grow(struct ggml_backend_sched_split
int new_cap = GGML_SCHED_MAX_SPLIT_INPUTS;
if (split->inputs_capacity > 0) {
new_cap = 2*split->inputs_capacity;
GGML_LOG_WARN("%s: increasing split inputs capacity from %d to %d\n", __func__, split->inputs_capacity, new_cap);
GGML_LOG_DEBUG("%s: increasing split inputs capacity from %d to %d\n", __func__, split->inputs_capacity, new_cap);
}
auto * pnew = (struct ggml_tensor **) realloc((void *) split->inputs, new_cap * sizeof(struct ggml_tensor *));
if (pnew == NULL) {
@@ -864,7 +864,7 @@ static void ggml_backend_sched_graph_inputs_grow(ggml_backend_sched_t sched) {
int new_cap = GGML_SCHED_MAX_SPLIT_INPUTS;
if (sched->graph_inputs_capacity > 0) {
new_cap = 2*sched->graph_inputs_capacity;
GGML_LOG_WARN("%s: increasing graph inputs capacity from %d to %d\n", __func__, sched->graph_inputs_capacity, new_cap);
GGML_LOG_DEBUG("%s: increasing graph inputs capacity from %d to %d\n", __func__, sched->graph_inputs_capacity, new_cap);
}
auto * pnew = (struct ggml_tensor **) realloc((void *) sched->graph_inputs, new_cap * sizeof(struct ggml_tensor *));
if (pnew == NULL) {
@@ -1338,17 +1338,6 @@ void ggml_backend_sched_split_graph(ggml_backend_sched_t sched, struct ggml_cgra
break;
}
}
// check if the split has too many inputs
// FIXME: count the number of inputs instead of only checking when full
if (split->n_inputs >= split->inputs_capacity) {
const size_t id = hash_id(src);
int src_backend_id = sched->hv_tensor_backend_ids[id];
bool supported = ggml_backend_sched_buffer_supported(sched, src, cur_backend_id);
if (src_backend_id != cur_backend_id && tensor_id_copy(id, cur_backend_id, 0) == NULL && !supported) {
need_new_split = true;
break;
}
}
}
}
+33 -6
View File
@@ -69,6 +69,8 @@
#define GGML_CUDA_CC_GCN4 (GGML_CUDA_CC_OFFSET_AMD + 0x803) // Tonga, Fiji, Polaris, minimum for fast fp16
#define GGML_CUDA_CC_VEGA (GGML_CUDA_CC_OFFSET_AMD + 0x900) // Vega56/64, minimum for fp16 dual issue
#define GGML_CUDA_CC_VEGA20 (GGML_CUDA_CC_OFFSET_AMD + 0x906) // MI50/Radeon VII, minimum for dp4a
#define GGML_CUDA_CC_GFX909 (GGML_CUDA_CC_OFFSET_AMD + 0x909) // GCN APU
#define GGML_CUDA_CC_GFX90C (GGML_CUDA_CC_OFFSET_AMD + 0x90c) // GCN APU
#define GGML_CUDA_CC_CDNA1 (GGML_CUDA_CC_OFFSET_AMD + 0x908) // MI100, minimum for MFMA, acc registers
#define GGML_CUDA_CC_CDNA2 (GGML_CUDA_CC_OFFSET_AMD + 0x90a) // MI210 (gfx90a), minimum acc register renaming
#define GGML_CUDA_CC_CDNA3 (GGML_CUDA_CC_OFFSET_AMD + 0x942) // MI300
@@ -89,12 +91,13 @@
#define GGML_CUDA_CC_IS_RDNA3_5(cc) (cc >= GGML_CUDA_CC_RDNA3_5 && cc < GGML_CUDA_CC_RDNA4)
#define GGML_CUDA_CC_IS_RDNA3(cc) (GGML_CUDA_CC_IS_RDNA3_0(cc) || GGML_CUDA_CC_IS_RDNA3_5(cc))
#define GGML_CUDA_CC_IS_RDNA4(cc) (cc >= GGML_CUDA_CC_RDNA4)
#define GGML_CUDA_CC_IS_GCN(cc) (cc > GGML_CUDA_CC_OFFSET_AMD && cc < GGML_CUDA_CC_CDNA1)
#define GGML_CUDA_CC_IS_CDNA(cc) (cc >= GGML_CUDA_CC_CDNA1 && cc < GGML_CUDA_CC_RDNA1)
#define GGML_CUDA_CC_IS_CDNA1(cc) (cc >= GGML_CUDA_CC_CDNA1 && cc < GGML_CUDA_CC_CDNA2)
#define GGML_CUDA_CC_IS_CDNA2(cc) (cc >= GGML_CUDA_CC_CDNA2 && cc < GGML_CUDA_CC_CDNA3)
#define GGML_CUDA_CC_IS_CDNA3(cc) (cc >= GGML_CUDA_CC_CDNA3 && cc < GGML_CUDA_CC_CDNA4)
#define GGML_CUDA_CC_IS_CDNA4(cc) (cc >= GGML_CUDA_CC_CDNA4 && cc < GGML_CUDA_CC_RDNA1)
#define GGML_CUDA_CC_IS_GCN_APU(cc) ((cc) == GGML_CUDA_CC_GFX909 || (cc) == GGML_CUDA_CC_GFX90C)
#define GGML_CUDA_CC_IS_GCN(cc) ((cc > GGML_CUDA_CC_OFFSET_AMD && cc < GGML_CUDA_CC_CDNA1) || GGML_CUDA_CC_IS_GCN_APU(cc))
#define GGML_CUDA_CC_IS_CDNA(cc) (!GGML_CUDA_CC_IS_GCN_APU(cc) && cc >= GGML_CUDA_CC_CDNA1 && cc < GGML_CUDA_CC_RDNA1)
#define GGML_CUDA_CC_IS_CDNA1(cc) (GGML_CUDA_CC_IS_CDNA(cc) && cc >= GGML_CUDA_CC_CDNA1 && cc < GGML_CUDA_CC_CDNA2)
#define GGML_CUDA_CC_IS_CDNA2(cc) (GGML_CUDA_CC_IS_CDNA(cc) && cc >= GGML_CUDA_CC_CDNA2 && cc < GGML_CUDA_CC_CDNA3)
#define GGML_CUDA_CC_IS_CDNA3(cc) (GGML_CUDA_CC_IS_CDNA(cc) && cc >= GGML_CUDA_CC_CDNA3 && cc < GGML_CUDA_CC_CDNA4)
#define GGML_CUDA_CC_IS_CDNA4(cc) (GGML_CUDA_CC_IS_CDNA(cc) && cc >= GGML_CUDA_CC_CDNA4 && cc < GGML_CUDA_CC_RDNA1)
// Moore Threads
#define MUSART_HMASK 40300 // MUSA rc4.3, min. ver. for half2 -> uint mask comparisons
@@ -976,6 +979,7 @@ template<>
struct ggml_cuda_type_traits<GGML_TYPE_F16> {
static constexpr int qk = 1;
static constexpr int qr = 1;
static constexpr int bs = sizeof(ggml_half);
};
template<>
@@ -983,6 +987,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_Q1_0> {
static constexpr int qk = QK1_0;
static constexpr int qr = QR1_0;
static constexpr int qi = QI1_0;
static constexpr int bs = sizeof(block_q1_0);
};
template<>
@@ -990,6 +995,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_Q2_0> {
static constexpr int qk = QK2_0;
static constexpr int qr = QR2_0;
static constexpr int qi = QI2_0;
static constexpr int bs = sizeof(block_q2_0);
};
template<>
@@ -997,6 +1003,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_Q4_0> {
static constexpr int qk = QK4_0;
static constexpr int qr = QR4_0;
static constexpr int qi = QI4_0;
static constexpr int bs = sizeof(block_q4_0);
};
template<>
@@ -1004,6 +1011,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_Q4_1> {
static constexpr int qk = QK4_1;
static constexpr int qr = QR4_1;
static constexpr int qi = QI4_1;
static constexpr int bs = sizeof(block_q4_1);
};
template<>
@@ -1011,6 +1019,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_Q5_0> {
static constexpr int qk = QK5_0;
static constexpr int qr = QR5_0;
static constexpr int qi = QI5_0;
static constexpr int bs = sizeof(block_q5_0);
};
template<>
@@ -1018,6 +1027,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_Q5_1> {
static constexpr int qk = QK5_1;
static constexpr int qr = QR5_1;
static constexpr int qi = QI5_1;
static constexpr int bs = sizeof(block_q5_1);
};
template<>
@@ -1025,6 +1035,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_Q8_0> {
static constexpr int qk = QK8_0;
static constexpr int qr = QR8_0;
static constexpr int qi = QI8_0;
static constexpr int bs = sizeof(block_q8_0);
};
template<>
@@ -1032,6 +1043,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_MXFP4> {
static constexpr int qk = QK_MXFP4;
static constexpr int qr = QR_MXFP4;
static constexpr int qi = QI_MXFP4;
static constexpr int bs = sizeof(block_mxfp4);
};
template<>
@@ -1039,6 +1051,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_NVFP4> {
static constexpr int qk = QK_NVFP4;
static constexpr int qr = QR_NVFP4;
static constexpr int qi = QI_NVFP4;
static constexpr int bs = sizeof(block_nvfp4);
};
template<>
@@ -1046,6 +1059,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_Q2_K> {
static constexpr int qk = QK_K;
static constexpr int qr = QR2_K;
static constexpr int qi = QI2_K;
static constexpr int bs = sizeof(block_q2_K);
};
template<>
@@ -1053,6 +1067,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_Q3_K> {
static constexpr int qk = QK_K;
static constexpr int qr = QR3_K;
static constexpr int qi = QI3_K;
static constexpr int bs = sizeof(block_q3_K);
};
template<>
@@ -1060,6 +1075,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_Q4_K> {
static constexpr int qk = QK_K;
static constexpr int qr = QR4_K;
static constexpr int qi = QI4_K;
static constexpr int bs = sizeof(block_q4_K);
};
template<>
@@ -1067,6 +1083,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_Q5_K> {
static constexpr int qk = QK_K;
static constexpr int qr = QR5_K;
static constexpr int qi = QI5_K;
static constexpr int bs = sizeof(block_q5_K);
};
template<>
@@ -1074,6 +1091,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_Q6_K> {
static constexpr int qk = QK_K;
static constexpr int qr = QR6_K;
static constexpr int qi = QI6_K;
static constexpr int bs = sizeof(block_q6_K);
};
template<>
@@ -1081,6 +1099,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_IQ2_XXS> {
static constexpr int qk = QK_K;
static constexpr int qr = QR2_XXS;
static constexpr int qi = QI2_XXS;
static constexpr int bs = sizeof(block_iq2_xxs);
};
template<>
@@ -1088,6 +1107,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_IQ2_XS> {
static constexpr int qk = QK_K;
static constexpr int qr = QR2_XS;
static constexpr int qi = QI2_XS;
static constexpr int bs = sizeof(block_iq2_xs);
};
template<>
@@ -1095,6 +1115,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_IQ2_S> {
static constexpr int qk = QK_K;
static constexpr int qr = QR2_S;
static constexpr int qi = QI2_S;
static constexpr int bs = sizeof(block_iq2_s);
};
template<>
@@ -1102,6 +1123,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_IQ3_XXS> {
static constexpr int qk = QK_K;
static constexpr int qr = QR3_XXS;
static constexpr int qi = QI3_XXS;
static constexpr int bs = sizeof(block_iq3_xxs);
};
template<>
@@ -1109,6 +1131,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_IQ1_S> {
static constexpr int qk = QK_K;
static constexpr int qr = QR1_S;
static constexpr int qi = QI1_S;
static constexpr int bs = sizeof(block_iq1_s);
};
template<>
@@ -1116,6 +1139,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_IQ1_M> {
static constexpr int qk = QK_K;
static constexpr int qr = QR1_M;
static constexpr int qi = QI1_M;
static constexpr int bs = sizeof(block_iq1_m);
};
template<>
@@ -1123,6 +1147,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_IQ4_NL> {
static constexpr int qk = QK4_NL;
static constexpr int qr = QR4_NL;
static constexpr int qi = QI4_NL;
static constexpr int bs = sizeof(block_iq4_nl);
};
template<>
@@ -1130,6 +1155,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_IQ4_XS> {
static constexpr int qk = QK_K;
static constexpr int qr = QR4_XS;
static constexpr int qi = QI4_XS;
static constexpr int bs = sizeof(block_iq4_xs);
};
template<>
@@ -1137,6 +1163,7 @@ struct ggml_cuda_type_traits<GGML_TYPE_IQ3_S> {
static constexpr int qk = QK_K;
static constexpr int qr = QR3_S;
static constexpr int qi = QI3_S;
static constexpr int bs = sizeof(block_iq3_s);
};
//////////////////////
+51 -51
View File
@@ -1545,77 +1545,77 @@ static __device__ __forceinline__ void flash_attn_ext_f16_process_tile(
}
}
if (np > 1 && threadIdx.y % np == 0) {
// Combine the meta data for parallel warps via shared memory.
// Warps with threadIdx.y % np != 0 must NOT return early.
// All threads must return simultaneously to avoid race conditions with work on the next tile.
if (np > 1) {
constexpr int nmeta = np*cols_per_warp >= warp_size ? np*cols_per_warp/warp_size : 1;
float KQ_cmn;
float KQ_cms[nmeta];
float KQ_crs;
const int jc_meta = threadIdx.y*cols_per_warp + (np*cols_per_warp < warp_size ? threadIdx.x % (np*cols_per_warp) : threadIdx.x);
float2 * const meta_ptr = ((float2 *) tile_Q) + jc_meta*(tile_stride/2) + nbatch_combine/2;
float2 meta[nmeta];
#pragma unroll
for (int imeta = 0; imeta < nmeta; ++imeta) {
meta[imeta] = meta_ptr[imeta * warp_size * tile_stride/2];
}
float KQ_cmn = meta[0].x; // KQ combine max new, max between all parallel warps.
if (threadIdx.y % np == 0) {
// Combine the meta data for parallel warps via shared memory.
float2 meta[nmeta];
#pragma unroll
for (int imeta = 1; imeta < nmeta; ++imeta) {
KQ_cmn = fmaxf(KQ_cmn, meta[imeta].x);
}
#pragma unroll
for (int offset = np*cols_per_warp/2; offset >= cols_per_warp; offset >>= 1) {
if (offset < warp_size) {
KQ_cmn = fmaxf(KQ_cmn, __shfl_xor_sync(0xFFFFFFFF, KQ_cmn, offset, warp_size));
for (int imeta = 0; imeta < nmeta; ++imeta) {
meta[imeta] = meta_ptr[imeta * warp_size * tile_stride/2];
}
}
float KQ_cms[nmeta]; // KQ combine max scale per warp.
KQ_cmn = meta[0].x; // KQ combine max new, max between all parallel warps.
#pragma unroll
for (int imeta = 0; imeta < nmeta; ++imeta) {
KQ_cms[imeta] = expf(meta[imeta].x - KQ_cmn);
}
for (int imeta = 1; imeta < nmeta; ++imeta) {
KQ_cmn = fmaxf(KQ_cmn, meta[imeta].x);
}
#pragma unroll
for (int offset = np*cols_per_warp/2; offset >= cols_per_warp; offset >>= 1) {
if (offset < warp_size) {
KQ_cmn = fmaxf(KQ_cmn, __shfl_xor_sync(0xFFFFFFFF, KQ_cmn, offset, warp_size));
}
}
float KQ_crs = KQ_cms[0]*meta[0].y; // KQ combine rowsum, scaled sum of all parallel warps.
#pragma unroll
for (int imeta = 1; imeta < nmeta; ++imeta) {
KQ_crs += KQ_cms[imeta]*meta[imeta].y;
}
for (int imeta = 0; imeta < nmeta; ++imeta) {
KQ_cms[imeta] = expf(meta[imeta].x - KQ_cmn);
}
KQ_crs = KQ_cms[0]*meta[0].y; // KQ combine rowsum, scaled sum of all parallel warps.
#pragma unroll
for (int offset = np*cols_per_warp/2; offset >= cols_per_warp; offset >>= 1) {
if (offset < warp_size) {
KQ_crs += __shfl_xor_sync(0xFFFFFFFF, KQ_crs, offset, warp_size);
for (int imeta = 1; imeta < nmeta; ++imeta) {
KQ_crs += KQ_cms[imeta]*meta[imeta].y;
}
#pragma unroll
for (int offset = np*cols_per_warp/2; offset >= cols_per_warp; offset >>= 1) {
if (offset < warp_size) {
KQ_crs += __shfl_xor_sync(0xFFFFFFFF, KQ_crs, offset, warp_size);
}
}
}
__syncthreads();
// Write back combined meta data:
if (threadIdx.y % np == 0) {
// Write back combined meta data:
#pragma unroll
for (int imeta = 0; imeta < nmeta; ++imeta) {
if (np*cols_per_warp >= warp_size || threadIdx.x < np*cols_per_warp) {
// Combined KQ max scale + rowsum.
meta_ptr[imeta * warp_size * tile_stride/2] = make_float2(KQ_cms[imeta], KQ_crs);
for (int imeta = 0; imeta < nmeta; ++imeta) {
if (np*cols_per_warp >= warp_size || threadIdx.x < np*cols_per_warp) {
// Combined KQ max scale + rowsum.
meta_ptr[imeta * warp_size * tile_stride/2] = make_float2(KQ_cms[imeta], KQ_crs);
}
}
// Combined KQ max + rowsum.
static_assert(cols_per_warp <= warp_size);
if (needs_fixup && (cols_per_warp == warp_size || threadIdx.x < cols_per_warp)) {
float2 * dstk_fixup_meta = dstk_fixup + blockIdx.x*ncols;
dstk_fixup_meta[(threadIdx.y/np)*cols_per_warp + threadIdx.x] = make_float2(KQ_cmn, KQ_crs);
}
if (is_fixup && (cols_per_warp == warp_size || threadIdx.x < cols_per_warp)) {
float2 * dstk_fixup_meta = dstk_fixup + (gridDim.x + blockIdx.x)*ncols;
dstk_fixup_meta[(threadIdx.y/np)*cols_per_warp + threadIdx.x] = make_float2(KQ_cmn, KQ_crs);
}
}
// Combined KQ max + rowsum.
static_assert(cols_per_warp <= warp_size);
if (needs_fixup && (cols_per_warp == warp_size || threadIdx.x < cols_per_warp)) {
float2 * dstk_fixup_meta = dstk_fixup + blockIdx.x*ncols;
dstk_fixup_meta[(threadIdx.y/np)*cols_per_warp + threadIdx.x] = make_float2(KQ_cmn, KQ_crs);
}
if (is_fixup && (cols_per_warp == warp_size || threadIdx.x < cols_per_warp)) {
float2 * dstk_fixup_meta = dstk_fixup + (gridDim.x + blockIdx.x)*ncols;
dstk_fixup_meta[(threadIdx.y/np)*cols_per_warp + threadIdx.x] = make_float2(KQ_cmn, KQ_crs);
}
} else if (np > 1) {
// Warps with threadIdx.y % np == 0 execute a __syncthreads() in the if branch.
// Therefore, all other warps also need to execute a __syncthreads().
// Otherwise the points at which warps synchronize with each other would become misaligned.
__syncthreads();
}
#pragma unroll
+1
View File
@@ -212,6 +212,7 @@ static int ggml_cuda_parse_id(char devName[]) {
}
archNum += archMajor * 0x100;
archNum += archMinor;
return archNum;
}
#endif // defined(GGML_USE_HIP)
+2 -1
View File
@@ -1,4 +1,5 @@
static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_config_ampere(ggml_type type, int J, bool fallback) {
constexpr bool use_typical_moe_ncols = false;
CASE(GGML_TYPE_Q1_0, 256, 1, 128, 8, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, true, true);
CASE(GGML_TYPE_Q1_0, 256, 1, 128, 16, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, true, true);
CASE(GGML_TYPE_Q1_0, 256, 1, 128, 32, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, true, true);
@@ -379,5 +380,5 @@ static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_conf
CASE(GGML_TYPE_NVFP4, 256, 1, 128, 112, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, true, false);
CASE(GGML_TYPE_NVFP4, 256, 1, 128, 128, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, true, false);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 1, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, false, true);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 1, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, use_typical_moe_ncols, false, true);
}
@@ -1,4 +1,5 @@
static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_config_blackwell(ggml_type type, int J, bool fallback) {
constexpr bool use_typical_moe_ncols = false;
CASE(GGML_TYPE_MXFP4, 256, 1, 128, 8, GGML_CUDA_MMQ_SRAM_LAYOUT_FP4, MMQ_ITER_K_FP4, true, true);
CASE(GGML_TYPE_MXFP4, 256, 1, 128, 16, GGML_CUDA_MMQ_SRAM_LAYOUT_FP4, MMQ_ITER_K_FP4, true, true);
CASE(GGML_TYPE_MXFP4, 256, 1, 128, 32, GGML_CUDA_MMQ_SRAM_LAYOUT_FP4, MMQ_ITER_K_FP4, true, true);
+2 -1
View File
@@ -1,4 +1,5 @@
static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_config_cdna(ggml_type type, int J, bool fallback) {
constexpr bool use_typical_moe_ncols = false;
CASE(GGML_TYPE_Q1_0, 512, 1, 128, 16, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, true, true);
CASE(GGML_TYPE_Q1_0, 512, 1, 128, 32, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, true, true);
CASE(GGML_TYPE_Q1_0, 512, 1, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, true, true);
@@ -181,5 +182,5 @@ static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_conf
CASE(GGML_TYPE_NVFP4, 512, 1, 128, 48, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, true, false);
CASE(GGML_TYPE_NVFP4, 512, 1, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, true, false);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 512, 1, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, false, true);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 512, 1, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, use_typical_moe_ncols, false, true);
}
@@ -1,4 +1,5 @@
static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_config_pascal_dp4a(ggml_type type, int J, bool fallback) {
constexpr bool use_typical_moe_ncols = false;
CASE(GGML_TYPE_Q1_0, 256, 2, 64, 8, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
CASE(GGML_TYPE_Q1_0, 256, 2, 64, 16, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
CASE(GGML_TYPE_Q1_0, 256, 2, 64, 32, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
@@ -269,5 +270,5 @@ static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_conf
CASE(GGML_TYPE_NVFP4, 256, 2, 64, 48, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, false, false);
CASE(GGML_TYPE_NVFP4, 256, 2, 64, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, false, false);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, false, true);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, use_typical_moe_ncols, false, true);
}
@@ -1,4 +1,5 @@
static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_config_pascal_older(ggml_type type, int J, bool fallback) {
constexpr bool use_typical_moe_ncols = false;
CASE(GGML_TYPE_Q1_0, 256, 2, 64, 8, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
CASE(GGML_TYPE_Q1_0, 256, 2, 64, 16, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
CASE(GGML_TYPE_Q1_0, 256, 2, 64, 32, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
@@ -269,5 +270,5 @@ static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_conf
CASE(GGML_TYPE_NVFP4, 256, 2, 64, 48, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, false, false);
CASE(GGML_TYPE_NVFP4, 256, 2, 64, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, false, false);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, false, true);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, use_typical_moe_ncols, false, true);
}
+2 -1
View File
@@ -1,4 +1,5 @@
static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_config_rdna2(ggml_type type, int J, bool fallback) {
constexpr bool use_typical_moe_ncols = false;
CASE(GGML_TYPE_Q1_0, 256, 2, 128, 8, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
CASE(GGML_TYPE_Q1_0, 256, 2, 128, 16, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
CASE(GGML_TYPE_Q1_0, 256, 2, 128, 32, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
@@ -269,5 +270,5 @@ static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_conf
CASE(GGML_TYPE_NVFP4, 256, 2, 128, 48, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, false, false);
CASE(GGML_TYPE_NVFP4, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, false, false);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, false, true);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, use_typical_moe_ncols, false, true);
}
+2 -1
View File
@@ -1,4 +1,5 @@
static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_config_rdna3_5(ggml_type type, int J, bool fallback) {
constexpr bool use_typical_moe_ncols = false;
CASE(GGML_TYPE_Q1_0, 128, 2, 64, 16, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
CASE(GGML_TYPE_Q1_0, 128, 2, 64, 32, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
CASE(GGML_TYPE_Q1_0, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
@@ -286,5 +287,5 @@ static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_conf
CASE(GGML_TYPE_NVFP4, 256, 2, 128, 112, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, false, false);
CASE(GGML_TYPE_NVFP4, 256, 2, 128, 128, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, false, false);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, false, true);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, use_typical_moe_ncols, false, true);
}
+2 -1
View File
@@ -1,4 +1,5 @@
static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_config_rdna3(ggml_type type, int J, bool fallback) {
constexpr bool use_typical_moe_ncols = true;
CASE(GGML_TYPE_Q1_0, 128, 2, 64, 16, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
CASE(GGML_TYPE_Q1_0, 128, 2, 64, 32, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
CASE(GGML_TYPE_Q1_0, 128, 2, 64, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
@@ -270,5 +271,5 @@ static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_conf
CASE(GGML_TYPE_NVFP4, 256, 2, 128, 96, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, false, false);
CASE(GGML_TYPE_NVFP4, 256, 2, 128, 128, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, false, false);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, false, true);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, use_typical_moe_ncols, false, true);
}
+2 -1
View File
@@ -1,4 +1,5 @@
static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_config_rdna4(ggml_type type, int J, bool fallback) {
constexpr bool use_typical_moe_ncols = true;
CASE(GGML_TYPE_Q1_0, 128, 2, 64, 16, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
CASE(GGML_TYPE_Q1_0, 128, 2, 64, 32, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
CASE(GGML_TYPE_Q1_0, 128, 2, 64, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, MMQ_ITER_K, false, true);
@@ -286,5 +287,5 @@ static constexpr __host__ __device__ ggml_cuda_mmq_config ggml_cuda_mmq_get_conf
CASE(GGML_TYPE_NVFP4, 256, 2, 128, 112, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, false, false);
CASE(GGML_TYPE_NVFP4, 256, 2, 128, 128, GGML_CUDA_MMQ_SRAM_LAYOUT_NVFP4, MMQ_ITER_K, false, false);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, false, true);
return ggml_cuda_mmq_config(GGML_TYPE_COUNT, 256, 2, 128, 64, GGML_CUDA_MMQ_SRAM_LAYOUT_Q8_0, 256, use_typical_moe_ncols, false, true);
}
+2 -2
View File
@@ -375,10 +375,10 @@ bool ggml_cuda_should_use_mmq(enum ggml_type type, int cc, int64_t ne11, int64_t
return true;
}
// gfx900 (Vega 10) lacks native dp4a, loses to dequant + hipBLAS
// gfx900 (Vega 10), gfx909, and gfx90c lack native dp4a, losing to dequant + hipBLAS
// for dense matrices; keep MMQ only for MoE, where the
// hipBLAS path is much slower.
if (cc == GGML_CUDA_CC_VEGA) {
if (cc == GGML_CUDA_CC_VEGA || GGML_CUDA_CC_IS_GCN_APU(cc)) {
return n_experts > 0;
}
+19 -4
View File
@@ -170,12 +170,13 @@ struct ggml_cuda_mmq_config {
int J; // SRAM tile width in src1->ne[1]/dst->ne[1] direction.
ggml_cuda_mmq_sram_layout sram_layout; // SRAM tile length in src0->ne[0]/src1->ne[0] direction (physical 32 bit elements).
int K_vram; // VRAM tile length in src0->ne[0]/src1->ne[0] direction (logical elements).
bool use_typical_moe_ncols;
bool stream_k; // Whether or not to use stream-k decomposition.
bool fallback; // Whether a fallback for out-of-bounds check in src0->ne[1] direction is needed.
constexpr __host__ __device__ ggml_cuda_mmq_config(
ggml_type type, int nthreads, int occupancy, int I, int J, ggml_cuda_mmq_sram_layout sram_layout, int K_vram, bool stream_k, bool fallback) :
type(type), nthreads(nthreads), occupancy(occupancy), I(I), J(J), sram_layout(sram_layout), K_vram(K_vram), stream_k(stream_k), fallback(fallback) {}
ggml_type type, int nthreads, int occupancy, int I, int J, ggml_cuda_mmq_sram_layout sram_layout, int K_vram, bool use_typical_moe_ncols, bool stream_k, bool fallback) :
type(type), nthreads(nthreads), occupancy(occupancy), I(I), J(J), sram_layout(sram_layout), K_vram(K_vram), use_typical_moe_ncols(use_typical_moe_ncols), stream_k(stream_k), fallback(fallback) {}
constexpr __device__ int rows_per_warp() const {
#if defined(AMD_MFMA_AVAILABLE) || defined(AMD_WMMA_AVAILABLE)
@@ -210,7 +211,7 @@ struct ggml_cuda_mmq_config {
static_assert((I_) % 32 == 0, "bad I"); \
static_assert((J_) % 8 == 0, "bad J"); \
static_assert((K_vram_) % 256 == 0, "bad K_vram"); \
return ggml_cuda_mmq_config((type_), (nthreads_), (occupancy_), (I_), (J_), (sram_layout_), (K_vram_), (stream_k_), (fallback_)); \
return ggml_cuda_mmq_config((type_), (nthreads_), (occupancy_), (I_), (J_), (sram_layout_), (K_vram_), use_typical_moe_ncols, (stream_k_), (fallback_)); \
} \
#include "mmq-config-pascal-older.cuh"
@@ -1473,6 +1474,20 @@ void mul_mat_q_switch_J(ggml_backend_cuda_context & ctx, const mmq_args & args,
const int cc = ggml_cuda_info().devices[id].cc;
const size_t smpbo = ggml_cuda_info().devices[id].smpbo;
int64_t ncols_picker = args.ncols_max;
if (args.expert_bounds != nullptr && args.nchannels_x > 0) {
const int J_max = ggml_cuda_mmq_get_J_max(type, fallback, cc, 128);
const ggml_cuda_mmq_config config_max = ggml_cuda_mmq_get_config(type, J_max, fallback, cc);
if (config_max.use_typical_moe_ncols) {
// Use the typical expert width only for tile selection.
// The launch grid still uses args.ncols_max.
const int64_t ncols_typical = (args.ncols_dst + args.nchannels_x - 1) / args.nchannels_x;
if (ncols_typical >= 1 && ncols_typical < J_max && ncols_typical < ncols_picker) {
ncols_picker = ncols_typical;
}
}
}
int J_best = 0;
int ntiles_J_best = INT_MAX;
@@ -1486,7 +1501,7 @@ void mul_mat_q_switch_J(ggml_backend_cuda_context & ctx, const mmq_args & args,
continue;
}
const int ntiles_x = (args.ncols_max + config.J - 1) / config.J;
const int ntiles_x = (ncols_picker + config.J - 1) / config.J;
if (ntiles_x < ntiles_J_best) {
J_best = J;
+51 -4
View File
@@ -6,6 +6,35 @@
#include <cstdint>
#include <type_traits>
// only enabled on DGX Spark, where it is a gain on every type below. On the higher-bandwidth parts the kernel
// has little exposed latency left to hide and the extra requests cost more than they save.
// For perf data, see https://github.com/ggml-org/llama.cpp/pull/26705#issuecomment-5569335031
#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ == GGML_CUDA_CC_DGX_SPARK
// returns true only for those quants that benefit from prefetch and false otherwise
static constexpr __host__ __device__ bool mmvq_should_prefetch(ggml_type type) {
switch (type) {
case GGML_TYPE_Q4_0:
case GGML_TYPE_Q5_0:
case GGML_TYPE_Q8_0:
case GGML_TYPE_MXFP4:
case GGML_TYPE_Q3_K:
case GGML_TYPE_Q4_K:
case GGML_TYPE_Q5_K:
case GGML_TYPE_Q6_K:
case GGML_TYPE_IQ1_M:
case GGML_TYPE_IQ4_NL:
case GGML_TYPE_IQ4_XS:
return true;
default:
return false;
}
}
static __device__ __forceinline__ void mmvq_prefetch_l2(const void * p) {
asm volatile("prefetch.global.L2 [%0];" :: "l"(p));
}
#endif
typedef float (*vec_dot_q_cuda_t)(const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & kbx, const int & iqs);
static constexpr __device__ vec_dot_q_cuda_t get_vec_dot_q_cuda(ggml_type type) {
@@ -298,9 +327,6 @@ bool ggml_cuda_should_use_mmvq(enum ggml_type type, int cc, int64_t ne11) {
return ne11 <= 4;
case GGML_TYPE_Q3_K:
return ne11 <= 6;
case GGML_TYPE_Q4_K:
case GGML_TYPE_Q5_K:
return ne11 <= 7;
default:
return ne11 <= MMVQ_MAX_BATCH_SIZE;
}
@@ -310,8 +336,9 @@ bool ggml_cuda_should_use_mmvq(enum ggml_type type, int cc, int64_t ne11) {
case GGML_TYPE_Q2_K:
case GGML_TYPE_Q3_K:
case GGML_TYPE_Q4_K:
case GGML_TYPE_Q5_K:
return ne11 <= 5;
case GGML_TYPE_Q5_K:
return ne11 <= 6;
case GGML_TYPE_Q6_K:
return ne11 <= 7;
default:
@@ -675,6 +702,26 @@ static __global__ void mul_mat_vec_q(
// x block quant index when casting the quants to int
const int kqs = vdr * (tid % (qi/vdr));
#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ == GGML_CUDA_CC_DGX_SPARK
// start the next iterations' weight loads early
if constexpr (mmvq_should_prefetch(type)) {
constexpr int pf_dist = 2; // loop iterations, not blocks
const int kbx_pf = kbx + pf_dist*blocks_per_iter;
if (kbx_pf < blocks_per_row_x) {
#pragma unroll
for (int i = 0; i < rows_per_cuda_block; ++i) {
const size_t off = (size_t)(kbx_offset + i*stride_row_x + kbx_pf) * ggml_cuda_type_traits<type>::bs;
mmvq_prefetch_l2((const char *) vx + off);
if constexpr (has_fusion) {
if (use_gate) {
mmvq_prefetch_l2((const char *) vgate + off);
}
}
}
}
}
#endif
#pragma unroll
for (int j = 0; j < ncols_dst; ++j) {
#pragma unroll
+25 -16
View File
@@ -936,16 +936,20 @@ static __device__ __forceinline__ float vec_dot_q4_K_q8_1(
v[0] = q4[0];
v[1] = q4[4];
// branchless so nvcc can hoist this out of the ncols_dst loop
const uint16_t * scales = (const uint16_t *)bq4_K->scales;
const int j = bq8_offset/2;
const int jm = j & 1;
const uint32_t s0 = scales[jm + 0];
const uint32_t s2 = scales[jm + 2];
const uint32_t s4 = scales[jm + 4];
const uint32_t hi = (uint32_t) -(int32_t) (j >= 2);
uint16_t aux[2];
const int j = bq8_offset/2;
if (j < 2) {
aux[0] = scales[j+0] & 0x3f3f;
aux[1] = scales[j+2] & 0x3f3f;
} else {
aux[0] = ((scales[j+2] >> 0) & 0x0f0f) | ((scales[j-2] & 0xc0c0) >> 2);
aux[1] = ((scales[j+2] >> 4) & 0x0f0f) | ((scales[j-0] & 0xc0c0) >> 2);
}
aux[0] = (uint16_t) (((s0 & 0x3f3f) & ~hi) | ((((s4 >> 0) & 0x0f0f) | ((s0 & 0xc0c0) >> 2)) & hi));
aux[1] = (uint16_t) (((s2 & 0x3f3f) & ~hi) | ((((s4 >> 4) & 0x0f0f) | ((s2 & 0xc0c0) >> 2)) & hi));
const uint8_t * sc = (const uint8_t *)aux;
const uint8_t * m = sc + 2;
@@ -981,16 +985,21 @@ static __device__ __forceinline__ float vec_dot_q5_K_q8_1(
vh[0] = qh[0] >> bq8_offset;
vh[1] = qh[4] >> bq8_offset;
// same as q4_K
const uint16_t * scales = (const uint16_t *)bq5_K->scales;
const int j = bq8_offset/2;
const int jm = j & 1;
const uint32_t s0 = scales[jm + 0];
const uint32_t s2 = scales[jm + 2];
const uint32_t s4 = scales[jm + 4];
const uint32_t hi = (uint32_t) -(int32_t) (j >= 2);
uint16_t aux[2];
const int j = bq8_offset/2;
if (j < 2) {
aux[0] = scales[j+0] & 0x3f3f;
aux[1] = scales[j+2] & 0x3f3f;
} else {
aux[0] = ((scales[j+2] >> 0) & 0x0f0f) | ((scales[j-2] & 0xc0c0) >> 2);
aux[1] = ((scales[j+2] >> 4) & 0x0f0f) | ((scales[j-0] & 0xc0c0) >> 2);
}
aux[0] = (uint16_t) (((s0 & 0x3f3f) & ~hi) | ((((s4 >> 0) & 0x0f0f) | ((s0 & 0xc0c0) >> 2)) & hi));
aux[1] = (uint16_t) (((s2 & 0x3f3f) & ~hi) | ((((s4 >> 4) & 0x0f0f) | ((s2 & 0xc0c0) >> 2)) & hi));
const uint8_t * sc = (const uint8_t *)aux;
const uint8_t * m = sc + 2;
+2 -2
View File
@@ -176,9 +176,9 @@
#define __CUDA_ARCH__ 1300
#if defined(__gfx900__) || defined(__gfx906__)
#if defined(__gfx900__) || defined(__gfx906__) || defined(__gfx909__) || defined(__gfx90c__)
#define GCN5
#endif // defined(__gfx900__) || defined(__gfx906__)
#endif // defined(__gfx900__) || defined(__gfx906__) || defined(__gfx909__) || defined(__gfx90c__)
#if defined(__gfx803__)
#define GCN4
+4
View File
@@ -1091,6 +1091,10 @@ static ggml_openvino_op_support is_op_supported_case(const ggml_tensor * op) {
if (op->ne[3] != 1) {
return {false, "GET_ROWS/SET_ROWS with ne[3] != 1 (ne[3]=" + std::to_string(op->ne[3]) + ") is not supported"};
}
if (op->op == GGML_OP_GET_ROWS && ggml_is_quantized(op->src[0]->type) &&
op->src[0]->view_src != nullptr && op->src[0]->view_offs != 0) {
return {false, "GET_ROWS with a nonzero quantized src0 view offset is not supported"};
}
if (op->op == GGML_OP_GET_ROWS && ggml_openvino_get_device_name() == "GPU" &&
op->src[0]->type == GGML_TYPE_BF16) {
return {false, "GET_ROWS with BF16 src0 is not supported on GPU"};
+83
View File
@@ -4858,6 +4858,78 @@ static bool ggml_sycl_mul_mat_glu_mmvq_fused(ggml_backend_sycl_context & ctx, gg
/*stride_col_dst=*/(int) glu->ne[0], stream);
}
// Batch the run of consecutive L2_NORM siblings starting at node_idx into one launch.
// Returns the number of extra graph nodes consumed, or 0 if the run is shorter than two
// (the caller then runs the norm through the per-tensor kernel).
static int ggml_sycl_l2_norm_batch_fused(ggml_backend_sycl_context & ctx, ggml_cgraph * cgraph, int node_idx) {
const ggml_tensor * node = cgraph->nodes[node_idx];
if (ggml_sycl_info().device_count != 1 || node->type != GGML_TYPE_F32 ||
node->src[0]->type != GGML_TYPE_F32 || node->src[0]->ne[0] >= 1024) {
return 0;
}
ggml_tensor * batch[GGML_SYCL_L2_BATCH_MAX];
int count = 0;
int last = node_idx;
float eps0;
memcpy(&eps0, node->op_params, sizeof(float));
// Conservative aliasing test: the batched norms run concurrently in one kernel,
// so none may read what another writes, and none may write where another writes.
auto overlaps = [](const ggml_tensor * a, const ggml_tensor * b) {
const char * ab = (const char *) a->data;
const char * bb = (const char *) b->data;
return ab < bb + ggml_nbytes(b) && bb < ab + ggml_nbytes(a);
};
for (int j = node_idx; j < cgraph->n_nodes && count < GGML_SYCL_L2_BATCH_MAX; ++j) {
ggml_tensor * nj = cgraph->nodes[j];
if (ggml_is_empty(nj) || nj->op == GGML_OP_RESHAPE || nj->op == GGML_OP_TRANSPOSE ||
nj->op == GGML_OP_VIEW || nj->op == GGML_OP_PERMUTE || nj->op == GGML_OP_NONE ||
(nj->flags & GGML_TENSOR_FLAG_COMPUTE) == 0) {
continue; // not a launch; cannot break a run of adjacent norms
}
if (nj->op != GGML_OP_L2_NORM || nj->type != GGML_TYPE_F32 ||
nj->src[0]->type != GGML_TYPE_F32 || !ggml_are_same_shape(nj, node) ||
!ggml_are_same_shape(nj->src[0], node->src[0])) {
break; // any other launch ends the run
}
bool same_nb = true;
for (int d = 0; d < GGML_MAX_DIMS; ++d) {
if (nj->nb[d] != node->nb[d] || nj->src[0]->nb[d] != node->src[0]->nb[d]) {
same_nb = false;
break;
}
}
if (!same_nb) {
break; // one nb[] stride set is shared by the whole batch
}
float epsj;
memcpy(&epsj, nj->op_params, sizeof(float));
if (epsj != eps0) {
break; // eps mismatch ends the run
}
bool indep = true;
for (int k = 0; k < count; ++k) {
if (overlaps(nj->src[0], batch[k]) || overlaps(nj, batch[k])) {
indep = false;
break;
}
}
if (!indep) {
break; // an overlapping tensor would race inside one launch
}
batch[count++] = nj;
last = j;
}
if (count < 2) {
return 0; // a lone norm falls through to the per-tensor kernel
}
ggml_sycl_l2_norm_batch(ctx, batch, count);
return last - node_idx;
}
__dpct_inline__ static void k_copy_src1_to_contiguous(
const char *__restrict__ src1_original, char *__restrict__ src1_contiguous,
const mmid_row_mapping *__restrict__ row_mapping,
@@ -5908,6 +5980,17 @@ static void ggml_backend_sycl_graph_compute_impl(ggml_backend_sycl_context * syc
continue;
}
// Batch consecutive independent same-shape F32 L2_NORM siblings (the GDN q/k
// norms) into one launch; sources are strided views of the fused qkv buffer, so
// the scan skips the interleaved view nodes instead of breaking on them.
if (node->op == GGML_OP_L2_NORM) {
const int l2_batch_skip = ggml_sycl_l2_norm_batch_fused(*sycl_ctx, cgraph, i);
if (l2_batch_skip > 0) {
i += l2_batch_skip;
continue;
}
}
if (node->op == GGML_OP_MUL_MAT && ggml_sycl_mul_mat_glu_mmvq_fused(*sycl_ctx, cgraph, i)) {
i += 2;
continue;
+83
View File
@@ -543,6 +543,62 @@ static void l2_norm_f32_sycl(const float * x,
}
}
// Batched L2 norm: N independent same-shape F32 tensors in one launch; the tensor
// index is folded into grid dim0 and each row's reduction is identical to the
// single-tensor kernel, so the result is bit-exact.
struct l2_batch_ptrs {
const float * src[GGML_SYCL_L2_BATCH_MAX];
float * dst[GGML_SYCL_L2_BATCH_MAX];
};
// One stride set shared by the whole batch: the caller only groups tensors whose nb[]
// all match, so per-tensor state stays two pointers.
struct l2_batch_strides {
int ne1, ne2;
int64_t ss0, ss1, ss2, ss3;
int64_t ds0, ds1, ds2, ds3;
};
template <int warp_size>
static void l2_norm_f32_batch(l2_batch_ptrs p, l2_batch_strides st, const int ncols, const float eps,
const sycl::nd_item<3> & item_ct1) {
const int t = item_ct1.get_group(0); // tensor index
const int r = item_ct1.get_group(2); // flattened row over ne1*ne2*ne3
const int tid = item_ct1.get_local_id(2);
const int i1 = r % st.ne1;
const int i2 = (r / st.ne1) % st.ne2;
const int i3 = r / (st.ne1 * st.ne2);
const float * x = p.src[t] + i3 * st.ss3 + i2 * st.ss2 + i1 * st.ss1;
float * dst = p.dst[t] + i3 * st.ds3 + i2 * st.ds2 + i1 * st.ds1;
float tmp = 0.0f;
for (int col = tid; col < ncols; col += warp_size) {
const float xi = x[col * st.ss0];
tmp += xi * xi;
}
tmp = block_reduce<block_reduce_method::SUM, warp_size>(tmp, (float *) nullptr, warp_size);
const float scale = sycl::rsqrt(sycl::fmax(tmp, eps * eps));
for (int col = tid; col < ncols; col += warp_size) {
dst[col * st.ds0] = scale * x[col * st.ss0];
}
}
template <int warp_size>
static void l2_norm_f32_batch_sycl(l2_batch_ptrs p, l2_batch_strides st, const int n_tensors,
const int ncols, const int nrows_total, const float eps,
queue_ptr stream) {
const dpct::dim3 blocks_num(nrows_total, 1, n_tensors);
const dpct::dim3 block_dims(warp_size, 1, 1);
stream->submit([&](sycl::handler & cgh) {
cgh.parallel_for(sycl::nd_range<3>(blocks_num * block_dims, block_dims),
[=](sycl::nd_item<3> item_ct1) [[sycl::reqd_sub_group_size(warp_size)]] {
l2_norm_f32_batch<warp_size>(p, st, ncols, eps, item_ct1);
});
});
}
void ggml_sycl_op_norm(ggml_backend_sycl_context& ctx, ggml_tensor* dst) {
const ggml_tensor * src0 = dst->src[0];
@@ -961,3 +1017,30 @@ void ggml_sycl_op_l2_norm(ggml_backend_sycl_context& ctx, ggml_tensor* dst) {
l2_norm_f32_sycl<WARP_SIZE>(src0_d, dst_d, ne00, ne01, ne02, ne03,
ss0, ss1, ss2, ss3, ds0, ds1, ds2, ds3, eps, stream, ctx.device);
}
// nodes[0..count) are independent, same-shape, same-eps, same-nb L2_NORM ops validated
// by the caller; requires ncols < 1024 (the warp reduction path).
void ggml_sycl_l2_norm_batch(ggml_backend_sycl_context & ctx, ggml_tensor ** nodes, int count) {
const ggml_tensor * s0 = nodes[0]->src[0];
const int ncols = (int) s0->ne[0];
const int nrows_total = (int) ggml_nrows(s0);
float eps;
memcpy(&eps, nodes[0]->op_params, sizeof(float));
GGML_ASSERT(eps >= 0.0f);
l2_batch_ptrs p{};
for (int t = 0; t < count; ++t) {
p.src[t] = (const float *) nodes[t]->src[0]->data;
p.dst[t] = (float *) nodes[t]->data;
}
const ggml_tensor * d0 = nodes[0];
const size_t ts = ggml_type_size(GGML_TYPE_F32);
l2_batch_strides st{};
st.ne1 = (int) s0->ne[1];
st.ne2 = (int) s0->ne[2];
st.ss0 = s0->nb[0] / ts; st.ss1 = s0->nb[1] / ts; st.ss2 = s0->nb[2] / ts; st.ss3 = s0->nb[3] / ts;
st.ds0 = d0->nb[0] / ts; st.ds1 = d0->nb[1] / ts; st.ds2 = d0->nb[2] / ts; st.ds3 = d0->nb[3] / ts;
l2_norm_f32_batch_sycl<WARP_SIZE>(p, st, count, ncols, nrows_total, eps, ctx.stream());
}
+3
View File
@@ -29,4 +29,7 @@ void ggml_sycl_op_group_norm(ggml_backend_sycl_context& ctx, ggml_tensor* dst);
void ggml_sycl_op_l2_norm(ggml_backend_sycl_context& ctx, ggml_tensor* dst);
#define GGML_SYCL_L2_BATCH_MAX 8
void ggml_sycl_l2_norm_batch(ggml_backend_sycl_context & ctx, ggml_tensor ** nodes, int count);
#endif // GGML_SYCL_NORM_HPP
+261 -6
View File
@@ -1110,6 +1110,9 @@ struct vk_device_struct {
vk_pipeline pipeline_cumsum_multipass2_f32;
vk_pipeline pipeline_argmax_f32;
vk_pipeline pipeline_count_equal_i32;
vk_pipeline pipeline_dsv4_hc_comb_f32;
vk_pipeline pipeline_dsv4_hc_pre_f32;
vk_pipeline pipeline_dsv4_hc_post_f32;
std::map<vk_solve_tri_pipeline_state, vk_pipeline> pipeline_solve_tri_f32;
vk_pipeline pipeline_im2col_f32, pipeline_im2col_f32_f16;
vk_pipeline pipeline_im2col_3d_f32, pipeline_im2col_3d_f32_f16;
@@ -1467,6 +1470,53 @@ struct vk_op_fwht_push_constants {
float scale;
};
struct vk_op_dsv4_hc_comb_push_constants {
uint32_t n_tokens;
uint32_t nbm0; uint32_t nbm1;
uint32_t nbs0;
uint32_t nbb0;
uint32_t nbd0; uint32_t nbd1; uint32_t nbd2;
uint32_t m_offset;
uint32_t s_offset;
uint32_t b_offset;
uint32_t d_offset;
float eps;
uint32_t n_iter;
};
struct vk_op_dsv4_hc_pre_push_constants {
uint32_t n_embd;
uint32_t n_tokens;
uint32_t nbx0; uint32_t nbx1; uint32_t nbx2;
uint32_t nbw0; uint32_t nbw1;
uint32_t nbd0; uint32_t nbd1;
uint32_t x_offset;
uint32_t w_offset;
uint32_t d_offset;
};
struct vk_op_dsv4_hc_post_push_constants {
uint32_t n_embd;
uint32_t n_tokens;
uint32_t nbx0; uint32_t nbx1;
uint32_t nbr0; uint32_t nbr1; uint32_t nbr2;
uint32_t nbp0; uint32_t nbp1;
uint32_t nbc0; uint32_t nbc1; uint32_t nbc2;
uint32_t nbd0; uint32_t nbd1; uint32_t nbd2;
uint32_t x_offset;
uint32_t r_offset;
uint32_t p_offset;
uint32_t c_offset;
uint32_t d_offset;
};
struct vk_op_count_experts_push_constants {
uint32_t ne00;
uint32_t ne01;
@@ -2515,9 +2565,38 @@ static uint64_t vk_tensor_offset(const ggml_tensor * tensor) {
return (uint8_t *) tensor->data - (uint8_t *) vk_ptr_base;
}
static uint32_t get_misalign_bytes(const ggml_backend_vk_context * ctx, const ggml_tensor * t)
{
return ((vk_tensor_offset(t) + t->view_offs) & (ctx->device->properties.limits.minStorageBufferOffsetAlignment - 1));;
static void ggml_vk_host_get(const vk_device& device, const void * ptr, vk_buffer& buf, size_t& buf_offset);
static size_t ggml_vk_tensor_buffer_offset(const ggml_backend_vk_context * ctx, const ggml_tensor * t) {
// vk_tensor_offset() is relative to vk_ptr_base, but mapped host tensors need an offset relative to their Vulkan buffer.
if (ctx->device->uma) {
vk_buffer buf = nullptr;
size_t off = 0;
ggml_vk_host_get(ctx->device, t->data, buf, off);
if (buf) {
return off;
}
}
return (size_t)(vk_tensor_offset(t) + t->view_offs);
}
static size_t ggml_vk_descriptor_offset(size_t tensor_offset, size_t alignment, size_t type_size) {
// Move the descriptor back until its distance to the tensor is divisible by the tensor type size.
size_t descriptor_offset = tensor_offset & ~(alignment - 1);
while ((tensor_offset - descriptor_offset) % type_size != 0) {
GGML_ASSERT(descriptor_offset >= alignment);
descriptor_offset -= alignment;
}
return descriptor_offset;
}
static uint32_t get_misalign_bytes(const ggml_backend_vk_context * ctx, const ggml_tensor * t) {
const size_t tensor_offset = ggml_vk_tensor_buffer_offset(ctx, t);
const size_t descriptor_offset = ggml_vk_descriptor_offset(
tensor_offset, ctx->device->properties.limits.minStorageBufferOffsetAlignment, ggml_type_size(t->type));
GGML_ASSERT(tensor_offset - descriptor_offset <= UINT32_MAX);
return tensor_offset - descriptor_offset;
}
static uint32_t ggml_vk_concat_unit_size(ggml_type type) {
@@ -2602,6 +2681,32 @@ template <> void init_pushconst_tensor_offsets(ggml_backend_vk_context * ctx, vk
GGML_UNUSED(src3);
}
template <> void init_pushconst_tensor_offsets(ggml_backend_vk_context * ctx, vk_op_dsv4_hc_comb_push_constants &p, const ggml_tensor * src0, const ggml_tensor * src1, const ggml_tensor * src2, const ggml_tensor * src3, ggml_tensor * dst) {
p.m_offset = get_misalign_bytes(ctx, src0) / ggml_type_size(src0->type);
p.s_offset = get_misalign_bytes(ctx, src1) / ggml_type_size(src1->type);
p.b_offset = get_misalign_bytes(ctx, src2) / ggml_type_size(src2->type);
p.d_offset = get_misalign_bytes(ctx, dst) / ggml_type_size(dst->type);
GGML_UNUSED(src3);
}
template <> void init_pushconst_tensor_offsets(ggml_backend_vk_context * ctx, vk_op_dsv4_hc_pre_push_constants &p, const ggml_tensor * src0, const ggml_tensor * src1, const ggml_tensor * src2, const ggml_tensor * src3, ggml_tensor * dst) {
p.x_offset = get_misalign_bytes(ctx, src0) / ggml_type_size(src0->type);
p.w_offset = get_misalign_bytes(ctx, src1) / ggml_type_size(src1->type);
p.d_offset = get_misalign_bytes(ctx, dst) / ggml_type_size(dst->type);
GGML_UNUSED(src2);
GGML_UNUSED(src3);
}
template <> void init_pushconst_tensor_offsets(ggml_backend_vk_context * ctx, vk_op_dsv4_hc_post_push_constants &p, const ggml_tensor * src0, const ggml_tensor * src1, const ggml_tensor * src2, const ggml_tensor * src3, ggml_tensor * dst) {
p.x_offset = get_misalign_bytes(ctx, src0) / ggml_type_size(src0->type);
p.r_offset = get_misalign_bytes(ctx, src1) / ggml_type_size(src1->type);
p.p_offset = get_misalign_bytes(ctx, src2) / ggml_type_size(src2->type);
p.c_offset = get_misalign_bytes(ctx, src3) / ggml_type_size(src3->type);
p.d_offset = get_misalign_bytes(ctx, dst) / ggml_type_size(dst->type);
}
struct ggml_backend_vk_buffer_context {
vk_device_ref device;
vk_buffer dev_buffer;
@@ -5948,6 +6053,16 @@ static void ggml_vk_load_shaders(vk_device& device, vk_pipeline requested) {
ggml_vk_create_pipeline(device, device->pipeline_count_experts, "count_experts", count_experts_len, count_experts_data, "main", 2, sizeof(vk_op_count_experts_push_constants), {1, 1, 1}, {}, 1, true);
}
// comb holds a token's 4x4 matrix in one 16-lane slice of a subgroup, so it
// needs at least 16 lanes, pinned to a known size.
if (device->subgroup_basic && device->subgroup_shuffle && device->subgroup_require_full_support && device->subgroup_size >= 16) {
const uint32_t tokens_per_workgroup = 4 * (device->subgroup_size / 16);
ggml_vk_create_pipeline(device, device->pipeline_dsv4_hc_comb_f32, "dsv4_hc_comb_f32", dsv4_hc_comb_f32_len, dsv4_hc_comb_f32_data, "main", 4, sizeof(vk_op_dsv4_hc_comb_push_constants), {tokens_per_workgroup, 1, 1}, { device->subgroup_size }, 1, true, true, device->subgroup_size);
}
ggml_vk_create_pipeline(device, device->pipeline_dsv4_hc_pre_f32, "dsv4_hc_pre_f32", dsv4_hc_pre_f32_len, dsv4_hc_pre_f32_data, "main", 3, sizeof(vk_op_dsv4_hc_pre_push_constants), {256, 1, 1}, { 256 }, 1);
ggml_vk_create_pipeline(device, device->pipeline_dsv4_hc_post_f32, "dsv4_hc_post_f32", dsv4_hc_post_f32_len, dsv4_hc_post_f32_data, "main", 5, sizeof(vk_op_dsv4_hc_post_push_constants), {256, 1, 1}, { 256 }, 1);
for (auto &s : device->pipeline_solve_tri_f32) {
const vk_solve_tri_pipeline_state &state = s.first;
@@ -8265,10 +8380,12 @@ static vk_subbuffer ggml_vk_tensor_subbuffer(
size_t size = ggml_nbytes(tensor);
size_t misalign_bytes = offset & (ctx->device->properties.limits.minStorageBufferOffsetAlignment - 1);
const size_t descriptor_offset = ggml_vk_descriptor_offset(
offset, ctx->device->properties.limits.minStorageBufferOffsetAlignment, ggml_type_size(tensor->type));
const size_t misalign_bytes = offset - descriptor_offset;
// The shader must support misaligned offsets when indexing into the buffer
GGML_ASSERT(allow_misalign || misalign_bytes == 0);
offset &= ~misalign_bytes;
offset = descriptor_offset;
size += misalign_bytes;
return vk_subbuffer{buffer, offset, size};
@@ -10173,6 +10290,98 @@ static void ggml_vk_fwht(ggml_backend_vk_context * ctx, vk_context& subctx, cons
ggml_vk_dispatch_pipeline(ctx, subctx, pipeline, { src_buf, dst_buf }, pc, { workgroups_x, 1, 1 });
}
static uint32_t ggml_vk_nb_elem(const ggml_tensor * t, int i) {
return (uint32_t)(t->nb[i] / ggml_type_size(t->type));
}
static void ggml_vk_dsv4_hc_comb(ggml_backend_vk_context * ctx, vk_context& subctx, const ggml_tensor * mixes, const ggml_tensor * scale, const ggml_tensor * base, ggml_tensor * dst) {
VK_LOG_DEBUG("ggml_vk_dsv4_hc_comb(" << mixes << ", " << scale << ", " << base << ", " << dst << ")");
vk_pipeline pipeline = ctx->device->pipeline_dsv4_hc_comb_f32;
GGML_ASSERT(pipeline != nullptr);
const uint32_t n_tokens = (uint32_t)mixes->ne[1];
ggml_pipeline_request_descriptor_sets(ctx, pipeline, 1);
const vk_subbuffer mixes_buf = ggml_vk_tensor_subbuffer(ctx, mixes, true);
const vk_subbuffer scale_buf = ggml_vk_tensor_subbuffer(ctx, scale, true);
const vk_subbuffer base_buf = ggml_vk_tensor_subbuffer(ctx, base, true);
const vk_subbuffer dst_buf = ggml_vk_tensor_subbuffer(ctx, dst, true);
vk_op_dsv4_hc_comb_push_constants pc = {
n_tokens,
ggml_vk_nb_elem(mixes, 0), ggml_vk_nb_elem(mixes, 1),
ggml_vk_nb_elem(scale, 0),
ggml_vk_nb_elem(base, 0),
ggml_vk_nb_elem(dst, 0), ggml_vk_nb_elem(dst, 1), ggml_vk_nb_elem(dst, 2),
0, 0, 0, 0,
ggml_get_op_params_f32(dst, 0),
(uint32_t)ggml_get_op_params_i32(dst, 1),
};
init_pushconst_tensor_offsets(ctx, pc, mixes, scale, base, nullptr, dst);
ggml_vk_dispatch_pipeline(ctx, subctx, pipeline, { mixes_buf, scale_buf, base_buf, dst_buf }, pc, { n_tokens, 1, 1 });
}
static void ggml_vk_dsv4_hc_pre(ggml_backend_vk_context * ctx, vk_context& subctx, const ggml_tensor * x, const ggml_tensor * weights, ggml_tensor * dst) {
VK_LOG_DEBUG("ggml_vk_dsv4_hc_pre(" << x << ", " << weights << ", " << dst << ")");
vk_pipeline pipeline = ctx->device->pipeline_dsv4_hc_pre_f32;
GGML_ASSERT(pipeline != nullptr);
const uint32_t n_embd = (uint32_t)x->ne[0];
const uint32_t n_tokens = (uint32_t)x->ne[2];
ggml_pipeline_request_descriptor_sets(ctx, pipeline, 1);
const vk_subbuffer x_buf = ggml_vk_tensor_subbuffer(ctx, x, true);
const vk_subbuffer w_buf = ggml_vk_tensor_subbuffer(ctx, weights, true);
const vk_subbuffer d_buf = ggml_vk_tensor_subbuffer(ctx, dst, true);
vk_op_dsv4_hc_pre_push_constants pc = {
n_embd, n_tokens,
ggml_vk_nb_elem(x, 0), ggml_vk_nb_elem(x, 1), ggml_vk_nb_elem(x, 2),
ggml_vk_nb_elem(weights, 0), ggml_vk_nb_elem(weights, 1),
ggml_vk_nb_elem(dst, 0), ggml_vk_nb_elem(dst, 1),
0, 0, 0,
};
init_pushconst_tensor_offsets(ctx, pc, x, weights, nullptr, nullptr, dst);
ggml_vk_dispatch_pipeline(ctx, subctx, pipeline, { x_buf, w_buf, d_buf }, pc, { n_embd, n_tokens, 1 });
}
static void ggml_vk_dsv4_hc_post(ggml_backend_vk_context * ctx, vk_context& subctx, const ggml_tensor * x, const ggml_tensor * residual, const ggml_tensor * post, const ggml_tensor * comb, ggml_tensor * dst) {
VK_LOG_DEBUG("ggml_vk_dsv4_hc_post(" << x << ", " << residual << ", " << post << ", " << comb << ", " << dst << ")");
vk_pipeline pipeline = ctx->device->pipeline_dsv4_hc_post_f32;
GGML_ASSERT(pipeline != nullptr);
const uint32_t n_embd = (uint32_t)x->ne[0];
const uint32_t n_tokens = (uint32_t)x->ne[1];
ggml_pipeline_request_descriptor_sets(ctx, pipeline, 1);
const vk_subbuffer x_buf = ggml_vk_tensor_subbuffer(ctx, x, true);
const vk_subbuffer r_buf = ggml_vk_tensor_subbuffer(ctx, residual, true);
const vk_subbuffer p_buf = ggml_vk_tensor_subbuffer(ctx, post, true);
const vk_subbuffer c_buf = ggml_vk_tensor_subbuffer(ctx, comb, true);
const vk_subbuffer d_buf = ggml_vk_tensor_subbuffer(ctx, dst, true);
vk_op_dsv4_hc_post_push_constants pc = {
n_embd, n_tokens,
ggml_vk_nb_elem(x, 0), ggml_vk_nb_elem(x, 1),
ggml_vk_nb_elem(residual, 0), ggml_vk_nb_elem(residual, 1), ggml_vk_nb_elem(residual, 2),
ggml_vk_nb_elem(post, 0), ggml_vk_nb_elem(post, 1),
ggml_vk_nb_elem(comb, 0), ggml_vk_nb_elem(comb, 1), ggml_vk_nb_elem(comb, 2),
ggml_vk_nb_elem(dst, 0), ggml_vk_nb_elem(dst, 1), ggml_vk_nb_elem(dst, 2),
0, 0, 0, 0, 0,
};
init_pushconst_tensor_offsets(ctx, pc, x, residual, post, comb, dst);
ggml_vk_dispatch_pipeline(ctx, subctx, pipeline, { x_buf, r_buf, p_buf, c_buf, d_buf }, pc, { n_embd, n_tokens, 1 });
}
static void ggml_vk_mul_mat(ggml_backend_vk_context * ctx, vk_context& subctx, const struct ggml_cgraph * cgraph, int node_idx) {
ggml_tensor * dst = cgraph->nodes[node_idx];
ggml_tensor * src0 = dst->src[0];
@@ -12155,7 +12364,9 @@ template <> void init_pushconst_tensor_offsets(ggml_backend_vk_context * ctx, vk
const uint32_t b_offset = get_misalign_bytes(ctx, src1) / ggml_type_size(src1->type);
const uint32_t d_offset = get_misalign_bytes(ctx, dst) / ggml_type_size(dst->type);
GGML_ASSERT(dst->op != GGML_OP_GET_ROWS || (a_offset == 0 && b_offset == 0 && d_offset == 0));
GGML_ASSERT(a_offset <= 0xFFFF);
GGML_ASSERT(b_offset <= 0xFF);
GGML_ASSERT(d_offset <= 0xFF);
p.misalign_offsets = (a_offset << 16) | (b_offset << 8) | d_offset;
@@ -16189,6 +16400,18 @@ static bool ggml_vk_build_graph(ggml_backend_vk_context * ctx, ggml_cgraph * cgr
case GGML_OP_CUMSUM:
ggml_vk_cumsum(ctx, compute_ctx, src0, node);
break;
case GGML_OP_DSV4_HC_COMB:
ggml_vk_dsv4_hc_comb(ctx, compute_ctx, src0, src1, src2, node);
break;
case GGML_OP_DSV4_HC_PRE:
ggml_vk_dsv4_hc_pre(ctx, compute_ctx, src0, src1, node);
break;
case GGML_OP_DSV4_HC_POST:
ggml_vk_dsv4_hc_post(ctx, compute_ctx, src0, src1, src2, src3, node);
break;
case GGML_OP_MEAN:
ggml_vk_mean(ctx, compute_ctx, src0, node);
@@ -19256,6 +19479,31 @@ static bool ggml_backend_vk_device_supports_op(ggml_backend_dev_t dev, const ggm
}
return false;
}
case GGML_OP_DSV4_HC_COMB:
case GGML_OP_DSV4_HC_PRE:
case GGML_OP_DSV4_HC_POST:
{
if (op->type != GGML_TYPE_F32) {
return false;
}
for (uint32_t i = 0; i < GGML_MAX_SRC; ++i) {
if (op->src[i] && op->src[i]->type != GGML_TYPE_F32) {
return false;
}
}
// hc is hardcoded to 4 in the shaders. ggml only constrains it
// to 4 for COMB, so PRE/POST have to be checked here.
if (op->op == GGML_OP_DSV4_HC_PRE && op->src[0]->ne[1] != 4) {
return false;
}
if (op->op == GGML_OP_DSV4_HC_POST && op->src[1]->ne[1] != 4) {
return false;
}
if (op->op == GGML_OP_DSV4_HC_COMB) {
return device->pipeline_dsv4_hc_comb_f32 != nullptr;
}
return true;
}
case GGML_OP_SOLVE_TRI:
{
if (op->type != GGML_TYPE_F32 || op->src[0]->type != GGML_TYPE_F32) {
@@ -20244,6 +20492,13 @@ static void ggml_vk_check_results_0(ggml_backend_vk_context * ctx, ggml_cgraph *
tensor_clone = ggml_sum_rows(ggml_ctx, src_clone[0]);
} else if (tensor->op == GGML_OP_CUMSUM) {
tensor_clone = ggml_cumsum(ggml_ctx, src_clone[0]);
} else if (tensor->op == GGML_OP_DSV4_HC_COMB) {
tensor_clone = ggml_dsv4_hc_comb(ggml_ctx, src_clone[0], src_clone[1], src_clone[2],
ggml_get_op_params_f32(tensor, 0), ggml_get_op_params_i32(tensor, 1));
} else if (tensor->op == GGML_OP_DSV4_HC_PRE) {
tensor_clone = ggml_dsv4_hc_pre(ggml_ctx, src_clone[0], src_clone[1]);
} else if (tensor->op == GGML_OP_DSV4_HC_POST) {
tensor_clone = ggml_dsv4_hc_post(ggml_ctx, src_clone[0], src_clone[1], src_clone[2], src_clone[3]);
} else if (tensor->op == GGML_OP_MEAN) {
tensor_clone = ggml_mean(ggml_ctx, src_clone[0]);
} else if (tensor->op == GGML_OP_ARGMAX) {
@@ -0,0 +1,90 @@
#version 450
#extension GL_EXT_control_flow_attributes : require
#extension GL_KHR_shader_subgroup_basic : require
#extension GL_KHR_shader_subgroup_shuffle : require
// 16 lanes per token, indexed idst + hc*isrc: idst in bits 0..1, isrc in bits 2..3,
// so subgroupShuffleXor by 1|2 reduces a row and by 4|8 a column.
layout(constant_id = 0) const uint SUBGROUP_SIZE = 32;
layout(local_size_x_id = 0, local_size_y = 4, local_size_z = 1) in;
layout(push_constant) uniform parameter
{
uint n_tokens;
uint nbm0; uint nbm1; // mixes
uint nbs0; // scale
uint nbb0; // base
uint nbd0; uint nbd1; uint nbd2; // dst
uint m_offset;
uint s_offset;
uint b_offset;
uint d_offset;
float eps;
uint n_iter;
};
layout(binding = 0, std430) readonly buffer M { float data_m[]; };
layout(binding = 1, std430) readonly buffer S { float data_s[]; };
layout(binding = 2, std430) readonly buffer B { float data_b[]; };
layout(binding = 3, std430) writeonly buffer D { float data_d[]; };
const uint hc = 4;
const uint comb_offset = 2 * hc;
const uint TOKENS_PER_SUBGROUP = SUBGROUP_SIZE / 16;
void main() {
const uint lane = gl_SubgroupInvocationID;
const uint blk = lane >> 4; // which 16-lane block, i.e. which token
const uint idx = lane & 15; // idst + hc*isrc
const uint sg = gl_WorkGroupID.x * gl_WorkGroupSize.y + gl_SubgroupID;
const uint it = sg * TOKENS_PER_SUBGROUP + blk;
// no early return, the shuffles need every lane; out-of-range blocks compute a discarded value
const bool in_range = it < n_tokens;
const float scale_comb = data_s[s_offset + 2 * nbs0];
float v = 0.0f;
if (in_range) {
v = data_m[m_offset + (comb_offset + idx) * nbm0 + it * nbm1] * scale_comb
+ data_b[b_offset + (comb_offset + idx) * nbb0];
}
// Softmax across destinations: the four lanes sharing an isrc.
float vmax = max(v, subgroupShuffleXor(v, 1));
vmax = max(vmax, subgroupShuffleXor(vmax, 2));
v = exp(v - vmax);
float sum = v + subgroupShuffleXor(v, 1);
sum += subgroupShuffleXor(sum, 2);
v = v / sum + eps;
// Normalize columns: equal destination indices are four lanes apart.
sum = v + subgroupShuffleXor(v, 4);
sum += subgroupShuffleXor(sum, 8);
v /= sum + eps;
for (uint i = 1; i < n_iter; ++i) {
sum = v + subgroupShuffleXor(v, 1);
sum += subgroupShuffleXor(sum, 2);
v /= sum + eps;
sum = v + subgroupShuffleXor(v, 4);
sum += subgroupShuffleXor(sum, 8);
v /= sum + eps;
}
if (in_range) {
const uint idst = idx & 3;
const uint isrc = idx >> 2;
data_d[d_offset + idst * nbd0 + isrc * nbd1 + it * nbd2] = v;
}
}
@@ -0,0 +1,83 @@
#version 450
#extension GL_EXT_control_flow_attributes : require
// Fan one stream back out to hc streams and add the combination-weighted
// residuals:
//
// dst[i0, idst, it] = x[i0, it]*post[idst, it]
// + sum_isrc residual[i0, isrc, it]*comb[idst, isrc, it]
layout(constant_id = 0) const uint BLOCK_SIZE = 256;
layout(local_size_x_id = 0, local_size_y = 1, local_size_z = 1) in;
layout(push_constant) uniform parameter
{
uint n_embd;
uint n_tokens;
uint nbx0; uint nbx1; // x
uint nbr0; uint nbr1; uint nbr2; // residual
uint nbp0; uint nbp1; // post
uint nbc0; uint nbc1; uint nbc2; // comb
uint nbd0; uint nbd1; uint nbd2; // dst
uint x_offset;
uint r_offset;
uint p_offset;
uint c_offset;
uint d_offset;
};
layout(binding = 0, std430) readonly buffer X { float data_x[]; };
layout(binding = 1, std430) readonly buffer R { float data_r[]; };
layout(binding = 2, std430) readonly buffer P { float data_p[]; };
layout(binding = 3, std430) readonly buffer C { float data_c[]; };
layout(binding = 4, std430) writeonly buffer D { float data_d[]; };
const uint hc = 4;
shared float post_s[hc];
shared float comb_s[hc * hc];
void main() {
const uint tid = gl_LocalInvocationID.x;
const uint it = gl_WorkGroupID.y;
if (tid < hc) {
post_s[tid] = data_p[p_offset + tid * nbp0 + it * nbp1];
}
if (tid < hc * hc) {
const uint idst = tid & 3;
const uint isrc = tid >> 2;
comb_s[tid] = data_c[c_offset + idst * nbc0 + isrc * nbc1 + it * nbc2];
}
barrier();
// After the barrier, so every invocation reaches it.
const uint i0 = gl_WorkGroupID.x * BLOCK_SIZE + tid;
if (i0 >= n_embd) {
return;
}
const float xv = data_x[x_offset + i0 * nbx0 + it * nbx1];
const uint rb = r_offset + i0 * nbr0 + it * nbr2;
float r[hc];
[[unroll]]
for (uint isrc = 0; isrc < hc; ++isrc) {
r[isrc] = data_r[rb + isrc * nbr1];
}
[[unroll]]
for (uint idst = 0; idst < hc; ++idst) {
float result = xv * post_s[idst];
[[unroll]]
for (uint isrc = 0; isrc < hc; ++isrc) {
result = fma(r[isrc], comb_s[idst + hc * isrc], result);
}
data_d[d_offset + i0 * nbd0 + idst * nbd1 + it * nbd2] = result;
}
}
@@ -0,0 +1,59 @@
#version 450
#extension GL_EXT_control_flow_attributes : require
// Collapse the hc residual streams of a token into one, weighted per stream:
//
// dst[i0, it] = sum_ih x[i0, ih, it] * weights[ih, it]
layout(constant_id = 0) const uint BLOCK_SIZE = 256;
layout(local_size_x_id = 0, local_size_y = 1, local_size_z = 1) in;
layout(push_constant) uniform parameter
{
uint n_embd;
uint n_tokens;
uint nbx0; uint nbx1; uint nbx2; // x
uint nbw0; uint nbw1; // weights
uint nbd0; uint nbd1; // dst
uint x_offset;
uint w_offset;
uint d_offset;
};
layout(binding = 0, std430) readonly buffer X { float data_x[]; };
layout(binding = 1, std430) readonly buffer W { float data_w[]; };
layout(binding = 2, std430) writeonly buffer D { float data_d[]; };
const uint hc = 4;
shared float w[hc];
void main() {
const uint tid = gl_LocalInvocationID.x;
const uint it = gl_WorkGroupID.y;
if (tid < hc) {
w[tid] = data_w[w_offset + tid * nbw0 + it * nbw1];
}
barrier();
// After the barrier, so every invocation reaches it.
const uint i0 = gl_WorkGroupID.x * BLOCK_SIZE + tid;
if (i0 >= n_embd) {
return;
}
const uint xb = x_offset + i0 * nbx0 + it * nbx2;
float result = 0.0f;
[[unroll]]
for (uint ih = 0; ih < hc; ++ih) {
result = fma(data_x[xb + ih * nbx1], w[ih], result);
}
data_d[d_offset + i0 * nbd0 + it * nbd1] = result;
}
@@ -27,10 +27,10 @@ void main() {
const uint i11 = gid_z / p.ne12;
const uint i12 = gid_z % p.ne12;
const uint i01 = data_b[i10*p.nb10 + i11*p.nb11 + i12*p.nb12];
const uint i01 = data_b[get_boffset() + i10*p.nb10 + i11*p.nb11 + i12*p.nb12];
const uint a_offset = i01*p.nb01 + i11*p.nb02 + i12*p.nb03;
const uint d_offset = i10*p.nb21 + i11*p.nb22 + i12*p.nb23;
const uint a_offset = get_aoffset() + i01*p.nb01 + i11*p.nb02 + i12*p.nb03;
const uint d_offset = get_doffset() + i10*p.nb21 + i11*p.nb22 + i12*p.nb23;
const uint ib = a_offset + i00/QUANT_K; // block index
const uint iqs = (i00%QUANT_K)/QUANT_R; // quant index
@@ -1042,6 +1042,9 @@ void process_shaders() {
string_to_spv("fwht_f32", "fwht.comp", {});
string_to_spv("fwht_shmem_f32", "fwht.comp", {{"FWHT_SHMEM", "1"}});
string_to_spv("count_equal_i32", "count_equal.comp", merge_maps(base_dict, {{"A_TYPE", "int"}, {"B_TYPE", "int"}, {"D_TYPE", "int"}}));
string_to_spv("dsv4_hc_comb_f32", "dsv4_hc_comb.comp", {});
string_to_spv("dsv4_hc_pre_f32", "dsv4_hc_pre.comp", {});
string_to_spv("dsv4_hc_post_f32", "dsv4_hc_post.comp", {});
string_to_spv("cumsum_f32", "cumsum.comp", merge_maps(base_dict, {{"A_TYPE", "float"}, {"D_TYPE", "float"}}));
string_to_spv("cumsum_multipass1_f32", "cumsum_multipass1.comp", merge_maps(base_dict, {{"A_TYPE", "float"}, {"D_TYPE", "float"}}));
string_to_spv("cumsum_multipass2_f32", "cumsum_multipass2.comp", merge_maps(base_dict, {{"A_TYPE", "float"}, {"D_TYPE", "float"}}));
+15 -5
View File
@@ -4324,12 +4324,22 @@ static bool ggml_backend_webgpu_device_supports_op(ggml_backend_dev_t dev, const
src0->type == GGML_TYPE_F32 && (src1->type == GGML_TYPE_I64 || src1->type == GGML_TYPE_I32));
break;
case GGML_OP_GET_ROWS:
if (src0->type == GGML_TYPE_F32 || src0->type == GGML_TYPE_F16 || ggml_webgpu_supported_qtype(src0->type)) {
supports_op = (op->type == GGML_TYPE_F32);
} else if (src0->type == GGML_TYPE_I32) {
supports_op = op->type == GGML_TYPE_I32;
{
const size_t storage_alignment =
ctx->webgpu_global_ctx->capabilities.limits.minStorageBufferOffsetAlignment;
const size_t src_address_unit =
src0->type == GGML_TYPE_F32 && op->ne[0] % 4 == 0 ? 4 * sizeof(float) : ggml_type_size(src0->type);
if (ggml_webgpu_tensor_misalignment(src0, storage_alignment) % src_address_unit != 0) {
break;
}
if (src0->type == GGML_TYPE_F32 || src0->type == GGML_TYPE_F16 ||
ggml_webgpu_supported_qtype(src0->type)) {
supports_op = (op->type == GGML_TYPE_F32);
} else if (src0->type == GGML_TYPE_I32) {
supports_op = op->type == GGML_TYPE_I32;
}
break;
}
break;
case GGML_OP_MUL_MAT:
{
switch (src1->type) {
+1
View File
@@ -215,6 +215,7 @@ class Keys:
KV_LORA_RANK_SWA = "{arch}.attention.kv_lora_rank_swa"
SHARED_KV_LAYERS = "{arch}.attention.shared_kv_layers"
SLIDING_WINDOW_PATTERN = "{arch}.attention.sliding_window_pattern"
RECURRENT_LAYERS = "{arch}.attention.recurrent_layers"
TEMPERATURE_SCALE = "{arch}.attention.temperature_scale"
ROPE_PATTERN = "{arch}.attention.rope_pattern"
+3
View File
@@ -841,6 +841,9 @@ class GGUFWriter:
else:
self.add_array(key, value)
def add_recurrent_layers(self, value: Sequence[bool]) -> None:
self.add_array(Keys.Attention.RECURRENT_LAYERS.format(arch=self.arch), value)
def add_rope_pattern(self, value: Sequence[bool]) -> None:
self.add_array(Keys.Attention.ROPE_PATTERN.format(arch=self.arch), value)
+38
View File
@@ -385,6 +385,7 @@ class TensorNameMap:
MODEL_TENSOR.ATTN_SINKS: (
"model.layers.{bid}.self_attn.sinks", # openai-moe
"model.layers.{bid}.self_attn.attention_sink_bias", # mimov2
"model.layers.{bid}.self_attn.learnable_sink_param", # hy-v4
),
MODEL_TENSOR.ATTN_GATE: (
@@ -392,6 +393,7 @@ class TensorNameMap:
"model.layers.{bid}.linear_attn.in_proj_z", # qwen3.5
"model.layers.{bid}.self_attn.g_proj", # step3.5 head-wise attention gate
"model.layers.{bid}.self_attn.output_gate", # minimax-01
"model.layers.{bid}.self_attn.linear_gate", # hy-v4
),
# Feed-forward norm
@@ -1329,6 +1331,42 @@ class TensorNameMap:
"model.layers.{bid}.self_attn.index_q_norm", # MSA
),
MODEL_TENSOR.HC_ATTN_FN: (
"model.layers.{bid}.hc_attn_layer.hc_pre.hc_fn", # hy-v4
),
MODEL_TENSOR.HC_ATTN_BASE: (
"model.layers.{bid}.hc_attn_layer.hc_pre.hc_base", # hy-v4
),
MODEL_TENSOR.HC_ATTN_SCALE: (
"model.layers.{bid}.hc_attn_layer.hc_pre.hc_scale", # hy-v4
),
MODEL_TENSOR.HC_FFN_FN: (
"model.layers.{bid}.hc_mlp_layer.hc_pre.hc_fn", # hy-v4
),
MODEL_TENSOR.HC_FFN_BASE: (
"model.layers.{bid}.hc_mlp_layer.hc_pre.hc_base", # hy-v4
),
MODEL_TENSOR.HC_FFN_SCALE: (
"model.layers.{bid}.hc_mlp_layer.hc_pre.hc_scale", # hy-v4
),
MODEL_TENSOR.HC_HEAD_FN: (
"model.hc_head.hc_head_fn", # hy-v4
),
MODEL_TENSOR.HC_HEAD_BASE: (
"model.hc_head.hc_head_base", # hy-v4
),
MODEL_TENSOR.HC_HEAD_SCALE: (
"model.hc_head.hc_head_scale", # hy-v4
),
############################################################################
# TODO: these do not belong to block_mappings_cfg - move them to mappings_cfg
MODEL_TENSOR.ENC_OUTPUT_NORM: (
+96 -15
View File
@@ -2336,27 +2336,40 @@ struct test_get_rows : public test_case {
const int r; // rows to get
const int be1; // batch size
const int be2; // batch size
const bool v; // view (non-contiguous src1)
const bool v; // view src1
const bool vs0; // view src0
std::string vars() override {
return VARS_TO_STR7(type, n, m, r, be1, be2, v);
return VARS_TO_STR8(type, n, m, r, be1, be2, v, vs0);
}
test_get_rows(ggml_type type = GGML_TYPE_F32, int n = 10, int m = 5, int r = 3, int be1 = 1, int be2 = 1, bool v = false)
: type(type), n(n), m(m), r(r), be1(be1), be2(be2), v(v) {}
test_get_rows(ggml_type type = GGML_TYPE_F32, int n = 10, int m = 5, int r = 3, int be1 = 1, int be2 = 1, bool v = false, bool vs0 = false)
: type(type), n(n), m(m), r(r), be1(be1), be2(be2), v(v), vs0(vs0) {}
ggml_tensor * build_graph(ggml_context * ctx) override {
ggml_tensor * in = ggml_new_tensor_4d(ctx, type, n, m, be1, be2);
ggml_set_name(in, "in");
ggml_tensor * in;
if (vs0) {
const int offset_rows = 3;
const int padded_m = m + offset_rows;
ggml_tensor * in_padded = ggml_new_tensor_4d(ctx, type, n, padded_m, be1, be2);
ggml_set_name(in_padded, "in_padded");
in = ggml_view_4d(ctx, in_padded, n, m, be1, be2,
in_padded->nb[1], in_padded->nb[2], in_padded->nb[3],
offset_rows * in_padded->nb[1]);
ggml_set_name(in, "in_view");
} else {
in = ggml_new_tensor_4d(ctx, type, n, m, be1, be2);
ggml_set_name(in, "in");
}
ggml_tensor * rows = ggml_new_tensor_3d(ctx, GGML_TYPE_I32, r, be1, be2);
ggml_tensor * rows = ggml_new_tensor_3d(ctx, GGML_TYPE_I32, v ? r + 1 : r, be1, be2);
ggml_set_name(rows, "rows");
if (v) {
rows = ggml_view_3d(ctx, rows, r/2, be1, be2, rows->nb[1], rows->nb[2], 0);
rows = ggml_view_3d(ctx, rows, r/2, be1, be2, rows->nb[1], rows->nb[2], rows->nb[0]);
ggml_set_name(rows, "view_of_rows");
}
const bool grad_supported = ggml_is_matrix(in) && ggml_is_vector(rows);
const bool grad_supported = !vs0 && ggml_is_matrix(in) && ggml_is_vector(rows);
if (grad_supported) {
ggml_set_param(in);
// rows is a constant input -> no gradients
@@ -2370,14 +2383,16 @@ struct test_get_rows : public test_case {
void initialize_tensors(ggml_context * ctx) override {
for (ggml_tensor * t = ggml_get_first_tensor(ctx); t != NULL; t = ggml_get_next_tensor(ctx, t)) {
if (ggml_is_view_op(t->op)) {
continue;
}
if (t->type == GGML_TYPE_I32) {
if (ggml_is_view_op(t->op)) { continue; }
// rows
std::vector<int> data(r*be1*be2);
for (int i = 0; i < r*be1*be2; i++) {
std::vector<int> data(ggml_nelements(t));
for (size_t i = 0; i < data.size(); i++) {
data[i] = rand() % m;
}
ggml_backend_tensor_set(t, data.data(), 0, r * be1 * be2 * sizeof(int));
ggml_backend_tensor_set(t, data.data(), 0, data.size() * sizeof(int));
} else {
init_tensor_uniform(t);
}
@@ -7191,6 +7206,49 @@ struct test_group_norm_mul_add : public test_case {
}
};
// GGML_OP_L2_NORM x N: independent same-shape norms in one graph (strided qkv views or
// contiguous), consuming adds nested so the norms stay adjacent in the graph.
struct test_l2_norm_batch : public test_case {
const ggml_type type;
const std::array<int64_t, 4> ne;
const int n_norms;
const float eps;
const bool strided;
std::string vars() override { return VARS_TO_STR5(type, ne, n_norms, eps, strided); }
std::string op_desc(ggml_tensor * t) override { GGML_UNUSED(t); return "L2_NORM_BATCH"; }
bool run_whole_graph() override { return true; }
test_l2_norm_batch(ggml_type type = GGML_TYPE_F32, std::array<int64_t, 4> ne = { 128, 16, 16, 1 },
int n_norms = 4, float eps = 1e-12f, bool strided = true)
: type(type), ne(ne), n_norms(n_norms), eps(eps), strided(strided) {}
ggml_tensor * build_graph(ggml_context * ctx) override {
GGML_ASSERT(n_norms >= 2 && n_norms <= 8);
ggml_tensor * parent = nullptr;
if (strided) {
parent = ggml_new_tensor_4d(ctx, type, ne[0], ne[1] * n_norms, ne[2], ne[3]); // qkv buffer
}
ggml_tensor * norms[8];
for (int t = 0; t < n_norms; ++t) {
ggml_tensor * src;
if (strided) {
src = ggml_view_4d(ctx, parent, ne[0], ne[1], ne[2], ne[3], parent->nb[1], parent->nb[2],
parent->nb[3], t * ne[1] * parent->nb[1]);
} else {
src = ggml_new_tensor(ctx, type, 4, ne.data());
}
norms[t] = ggml_l2_norm(ctx, src, eps);
}
ggml_tensor * out = norms[n_norms - 1];
for (int t = n_norms - 2; t >= 0; --t) {
out = ggml_add(ctx, norms[t], out);
}
ggml_set_name(out, "out");
return out;
}
};
// GGML_OP_L2_NORM
struct test_l2_norm : public test_case {
const ggml_type type;
@@ -8792,6 +8850,11 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
test_cases.emplace_back(new test_dsv4_hc_comb(17, 4));
test_cases.emplace_back(new test_dsv4_hc_comb(257, 8));
test_cases.emplace_back(new test_dsv4_hc_comb(17, 20));
// production n_iter (DeepSeek-V4 uses 20) across batch sizes that cross
// subgroup and workgroup boundaries; 1 = single-token decode
for (int64_t n_tokens : {1, 256, 336, 512, 513, 1024, 2048}) {
test_cases.emplace_back(new test_dsv4_hc_comb(n_tokens, 20));
}
test_cases.emplace_back(new test_dsv4_hc_pre(1, 1));
test_cases.emplace_back(new test_dsv4_hc_pre(31, 17));
@@ -8848,13 +8911,17 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
for (ggml_type type : all_types) {
for (int b : {1, 7}) {
for (bool v : {false, true}) {
test_cases.emplace_back(new test_get_rows(type, 256, 5, 4, b, 1, v));
for (bool vs0 : {false, true}) {
test_cases.emplace_back(new test_get_rows(type, 256, 5, 4, b, 1, v, vs0));
}
}
}
}
for (int b : {1, 7}) {
for (bool v : {false, true}) {
test_cases.emplace_back(new test_get_rows(GGML_TYPE_I32, 256, 5, 4, b, 1, v));
for (bool vs0 : {false, true}) {
test_cases.emplace_back(new test_get_rows(GGML_TYPE_I32, 256, 5, 4, b, 1, v, vs0));
}
}
}
@@ -9471,6 +9538,10 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
test_cases.emplace_back(new test_l2_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, eps, false));
test_cases.emplace_back(new test_l2_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, eps, true));
test_cases.emplace_back(new test_l2_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, eps, false, true));
// sibling batching: strided (production shape) and contiguous, 2 and 4 wide
test_cases.emplace_back(new test_l2_norm_batch(GGML_TYPE_F32, { n, 5, 4, 3 }, 2, eps, true));
test_cases.emplace_back(new test_l2_norm_batch(GGML_TYPE_F32, { n, 5, 4, 3 }, 4, eps, true));
test_cases.emplace_back(new test_l2_norm_batch(GGML_TYPE_F32, { n, 5, 4, 3 }, 4, eps, false));
}
// row lengths that are not a multiple of 32, for the scalar (33) and float4 (132, 260) paths
for (uint32_t n : { 33, 132, 260 }) {
@@ -11157,6 +11228,16 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_perf() {
}
}
// launch-overhead isolation: single L2_NORM launch vs batched siblings at the GDN
// production shape (strided qkv views) -- perf-mode only, the eval list has its own
// 2/4-wide coverage
for (int n : { 128, 256 }) {
test_cases.emplace_back(new test_l2_norm(GGML_TYPE_F32, { n, 16, 16, 1 }, 1e-12f, false, false));
test_cases.emplace_back(new test_l2_norm_batch(GGML_TYPE_F32, { n, 16, 16, 1 }, 2, 1e-12f, true));
test_cases.emplace_back(new test_l2_norm_batch(GGML_TYPE_F32, { n, 16, 16, 1 }, 4, 1e-12f, true));
}
return test_cases;
}
+137 -4
View File
@@ -15,6 +15,7 @@
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <map>
#include <thread>
#include <string>
@@ -139,7 +140,7 @@ static const std::vector<std::string> hole = {
"dflash-model-Q8_0.gguf",
};
// unsloth-style naming with UD quants and a suffix MTP file
// unsloth-style naming with UD quants and an uppercase infix MTP head
static const std::vector<std::string> unsloth = {
"model-UD-Q8_K_XL.gguf",
"mmproj-BF16.gguf",
@@ -180,6 +181,13 @@ static const std::vector<std::string> spark = {
"dspark-model-MXFP4.gguf",
};
// main weights plus a trailing-form mtp head, as in unsloth
// gemma-4-E2B-it-GGUF (Model-Q4_0-mtp.gguf)
static const std::vector<std::string> trailing = {
"model-BF16.gguf",
"model-BF16-mtp.gguf",
};
// dspark outranks dflash in the type auto-selection
static const std::vector<std::string> dspark_dflash = {
"model-Q8_0.gguf",
@@ -223,7 +231,7 @@ static const plan_case plan_cases[] = {
// no tag and no default match falls back to the first model in the listing
{"unsloth fallback", unsloth, "test/repo", "", true, true,
"model-UD-Q8_K_XL.gguf", {"model-UD-Q8_K_XL.gguf"},
"mmproj-BF16.gguf", "", "", "", ""},
"mmproj-BF16.gguf", "model-MTP-BF16.gguf", "", "", ""},
// explicit hf_file picks that exact file
{"flat hf_file", flat, "test/repo", "model-BF16.gguf", false, false,
@@ -258,10 +266,11 @@ static const plan_case plan_cases[] = {
"model-Q4_K_M.gguf", {"model-Q4_K_M.gguf"},
"", "mtp-model-Q4_0.gguf", "dflash-model-Q8_0.gguf", "", ""},
// the mtp- keyword is case sensitive, a suffix -MTP file is not discovered
// the uppercase infix MTP head is a sidecar too; it resolves at the
// nearest quant to the tag when the exact one does not exist
{"unsloth suffix mtp", unsloth, "test/repo:Q8_K_XL", "", true, false,
"model-UD-Q8_K_XL.gguf", {"model-UD-Q8_K_XL.gguf"},
"mmproj-BF16.gguf", "", "", "", ""},
"mmproj-BF16.gguf", "model-MTP-BF16.gguf", "", "", ""},
// vendor prefixes and the dot quant convention both match the tag,
// first match wins between two files at the same quant
@@ -283,6 +292,45 @@ static const plan_case plan_cases[] = {
{"spark tag sidecar", spark, "test/repo:BF16", "", true, false,
"", {},
"", "", "", "", "dspark-model-BF16.gguf"},
// a `<quant>-<sidecar>` tag resolves that sidecar alone, without a primary
{"hole quant-sidecar tag", hole, "test/repo:Q4_0-mtp", "", false, false,
"", {},
"", "mtp-model-Q4_0.gguf", "", "", ""},
{"spark quant-sidecar tag", spark, "test/repo:BF16-dspark", "", false, false,
"", {},
"", "", "", "", "dspark-model-BF16.gguf"},
// a bare sidecar tag resolves the sidecar at any quant
{"hole bare sidecar tag", hole, "test/repo:mtp", "", false, false,
"", {},
"", "mtp-model-Q4_0.gguf", "", "", ""},
// a trailing-form sidecar resolves as the sidecar alone, never as a primary
{"trailing quant-sidecar tag", trailing, "test/repo:BF16-mtp", "", false, false,
"", {},
"", "model-BF16-mtp.gguf", "", "", ""},
// the plain tag resolves the plain model; the trailing-token file is skipped
{"trailing plain tag", trailing, "test/repo:BF16", "", false, false,
"model-BF16.gguf", {"model-BF16.gguf"},
"", "", "", "", ""},
// a short-form sidecar (`mmproj-F16.gguf`) resolves by its bare quant tag
{"unsloth quant-sidecar tag", unsloth, "test/repo:BF16-mmproj", "", false, false,
"", {},
"mmproj-BF16.gguf", "", "", "", ""},
// an uppercase infix sidecar resolves through the lowercase tag
{"unsloth uppercase infix tag", unsloth, "test/repo:BF16-mtp", "", false, false,
"", {},
"", "model-MTP-BF16.gguf", "", "", ""},
// a sidecar token in the middle of the name resolves as the sidecar alone
{"subdir quant-sidecar tag", subdir, "test/repo:Q8_0-mtp", "", false, false,
"", {},
"", "model-mtp-Q8_0.gguf", "", "", ""},
};
static void check_plan(const plan_case & c) {
@@ -468,6 +516,90 @@ static void test_task_assembly() {
g_repos.clear();
}
//
// cache listing and removal against the isolated LLAMA_CACHE, using the
// same filename grammar the plan tests exercise above
//
static void cache_put(const std::string & repo, const std::string & path) {
namespace fs = std::filesystem;
auto local = fs::path(cached(repo, path));
fs::create_directories(local.parent_path());
{ std::ofstream(local) << "gguf"; }
auto repo_dir = local.parent_path().parent_path().parent_path();
auto refs = repo_dir / "refs";
fs::create_directories(refs);
auto ref = refs / "main";
if (!fs::exists(ref)) {
std::ofstream(ref) << COMMIT << "\n";
}
}
static bool cache_lists(const std::string & repo_tag) {
for (const auto & e : common_list_cached_models()) {
if (e.to_string() == repo_tag) {
return true;
}
}
return false;
}
static void test_cache_listing_and_remove() {
namespace fs = std::filesystem;
const std::string repo = "test/eh";
printf("test-model-resolution: cache listing and removal\n");
g_context = "cache fixture";
cache_put(repo, "gemma-4-E2B-it-BF16.gguf"); // main weights
cache_put(repo, "gemma-4-E2B-it-BF16-mtp.gguf"); // trailing-form draft head
cache_put(repo, "gemma-4-31B-it-MTP-BF16.gguf"); // uppercase infix draft head
cache_put(repo, "mmproj-gemma-4-E2B-it-BF16.gguf"); // mmproj sidecar
cache_put(repo, "mmproj-F16.gguf"); // short-form mmproj
cache_put(repo, "model-mtp-Q8_0.gguf"); // mid-name mtp sidecar
// a sidecar is listed under `<quant>-<sidecar>` in every form and case it
// can be named; only the token-less main weights stay a loadable model
REQUIRE(cache_lists("test/eh:BF16"));
REQUIRE(cache_lists("test/eh:BF16-mtp"));
REQUIRE(cache_lists("test/eh:BF16-mmproj"));
REQUIRE(cache_lists("test/eh:F16-mmproj"));
REQUIRE(cache_lists("test/eh:Q8_0-mtp"));
REQUIRE(!cache_lists("test/eh:MTP"));
// a plain quant tag removes the model files and leaves every sidecar,
// whatever form or case its name carries
g_context = "remove plain quant";
REQUIRE(common_download_remove("test/eh:BF16"));
REQUIRE(!fs::exists(cached(repo, "gemma-4-E2B-it-BF16.gguf")));
REQUIRE(fs::exists(cached(repo, "gemma-4-E2B-it-BF16-mtp.gguf")));
REQUIRE(fs::exists(cached(repo, "gemma-4-31B-it-MTP-BF16.gguf")));
REQUIRE(fs::exists(cached(repo, "mmproj-gemma-4-E2B-it-BF16.gguf")));
REQUIRE(fs::exists(cached(repo, "mmproj-F16.gguf")));
REQUIRE(fs::exists(cached(repo, "model-mtp-Q8_0.gguf")));
// a `<quant>-<sidecar>` tag removes every sidecar listed under it,
// across forms and cases
g_context = "remove quant-sidecar";
REQUIRE(common_download_remove("test/eh:BF16-mtp"));
REQUIRE(!fs::exists(cached(repo, "gemma-4-E2B-it-BF16-mtp.gguf")));
REQUIRE(!fs::exists(cached(repo, "gemma-4-31B-it-MTP-BF16.gguf")));
REQUIRE(fs::exists(cached(repo, "mmproj-gemma-4-E2B-it-BF16.gguf")));
// the short form removes by its bare quant tag
REQUIRE(common_download_remove("test/eh:F16-mmproj"));
REQUIRE(!fs::exists(cached(repo, "mmproj-F16.gguf")));
REQUIRE(common_download_remove("test/eh:Q8_0-mtp"));
REQUIRE(!fs::exists(cached(repo, "model-mtp-Q8_0.gguf")));
// a bare sidecar tag is ambiguous across quants and is rejected
g_context = "remove bare sidecar";
cache_put(repo, "mtp-Model-Q4_0.gguf");
REQUIRE(!common_download_remove("test/eh:mtp"));
REQUIRE(fs::exists(cached(repo, "mtp-Model-Q4_0.gguf")));
}
int main(void) {
// unbuffered, so a crash cannot swallow the reports already printed
setvbuf(stdout, nullptr, _IONBF, 0);
@@ -496,6 +628,7 @@ int main(void) {
test_plan_resolution();
test_task_assembly();
test_cache_listing_and_remove();
server.stop();
server_thread.join();
+81 -102
View File
@@ -80,18 +80,19 @@ struct server_lru_sched {
}
// returns "" if no model can be given up
std::string pick_victim(std::unique_lock<std::mutex> & lk, const std::string & exclude) {
std::string pick_victim(std::unique_lock<std::mutex> & lk) {
check_lock(lk);
std::string victim;
int64_t victim_last_used = 0;
for (const auto & m : models.mapping) {
if (m.first == exclude) {
continue;
}
// a busy model is mid-request, one still coming up has no request to finish
if (m.second.req_count != 0 || !m.second.meta.is_ready_or_sleep()) {
continue;
}
// already on its way out, or a queued request wants it
if (models.stopping_models.count(m.first) || find(m.first)) {
continue;
}
if (victim.empty() || m.second.meta.last_used < victim_last_used) {
victim = m.first;
victim_last_used = m.second.meta.last_used;
@@ -109,7 +110,7 @@ struct server_lru_sched {
SRV_INF("request for name=%s joined the queue, %d waiting\n", model_id.c_str(), e->n_waiters);
return;
}
queue.push_back({ model_id, 1, false, false });
queue.push_back({ model_id, 1, false });
SRV_INF("models_max reached, request for name=%s queued at position %zu\n",
model_id.c_str(), queue.size());
}
@@ -144,85 +145,67 @@ struct server_lru_sched {
return true;
}
// ok means the model is up: drop the entry, the other waiters just watch its status now
// on failure the entry is back in line; on success it stays until its waiters leave,
// so the model coming up is never picked as a victim before they use it
void claim_done(std::unique_lock<std::mutex> & lk, const std::string & model_id, bool ok) {
check_lock(lk);
if (ok) {
return;
}
for (auto it = queue.begin(); it != queue.end(); ++it) {
if (it->model_id == model_id) {
if (ok) {
queue.erase(it);
} else {
it->loading = false;
}
it->loading = false;
return;
}
}
}
// a model is on its way out for this entry, so other requests do not also give up one
void mark_slot_pending(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
// evict idle models while queued requests outnumber the slots that are free or being freed
// caller must hold models.mutex; never blocks, so it is safe from any thread
void tick(std::unique_lock<std::mutex> & lk) {
check_lock(lk);
if (entry_t * e = find(model_id)) {
e->slot_pending = true;
if (models.base_params.models_max <= 0 || queue.empty()) {
return;
}
}
// model_id went idle: give up its slot if a queued request needs one
// thread-safe, caller must NOT hold models.mutex
void on_model_idle(const std::string & model_id) {
if (models.base_params.models_max <= 0) {
return; // no limit, nothing is ever queued
}
{
std::unique_lock<std::mutex> lk(models.mutex);
if (queue.empty()) {
return;
}
size_t promised = 0;
bool has_unserved = false;
for (const auto & e : queue) {
if (e.needs_slot()) {
has_unserved = true;
} else {
promised++;
}
}
if (!has_unserved) {
return;
}
if ((int) count_running() - (int) promised < models.base_params.models_max) {
return; // a slot is already on its way
}
// never give up a model that a queued request wants
for (const auto & e : queue) {
if (e.model_id == model_id) {
return;
}
}
auto it = models.mapping.find(model_id);
if (it == models.mapping.end() || it->second.req_count != 0 || !it->second.meta.is_ready_or_sleep()) {
return;
}
for (auto & e : queue) {
if (!e.slot_pending) {
e.slot_pending = true;
break;
int n_running = 0;
int n_stopping = 0;
for (const auto & m : models.mapping) {
if (m.second.meta.is_running()) {
n_running++;
if (models.stopping_models.count(m.first)) {
n_stopping++;
}
}
}
SRV_INF("model name=%s went idle, giving up its slot to a queued request\n", model_id.c_str());
models.unload(model_id);
int n_needed = 0;
int n_claimed = 0; // claimed the slot, but load() has not spawned yet
for (const auto & e : queue) {
if (!e.loading) {
n_needed++;
continue;
}
auto it = models.mapping.find(e.model_id);
if (it != models.mapping.end() && !it->second.meta.is_running()) {
n_claimed++;
}
}
int n_free = models.base_params.models_max - n_running + n_stopping - n_claimed;
while (n_free < n_needed) {
std::string victim = pick_victim(lk);
if (victim.empty()) {
return; // all remaining models are busy, wait for a request to end
}
SRV_INF("evicting idle LRU name=%s for a queued request\n", victim.c_str());
models.request_stop(victim);
n_free++;
}
}
private:
struct entry_t {
std::string model_id;
int n_waiters; // requests waiting for this model
bool slot_pending; // a model is already being evicted for this entry
bool loading; // one of the waiters is doing the load right now
// a slot is already coming, or already taken by the load in flight
bool needs_slot() const { return !slot_pending && !loading; }
int n_waiters; // requests waiting for this model
bool loading; // one of the waiters is doing the load right now
};
entry_t * find(const std::string & model_id) {
@@ -946,7 +929,7 @@ void server_models::unload_lru() {
if (sched->has_capacity(lk)) {
return;
}
lru_model_name = sched->pick_victim(lk, "");
lru_model_name = sched->pick_victim(lk);
}
if (!lru_model_name.empty()) {
SRV_INF("models_max limit reached, removing LRU name=%s\n", lru_model_name.c_str());
@@ -1169,6 +1152,11 @@ void server_models::load(const std::string & name, const load_options & opts) {
cv.notify_all();
}
void server_models::request_stop(const std::string & name) {
stopping_models.insert(name);
cv_stop.notify_all();
}
void server_models::unload(const std::string & name) {
std::unique_lock<std::mutex> lk(mutex);
auto it = mapping.find(name);
@@ -1182,13 +1170,12 @@ void server_models::unload(const std::string & name) {
});
} else if (it->second.meta.is_running()) {
SRV_INF("stopping model instance name=%s\n", name.c_str());
stopping_models.insert(name);
if (it->second.meta.status == SERVER_MODEL_STATUS_LOADING) {
// special case: if model is in loading state, unloading means force-killing it
SRV_WRN("model name=%s is still loading, force-killing\n", name.c_str());
it->second.subproc->terminate();
}
cv_stop.notify_all();
request_stop(name);
// status change will be handled by the managing thread
} else {
SRV_WRN("model instance name=%s is not running\n", name.c_str());
@@ -1206,8 +1193,7 @@ void server_models::unload_all() {
inst.subproc->stopped.store(true, std::memory_order_relaxed);
} else if (inst.meta.is_running()) {
SRV_INF("stopping model instance name=%s\n", name.c_str());
stopping_models.insert(name);
cv_stop.notify_all();
request_stop(name);
// status change will be handled by the managing thread
}
// moving the thread to join list to avoid deadlock
@@ -1234,6 +1220,8 @@ void server_models::update_status(const std::string & name, const update_status_
if (!args.progress.is_null()) {
meta.progress = args.progress;
}
// a model that comes up idle or goes down changes the slot count for queued requests
sched->tick(lk);
}
// broadcast status change to SSE
{
@@ -1332,14 +1320,22 @@ bool server_models::remove(const std::string & name) {
return true;
}
// join before erasing - thread no longer acquires this mutex
if (it->second.th.joinable()) {
it->second.th.join();
// on the cancelled-download path the status flips to DOWNLOADED while the
// monitoring thread still has a mutex-guarded step left, so joining under
// the lock would deadlock - join outside, as load_models() does
std::thread th = std::move(it->second.th);
mapping.erase(name);
lk.unlock();
// join first so the monitoring thread's final mutex-guarded cleanup cannot
// race the disk removal, then remove from disk without holding the lock
// (best-effort: cancelled downloads may have no cached files)
if (th.joinable()) {
th.join();
}
// remove from disk (best-effort: cancelled downloads may have no cached files)
bool ok = common_download_remove(name);
mapping.erase(name);
if (!ok) {
SRV_WRN("removing model name=%s from disk returned false (no cached files?)\n", name.c_str());
}
@@ -1380,13 +1376,11 @@ bool server_models::ensure_model_ready(const std::string & name, const std::func
bool queued = false;
bool did_load = false;
std::string victim;
{
std::unique_lock<std::mutex> lk(mutex);
auto it = mapping.find(name);
if (it != mapping.end() && it->second.meta.status == SERVER_MODEL_STATUS_UNLOADED) {
bool has_capacity = sched->has_capacity(lk);
if (has_capacity && sched->queue_empty(lk)) {
if (sched->has_capacity(lk) && sched->queue_empty(lk)) {
lk.unlock();
SRV_INF("model name=%s is not loaded, loading...\n", name.c_str());
load(name);
@@ -1394,21 +1388,11 @@ bool server_models::ensure_model_ready(const std::string & name, const std::func
} else {
// also queue when a slot looks free but others wait already, else they starve
sched->join(lk, name);
sched->tick(lk);
queued = true;
if (!has_capacity) {
// an idle model may sit here right now, do not wait for a request to end
victim = sched->pick_victim(lk, name);
if (!victim.empty()) {
sched->mark_slot_pending(lk, name);
}
}
}
}
}
if (!victim.empty()) {
SRV_INF("evicting idle LRU name=%s to make room for name=%s\n", victim.c_str(), name.c_str());
unload(victim);
}
// while queued, this is also where the load happens: the head of the queue does it
SRV_INF("waiting until model name=%s is fully loaded...\n", name.c_str());
@@ -1470,9 +1454,7 @@ bool server_models::ensure_model_ready(const std::string & name, const std::func
}
lk.lock();
sched->claim_done(lk, name, ok);
if (ok) {
queued = false; // entry is gone, the other waiters watch the status now
}
sched->tick(lk);
continue;
}
@@ -1480,6 +1462,7 @@ bool server_models::ensure_model_ready(const std::string & name, const std::func
}
} catch (...) {
leave_queue();
sched->tick(lk); // a slot freed for this waiter goes to the next one
throw;
}
leave_queue();
@@ -1529,18 +1512,14 @@ server_http_res_ptr server_models::proxy_request(const server_http_req & req, co
);
proxy->cleanup = [this, name]() {
bool went_idle = false;
{
std::unique_lock<std::mutex> lk(mutex);
auto it = mapping.find(name);
if (it != mapping.end() && it->second.req_count > 0) {
it->second.req_count--;
went_idle = it->second.req_count == 0;
std::unique_lock<std::mutex> lk(mutex);
auto it = mapping.find(name);
if (it != mapping.end() && it->second.req_count > 0) {
it->second.req_count--;
if (it->second.req_count == 0) {
sched->tick(lk);
}
}
if (went_idle) {
sched->on_model_idle(name);
}
};
return proxy;
+4
View File
@@ -216,6 +216,10 @@ private:
// not thread-safe, caller must hold mutex
void add_model(server_model_meta && meta);
// ask the monitoring thread to stop a running instance
// not thread-safe, caller must hold mutex
void request_stop(const std::string & name);
// notify SSE clients
void notify_sse(const std::string & event, const std::string & model_id, const json & data = nullptr);
+20
View File
@@ -297,6 +297,26 @@ def test_router_queue_is_fifo():
assert first.done_at < second.done_at, "queue was not served in arrival order"
def test_router_queue_two_waiters_share_one_eviction():
"""two requests that both find the same idle model must both be served in the end"""
global server
server.models_max = 1
server.start()
_load_model_and_wait(MODEL_A, timeout=120)
# both arrive while MODEL_A is idle, so both want its slot; only one eviction can happen
first = _Bg(lambda: _tokenize(MODEL_B)).start()
second = _Bg(lambda: _tokenize(MODEL_C)).start()
first.join(90)
second.join(90)
first.assert_ok("first queued request")
second.assert_ok("second queued request")
assert _get_model_status(MODEL_A) == "unloaded"
def test_router_no_models_autoload():
global server
server.no_models_autoload = True
+8 -5
View File
@@ -71,7 +71,7 @@
"svelte-check": "4.6.0",
"svelte-sonner": "1.1.1",
"tailwind-merge": "3.6.0",
"tailwind-variants": "3.2.2",
"tailwind-variants": "3.3.1",
"tailwindcss": "4.3.0",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
@@ -16048,13 +16048,13 @@
}
},
"node_modules/tailwind-variants": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
"integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz",
"integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.x",
"node": ">=16.9.x",
"pnpm": ">=7.x"
},
"peerDependencies": {
@@ -16064,6 +16064,9 @@
"peerDependenciesMeta": {
"tailwind-merge": {
"optional": true
},
"tailwindcss": {
"optional": true
}
}
},
+2 -1
View File
@@ -13,6 +13,7 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"reset": "rm -rf .svelte-kit node_modules",
"format": "eslint --fix . && prettier --write .",
"format:files": "sh -c 'eslint --fix \"$@\" && prettier --write \"$@\"' sh",
"lint": "prettier --check . && eslint .",
"test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test",
@@ -90,7 +91,7 @@
"svelte-check": "4.6.0",
"svelte-sonner": "1.1.1",
"tailwind-merge": "3.6.0",
"tailwind-variants": "3.2.2",
"tailwind-variants": "3.3.1",
"tailwindcss": "4.3.0",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
+12 -18
View File
@@ -18,20 +18,16 @@ import type {
ApiErrorResponse,
ApiLlamaCppServerProps,
ApiModelDataEntry,
ApiModelListResponse,
ApiModelLoadStage,
ApiModelsDownloadRequest,
ApiModelsDownloadResponse,
ApiModelsListResponse,
ApiModelsLoadResponse,
ApiModelsSseData,
ApiModelsSseEvent,
ApiModelsSseProgress,
ApiModelsUnloadResponse,
ApiProcessingState,
ApiRouterModelMeta,
ApiRouterModelsListResponse,
ApiRouterModelsLoadRequest,
ApiRouterModelsLoadResponse,
ApiRouterModelsStatusRequest,
ApiRouterModelsStatusResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse,
ChatAttachmentDisplayItem,
// Chat types
ChatMessagePromptProgress,
@@ -86,19 +82,17 @@ declare global {
ApiLlamaCppServerProps,
ApiModelDataEntry,
ApiModelLoadStage,
ModelDownloadProgress,
ApiModelsSseProgress,
ApiModelsSseData,
ApiModelsSseDownloadProgressData,
ApiModelsSseEvent,
ApiModelListResponse,
ApiModelsListResponse,
ApiModelsLoadResponse,
ApiModelsDownloadRequest,
ApiModelsDownloadResponse,
ApiModelsUnloadResponse,
ApiProcessingState,
ApiRouterModelMeta,
ApiRouterModelsLoadRequest,
ApiRouterModelsLoadResponse,
ApiRouterModelsStatusRequest,
ApiRouterModelsStatusResponse,
ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse,
// Chat types
ChatAttachmentDisplayItem,
ChatMessagePromptProgress,
@@ -220,8 +220,7 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
*/
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
/**
* Dropdown submenu for selecting reasoning effort level.
/** Dropdown submenu for selecting reasoning effort level.
*
* Shows a "Reasoning" sub-menu item with a lightbulb icon indicating
* thinking status, and a nested list of effort levels.
@@ -7,6 +7,7 @@
getMdastNodeHash,
isAppendMode
} from './markdown-utils';
import { SAFE_HTML_CONFIG } from './safe-html-config';
import {
ActionIconCopyToClipboard,
CodeBlockActions,
@@ -44,6 +45,7 @@
import { detectIncompleteCodeBlock, highlightCode, type IncompleteCodeBlock } from '$lib/utils';
import { sanitizeSvg } from '$lib/utils/sanitize-svg';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
import DOMPurify from 'dompurify';
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
@@ -57,6 +59,8 @@
content: string;
class?: string;
disableMath?: boolean;
/** Render raw HTML found in the markdown (sanitized) instead of escaping it. */
allowHtml?: boolean;
}
interface MarkdownBlock {
@@ -65,7 +69,13 @@
contentHash?: string;
}
let { attachments, class: className = '', content, disableMath = false }: Props = $props();
let {
allowHtml = false,
attachments,
class: className = '',
content,
disableMath = false
}: Props = $props();
let containerRef = $state<HTMLDivElement>();
let renderedBlocks = $state<MarkdownBlock[]>([]);
@@ -119,6 +129,11 @@
let pendingMarkdown: string | null = null;
let isProcessing = false;
// Raw HTML in model cards renders after sanitization with an explicit allow
// list: no scripts, event handlers, forms, iframes or style blocks pass.
// Covers the tags model cards use plus the KaTeX output (spans with inline
// styles and MathML).
// Per-instance transform cache, avoids re-transforming stable blocks during streaming
// Garbage collected when component is destroyed (on conversation change)
const transformCache = new SvelteMap<string, string>();
@@ -183,7 +198,10 @@
index: number
): Promise<{ html: string; hash: string }> {
const hash = getMdastNodeHash(node, index);
const cached = transformCache.get(hash);
// the rendered HTML also depends on allowHtml (sanitized raw vs escaped),
// so the cache is keyed per mode
const cacheKey = `${allowHtml ? 'a' : 'p'}:${hash}`;
const cached = transformCache.get(cacheKey);
if (cached) {
return { hash, html: cached };
@@ -192,10 +210,13 @@
const singleNodeRoot = { children: [node], type: 'root' };
const transformedRoot = (await processorInstance.run(singleNodeRoot as MdastRoot)) as HastRoot;
const html = processorInstance.stringify(transformedRoot);
const safeHtml = allowHtml
? (DOMPurify.sanitize(html, SAFE_HTML_CONFIG) as unknown as string)
: html;
transformCache.set(hash, html);
transformCache.set(cacheKey, safeHtml);
return { hash, html };
return { hash, html: safeHtml };
}
/**
@@ -300,7 +321,7 @@
if (prefixMarkdown.trim()) {
const normalizedPrefix = preprocessLaTeX(prefixMarkdown);
const processorInstance = getMarkdownProcessor({ attachments, disableMath });
const processorInstance = getMarkdownProcessor({ allowHtml, attachments, disableMath });
const ast = processorInstance.parse(normalizedPrefix) as MdastRoot;
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
const nextBlocks: MarkdownBlock[] = [];
@@ -350,7 +371,7 @@
incompleteCodeBlock = null;
const normalized = preprocessLaTeX(markdown);
const processorInstance = getMarkdownProcessor({ attachments, disableMath });
const processorInstance = getMarkdownProcessor({ allowHtml, attachments, disableMath });
const ast = processorInstance.parse(normalized) as MdastRoot;
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
const stableCount = Math.max(mdastChildren.length - 1, 0);
@@ -394,6 +415,10 @@
)) as HastRoot;
unstableHtml = processorInstance.stringify(transformedRoot);
if (allowHtml) {
unstableHtml = DOMPurify.sanitize(unstableHtml, SAFE_HTML_CONFIG) as unknown as string;
}
}
renderedBlocks = nextBlocks;
@@ -41,12 +41,15 @@ export interface MarkdownProcessor {
export interface MarkdownProcessorOptions {
attachments?: DatabaseMessageExtra[];
disableMath?: boolean;
/** Render raw HTML found in the markdown instead of escaping it. */
allowHtml?: boolean;
}
const sharedPipelines = new Map<string, MarkdownProcessor>();
const attachmentPipelines = new WeakMap<object, MarkdownProcessor>();
const attachmentPipelines = new WeakMap<object, Map<string, MarkdownProcessor>>();
function buildPipeline({
allowHtml = false,
attachments,
disableMath = false
}: MarkdownProcessorOptions): MarkdownProcessor {
@@ -57,11 +60,15 @@ function buildPipeline({
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
}
proc = proc
.use(remarkBreaks) // Convert line breaks to <br>
proc = proc.use(remarkBreaks); // Convert line breaks to <br>
if (!allowHtml) {
// Treat raw HTML as literal text with preserved indentation
.use(remarkLiteralHtml)
.use(remarkRehype); // Convert Markdown AST to rehype
proc = proc.use(remarkLiteralHtml);
}
// Convert Markdown AST to rehype. Keep raw HTML as-is when allowHtml is set.
proc = proc.use(remarkRehype, allowHtml ? { allowDangerousHtml: true } : undefined);
if (!disableMath) {
proc = proc.use(rehypeKatex); // Render math using KaTeX
@@ -89,17 +96,26 @@ function buildPipeline({
export function getMarkdownProcessor(options: MarkdownProcessorOptions): MarkdownProcessor {
if (options.attachments && options.attachments.length > 0) {
let cached = attachmentPipelines.get(options.attachments);
let byOptions = attachmentPipelines.get(options.attachments);
if (!byOptions) {
byOptions = new Map<string, MarkdownProcessor>();
attachmentPipelines.set(options.attachments, byOptions);
}
const key = `${Boolean(options.disableMath)}:${Boolean(options.allowHtml)}`;
let cached = byOptions.get(key);
if (!cached) {
cached = buildPipeline(options);
attachmentPipelines.set(options.attachments, cached);
byOptions.set(key, cached);
}
return cached;
}
const key = String(Boolean(options.disableMath));
const key = `${Boolean(options.disableMath)}:${Boolean(options.allowHtml)}`;
let cached = sharedPipelines.get(key);
@@ -0,0 +1,121 @@
// Sanitization rules for raw HTML in model cards (MarkdownContent's
// allowHtml mode). Kept as its own module so the adversarial test in
// tests/client can import the exact shipped configuration.
/**
* Explicit DOMPurify allow list: the tags and attributes model cards may use
* plus the KaTeX output (spans with inline styles and MathML). Everything
* else - scripts, event handlers, forms, iframes, style tags - is stripped.
*/
export const SAFE_HTML_CONFIG = {
ALLOWED_ATTR: [
'class',
'style',
'id',
'title',
'role',
'aria-hidden',
'aria-label',
'aria-describedby',
'aria-expanded',
'href',
'target',
'rel',
'src',
'srcset',
'alt',
'width',
'height',
'align',
'valign',
'colspan',
'rowspan',
'controls',
'type',
// MathML
'xmlns',
'display',
'encoding',
'mathvariant'
],
ALLOWED_TAGS: [
// text and structure
'a',
'b',
'i',
'u',
's',
'em',
'strong',
'code',
'pre',
'kbd',
'sub',
'sup',
'br',
'hr',
'p',
'span',
'div',
'blockquote',
'center',
'ul',
'ol',
'li',
'dl',
'dt',
'dd',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'colgroup',
'col',
'caption',
// media
'img',
'picture',
'source',
'figure',
'figcaption',
'video',
'audio',
// MathML, for the KaTeX output
'math',
'mrow',
'mi',
'mo',
'mn',
'ms',
'mtext',
'mfrac',
'mroot',
'msqrt',
'msub',
'msup',
'msubsup',
'munder',
'mover',
'mmultiscripts',
'mtable',
'mtr',
'mtd',
'mspace',
'mglyph',
'maligngroup',
'malignmark',
'mpadded',
'mphantom',
'mstyle',
'semantics',
'annotation'
]
};
@@ -0,0 +1,60 @@
<script lang="ts">
import DialogConfirmation from '$lib/components/app/dialogs/DialogConfirmation.svelte';
import { ModelDownloadConfirmAction } from '$lib/enums';
import { modelsStore } from '$lib/stores';
interface Props {
/** Action being confirmed; drives the wording. */
action: ModelDownloadConfirmAction;
/** `<repo>:<tag>` the action targets. */
repoWithTag: string;
onClose: () => void;
/** Overrides the default store removal; defaults to removing the entry. */
onConfirm?: (repoWithTag: string) => void;
open?: boolean;
}
let { action, onClose, onConfirm, open = true, repoWithTag }: Props = $props();
// Both actions resolve through the same store removal (cancelDownload drops a
// running download's partial files or a cached model's files); only the copy
// differs. One component so the discover chips and the selector rows word the
// destructive confirmations identically.
const COPY = {
[ModelDownloadConfirmAction.CANCEL]: {
cancelText: 'Keep downloading',
confirmText: 'Cancel download',
description: (name: string) =>
`This stops the download of ${name} and removes the partial files. Pause it instead to keep the progress.`,
title: 'Cancel download'
},
[ModelDownloadConfirmAction.DELETE]: {
cancelText: 'Keep model',
confirmText: 'Delete',
description: (name: string) =>
`This permanently removes ${name} from disk. You can download it again later.`,
title: 'Delete model'
}
} as const;
let copy = $derived(COPY[action]);
let displayName = $derived(modelsStore.toDisplayName(repoWithTag));
function confirm() {
if (onConfirm) onConfirm(repoWithTag);
else void modelsStore.status.cancelDownload(repoWithTag);
onClose();
}
</script>
<DialogConfirmation
cancelText={copy.cancelText}
confirmText={copy.confirmText}
description={copy.description(displayName)}
onCancel={onClose}
onConfirm={confirm}
{open}
title={copy.title}
variant="destructive"
/>
@@ -0,0 +1,25 @@
<script lang="ts">
import { ModelsDiscover } from '$lib/components/app/models/discover';
import * as Dialog from '$lib/components/ui/dialog';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
let { onOpenChange, open = $bindable(false) }: Props = $props();
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
class="grid gap-0 p-0 md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-380!"
style="grid-template-columns: auto 1fr;"
>
<ModelsDiscover />
</Dialog.Content>
</Dialog.Root>
@@ -108,6 +108,17 @@ export { default as DialogExportSettings } from './DialogExportSettings.svelte';
*/
export { default as DialogConfirmation } from './DialogConfirmation.svelte';
/**
* **DialogConfirmDownload** - Confirm a destructive download action
*
* Shared confirmation for stopping/cancelling an in-flight download or deleting
* a downloaded model, used by the discover quant chips and the model selector's
* download rows so both word the action identically. Owns the copy and the
* default store removal; render one instance per surface keyed by the acted-on
* repo:tag.
*/
export { default as DialogConfirmDownload } from './DialogConfirmDownload.svelte';
/**
* **DialogConversationRename** - Rename a conversation
*
@@ -526,3 +537,13 @@ export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.
* ```
*/
export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
/**
* **DialogModelsDiscover** - full-screen model discovery dialog.
*
* Two-pane layout: searchable model list (Hugging Face + llama.app catalog)
* on the left, model details with download options on the right.
*
* @see ModelsDiscover in $lib/components/app/models/discover
*/
export { default as DialogModelsDiscover } from './DialogModelsDiscover.svelte';
@@ -0,0 +1,93 @@
<script lang="ts">
import { Image, Lightbulb, Mic, Video, Wrench } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { ModelModalities } from '$lib/types/models';
interface Props {
modalities?: ModelModalities;
supportsThinking?: boolean;
supportsToolUse?: boolean;
hideCapabilities?: boolean;
hideModalities?: boolean;
iconSize?: string;
gapClass?: string;
}
let {
gapClass = 'gap-1.25',
hideCapabilities = false,
hideModalities = false,
iconSize = 'h-3 w-3',
modalities,
supportsThinking = false,
supportsToolUse = false
}: Props = $props();
let hasModalityIcons = $derived(modalities?.vision || modalities?.video || modalities?.audio);
</script>
<span class="inline-flex items-center {gapClass}">
{#if supportsToolUse && !hideCapabilities}
<Tooltip.Root>
<Tooltip.Trigger>
<Wrench class="{iconSize} text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool use</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if supportsThinking && !hideCapabilities}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="{iconSize} text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasModalityIcons && !hideModalities}
<span class="inline-flex items-center text-muted-foreground">
{#if modalities?.vision}
<Tooltip.Root>
<Tooltip.Trigger>
<Image class={iconSize} />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Vision</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.video}
<Tooltip.Root>
<Tooltip.Trigger>
<Video class={iconSize} />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Video</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.audio}
<Tooltip.Root>
<Tooltip.Trigger>
<Mic class={iconSize} />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Audio</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
{/if}
</span>
@@ -1,45 +1,66 @@
<script lang="ts">
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
import { Database, ScrollText } from '@lucide/svelte';
import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
CAPABILITY_FLAG_KEYS,
CAPABILITY_ICONS,
CAPABILITY_LABELS,
MODALITY_FLAG_KEYS,
MODALITY_ICONS,
MODALITY_LABELS
} from '$lib/constants';
import { ModelCapability, ModelModality } from '$lib/enums';
import { type ModelSidecar } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { ModelsService } from '$lib/services/models.service';
import { settingsStore } from '$lib/stores';
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
import type { ModelModalities } from '$lib/types/models';
import { isAuxSidecar } from '$lib/utils';
import { formatParameters } from '$lib/utils';
interface Props {
modelId: string;
hideOrgName?: boolean;
hideName?: boolean;
hideModalities?: boolean;
hideCapabilities?: boolean;
hideParameters?: boolean;
showRaw?: boolean;
showRawTooltip?: boolean;
hideQuantization?: boolean;
hideTags?: boolean;
aliases?: string[];
tags?: string[];
/** Render the capability/modality/context icons on a second row. */
iconsOnNewLine?: boolean;
modalities?: ModelModalities;
capabilities?: ModelCapabilities;
supportsThinking?: boolean;
supportsToolUse?: boolean;
/** Context length in tokens; renders a context icon when set. */
contextLength?: number;
/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
sizeRange?: { min: number; max: number } | null;
draftSidecars?: ModelSidecar[];
/** Allow badges to wrap onto new lines instead of truncating. */
wrap?: boolean;
class?: string;
}
let {
aliases,
capabilities,
class: className = '',
contextLength,
draftSidecars = [],
hideCapabilities = false,
hideModalities = false,
hideName = false,
hideOrgName = false,
hideParameters = false,
hideQuantization,
hideTags,
iconsOnNewLine = false,
modalities,
modelId,
showRaw = undefined,
showRawTooltip = false,
sizeRange,
supportsThinking = false,
supportsToolUse = false,
tags,
wrap = false,
...rest
}: Props = $props();
@@ -47,6 +68,11 @@
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';
const tagBadgeClass =
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
const variantBadgeClass =
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-primary px-1.5 py-0 text-[10px] font-mono font-semibold uppercase tracking-wide text-primary-foreground';
/** Alias badges beyond this many collapse into a single `+x more` badge. */
const MAX_ALIAS_BADGES = 2;
let parsed = $derived(ModelsService.parseModelId(modelId));
let resolvedShowRaw = $derived(
@@ -59,104 +85,147 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
const allModalities = [ModelModality.VISION, ModelModality.VIDEO, ModelModality.AUDIO] as const;
const allCapabilities: ModelCapability[] = [ModelCapability.REASONING];
let activeModalities = $derived(
allModalities.filter((modality) => modalities?.[MODALITY_FLAG_KEYS[modality]])
);
let activeCapabilities = $derived(
allCapabilities.filter((capability) => capabilities?.[CAPABILITY_FLAG_KEYS[capability]])
);
let uniqueDraftSidecars = $derived([...new Set(draftSidecars)].filter((s) => !isAuxSidecar(s)));
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
let hasBadges = $derived(
parsed.sidecar ||
(parsed.params && !hideParameters) ||
(parsed.quantization && !resolvedHideQuantization) ||
primaryAlias ||
uniqueAliases.length > 1 ||
(uniqueTags.length > 0 && !resolvedHideTags)
);
</script>
{#if resolvedShowRaw}
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
{:else}
{#snippet nameAndBadges()}
<span class="min-w-0 truncate font-medium">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span>
<span class="inline-flex items-center gap-1">
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
{/if}
{:else if uniqueAliases.length > 1}
{#each uniqueAliases as alias (alias)}
<span class={badgeClass}>{alias}</span>
{/each}
{/if}
{#if uniqueTags.length > 0 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{/if}
</span>
{/snippet}
<span class="flex min-w-0 items-center gap-1.5 {className}" {...rest}>
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
{@render nameAndBadges()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{modelId}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render nameAndBadges()}
{#if !hideName}
<span class="min-w-0 truncate font-medium">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span>
{/if}
{#if activeCapabilities.length > 0 || activeModalities.length > 0}
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
{#each activeCapabilities as capability (capability)}
{@const CapabilityIcon = CAPABILITY_ICONS[capability]}
{#if hasBadges}
<span class="inline-flex items-center gap-1 {wrap ? 'flex-wrap' : ''}">
{#if parsed.sidecar}
<span class={variantBadgeClass} title={`${parsed.sidecar.toUpperCase()} draft model`}>
{parsed.sidecar}
</span>
{/if}
<Tooltip.Root>
<Tooltip.Trigger>
<CapabilityIcon class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
{#if parsed.params && !hideParameters}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
<Tooltip.Content>
<p>{CAPABILITY_LABELS[capability]}</p>
</Tooltip.Content>
</Tooltip.Root>
{#each uniqueDraftSidecars as sidecar (sidecar)}
<span class={variantBadgeClass} title={`${sidecar.toUpperCase()} draft model available`}>
{sidecar}
</span>
{/each}
{#each activeModalities as modality (modality)}
{@const ModalityIcon = MODALITY_ICONS[modality]}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
<Tooltip.Root>
<Tooltip.Trigger>
<ModalityIcon class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class="{badgeClass} max-w-32 truncate" title={parsed.modelName ?? modelId}>
{parsed.modelName ?? modelId}
</span>
{/if}
{:else if uniqueAliases.length > 1}
{#each uniqueAliases.slice(0, MAX_ALIAS_BADGES) as alias (alias)}
<span class="{badgeClass} max-w-32 truncate" title={alias}>
{alias}
</span>
{/each}
<Tooltip.Content>
<p>{MODALITY_LABELS[modality]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
{#if uniqueAliases.length > MAX_ALIAS_BADGES}
<span class={badgeClass} title={uniqueAliases.slice(MAX_ALIAS_BADGES).join(', ')}>
+{uniqueAliases.length - MAX_ALIAS_BADGES} more
</span>
{/if}
{/if}
{#if uniqueTags.length > 0 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{/if}
</span>
{/if}
{/snippet}
<span
class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {iconsOnNewLine
? 'flex-col items-start'
: ''} {className}"
{...rest}
>
<span class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''}">
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
{@render nameAndBadges()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{modelId}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render nameAndBadges()}
{/if}
{#if !iconsOnNewLine}
<ModelCapabilityIcons
{hideCapabilities}
{hideModalities}
{modalities}
{supportsThinking}
{supportsToolUse}
/>
{/if}
</span>
{#if iconsOnNewLine || contextLength || sizeRange}
<span class="inline-flex items-center gap-1.5">
{#if iconsOnNewLine}
<ModelCapabilityIcons
{hideCapabilities}
{hideModalities}
{modalities}
{supportsThinking}
{supportsToolUse}
/>
{/if}
{#if contextLength}
<span class="inline-flex items-center gap-1 text-muted-foreground">
<ScrollText class="h-3 w-3" />
<span class="text-xs">{formatParameters(contextLength)}</span>
</span>
{/if}
{#if sizeRange}
<span class="inline-flex items-center gap-1 text-muted-foreground">
<Database class="h-3 w-3" />
<span class="text-xs"
>{HuggingFaceService.formatSizeRange(sizeRange.min, sizeRange.max)}</span
>
</span>
{/if}
</span>
{/if}
</span>
@@ -12,7 +12,7 @@
} from '@lucide/svelte';
import { ActionIcon, ModelId } from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import { ModelCapability, ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
@@ -60,7 +60,8 @@
let loadTitle = $derived(modelLoadProgressText(loadProgress));
let modalities = $derived(option.modalities);
let capabilities = $derived.by(() => ({
reasoning: modelsStore.props.checkModelSupportsThinking(option.model)
reasoning: modelsStore.props.checkModelSupportsThinking(option.model),
tools: option.capabilities.includes(ModelCapability.TOOL_USE)
}));
</script>
@@ -85,12 +86,13 @@
>
<ModelId
aliases={option.aliases}
{capabilities}
class="flex-1"
{hideOrgName}
{modalities}
modelId={option.model}
showRawTooltip
supportsThinking={capabilities.reasoning}
supportsToolUse={capabilities.tools}
tags={option.tags}
/>
@@ -0,0 +1,138 @@
<script lang="ts">
import {
ModelsDiscoverDetails,
ModelsDiscoverList,
ModelsDiscoverListSearch
} from '$lib/components/app/models/discover';
import { Button } from '$lib/components/ui/button';
import { HuggingFaceService } from '$lib/services';
import { modelsDiscoverStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types';
let selectedId = $state<string | null>(null);
let searchQuery = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// Detail pane state, reloaded when the selection changes.
let details = $state<HfModelDetailInfo | null>(null);
let files = $state<HfModelSibling[]>([]);
let readme = $state<string | null>(null);
let detailLoading = $state(false);
let detailError = $state<string | null>(null);
// Load the sidebar list on mount (the component is mounted when the dialog opens).
$effect(() => {
void modelsDiscoverStore.fetch();
void modelsDiscoverStore.search('');
});
// Auto-select the first model.
$effect(() => {
const first = modelsDiscoverStore.firstModel;
if (!selectedId && first) {
selectedId = first.id;
}
});
function handleSearchInput(value: string) {
searchQuery = value;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
void modelsDiscoverStore.search(value);
}, 300);
}
// Load the detail pane for the selected model (component is reused across
// selections, so this re-fetches on every change).
$effect(() => {
const id = selectedId;
if (!id) return;
let cancelled = false;
detailLoading = true;
detailError = null;
void (async () => {
try {
const [info, tree, readmeText] = await Promise.all([
HuggingFaceService.getDetails(id),
HuggingFaceService.getTree(id),
HuggingFaceService.getReadme(id)
]);
if (cancelled) return;
if (!info) {
detailError = 'Model not found';
return;
}
details = info;
files = HuggingFaceService.filterByExtension(
HuggingFaceService.collapseGgufShards(tree),
'.gguf'
);
readme = readmeText;
} catch (err) {
if (cancelled) return;
detailError = err instanceof Error ? err.message : 'Failed to load model';
} finally {
if (!cancelled) detailLoading = false;
}
})();
return () => {
cancelled = true;
};
});
</script>
<aside
class="w-md shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:p-4 h-full space-y-1"
>
<ModelsDiscoverListSearch bind:value={searchQuery} onSearch={handleSearchInput} />
<!-- One list instance, so the rows keep their state across search round trips;
skeleton rows replace them while the initial catalog or a query loads. -->
<div>
{#if modelsDiscoverStore.error}
<div class="flex flex-col items-start gap-2 p-4">
<p class="text-sm text-destructive">{modelsDiscoverStore.error}</p>
<Button onclick={() => void modelsDiscoverStore.fetch()} size="sm" variant="outline">
Retry
</Button>
</div>
{:else if !modelsDiscoverStore.loading && !modelsDiscoverStore.searching && modelsDiscoverStore.models.length === 0}
<p class="p-4 text-sm text-muted-foreground">No models found</p>
{:else}
<ModelsDiscoverList
activeId={selectedId}
loading={modelsDiscoverStore.loading || modelsDiscoverStore.searching}
models={modelsDiscoverStore.models}
onSelect={(id) => (selectedId = id)}
showBaseModelAvatar
/>
{/if}
</div>
</aside>
<main class="overflow-y-auto">
{#if selectedId}
<ModelsDiscoverDetails
{details}
error={detailError}
{files}
loading={detailLoading}
modelId={selectedId}
{readme}
/>
{/if}
</main>
@@ -0,0 +1,105 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
interface Props {
class?: string;
org: string;
quantOrg?: string;
size?: string;
baseImageClass?: string;
quantImageClass?: string;
quantPositionClass?: string;
quantSize?: string;
}
let {
baseImageClass = '',
class: className = '',
org,
quantImageClass = 'h-full w-full',
quantOrg,
quantPositionClass = '-bottom-0.75 -right-0.75',
quantSize = 'h-4.25 w-4.25',
size = 'h-9 w-9'
}: Props = $props();
let avatarError = $state(false);
let quantError = $state(false);
let invertAvatar = $derived(DARK_INVERT_AVATAR_ORGS.includes(org));
let invertQuant = $derived(DARK_INVERT_AVATAR_ORGS.includes(quantOrg ?? ''));
// Monogram fallback: org initial on a hue derived from its name, so each org
// gets a stable distinct color.
let hue = $derived.by(() => {
let h = 0;
for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) >>> 0;
return h % 360;
});
let quantHue = $derived.by(() => {
const name = quantOrg ?? '';
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return h % 360;
});
</script>
<span class="relative mt-0.5 inline-flex shrink-0 {className}">
{#if avatarError}
<span
aria-hidden="true"
class="flex {size} items-center justify-center rounded-md text-sm font-semibold text-white"
style="background-color: hsl({hue} 60% 45%)"
>
{org.charAt(0).toUpperCase()}
</span>
{:else}
<div class="rounded-md">
<img
alt=""
class="{size} rounded-md {invertAvatar ? 'dark:invert' : ''} {baseImageClass}"
loading="lazy"
onerror={() => (avatarError = true)}
src={HuggingFaceService.getAvatarUrl(org)}
/>
</div>
{/if}
{#if quantOrg && quantOrg !== org}
<Tooltip.Root>
<Tooltip.Trigger
class="absolute {quantPositionClass} {quantSize} overflow-hidden rounded-full border border-background bg-muted "
>
{#if quantError}
<span
aria-hidden="true"
class="flex h-full w-full items-center justify-center rounded-full text-[8px] font-semibold text-white"
style="background-color: hsl({quantHue} 60% 45%)"
>
{quantOrg.charAt(0).toUpperCase()}
</span>
{:else}
<img
alt=""
class="{quantImageClass} rounded-full {invertQuant ? 'dark:invert' : ''}"
loading="lazy"
onerror={() => (quantError = true)}
src={HuggingFaceService.getAvatarUrl(quantOrg)}
/>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{quantOrg}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
@@ -0,0 +1,67 @@
<script lang="ts">
import { Check, Copy, X } from '@lucide/svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { copyToClipboard } from '$lib/utils';
interface Props {
open?: boolean;
chatTemplate: string;
onOpenChange?: (open: boolean) => void;
}
let { chatTemplate, onOpenChange, open = $bindable(false) }: Props = $props();
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
let copied = $state(false);
async function copy() {
await copyToClipboard(chatTemplate);
copied = true;
setTimeout(() => (copied = false), 1500);
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
class="flex max-h-[calc(100vh-4rem)] flex-col gap-0 p-0 md:w-[calc(100vw-4rem)]! md:max-w-4xl!"
>
<!-- The header's corner-pinned close X never lines up with a padded flex row,
so it is replaced by one inside the row, aligned with the title -->
<Dialog.Header
class="flex-row items-center gap-2 border-b border-border/40 p-4"
showCloseButton={false}
>
<Dialog.Title class="text-sm font-semibold">Chat template</Dialog.Title>
<button
aria-label="Copy chat template"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
onclick={() => void copy()}
type="button"
>
{#if copied}
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
Copy
</button>
<Dialog.Close
aria-label="Close"
class="ml-auto inline-flex cursor-pointer items-center justify-center rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-muted-foreground/10 hover:text-foreground"
type="button"
>
<X class="h-4 w-4" />
</Dialog.Close>
</Dialog.Header>
<pre
class="flex-1 overflow-auto p-4 font-mono text-xs break-all whitespace-pre-wrap text-muted-foreground">{chatTemplate}</pre>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,96 @@
<script lang="ts">
import { ModelsDiscoverDetailsDownloadOptions } from './ModelsDiscoverDetailsDownloadOptions';
import ModelsDiscoverDetailsHeader from './ModelsDiscoverDetailsHeader.svelte';
import ModelsDiscoverDetailsReadme from './ModelsDiscoverDetailsReadme.svelte';
import ModelsDiscoverDetailsSkeleton from './ModelsDiscoverDetailsSkeleton.svelte';
import { OTHER_BIT_DEPTH } from '$lib/constants';
import { ModelAuxSidecar } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
import type { HfModelDetailInfo, HfModelSibling, ModelBitDepthRow } from '$lib/types';
import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
interface Props {
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
modelId: string;
/** Model details from `/api/models/{id}?full=true`; null while loading. */
details: HfModelDetailInfo | null;
/** GGUF files of the repo, shards collapsed, sorted by size desc. */
files: HfModelSibling[];
/** README.md content, frontmatter stripped; null when unavailable. */
readme: string | null;
/** True while the model data is being fetched. */
loading?: boolean;
/** Error message when loading failed. */
error?: string | null;
}
let { details, error = null, files, loading = false, modelId, readme }: Props = $props();
let gguf = $derived(details?.gguf);
let baseModels = $derived(HuggingFaceService.getBaseModels(details));
let licenseTag = $derived.by(() => {
const tags = details?.tags ?? [];
return tags.find((t) => t.startsWith('license:'))?.replace('license:', '') ?? null;
});
// Capabilities derived from HF metadata. Vision comes from an mmproj sidecar
// or a multimodal pipeline tag; tool use / reasoning from the chat template.
let hasMmproj = $derived(
files.some(
(f) => HuggingFaceService.extractQuantMeta(f.path)?.sidecar === ModelAuxSidecar.MMPROJ
)
);
let hasVision = $derived(hasMmproj || details?.pipeline_tag === 'image-text-to-text');
let hasTools = $derived(detectToolUseSupport(gguf?.chat_template ?? ''));
let hasReasoning = $derived(detectThinkingSupport(gguf?.chat_template ?? ''));
let bitDepthRows = $derived.by<ModelBitDepthRow[]>(() => {
const rows = new SvelteMap<number, HfModelSibling[]>();
for (const file of files) {
const meta = HuggingFaceService.extractQuantMeta(file.path);
// mmproj sidecars are already conveyed by the Vision capability badge;
// imatrix ships as a normal chip with its own badge.
if (meta?.sidecar === ModelAuxSidecar.MMPROJ) continue;
const depth = meta?.quant ? HuggingFaceService.getBitDepth(meta.quant) : null;
const bucket = depth ?? OTHER_BIT_DEPTH;
const list = rows.get(bucket) ?? [];
list.push(file);
rows.set(bucket, list);
}
return Array.from(rows.entries())
.map(([bitDepth, rowFiles]) => ({ bitDepth, files: rowFiles }))
.sort((a, b) => a.bitDepth - b.bitDepth);
});
</script>
{#if loading}
<ModelsDiscoverDetailsSkeleton />
{:else if error}
<div class="flex h-full items-center justify-center py-20">
<p class="text-sm text-destructive">{error}</p>
</div>
{:else if details}
<div class="space-y-6 p-6">
<ModelsDiscoverDetailsHeader
{baseModels}
{details}
{gguf}
{hasReasoning}
{hasTools}
{hasVision}
{licenseTag}
{modelId}
/>
<ModelsDiscoverDetailsDownloadOptions {bitDepthRows} {modelId} />
<ModelsDiscoverDetailsReadme {readme} />
</div>
{/if}
@@ -0,0 +1,173 @@
<script lang="ts">
import { classify, labelFor } from './download-options.utils';
import ModelsDiscoverDetailsDownloadOptionsDownloadCommand from './ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte';
import ModelsDiscoverDetailsDownloadOptionsRow from './ModelsDiscoverDetailsDownloadOptionsRow.svelte';
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
import { ModelDownloadConfirmAction, ModelSelectableFileKind } from '$lib/enums';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type {
ModelBitDepthRow,
ModelDownloadEntryState,
ModelQuantOption,
ModelSelectableFile
} from '$lib/types';
interface Props {
/** Full HuggingFace repo id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
modelId: string;
/** GGUF files grouped by bit depth. */
bitDepthRows: ModelBitDepthRow[];
/** Download state lookup; defaults to the models store status feed. */
getDownloadState?: (
repoWithTag: string,
filePath: string,
isSidecar: boolean
) => ModelDownloadEntryState;
}
let { bitDepthRows, getDownloadState, modelId }: Props = $props();
// Destructive chip actions (delete a downloaded model, cancel a download) are
// confirmed here rather than inside each chip: a single shared dialog owned by
// the options panel, keyed by the repo+tag the user acted on, so one dialog is
// mounted for the whole panel instead of one per chip.
// The acted-on target is kept after closing so the copy stays rendered through
// the dialog's close transition.
let pending: { action: ModelDownloadConfirmAction; repoWithTag: string } = $state({
action: ModelDownloadConfirmAction.CANCEL,
repoWithTag: ''
});
let confirmOpen = $state(false);
function requestCancel(repoWithTag: string) {
pending = { action: ModelDownloadConfirmAction.CANCEL, repoWithTag };
confirmOpen = true;
}
function requestDelete(repoWithTag: string) {
pending = { action: ModelDownloadConfirmAction.DELETE, repoWithTag };
confirmOpen = true;
}
function stateFor(
repoWithTag: string,
filePath: string,
isSidecar: boolean
): ModelDownloadEntryState {
if (getDownloadState) return getDownloadState(repoWithTag, filePath, isSidecar);
const isDownloading = modelsStore.status.isDownloadInProgress(repoWithTag);
const isPaused = modelsStore.status.isDownloadPaused(repoWithTag);
return {
// solo downloads register in /v1/models under the tag (args stay empty),
// while drafts pulled by a loaded model only show up as its --model-draft
isDownloaded:
!isDownloading &&
(modelsStore.status.isModelDownloaded(repoWithTag) ||
(isSidecar && modelsStore.status.isSidecarDownloaded(modelId, filePath))),
isDownloading,
isFailed: modelsStore.status.hasFailedDownload(repoWithTag),
isPaused,
// live progress while downloading, else the frozen snapshot of the pause
progress:
modelsStore.status.getDownloadProgress(repoWithTag) ??
modelsStore.status.getPausedDownloadProgress(repoWithTag),
repoWithTag
};
}
/**
* Every selectable file with its kind and download state, in row order.
* Single source of truth for the chip rows and the command selects.
*/
let selectableFiles = $derived.by(() => {
const files: (ModelSelectableFile & { state: ModelDownloadEntryState })[] = [];
for (const row of bitDepthRows) {
for (const file of row.files) {
const meta = HuggingFaceService.extractQuantMeta(file.path);
const tag = ModelsService.buildDownloadTag(
modelId,
meta?.quant ?? null,
meta?.sidecar ?? null
);
files.push({
...file,
kind: classify(file.path),
state: stateFor(tag, file.path, Boolean(meta?.sidecar))
});
}
}
return files;
});
/** Rows for the row component: per-bit-depth files with state attached. */
let rows = $derived.by(() =>
bitDepthRows.map((row) => {
const paths = new Set(row.files.map((f) => f.path));
return {
bitDepth: row.bitDepth,
files: selectableFiles.filter((f) => paths.has(f.path))
};
})
);
function optionFor(file: ModelSelectableFile): ModelQuantOption {
return {
label: labelFor(file.path),
path: file.path
};
}
/** Non-draft quants for the command's base select, in row order. */
let mainOptions = $derived(
selectableFiles.filter((f) => f.kind === ModelSelectableFileKind.MAIN).map(optionFor)
);
/**
* Draft options for the command's draft select, with their sidecar type
* (MTP, DFLASH...) since a repo can ship more than one draft flavour.
*/
let draftOptions = $derived(
selectableFiles
.filter((f) => f.kind === ModelSelectableFileKind.DRAFT)
.map((f) => ({
...optionFor(f),
badge: HuggingFaceService.extractQuantMeta(f.path)?.sidecar ?? null
}))
);
</script>
{#if bitDepthRows.length}
<section class="rounded-3xl border border-border/30 bg-muted/60 shadow-xs dark:border-border/20">
<!-- One chip per file, each an independent download action with its own
lifecycle state; nothing here selects anything. -->
<div class="flex w-full flex-col divide-y divide-border/50 px-4 pb-1 dark:divide-border/35">
{#each rows as row (row.bitDepth)}
<ModelsDiscoverDetailsDownloadOptionsRow
bitDepth={row.bitDepth}
files={row.files}
onRequestCancel={requestCancel}
onRequestDelete={requestDelete}
/>
{/each}
</div>
<!-- Terminal command preview, standalone: its picks are not bound to the chips. -->
<div class="border-t border-border/50 px-4 pt-3.5 pb-4 dark:border-border/35">
<ModelsDiscoverDetailsDownloadOptionsDownloadCommand {draftOptions} {mainOptions} {modelId} />
</div>
</section>
{/if}
<DialogConfirmDownload
action={pending.action}
onClose={() => (confirmOpen = false)}
open={confirmOpen}
repoWithTag={pending.repoWithTag}
/>
@@ -0,0 +1,278 @@
<script lang="ts">
import { Check, Copy, Plus, X } from '@lucide/svelte';
import * as Select from '$lib/components/ui/select';
import {
DEFAULT_BASE_BIT_DEPTH,
MODEL_ID,
type ModelSidecar,
OTHER_BIT_DEPTH,
SERVE_COMMAND,
SPEC_TYPE
} from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { ModelQuantOption } from '$lib/types';
import { copyToClipboard } from '$lib/utils';
interface Props {
modelId: string;
/** Non-draft quants of the repo, in bit-depth row order. */
mainOptions: ModelQuantOption[];
/** Draft sidecar files with their sidecar badge; empty when the repo ships none. */
draftOptions: (ModelQuantOption & { badge: ModelSidecar | null })[];
}
let { draftOptions, mainOptions, modelId }: Props = $props();
// Command picks, owned here: nothing two-way binds them to the quant chips.
let basePick = $state<string | null>(null);
let draftPick = $state<string | null>(null);
let draftTypePick = $state<ModelSidecar | null>(null);
let withDraft = $state(false);
function bitDepthOf(path: string): number {
const quant = HuggingFaceService.extractQuantMeta(path)?.quant;
return quant ? (HuggingFaceService.getBitDepth(quant) ?? OTHER_BIT_DEPTH) : OTHER_BIT_DEPTH;
}
/**
* Base file the command points at: the user's pick while it still exists in
* the options, else the 4-bit file, else the lowest bit depth available. A
* stale pick (the details pane switched models) falls back on its own.
*/
let baseOption = $derived.by(() => {
const picked = mainOptions.find((option) => option.path === basePick);
if (picked) return picked;
const preferred = mainOptions.find(
(option) => bitDepthOf(option.path) === DEFAULT_BASE_BIT_DEPTH
);
if (preferred) return preferred;
const ranked = [...mainOptions].sort((a, b) => bitDepthOf(a.path) - bitDepthOf(b.path));
return ranked[0] ?? null;
});
/** Draft sidecar types the repo ships, in option order. */
let specTypes = $derived(
draftOptions
.map((option) => option.badge)
.filter((badge): badge is ModelSidecar => badge !== null)
.filter((badge, index, all) => all.indexOf(badge) === index)
);
/**
* Draft type the --spec-type select points at: the user's pick while it
* still exists, else the first type the repo ships.
*/
let draftType = $derived(
draftTypePick && specTypes.includes(draftTypePick) ? draftTypePick : (specTypes[0] ?? null)
);
/** Draft files of the picked type; the quant select only offers these. */
let typeDraftOptions = $derived(draftOptions.filter((option) => option.badge === draftType));
/** Draft file the -hfd tag points at: the user's pick, else the first of the type. */
let draftOption = $derived(
withDraft
? (typeDraftOptions.find((option) => option.path === draftPick) ??
typeDraftOptions[0] ??
null)
: null
);
/** Quant of the file the `-hf` tag points at; null when the file carries no quant. */
let mainQuant = $derived(
baseOption ? (HuggingFaceService.extractQuantMeta(baseOption.path)?.quant ?? null) : null
);
/** Quant of the file the `-hfd` tag points at. */
let draftQuant = $derived(
draftOption ? (HuggingFaceService.extractQuantMeta(draftOption.path)?.quant ?? null) : null
);
/** `--spec-type` value; null when no draft type resolved. */
let specType = $derived(draftType ? SPEC_TYPE[draftType] : null);
/** The llama serve command, composed from the inline picks. */
let command = $derived.by(() => {
const parts = [
SERVE_COMMAND.BIN,
SERVE_COMMAND.SUBCOMMAND,
SERVE_COMMAND.MODEL_FLAG,
mainQuant ? `${modelId}${MODEL_ID.QUANTIZATION_SEPARATOR}${mainQuant}` : modelId
];
if (draftOption && draftQuant) {
parts.push(
SERVE_COMMAND.DRAFT_FLAG,
`${modelId}${MODEL_ID.QUANTIZATION_SEPARATOR}${draftQuant}`,
SERVE_COMMAND.SPEC_TYPE_FLAG,
specType ?? ''
);
}
return parts.join(' ');
});
let copied = $state(false);
async function copy() {
await copyToClipboard(command);
copied = true;
setTimeout(() => (copied = false), 1500);
}
</script>
<!-- <div aria-hidden="true" class="flex items-center gap-3 mt-2 mb-4">
<span class="h-px flex-1 bg-border/50"></span>
<span class="text-xs whitespace-nowrap text-muted-foreground"> or run in your terminal </span>
<span class="h-px flex-1 bg-border/50"></span>
</div> -->
<div
class="relative flex items-center gap-2 overflow-hidden rounded-lg border border-border/40 bg-background py-2.5 pl-4 pr-10 shadow-xs dark:border-border/35 dark:bg-background/50"
>
<!-- Single line: long commands scroll horizontally instead of wrapping. -->
<div
class="flex min-w-0 flex-1 items-center gap-x-2 overflow-x-auto py-0.5 font-mono text-xs whitespace-nowrap text-foreground/90"
>
<span class="shrink-0">{SERVE_COMMAND.BIN}</span>
<span class="shrink-0">{SERVE_COMMAND.SUBCOMMAND}</span>
<span class="shrink-0">{SERVE_COMMAND.MODEL_FLAG}</span>
<span class="shrink-0">
{modelId}{mainQuant ? MODEL_ID.QUANTIZATION_SEPARATOR : ''}
</span>
<!-- Base quant: always part of the command, the 4-bit file by default. -->
{#if baseOption}
<Select.Root onValueChange={(v) => v && (basePick = v)} type="single" value={baseOption.path}>
<Select.Trigger
aria-label="Base model quantization"
class="-ml-2 border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0"
size="xs"
>
{baseOption.label}
</Select.Trigger>
<Select.Content class="font-mono text-xs">
{#each mainOptions as option (option.path)}
<Select.Item class="text-xs" label={option.label} value={option.path}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
<!-- Draft add: a tiny dashed affordance right of the base part; gone once added. -->
{#if draftOptions.length && !withDraft}
<button
aria-label="Add draft model"
class="mx-1 inline-flex h-5 shrink-0 cursor-pointer items-center gap-1 rounded-md border border-dashed border-border/60 px-1.5 text-[10px] text-muted-foreground transition-colors hover:border-border hover:text-foreground"
onclick={() => (withDraft = true)}
type="button"
>
<Plus class="h-3 w-3" />
add draft model
</button>
{/if}
<!-- Draft segment: quant and spec type of the picked draft flavour. The X
at the end drops the whole segment (the add button is gone once added);
it only appears while hovering the segment, or directly on touch -->
{#if draftOption}
<span class="group/draft inline-flex shrink-0 items-center gap-x-2">
<span>{SERVE_COMMAND.DRAFT_FLAG}</span>
<span class="shrink-0">
{modelId}{draftQuant ? MODEL_ID.QUANTIZATION_SEPARATOR : ''}
</span>
<Select.Root
onValueChange={(v) => v && (draftPick = v)}
type="single"
value={draftOption.path}
>
<Select.Trigger
aria-label="Draft model quantization"
class="-ml-2 border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0"
size="xs"
>
{draftOption.label}
</Select.Trigger>
<Select.Content class="font-mono text-xs">
{#each typeDraftOptions as option (option.path)}
<Select.Item class="text-xs" label={option.label} value={option.path}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if draftType}
<span>{SERVE_COMMAND.SPEC_TYPE_FLAG}</span>
<!-- the select only earns its chrome when there is a real choice to make -->
{#if specTypes.length > 1}
<Select.Root
onValueChange={(v) => v && (draftTypePick = v as ModelSidecar)}
type="single"
value={draftType}
>
<Select.Trigger
aria-label="Draft type"
class="border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0"
size="xs"
>
{SPEC_TYPE[draftType]}
</Select.Trigger>
<Select.Content class="font-mono text-xs">
{#each specTypes as type (type)}
<Select.Item class="text-xs" label={SPEC_TYPE[type]} value={type}>
{SPEC_TYPE[type]}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{:else}
<span>{SPEC_TYPE[draftType]}</span>
{/if}
{/if}
<button
aria-label="Remove draft model"
class="shrink-0 cursor-pointer text-muted-foreground/60 opacity-0 transition-[opacity,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-destructive group-hover/draft:opacity-100 [@media(pointer:coarse)]:opacity-100"
onclick={() => (withDraft = false)}
type="button"
>
<X class="h-3 w-3" />
</button>
</span>
{/if}
</div>
<button
aria-label="Copy command"
class="absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-primary/10 hover:text-foreground"
onclick={copy}
type="button"
>
{#if copied}
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
</button>
</div>
@@ -0,0 +1,244 @@
<script lang="ts">
import ModelsDiscoverDownloadProgressBar from '../../ModelsDiscoverDownloadProgressBar.svelte';
import { labelFor } from './download-options.utils';
import { Check, Download, Loader2, Pause, Play, RotateCw, X } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { HuggingFaceService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { HfModelSibling, ModelDownloadEntryState } from '$lib/types';
import type { Snippet } from 'svelte';
interface Props {
/** GGUF file the chip stands for. */
file: HfModelSibling;
/** Download state of the file, from the parent's status feed. */
entry: ModelDownloadEntryState;
/**
* Ask the parent to confirm deleting a downloaded model. The chip owns no
* dialog; the parent renders the single confirmation and acts on confirm.
*/
onRequestDelete?: (repoWithTag: string) => void;
/** Ask the parent to confirm cancelling an in-flight download. */
onRequestCancel?: (repoWithTag: string) => void;
}
let { entry, file, onRequestCancel, onRequestDelete }: Props = $props();
// Sidecar kind (mtp, mmproj, ...) tag shown on every chip state; one source so
// the idle, in-flight and downloaded variants stay identical.
const SIDECAR_BADGE_CLASS =
'rounded-md bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase';
/** Queue the download; a failed attempt leaves partial files, drop them first. */
async function startDownload() {
try {
if (entry.isFailed) await modelsStore.status.cancelDownload(entry.repoWithTag);
await modelsStore.status.downloadModel(entry.repoWithTag);
} catch {
// the store already toasted the failure
}
}
let meta = $derived(HuggingFaceService.extractQuantMeta(file.path));
let label = $derived(labelFor(file.path));
let percent = $derived(
entry.progress && entry.progress.totalBytes > 0
? Math.round((entry.progress.downloadedBytes / entry.progress.totalBytes) * 100)
: null
);
let tooltipText = $derived(
entry.isDownloading
? 'Pause downloading'
: entry.isPaused
? 'Resume downloading'
: entry.isDownloaded
? 'Delete model'
: entry.isFailed
? `Retry download: ${file.path}`
: `Download ${file.path}`
);
</script>
{#snippet chipBody(dividerClass: string)}
<!-- badge, label and divider: identical on every chip state -->
{#if meta?.sidecar}
<span class={SIDECAR_BADGE_CLASS}>
{meta.sidecar}
</span>
{/if}
<span class="font-medium">{label}</span>
<span class={dividerClass}></span>
{/snippet}
<!-- every chip is a tooltip trigger; the button renders as the trigger's child
so no nested button element is created -->
{#snippet tooltipTrigger(tip: string, button: Snippet<[Record<string, unknown>]>)}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
{@render button(props)}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tip}</p>
</Tooltip.Content>
</Tooltip.Root>
{/snippet}
{#snippet deleteChip(props: Record<string, unknown>)}
<!-- downloaded chips are delete actions: green check by default, red X on hover -->
<button
{...props}
aria-label={tooltipText}
class="group relative inline-flex h-auto cursor-pointer items-center gap-1 rounded-md! border px-2 py-1 text-left font-mono text-xs shadow-xs transition-[background-color,border-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]
border-green-600/25 bg-green-500/5 hover:border-destructive/50 hover:bg-destructive/10 dark:border-green-500/30 dark:bg-green-500/10 dark:hover:border-destructive/50 dark:hover:bg-destructive/15"
onclick={() => onRequestDelete?.(entry.repoWithTag)}
type="button"
>
{@render chipBody(
'-my-1 mx-0.75 w-px self-stretch bg-green-600/25 transition-colors duration-200 group-hover:bg-destructive/30 dark:bg-green-600/30 dark:group-hover:bg-destructive/30'
)}
<span>{HuggingFaceService.formatFileSize(file.size ?? 0)}</span>
<!-- icon slot: crossfade check -> x; touch devices show the delete affordance directly -->
<span class="relative inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Check
class="absolute h-3.5 w-3.5 text-green-500 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-75 group-hover:opacity-0 [@media(pointer:coarse)]:hidden"
/>
<X
class="absolute h-3.5 w-3.5 scale-75 text-destructive opacity-0 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
/>
</span>
</button>
{/snippet}
{#snippet pauseResumeChip(props: Record<string, unknown>)}
<button
{...props}
aria-label={tooltipText}
class="flex h-auto min-w-0 flex-1 cursor-pointer items-center gap-1 text-left"
onclick={() => {
if (entry.isDownloading) void modelsStore.status.pauseDownload(entry.repoWithTag);
else void modelsStore.status.downloadModel(entry.repoWithTag).catch(() => {});
}}
type="button"
>
{@render chipBody('-my-1 mx-0.75 w-px self-stretch bg-border')}
{#if percent !== null}
<span class="mr-1 tabular-nums">{percent}%</span>
{:else if entry.isPaused}
<span>Paused</span>
{:else}
<span>{HuggingFaceService.formatFileSize(file.size ?? 0)}</span>
{/if}
{#if entry.isDownloading}
<!-- spinner fades into the pause affordance on hover; opacity only, the
spin keyframes own the transform so scale would fight them -->
<span class="relative inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Loader2
class="absolute h-3.5 w-3.5 animate-spin text-muted-foreground transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-0 [@media(pointer:coarse)]:hidden"
/>
<Pause
class="absolute h-3.5 w-3.5 opacity-0 transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
/>
</span>
{:else}
<!-- paused: the play affordance fades in on hover; visible directly on touch -->
<span class="relative inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Play
class="absolute h-3.5 w-3.5 scale-75 opacity-0 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
/>
</span>
{/if}
</button>
{/snippet}
{#snippet cancelChip(props: Record<string, unknown>)}
<!-- cancel slot: same fixed slot and fade as the pause / play affordances
so the two icons line up exactly; the X turns destructive on its hover -->
<button
{...props}
aria-label="Cancel downloading"
class="relative grid h-3.5 w-3.5 shrink-0 cursor-pointer items-center justify-center text-muted-foreground/70"
onclick={() => onRequestCancel?.(entry.repoWithTag)}
type="button"
>
<X
class="h-3.5 w-3.5 transition-[opacity,transform,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] text-destructive [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
/>
</button>
{/snippet}
{#snippet downloadChip(props: Record<string, unknown>)}
<!-- idle chips download on click (retry when the last attempt failed) -->
<button
{...props}
aria-label={tooltipText}
class="group relative inline-flex h-auto cursor-pointer items-center gap-1 overflow-hidden rounded-md! border px-2 py-1 text-left font-mono text-xs shadow-xs transition-[background-color,border-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]
border-border/30 bg-background hover:bg-muted-foreground/10 dark:border-border/20 dark:bg-muted-foreground/15 dark:text-secondary-foreground dark:hover:bg-muted-foreground/25
{entry.isFailed ? 'border-destructive!' : ''}"
onclick={() => void startDownload()}
type="button"
>
{#if entry.isFailed}
<span
class="rounded-md bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase"
>
Failed
</span>
{/if}
{@render chipBody('-my-1 mx-0.75 w-px self-stretch bg-border')}
<span>{HuggingFaceService.formatFileSize(file.size ?? 0)}</span>
<span class="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
{#if entry.isFailed}
<RotateCw class="h-3.5 w-3.5 text-destructive" />
{:else}
<Download
class="h-3.5 w-3.5 text-muted-foreground transition-colors duration-150 group-hover:text-foreground"
/>
{/if}
</span>
</button>
{/snippet}
{#if entry.isDownloaded}
{@render tooltipTrigger(tooltipText, deleteChip)}
{:else if entry.isDownloading || entry.isPaused}
<!-- in-flight / paused chips: the chip body pauses / resumes on click, the X
inside the chip cancels (stops and discards the partial files). The X slot
is reserved, so the chip never reflows when the affordance fades in -->
<div
class="group relative inline-flex h-auto items-center gap-1 overflow-hidden rounded-md! border px-2 py-1 text-left font-mono text-xs shadow-xs transition-[background-color,border-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]
{entry.isPaused
? 'border-yellow-600/40 bg-yellow-500/10 hover:bg-yellow-500/20 dark:border-yellow-500/30 dark:bg-yellow-500/10'
: 'border-border/30 bg-background hover:bg-muted-foreground/10 dark:border-border/20 dark:bg-muted-foreground/15'}"
>
{@render tooltipTrigger(tooltipText, pauseResumeChip)}
{@render tooltipTrigger('Cancel downloading', cancelChip)}
{#if percent !== null}
<ModelsDiscoverDownloadProgressBar
downloadedBytes={entry.progress?.downloadedBytes ?? 0}
overlay
totalBytes={entry.progress?.totalBytes ?? 0}
/>
{/if}
</div>
{:else}
{@render tooltipTrigger(tooltipText, downloadChip)}
{/if}
@@ -0,0 +1,60 @@
<script lang="ts">
import ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton from './ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte';
import {
BIT_DEPTH_LABEL_SUFFIX,
GIGABYTE_LABEL,
OTHER_BIT_DEPTH,
OTHER_BIT_DEPTH_LABEL
} from '$lib/constants';
import { ModelSelectableFileKind } from '$lib/enums';
import type { ModelDownloadEntryState, ModelSelectableFile } from '$lib/types';
import { minMemoryTierGb } from '$lib/utils';
interface Props {
/** Bit depth of the row; `99` renders as "Other". */
bitDepth: number;
/** Every GGUF of this bit depth, with download state attached. */
files: (ModelSelectableFile & { state: ModelDownloadEntryState })[];
/** Forwarded to each chip: ask the parent to confirm a cancel. */
onRequestCancel?: (repoWithTag: string) => void;
/** Forwarded to each chip: ask the parent to confirm a delete. */
onRequestDelete?: (repoWithTag: string) => void;
}
let { bitDepth, files, onRequestCancel, onRequestDelete }: Props = $props();
let mainFile = $derived(files.find((f) => f.kind === ModelSelectableFileKind.MAIN) ?? null);
let draftFile = $derived(files.find((f) => f.kind === ModelSelectableFileKind.DRAFT) ?? null);
let mainMemGb = $derived(mainFile ? minMemoryTierGb(mainFile.size ?? 0) : null);
let draftMemGb = $derived(draftFile ? minMemoryTierGb(draftFile.size ?? 0) : null);
</script>
<div class="grid grid-cols-[5rem_1fr] items-center gap-3 py-3">
<div class="pt-1 text-sm tabular-nums text-muted-foreground">
{#if bitDepth === OTHER_BIT_DEPTH}
{OTHER_BIT_DEPTH_LABEL}
{:else}
{bitDepth}{BIT_DEPTH_LABEL_SUFFIX}
{/if}
{#if mainMemGb}
<span class="block text-[10px] whitespace-nowrap text-muted-foreground/60">
needs at least {mainMemGb}{GIGABYTE_LABEL}{draftMemGb
? ` + ${draftMemGb}${GIGABYTE_LABEL}`
: ''} memory
</span>
{/if}
</div>
<div class="flex flex-wrap justify-end gap-1.5">
{#each files as file (file.path)}
<ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton
entry={file.state}
{file}
{onRequestCancel}
{onRequestDelete}
/>
{/each}
</div>
</div>
@@ -0,0 +1,28 @@
import { DRAFT_FILE_LABEL, MODEL_ID, PATH_SEPARATOR } from '$lib/constants';
import { ModelSelectableFileKind } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
import { isAuxSidecar, isDraftSidecar } from '$lib/utils';
/** Kind of a file path: the main weights, a draft sidecar, or an aux sidecar (mmproj). */
export function classify(path: string): ModelSelectableFileKind {
const sidecar = HuggingFaceService.extractQuantMeta(path)?.sidecar;
if (!sidecar) return ModelSelectableFileKind.MAIN;
return isAuxSidecar(sidecar) ? ModelSelectableFileKind.AUX : ModelSelectableFileKind.DRAFT;
}
/** Display label of a file: its quant, else the file name without the extension. */
export function labelFor(path: string): string {
const meta = HuggingFaceService.extractQuantMeta(path);
if (meta?.quant) return meta.quant;
// Quantless sidecar files: the chip badge already carries the sidecar type,
// so the label only marks draft files; aux sidecars badge alone.
if (meta?.sidecar) return isDraftSidecar(meta.sidecar) ? DRAFT_FILE_LABEL : '';
const basename = path.split(PATH_SEPARATOR).pop() ?? path;
return basename.replace(MODEL_ID.WEIGHT_EXTENSION_REGEX, '');
}
@@ -0,0 +1,40 @@
/**
*
* MODELS DISCOVER - DETAILS - DOWNLOAD OPTIONS
*
* The download area of the detail pane: GGUF files grouped by bit depth, each an
* independent download action chip, plus the standalone terminal command preview.
*
*/
/**
* **ModelsDiscoverDetailsDownloadOptions** - GGUF download options
*
* Groups GGUF files by bit depth and renders one independent download
* action chip per file, plus the standalone terminal command preview.
*/
export { default as ModelsDiscoverDetailsDownloadOptions } from './ModelsDiscoverDetailsDownloadOptions.svelte';
/**
* **ModelsDiscoverDetailsDownloadOptionsRow** - One bit-depth group of quants
*
* A single bit-depth row inside ModelsDiscoverDetailsDownloadOptions: the
* depth label with its memory hint and the quant chips of that depth.
*/
export { default as ModelsDiscoverDetailsDownloadOptionsRow } from './ModelsDiscoverDetailsDownloadOptionsRow.svelte';
/**
* **ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton** - One quant chip
*
* A single GGUF file as an independent action chip: download / retry when
* idle, pause / resume / cancel while in flight, delete when downloaded.
*/
export { default as ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton } from './ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte';
/**
* **ModelsDiscoverDetailsDownloadOptionsDownloadCommand** - Terminal command
*
* The `llama serve -hf ...` command box with inline quant selects and a copy
* button; owns its picks, nothing two-way binds them to the quant chips.
*/
export { default as ModelsDiscoverDetailsDownloadOptionsDownloadCommand } from './ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte';
@@ -0,0 +1,93 @@
<script lang="ts">
import ModelCapabilityIcons from '../../ModelCapabilityIcons.svelte';
import ModelsDiscoverAvatar from '../ModelsDiscoverAvatar.svelte';
import ModelsDiscoverDetailsHfHubStats from './ModelsDiscoverDetailsHfHubStats.svelte';
import ModelsDiscoverDetailsMetadata from './ModelsDiscoverDetailsMetadata.svelte';
import { ExternalLink } from '@lucide/svelte';
import { ICON_CLASS_SM } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
import { orgOf } from '$lib/utils';
interface Props {
modelId: string;
details: HfModelDetailInfo;
gguf?: HfModelGguf;
baseModels: string[];
licenseTag: string | null;
hasVision: boolean;
hasTools: boolean;
hasReasoning: boolean;
}
let { baseModels, details, gguf, hasReasoning, hasTools, hasVision, licenseTag, modelId }: Props =
$props();
// Avatar shows the base model's org (e.g. the Qwen logo for a ggml-org GGUF)
// with the quant org as a corner badge when they differ.
let repoOrg = $derived(orgOf(details.id) || orgOf(modelId));
let baseOrg = $derived(orgOf(baseModels[0]));
let avatarOrg = $derived(baseOrg || repoOrg);
let quantOrg = $derived(baseOrg && baseOrg !== repoOrg ? repoOrg : undefined);
</script>
<header class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<ModelsDiscoverAvatar
org={avatarOrg}
{quantOrg}
quantPositionClass="-bottom-1.5 -right-1.5"
quantSize="h-6 w-6"
size="h-12 w-12"
/>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h1 class="truncate text-lg font-semibold">{details.id ?? modelId}</h1>
<ModelCapabilityIcons
gapClass="gap-2"
iconSize="h-4 w-4"
modalities={{ audio: false, video: false, vision: hasVision }}
supportsThinking={hasReasoning}
supportsToolUse={hasTools}
/>
</div>
{#if baseModels.length}
<div class="flex items-center gap-1">
<span class="truncate text-xs text-muted-foreground">{baseModels.join(', ')}</span>
<a
aria-label="View base model on HuggingFace"
class="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
href={HuggingFaceService.getModelUrl(baseModels[0])}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink class="h-3 w-3" />
</a>
</div>
{/if}
</div>
</div>
<a
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
href={HuggingFaceService.getModelUrl(modelId)}
rel="noopener noreferrer"
target="_blank"
>
<img alt="" class="h-3.5 w-3.5" src="/recommended-mcp/huggingface.ico" />
View on Hugging Face
<ExternalLink class={ICON_CLASS_SM} />
</a>
</div>
<ModelsDiscoverDetailsHfHubStats {details} />
<ModelsDiscoverDetailsMetadata {details} {gguf} {licenseTag} {modelId} />
</header>
@@ -0,0 +1,31 @@
<script lang="ts">
import { Download, Heart } from '@lucide/svelte';
import { HuggingFaceService } from '$lib/services';
import type { HfModelDetailInfo } from '$lib/types/huggingface';
interface Props {
details: HfModelDetailInfo;
}
let { details }: Props = $props();
</script>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
{#if typeof details.downloads === 'number'}
<span class="inline-flex items-center gap-1.5">
<Download class="h-3.5 w-3.5" />
{HuggingFaceService.formatDownloads(details.downloads)}
</span>
{/if}
{#if typeof details.likes === 'number'}
<span class="inline-flex items-center gap-1.5">
<Heart class="h-3.5 w-3.5" />
{HuggingFaceService.formatLikes(details.likes)}
</span>
{/if}
{#if details.lastModified}
<span>Updated {HuggingFaceService.formatRelativeTime(details.lastModified)}</span>
{/if}
</div>
@@ -0,0 +1,80 @@
<script lang="ts">
import ModelsDiscoverChatTemplateDialog from './ModelsDiscoverChatTemplateDialog.svelte';
import ModelsDiscoverDetailsMetadataItem from './ModelsDiscoverDetailsMetadataItem.svelte';
import { MessageSquareCode } from '@lucide/svelte';
import { modelsDiscoverStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
import { formatParameters } from '$lib/utils';
interface Props {
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
modelId: string;
details: HfModelDetailInfo;
gguf?: HfModelGguf;
licenseTag: string | null;
}
let { details, gguf, licenseTag, modelId }: Props = $props();
// Catalog family description when curated, else the HF card description.
let description = $derived(
modelsDiscoverStore.descriptionFor(modelId) ?? details.cardData?.description
);
let chatTemplateOpen = $state(false);
</script>
{#if description}
<p class="text-sm text-muted-foreground">{description}</p>
{/if}
<!-- Metadata chips: label | value pairs, matching the HF model page style -->
<div class="flex flex-wrap items-center gap-1.5">
{#if gguf?.total}
<ModelsDiscoverDetailsMetadataItem
label="Model size"
value="{formatParameters(gguf.total)} params"
/>
{/if}
{#if gguf?.context_length}
<ModelsDiscoverDetailsMetadataItem
label="Context"
value={gguf.context_length.toLocaleString()}
/>
{/if}
{#if gguf?.architecture}
<ModelsDiscoverDetailsMetadataItem label="Architecture" value={gguf.architecture} />
{/if}
{#if gguf?.chat_template}
<button
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted"
onclick={() => (chatTemplateOpen = true)}
type="button"
>
<MessageSquareCode class="h-3 w-3" />
Chat template
</button>
{/if}
{#if licenseTag}
<ModelsDiscoverDetailsMetadataItem label="License" value={licenseTag} />
{/if}
{#if details.gated === true}
<span
class="rounded bg-yellow-500/10 px-2 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400"
>
gated
</span>
{/if}
</div>
{#if gguf?.chat_template}
<ModelsDiscoverChatTemplateDialog
bind:open={chatTemplateOpen}
chatTemplate={gguf.chat_template}
/>
{/if}
@@ -0,0 +1,15 @@
<script lang="ts">
interface Props {
label: string;
value: string;
}
let { label, value }: Props = $props();
</script>
<!-- Metadata chip: label | value pair, matching the HF model page style -->
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">{label}</span>
<span class="px-2.5 py-1 font-medium">{value}</span>
</span>
@@ -0,0 +1,17 @@
<script lang="ts">
import { MarkdownContent } from '$lib/components/app';
interface Props {
readme: string | null;
}
let { readme }: Props = $props();
</script>
{#if readme}
<section
class="p-3 rounded-3xl border border-border/30 bg-muted/60 shadow-xs dark:border-border/20"
>
<MarkdownContent allowHtml class="prose-sm max-w-none" content={readme} />
</section>
{/if}
@@ -0,0 +1,93 @@
<script lang="ts">
import { Skeleton } from '$lib/components/ui/skeleton';
</script>
<!-- Static skeleton of ModelsDiscoverDetails: header, metadata chips, download options and readme lines. -->
<div class="space-y-6 p-6" data-slot="model-details-skeleton">
<!-- Header: avatar, name with capability icons, base model line, HF link -->
<div class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<Skeleton class="h-12 w-12 rounded-md" />
<div class="min-w-0 space-y-1.5">
<Skeleton class="h-5 w-56 max-w-full" />
<Skeleton class="h-3 w-40 max-w-full" />
</div>
</div>
<Skeleton class="h-7.5 w-44 shrink-0 rounded-md" />
</div>
<!-- downloads / likes / last updated -->
<div class="flex items-center gap-4">
<Skeleton class="h-3.5 w-16" />
<Skeleton class="h-3.5 w-12" />
<Skeleton class="h-3.5 w-24" />
</div>
<!-- metadata chips -->
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-1.5">
<Skeleton class="h-7 w-36 rounded-md" />
<Skeleton class="h-7 w-28 rounded-md" />
<Skeleton class="h-7 w-32 rounded-md" />
<Skeleton class="h-7 w-24 rounded-md" />
</div>
</div>
</div>
<!-- Download options: quant rows, CTA, terminal command -->
<div
class="rounded-3xl border border-border/30 bg-muted/40 p-4 shadow-xs dark:border-border/20 dark:bg-muted/50"
>
<div class="space-y-3 divide-y divide-border/50 dark:divide-border/35 pb-1">
{#each [0, 1, 2] as _, index (index)}
<div class="grid grid-cols-[5rem_1fr] items-start gap-3 py-3">
<div class="space-y-1.5 pt-1">
<Skeleton class="h-4 w-14" />
<Skeleton class="h-2.5 w-24" />
</div>
<div class="flex flex-wrap items-center justify-end gap-1.5">
<Skeleton class="h-6 w-28 rounded-md" />
<Skeleton class="h-6 w-20 rounded-md" />
</div>
</div>
{/each}
</div>
<div class="space-y-2.5 border-t border-border/50 pt-3.5 dark:border-border/35">
<Skeleton class="h-9 w-full rounded-md" />
<div class="flex items-center gap-3">
<span class="h-px flex-1 bg-border/50"></span>
<Skeleton class="h-3 w-44" />
<span class="h-px flex-1 bg-border/50"></span>
</div>
<Skeleton class="h-11 w-full rounded-md" />
</div>
</div>
<!-- Readme -->
<div class="space-y-2.5">
<Skeleton class="h-3.5 w-full" />
<Skeleton class="h-3.5 w-full" />
<Skeleton class="h-3.5 w-5/6" />
<Skeleton class="h-3.5 w-2/3" />
</div>
</div>
@@ -0,0 +1,72 @@
/**
*
* MODELS DISCOVER - DETAILS
*
* The right-hand detail pane of the discover view: header (avatar, name, stats, metadata
* chips, capability badges), the download options area and the model-card README.
*
*/
/**
* **ModelsDiscoverDetails** - Model detail view
*
* Detail pane for the selected model. Loads its own data (details + GGUF file
* list) from HuggingFaceService based on the `modelId` route param.
*/
export { default as ModelsDiscoverDetails } from './ModelsDiscoverDetails.svelte';
/**
* **ModelsDiscoverDetailsHeader** - Detail view header
*
* Shows the model avatar (base org + quant org corner badge), name, base model
* info, stats, metadata chips and capability badges.
*/
export { default as ModelsDiscoverDetailsHeader } from './ModelsDiscoverDetailsHeader.svelte';
/**
* **ModelsDiscoverDetailsHfHubStats** - HuggingFace Hub stats row
*
* Downloads, likes and last-updated for the viewed model, formatted the way
* the Hub shows them.
*/
export { default as ModelsDiscoverDetailsHfHubStats } from './ModelsDiscoverDetailsHfHubStats.svelte';
/**
* **ModelsDiscoverDetailsSkeleton** - Detail view loading skeleton
*
* Static placeholder matching the detail layout: header with avatar and name,
* metadata chips, the download options box and readme text lines.
*/
export { default as ModelsDiscoverDetailsSkeleton } from './ModelsDiscoverDetailsSkeleton.svelte';
/**
* **ModelsDiscoverDetailsReadme** - Detail view README
*
* Renders the model card README as markdown.
*/
export { default as ModelsDiscoverDetailsReadme } from './ModelsDiscoverDetailsReadme.svelte';
/**
* **ModelsDiscoverDetailsMetadata** - Detail view metadata row
*
* The detail view's metadata chips (model size, context, architecture, license,
* chat template).
*/
export { default as ModelsDiscoverDetailsMetadata } from './ModelsDiscoverDetailsMetadata.svelte';
/**
* **ModelsDiscoverDetailsMetadataItem** - Single metadata chip
*
* One label | value chip of the detail view's metadata row (model size,
* context, architecture, license).
*/
export { default as ModelsDiscoverDetailsMetadataItem } from './ModelsDiscoverDetailsMetadataItem.svelte';
/**
* **ModelsDiscoverChatTemplateDialog** - Chat template viewer
*
* Shows the model's chat template in a scrollable dialog with a copy button.
*/
export { default as ModelsDiscoverChatTemplateDialog } from './ModelsDiscoverChatTemplateDialog.svelte';
export * from './ModelsDiscoverDetailsDownloadOptions';
@@ -0,0 +1,32 @@
<script lang="ts">
interface Props {
downloadedBytes: number;
totalBytes: number;
overlay?: boolean;
}
let { downloadedBytes, overlay = false, totalBytes }: Props = $props();
let fraction = $derived.by(() => {
if (totalBytes <= 0) return 0;
return Math.min(Math.max(downloadedBytes / totalBytes, 0), 1);
});
let percent = $derived(Math.round(fraction * 100));
</script>
{#if overlay}
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
<div
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{:else}
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{/if}
@@ -0,0 +1,41 @@
<script lang="ts">
import ModelsDiscoverListItem from './ModelsDiscoverListItem.svelte';
import ModelsDiscoverListItemSkeleton from './ModelsDiscoverListItemSkeleton.svelte';
import type { HfModelInfo } from '$lib/types/huggingface';
interface Props {
models: HfModelInfo[];
activeId?: string | null;
loading?: boolean;
loadingSkeletonRowsCount?: number;
/** Show the original (base) model's org avatar instead of the repo's org. */
showBaseModelAvatar?: boolean;
onSelect?: (modelId: string) => void;
}
let {
activeId = null,
loading = false,
loadingSkeletonRowsCount = 8,
models,
onSelect,
showBaseModelAvatar = false
}: Props = $props();
</script>
<ul class="space-y-0.5 p-2">
{#if loading}
{#each Array(loadingSkeletonRowsCount) as _, index (index)}
<ModelsDiscoverListItemSkeleton {index} />
{/each}
{:else}
{#each models as model (model.id)}
<ModelsDiscoverListItem
active={model.id === activeId}
{model}
{onSelect}
{showBaseModelAvatar}
/>
{/each}
{/if}
</ul>
@@ -0,0 +1,129 @@
<script lang="ts">
import ModelId from '../../ModelId.svelte';
import ModelsDiscoverAvatar from '../ModelsDiscoverAvatar.svelte';
import {
HF_MMPROJ_FILENAME_TOKEN,
HF_MODALITY_PIPELINE_TAGS,
type ModelSidecar
} from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { modelsDiscoverStore } from '$lib/stores';
import type { ModelsDiscoverSizeRange } from '$lib/stores/models-discover/index.svelte';
import type { HfModelInfo } from '$lib/types/huggingface';
import type { ModelModalities } from '$lib/types/models';
import { detectThinkingSupport, detectToolUseSupport, isAuxSidecar, orgOf } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
model: HfModelInfo;
active?: boolean;
/** Show the original (base) model's org avatar instead of the repo's org. */
showBaseModelAvatar?: boolean;
onSelect?: (modelId: string) => void;
}
let { active = false, model, onSelect, showBaseModelAvatar = false }: Props = $props();
let org = $derived(orgOf(model.id));
// Org whose avatar is shown: the base model's org when showBaseModelAvatar
// (e.g. the Qwen logo for ggml-org/Qwen3.8-27B-GGUF), else the repo's org.
let avatarOrg = $derived.by(() => {
if (!showBaseModelAvatar) return org;
return orgOf(HuggingFaceService.getBaseModels(model)[0]) || org;
});
let contextLength = $derived(model.gguf?.context_length);
// Reasoning support from the chat template, matching the details view.
let supportsThinking = $derived(detectThinkingSupport(model.gguf?.chat_template ?? ''));
// Tool use support from the chat template.
let supportsToolUse = $derived(detectToolUseSupport(model.gguf?.chat_template ?? ''));
// Modalities derived from HF metadata: vision from an mmproj sidecar or a
// multimodal pipeline tag, audio/video from their pipeline tags.
let modalities = $derived.by<ModelModalities>(() => {
const tag = model.pipeline_tag ?? '';
const vision =
HF_MODALITY_PIPELINE_TAGS.vision.includes(tag) ||
Boolean(
model.siblings?.some((s) => s.rfilename.toLowerCase().includes(HF_MMPROJ_FILENAME_TOKEN))
);
const audio = HF_MODALITY_PIPELINE_TAGS.audio.includes(tag);
const video = HF_MODALITY_PIPELINE_TAGS.video.includes(tag);
return { audio, video, vision };
});
// Draft sidecars (mtp, dflash, dspark, eagle3) present in the repo, e.g.
// speculative-decoding drafts. mmproj is excluded: it is vision, already
// conveyed by the modalities.
let draftSidecars = $derived.by<ModelSidecar[]>(() => {
const set = new SvelteSet<ModelSidecar>();
for (const sibling of model.siblings ?? []) {
const sidecar = HuggingFaceService.extractQuantMeta(sibling.rfilename)?.sidecar;
if (sidecar && !isAuxSidecar(sidecar)) set.add(sidecar);
}
return [...set];
});
// Min/max size across the repo's quants, draft sidecars included. The store
// has catalog rows covered already; any other row (a search hit) measures
// its repo once here and the result is cached per repo.
let measuredSize = $state<ModelsDiscoverSizeRange | null>(null);
let sizeRange = $derived(modelsDiscoverStore.cachedSizeRangeFor(model.id) ?? measuredSize);
$effect(() => {
const id = model.id;
if (modelsDiscoverStore.cachedSizeRangeFor(id)) return;
let cancelled = false;
void modelsDiscoverStore.sizeRange(id).then((range) => {
if (!cancelled) measuredSize = range ?? null;
});
return () => {
cancelled = true;
};
});
</script>
<li>
<button
aria-current={active ? 'page' : undefined}
class="flex w-full cursor-pointer items-start gap-2.5 rounded-lg p-2.5 text-left transition-colors {active
? 'bg-primary/10 hover:bg-primary/15'
: 'hover:bg-muted/60'}"
onclick={() => onSelect?.(model.id)}
type="button"
>
<ModelsDiscoverAvatar
class="mt-1"
org={avatarOrg}
quantOrg={showBaseModelAvatar ? org : undefined}
/>
<span class="min-w-0 flex-1">
<ModelId
class="min-w-0"
{contextLength}
{draftSidecars}
hideOrgName
iconsOnNewLine
{modalities}
modelId={model.id}
{sizeRange}
{supportsThinking}
{supportsToolUse}
wrap
/>
</span>
</button>
</li>
@@ -0,0 +1,39 @@
<script lang="ts">
import { Skeleton } from '$lib/components/ui/skeleton';
interface Props {
/** Row index; varies skeleton widths so the list does not look mechanical. */
index?: number;
}
let { index = 0 }: Props = $props();
// Deterministic width variations keyed by row position.
const NAME_WIDTHS = ['w-40', 'w-52', 'w-44', 'w-56'];
const BADGE_WIDTHS = [
['w-12', 'w-14', 'w-10'],
['w-16', 'w-12', 'w-12'],
['w-10', 'w-16', 'w-10'],
['w-14', 'w-10', 'w-14']
];
let nameWidth = $derived(NAME_WIDTHS[index % NAME_WIDTHS.length]);
let badgeWidths = $derived(BADGE_WIDTHS[index % BADGE_WIDTHS.length]);
</script>
<!-- Static skeleton of ModelsDiscoverListItem: avatar, name and badge rows. -->
<li>
<div class="flex w-full items-start gap-2.5 rounded-lg p-2.5 text-left">
<Skeleton class="h-9 w-9 shrink-0 rounded-md" />
<div class="min-w-0 flex-1 space-y-1.5">
<Skeleton class="{nameWidth} h-4 max-w-full" />
<div class="flex items-center gap-1">
{#each badgeWidths as width, i (i)}
<Skeleton class="{width} h-3.5 rounded" />
{/each}
</div>
</div>
</div>
</li>
@@ -0,0 +1,22 @@
<script lang="ts">
import { SearchInput } from '$lib/components/app';
interface Props {
value?: string;
/** Search callback; the parent owns debouncing. */
onSearch?: (query: string) => void;
placeholder?: string;
}
let { onSearch, placeholder = 'Search models...', value = $bindable('') }: Props = $props();
function handleInput(next: string) {
value = next;
onSearch?.(value);
}
</script>
<div class="sticky top-0 z-99 p-2">
<SearchInput bind:value onInput={(v) => handleInput(v)} {placeholder} />
</div>
@@ -0,0 +1,38 @@
/**
*
* MODELS DISCOVER - LIST
*
* The discover sidebar column: a debounced search field above a navigable list of
* HuggingFace GGUF models, with skeleton rows while loading.
*
*/
/**
* **ModelsDiscoverList** - Sidebar model list
*
* Renders the discover model list as a navigable column. Each row links to the
* model's detail and highlights the active one.
*/
export { default as ModelsDiscoverList } from './ModelsDiscoverList.svelte';
/**
* **ModelsDiscoverListSearch** - Sidebar search input
*
* Debounced search field for the model list.
*/
export { default as ModelsDiscoverListSearch } from './ModelsDiscoverListSearch.svelte';
/**
* **ModelsDiscoverListItem** - Single sidebar row
*
* One model entry in the discover sidebar list, selectable via `onSelect`.
*/
export { default as ModelsDiscoverListItem } from './ModelsDiscoverListItem.svelte';
/**
* **ModelsDiscoverListItemSkeleton** - Skeleton sidebar row
*
* Pulsing placeholder matching a ModelsDiscoverListItem row, shown while the
* list is loading.
*/
export { default as ModelsDiscoverListItemSkeleton } from './ModelsDiscoverListItemSkeleton.svelte';
@@ -0,0 +1,39 @@
/**
*
* MODELS DISCOVER
*
* Components for the Models Discover view: a sidebar search + list of
* HuggingFace GGUF models and a detail view for the selected model, used as the
* body of the discovery dialog. The list and detail trees live in their own
* subfolders; this barrel re-exports them alongside the shared leaves.
*
*/
/**
* **ModelsDiscover** - Models discover explorer
*
* The complete discovery layout: a sidebar search + model list on the left and a
* detail view for the selected model on the right. Used as the body of the
* discovery dialog.
*/
export { default as ModelsDiscover } from './ModelsDiscover.svelte';
/**
* **ModelsDiscoverAvatar** - Org avatar for a model row
*
* Shows the org's avatar image, falling back to a monogram on a stable hue
* derived from the org name when the image fails to load. Shared by the list,
* the detail header and the model selector rows.
*/
export { default as ModelsDiscoverAvatar } from './ModelsDiscoverAvatar.svelte';
/**
* **ModelsDiscoverDownloadProgressBar** - Thin download progress bar
*
* Normalizes bytes to a 0..100% bar; can pin to the bottom edge as an overlay.
* Shared by the quant chips and the model selector's download rows.
*/
export { default as ModelsDiscoverDownloadProgressBar } from './ModelsDiscoverDownloadProgressBar.svelte';
export * from './ModelsDiscoverList';
export * from './ModelsDiscoverDetails';
@@ -109,3 +109,12 @@ export { default as ModelBadge } from './ModelBadge.svelte';
* Respects the user's `showRawModelNames` setting.
*/
export { default as ModelId } from './ModelId.svelte';
/**
* **ModelCapabilityIcons** - Capability and modality icon row
*
* The shared tool-use / reasoning / vision / video / audio icon cluster with
* tooltips, used by ModelId and the discover details header so the order and
* styling stay consistent across every model-id surface.
*/
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
@@ -5,6 +5,7 @@
import {
ActionIcon,
DialogConversationRename,
DialogModelsDiscover,
DialogSettingsChat,
Logo,
SidebarNavigationActions,
@@ -93,6 +94,7 @@
let renameDialogOpen = $state(false);
let settingsDialogOpen = $state(false);
let modelsDiscoverOpen = $state(false);
let renameTargetConversationId = $state<string | null>(null);
let renameDraft = $state('');
let renameOriginalTitle = $state('');
@@ -389,6 +391,7 @@
bind:searchQuery
class="px-2"
isExpandedMode={innerWidth > 768 ? uiStore.isSidebarExpanded : true}
onDiscoverModelsClick={() => (modelsDiscoverOpen = true)}
onNewChat={() => {
if (deviceStore.isMobile) {
scheduleMobileCollapse();
@@ -452,6 +455,8 @@
<DialogSettingsChat bind:open={settingsDialogOpen} />
<DialogModelsDiscover bind:open={modelsDiscoverOpen} />
<style>
aside {
@media (max-width: 768px) {
@@ -12,7 +12,7 @@
SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants';
import { SidebarAction, TooltipSide } from '$lib/enums';
import { conversationsStore, deviceStore } from '$lib/stores';
import { conversationsStore, deviceStore, serverStore } from '$lib/stores';
import type { Component } from 'svelte';
import { onMount } from 'svelte';
import { circIn } from 'svelte/easing';
@@ -26,6 +26,7 @@
onSearchDeactivated?: () => void;
onSearchClick?: () => void;
onNewChat?: () => void;
onDiscoverModelsClick?: () => void;
onSettingsClick?: () => void;
}
@@ -33,6 +34,7 @@
class: className,
isExpandedMode = false,
isSearchModeActive = $bindable(false),
onDiscoverModelsClick,
onNewChat,
onSearchClick,
onSearchDeactivated,
@@ -46,6 +48,14 @@
const isOnMobile = $derived(deviceStore.isMobile);
// Discover models needs the router's download endpoints; hide it in
// single-model mode instead of opening a dialog that can only toast.
const actionsItems = $derived(
serverStore.isRouterMode
? SIDEBAR_ACTIONS_ITEMS
: SIDEBAR_ACTIONS_ITEMS.filter((item) => item.action !== SidebarAction.DISCOVER_MODELS)
);
$effect(() => {
if (isSearchModeActive && searchInputRef) {
searchInputRef.focus();
@@ -57,7 +67,7 @@
setTimeout(() => {
initialized = true;
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * SIDEBAR_ACTIONS_ITEMS.length);
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * actionsItems.length);
});
function handleSearchModeDeactivate() {
@@ -107,7 +117,7 @@
? 'hidden pointer-events-none'
: ''}"
>
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{#each actionsItems as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
@@ -117,16 +127,18 @@
onNewChat?.();
void conversationsStore.openNewChat();
}
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
: item.action === SidebarAction.DISCOVER_MODELS
? () => onDiscoverModelsClick?.()
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -164,7 +176,7 @@
</div>
{:else}
<div class="{className} flex-col gap-1 hidden md:flex">
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{#each actionsItems as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemOnClick =
@@ -173,16 +185,18 @@
onNewChat?.();
void conversationsStore.openNewChat();
}
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
: item.action === SidebarAction.DISCOVER_MODELS
? () => onDiscoverModelsClick?.()
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -11,18 +11,24 @@
variant = 'default',
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: 'sm' | 'default';
size?: 'xs' | 'sm' | 'default';
variant?: 'default' | 'plain';
} = $props();
// Super small trigger: fits its selected value, for dense inline use.
const xsClasses =
"group flex h-6 w-fit items-center justify-between gap-1 rounded-md border border-input bg-transparent px-2 py-0 text-xs whitespace-nowrap outline-none select-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground";
const baseClasses = $derived(
variant === 'plain'
? "group inline-flex w-full items-center justify-end gap-2 whitespace-nowrap px-0 py-0 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3 [&_svg:not([class*='text-'])]:text-muted-foreground"
: "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground"
: size === 'xs'
? xsClasses
: "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground"
);
const chevronClasses = $derived(
variant === 'plain'
variant === 'plain' || size === 'xs'
? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180'
: 'size-4 opacity-50'
);
@@ -1,4 +1,7 @@
export const API_MODELS = {
/** Download a model from HuggingFace (ROUTER mode, POST) or cancel/remove it (DELETE) */
DELETE: '/models',
DOWNLOAD: '/models',
LIST: '/v1/models',
LOAD: '/models/load',
SSE: '/models/sse',
@@ -2,6 +2,12 @@ export const CLI_FLAGS = {
AGENT: '--agent',
API_KEY: '--api-key',
MCP_PROXY: '--ui-mcp-proxy',
/** Multimodal projector path; unlocks vision/audio for the model. */
MMPROJ: '--mmproj',
/** Draft model weights path (long form); the router records it per model. */
MODEL_DRAFT: '--model-draft',
/** Draft model weights path (short form). */
MODEL_DRAFT_SHORT: '-md',
SLOTS: '--slots',
TOOLS: '--tools'
} as const;
@@ -0,0 +1,232 @@
/**
* HuggingFace Hub constants.
*
* URLs, parsing regexes and formatting units for the HuggingFaceService.
* Reference: https://huggingface.co/docs/huggingface_hub/package_reference/hf_api
*/
// API endpoints
export const HF_BASE_URL = 'https://huggingface.co';
export const HF_API_MODELS_URL = `${HF_BASE_URL}/api/models`;
export const HF_AVATARS_URL = `${HF_BASE_URL}/api/avatars`;
// Query params
export const HF_FULL_DETAIL_PARAM = 'full=true';
export const HF_RECURSIVE_TREE_PARAM = 'recursive=true';
/** Search filter that restricts results to repos containing GGUF files. */
export const HF_GGUF_FILTER = 'gguf';
/** Repeatable `expand` query param selecting fields on the list endpoint. */
export const HF_EXPAND_PARAM = 'expand';
/**
* Fields the model list endpoint omits by default but the discover list rows
* render: `gguf` (chat template, context length, param count) drives the
* reasoning / tool-use icons and the context badge, `siblings` the vision and
* draft-sidecar badges. Without them those parts of a row stay empty.
*/
export const HF_MODEL_LIST_EXPAND: readonly string[] = [
'author',
'downloads',
'gguf',
'lastModified',
'likes',
'pipeline_tag',
'siblings',
// `base_model:` tags, so search rows can show the base org's avatar as the
// main avatar with the quant org as the corner badge, like catalog rows.
'tags'
];
// Repo file conventions
export const HF_MAIN_BRANCH = 'main';
export const HF_README_FILENAME = 'README.md';
export const HF_RAW_PATH = 'raw';
export const HF_TREE_PATH = 'tree';
// Pagination
export const HF_LINK_NEXT_REGEX = /<([^>]+)>;\s*rel="next"/;
/** `Link` response header carrying the next page URL for cursor pagination. */
export const HF_LINK_HEADER = 'Link';
// Fetch retry
export const HF_RETRY_ATTEMPTS = 3;
export const HF_RETRY_DELAY_MS = 1000;
export const HF_HTTP_NOT_FOUND = 404;
export const HF_HTTP_SERVER_ERROR_MIN = 500;
// Search limits
export const HF_DEFAULT_LIMIT = 50;
/** Safety cap on `/tree` pagination: more pages means a misbehaving endpoint. */
export const HF_TREE_MAX_PAGES = 10;
export const HF_MAX_LIMIT = 100;
// GGUF shard files
/** Matches a split-shard GGUF file name, e.g. `Model-00001-of-00015.gguf`. */
export const HF_SHARD_REGEX = /-(\d{5})-of-(\d{5})\.gguf$/i;
/** Index (1-based) of the first shard in a split-shard set. */
export const HF_FIRST_SHARD = 1;
/** Zero-padded width of the shard index in a split-shard file name. */
export const HF_SHARD_PAD_WIDTH = 5;
// Quantization tokens
/** `UD-` (Unsloth Dynamic) custom quantization prefix, e.g. `UD-Q4_K_XL`. */
export const HF_UD_QUANT_PREFIX = 'UD';
export const HF_UD_QUANT_PREFIX_REGEX = /^UD-/i;
/**
* Segment marking an Unsloth `shared-` draft head that borrows the target
* model's embedding/output weights, e.g. `...-shared-Q4_K_M.gguf`.
*/
export const HF_SHARED_DRAFT_TOKEN = 'shared';
/**
* Extracts the leading precision digits from a quant token, e.g.
* `Q4_K_XL` -> 4, `IQ2_XXS` -> 2, `TQ1_0` -> 1, `BF16` -> 16.
*/
export const HF_QUANT_PRECISION_REGEX = /^(?:I?Q|TQ|BF|F|MXFP)?(\d+)/i;
// Model card tags
/** Matches the `base_model:` tag (plain or `quantized:`), capturing the repo id. */
export const HF_BASE_MODEL_TAG_REGEX = /^base_model:(?:quantized:)?(.+)$/;
export const HF_LICENSE_TAG_PREFIX = 'license:';
export const HF_GATED_TAG = 'gated';
export const HF_GGUF_TAG = 'gguf';
export const HF_SAFETENSORS_TAG = 'safetensors';
// Pipeline tasks (logic use only - matching `pipeline_tag` values against tags)
/**
* `pipeline_tag` values grouped by the input/output modality they imply, used
* to derive a discover row's modality icons. A tag in more than one group (e.g.
* `image-to-video`) lights up each modality it belongs to.
*/
export const HF_MODALITY_PIPELINE_TAGS: Readonly<
Record<'audio' | 'video' | 'vision', readonly string[]>
> = {
audio: [
'audio-classification',
'audio-to-audio',
'automatic-speech-recognition',
'text-to-speech',
'voice-activity-detection'
],
video: ['text-to-video', 'image-to-video', 'video-to-video'],
vision: ['image-text-to-text', 'image-to-text', 'text-to-image', 'image-to-video']
};
/** Filename token marking an mmproj sidecar sibling (unlocks vision / audio). */
export const HF_MMPROJ_FILENAME_TOKEN = 'mmproj';
export const HF_TASK_TAGS: readonly string[] = [
'audio-classification',
'audio-to-audio',
'automatic-speech-recognition',
'conversational',
'depth-estimation',
'feature-extraction',
'fill-mask',
'image-classification',
'image-feature-extraction',
'image-segmentation',
'image-text-to-text',
'image-to-text',
'image-to-video',
'object-detection',
'question-answering',
'reinforcement-learning',
'robotics',
'sentence-similarity',
'summarization',
'text2text-generation',
'text-classification',
'text-generation',
'text-to-image',
'text-to-speech',
'text-to-video',
'token-classification',
'translation',
'video-to-video',
'voice-activity-detection',
'zero-shot-classification'
];
// Formatting
export const BYTE = 1;
export const KILOBYTE = 1_000;
export const MEGABYTE = 1_000_000;
export const GIGABYTE = 1_000_000_000;
export const TERABYTE = 1_000_000_000_000;
/**
* Matches a human size string (`177GB`, `1.2 TB`, `500MB`), capturing the
* numeric value and its unit suffix. Used by `parseSizeBytes`.
*/
export const HF_SIZE_STRING_REGEX = /^\s*([\d.]+)\s*([a-z]+)\s*$/i;
/**
* Byte multiplier for a size suffix (`k` kilobyte, `m` megabyte, ...) as used by
* the llama.app catalog `size` strings, whose suffix is lowercase.
*/
export const HF_SIZE_SUFFIX_BYTES: Readonly<Record<string, number>> = {
b: BYTE,
g: GIGABYTE,
k: KILOBYTE,
m: MEGABYTE,
t: TERABYTE
};
export const BYTE_LABEL = 'B';
export const KILOBYTE_LABEL = 'KB';
export const MEGABYTE_LABEL = 'MB';
export const GIGABYTE_LABEL = 'GB';
/** Count suffixes for compact number formatting, e.g. `1.5K`, `2.0M`. */
export const KILO_LABEL = 'K';
export const MEGA_LABEL = 'M';
export const GIGA_LABEL = 'B';
// Relative time
export const MS_PER_DAY = 1000 * 60 * 60 * 24;
export const DAYS_PER_WEEK = 7;
/** Rough month length in days, used to bucket relative timestamps. */
export const DAYS_PER_MONTH = 30;
export const DAYS_PER_YEAR = 365;
export const TODAY_LABEL = 'Today';
export const YESTERDAY_LABEL = 'Yesterday';
export const DAYS_AGO_LABEL = 'days ago';
export const WEEKS_AGO_LABEL = 'weeks ago';
export const MONTHS_AGO_LABEL = 'months ago';
export const YEARS_AGO_LABEL = 'years ago';
// Cache paths
/**
* Matches a local HF cache file path
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`), capturing the repo
* directory name and the repo-relative file path.
*/
export const HF_CACHE_PATH_REGEX = /models--(.+?)\/snapshots\/[^/]+\/(.+)$/;
/** Separator between org and name segments in an HF cache directory name. */
export const HF_CACHE_DIR_SEPARATOR = '--';
// README
/** Matches a leading YAML frontmatter block (--- ... ---) in a markdown document. */
export const HF_FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
// Param counts
/**
* Best-effort parameter count token in a model id/name, e.g. `27B` from
* `Qwen3.8-27B-GGUF` or `300M` from `embeddinggemma-300M-GGUF`.
*/
export const HF_PARAM_COUNT_REGEX = /(?:^|[^a-z0-9])(\d+(?:[._]\d+)?)\s*([bm])(?![a-z0-9])/i;
@@ -10,7 +10,8 @@ import {
Image as ImageIcon,
Lightbulb as ReasoningIcon,
Mic as AudioIcon,
Video as VideoIcon
Video as VideoIcon,
Wrench as ToolUseIcon
} from '@lucide/svelte';
import { FileTypeCategory, ModelCapability, ModelModality } from '$lib/enums';
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
@@ -49,16 +50,19 @@ export const MODALITY_FLAG_KEYS: Record<
};
export const CAPABILITY_ICONS: Record<ModelCapability, Component> = {
[ModelCapability.REASONING]: ReasoningIcon
[ModelCapability.REASONING]: ReasoningIcon,
[ModelCapability.TOOL_USE]: ToolUseIcon
} as const;
export const CAPABILITY_LABELS: Record<ModelCapability, string> = {
[ModelCapability.REASONING]: 'Reasoning'
[ModelCapability.REASONING]: 'Reasoning',
[ModelCapability.TOOL_USE]: 'Tool use'
} as const;
/** Maps a ModelCapability to the boolean flag it drives on the ModelCapabilities type */
export const CAPABILITY_FLAG_KEYS: Record<ModelCapability, keyof ModelCapabilities> = {
[ModelCapability.REASONING]: 'reasoning'
[ModelCapability.REASONING]: 'reasoning',
[ModelCapability.TOOL_USE]: 'tools'
};
// Shared SVG icon strings for copy and preview buttons
+4
View File
@@ -45,6 +45,10 @@ export * from './message-export.constants';
export * from './path-display.constants';
export * from './model-id.constants';
export * from './model-loading.constants';
export * from './models-discover.constants';
export * from './models-discover-download.constants';
export * from './model-compatibility.constants';
export * from './huggingface.constants';
export * from './precision.constants';
export * from './pwa.constants';
export * from './routes.constants';
@@ -0,0 +1,32 @@
/**
* Model memory-fit constants.
*
* Mirrors the app's compatibility check (Model+Compatibility.swift):
* budget = RAM x RAM_BUDGET_RATIO - RAM_OVERHEAD_MB
* weightBytes = fileBytes x QUANT_WEIGHT
* a file fits when weightBytes <= budget. Kept here so the estimation util and
* any caller share one source.
*/
/** Bytes in one mebibyte (MiB), used to convert a file size to MB. */
export const MIB_BYTES = 1_048_576;
/** MB in one GB. */
export const MB_PER_GB = 1024;
/** Overhead multiplier applied to the file size when estimating weight memory. */
export const QUANT_WEIGHT = 1.05;
/** Share of RAM the app allows the model to occupy. */
export const RAM_BUDGET_RATIO = 0.75;
/** Fixed RAM overhead (MB) reserved for the system and KV cache. */
export const RAM_OVERHEAD_MB = 2048;
/**
* Memory tiers (GB) covering the RAM sizes common machines ship with, in
* small enough steps that the requirement reads honestly. Device-agnostic on
* purpose: the server exposes no host RAM, so the UI presents the tier and
* lets the user judge.
*/
export const MEM_TIERS = [4, 6, 8, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024];
@@ -2,35 +2,48 @@
* Parsing of `org/ModelName[-tag][:quant]` style model IDs.
*/
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
/** Any sidecar file type: a draft variant or an auxiliary sidecar like mmproj. */
export type ModelSidecar = ModelDraftSidecar | ModelAuxSidecar;
/** All sidecar filename tokens: the bare lowercase enum values, e.g. `mtp`, `mmproj`. */
export const SIDECAR_TOKENS: string[] = [
...Object.values(ModelDraftSidecar),
...Object.values(ModelAuxSidecar)
];
/** Separator between token alternatives in the sidecar regexes. */
const REGEX_ALTERNATION_SEPARATOR = '|';
const SIDECAR_TOKEN_ALTERNATION = SIDECAR_TOKENS.join(REGEX_ALTERNATION_SEPARATOR);
export const MODEL_ID = {
/**
* Matches an activated-parameter-count segment, e.g. `A10B`, `a2.4b`.
* The leading `A`/`a` distinguishes it from a regular params segment.
*/
ACTIVATED_PARAMS_RE: /^[Aa]\d+(\.\d+)?[BbMmKkTt]$/,
ACTIVATED_PARAMS_REGEX: /^[Aa]\d+(\.\d+)?[BbMmKkTt]$/,
/** Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`. */
CUSTOM_QUANTIZATION_PREFIX_RE: /^UD$/i,
CUSTOM_QUANTIZATION_PREFIX_REGEX: /^UD$/i,
/** Container format segments to exclude from tags (every model uses these). */
IGNORED_SEGMENTS: new Set(['GGUF', 'GGML']),
/** Sentinel value returned by `indexOf` when a substring is not found. */
NOT_FOUND: -1,
/** Separates `<org>` from `<model>` in a model ID, e.g. `org/ModelName`. */
ORG_SEPARATOR: '/',
/**
* Matches a parameter-count segment, e.g. `7B`, `1.5b`, `120M`.
* The optional leading `E` covers effective-parameter sizes, e.g. Gemma's
* `E2B`/`E4B` (MatFormer models sized by resident params).
*/
PARAMS_RE: /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/,
PARAMS_REGEX: /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/,
/**
* Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `F16`, `BF16`, `MXFP4`.
* Case-insensitive to handle both uppercase and lowercase inputs.
*/
QUANTIZATION_SEGMENT_RE: /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i,
QUANTIZATION_SEGMENT_REGEX: /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i,
/** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */
QUANTIZATION_SEPARATOR: ':',
@@ -38,6 +51,36 @@ export const MODEL_ID = {
/** Separates named segments within the model path, e.g. `ModelName-7B-GGUF`. */
SEGMENT_SEPARATOR: '-',
/**
* Sidecar token between name segments, e.g. `Model-mtp-Q4_0.gguf`,
* `model-eagle3-BF16.gguf`. Captures the name head and tail around the
* token; same case-insensitive rule as the prefix form.
*/
SIDECAR_INFIX_REGEX: new RegExp(`^(.*)-(${SIDECAR_TOKEN_ALTERNATION})-(.+)$`, 'i'),
/**
* Sidecar prefix that wraps a model id with a sidecar type, e.g.
* `mtp-<name>.gguf`, `dflash-<name>.gguf`, `dspark-<name>.gguf`,
* `eagle3-<name>.gguf`, `mmproj-<name>.gguf`. Captures the bare type
* token for typed lookup.
*
* The token matches case-insensitively (real repos ship uppercase
* heads, e.g. `Model-MTP-BF16.gguf`) and is normalized through
* `sidecarFromFileToken`; the server's filename grammar
* (common/download.cpp) matches the same segments.
*/
SIDECAR_PREFIX_REGEX: new RegExp(`^(${SIDECAR_TOKEN_ALTERNATION})-(.*)$`, 'i'),
/**
* Trailing `-<type>` suffix marking a GGUF with an embedded draft in the
* same weight file (MTP) or a sidecar download entry, e.g.
* `Hy3-IQ1_M-mtp.gguf`, `Q4_K_M-dspark`. An optional `-draft` tail covers
* standalone sidecar files, e.g. `Model-mtp-draft.gguf`. The captured
* prefix is the candidate model id; the caller decides whether it looks
* quantized. Case-insensitive, like the prefix form.
*/
SIDECAR_SUFFIX_REGEX: new RegExp(`^(.*)-(${SIDECAR_TOKEN_ALTERNATION})(-draft)?$`, 'i'),
/** Matches a trailing weight file extension, e.g. `model.gguf` -> `model`. */
WEIGHT_EXTENSION_RE: /\.(gguf|ggml)$/i
WEIGHT_EXTENSION_REGEX: /\.(gguf|ggml)$/i
};
@@ -0,0 +1,54 @@
/**
* Models Discover - download options constants.
*
* Shared values backing the download-options area of the details pane: the
* serve `--spec-type` mapping, the bit-depth buckets, and the standalone
* command builder. Kept here (not component-local) so they sit with the rest
* of the discover domain constants.
*/
import type { ModelSidecar } from '$lib/constants';
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
/**
* `--spec-type` value for each draft sidecar; aux sidecars (mmproj, imatrix)
* carry none and stay empty.
*/
export const SPEC_TYPE: Record<ModelSidecar, string> = {
[ModelAuxSidecar.IMATRIX]: '',
[ModelAuxSidecar.MMPROJ]: '',
[ModelDraftSidecar.DFLASH]: 'draft-dflash',
[ModelDraftSidecar.DSPARK]: 'draft-dspark',
[ModelDraftSidecar.EAGLE3]: 'draft-eagle3',
[ModelDraftSidecar.MTP]: 'draft-mtp'
};
/** Bit-depth bucket for files that carry no quant token; rendered as "Other". */
export const OTHER_BIT_DEPTH = 99;
/** Label for the OTHER_BIT_DEPTH bucket. */
export const OTHER_BIT_DEPTH_LABEL = 'Other';
/** Suffix appended after a bit-depth number to label a row, e.g. `4-bit`. */
export const BIT_DEPTH_LABEL_SUFFIX = '-bit';
/**
* Fixed tokens of the standalone `llama serve` command preview, so the command
* builder and its rendered spans share one source of the CLI spelling.
*/
export const SERVE_COMMAND = {
BIN: 'llama',
/** Draft model repo:tag argument (download sidecar / draft weights). */
DRAFT_FLAG: '-hfd',
/** Main model repo:tag argument. */
MODEL_FLAG: '-hf',
/** Speculative-decoding type argument for the draft. */
SPEC_TYPE_FLAG: '--spec-type',
SUBCOMMAND: 'serve'
} as const;
/** Bit depth preferred for the command's default base quant. */
export const DEFAULT_BASE_BIT_DEPTH = 4;
/** Label shown for a draft-sidecar chip whose quant is unknown. */
export const DRAFT_FILE_LABEL = 'draft';
@@ -0,0 +1,14 @@
/**
* Models discover constants.
*
* Endpoints and settings for the Models Discover dialog.
*/
/** llama.app model catalog used as the default model list. Online-only source; the discover feature requires an internet connection anyway. */
export const MODELS_DISCOVER_CATALOG_URL = 'https://llama.app/v1/catalog.json';
/**
* Catalog repos fetched in parallel per batch; small on purpose so the HF API
* is not hit with the whole catalog at once.
*/
export const MODELS_DISCOVER_CATALOG_BATCH = 4;
@@ -15,6 +15,9 @@ export const STORAGE_APP_NAME_DEPRECATED = 'LlamaCppWebui';
export const DB_APP_NAME_DEPRECATED = 'LlamacppWebui';
export const ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.alwaysAllowedTools`;
/** Paused model download ids (`<repo>:<tag>`), restored on the next page load. */
export const PAUSED_MODEL_DOWNLOADS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.pausedModelDownloads`;
export const CONFIG_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.config`;
export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTools`;
+9 -1
View File
@@ -1,4 +1,4 @@
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
import { Package, PackageSearch, Search, Settings, SquarePen } from '@lucide/svelte';
import { SidebarAction, ToolSource } from '$lib/enums';
import type { DesktopIconStripItem } from '$lib/types';
@@ -44,6 +44,9 @@ export const STATS_UNITS = {
export const DEFAULT_MOBILE_BREAKPOINT = 768;
/** Orgs whose avatar is dark and needs inverting in dark mode. */
export const DARK_INVERT_AVATAR_ORGS = ['openai'];
/** Icon used for the model selector and the `/model` slash command. */
export const MODEL_SELECTOR_ICON = Package;
@@ -61,6 +64,11 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
tooltip: 'New chat'
},
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
{
action: SidebarAction.DISCOVER_MODELS,
icon: PackageSearch,
tooltip: 'Discover models'
},
{
action: SidebarAction.SETTINGS,
icon: Settings,
@@ -0,0 +1,35 @@
/**
* HuggingFace Hub enums.
*
* Values mirror the strings used by the HF REST API
* (https://huggingface.co/docs/huggingface_hub/package_reference/hf_api)
* so they can be sent and compared directly.
*/
/** Sort field for /api/models search queries. */
export enum HfModelSort {
CREATED_AT = 'createdAt',
DOWNLOADS = 'downloads',
LAST_MODIFIED = 'lastModified',
LIKES = 'likes',
TRENDING_SCORE = 'trendingScore'
}
/**
* Where the sidecar token (`mtp` / `dflash` / `mmproj` / ...) sits in the
* filename.
* - `prefix` sidecar file that lives next to the main weights, e.g. `mtp-Q4_0.gguf`
* - `suffix` embedded draft baked into the main weights, e.g. `Hy3-IQ1_M-mtp.gguf`
* - `infix` standalone sidecar named between head and quant, e.g. `model-mtp-Q8_0.gguf`
*/
export enum SidecarForm {
INFIX = 'infix',
PREFIX = 'prefix',
SUFFIX = 'suffix'
}
/** Entry type in a model repository file tree (`/tree` responses). */
export enum HfEntryType {
DIRECTORY = 'directory',
FILE = 'file'
}
+11 -1
View File
@@ -57,6 +57,8 @@ export {
SpecialFileType
} from './files.enums';
export { HfEntryType, HfModelSort, SidecarForm } from './huggingface.enums';
export {
MCPConnectionPhase,
MCPLogLevel,
@@ -67,7 +69,15 @@ export {
JsonSchemaType
} from './mcp.enums';
export { ModelCapability, ModelModality } from './model.enums';
export {
ModelAuxSidecar,
ModelCapability,
ModelDraftSidecar,
ModelModality,
ModelSelectableFileKind
} from './model.enums';
export { ModelDownloadConfirmAction, ModelDownloadStopRequest } from './model.enums';
export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums';
+58 -1
View File
@@ -6,5 +6,62 @@ export enum ModelModality {
}
export enum ModelCapability {
REASONING = 'REASONING'
REASONING = 'reasoning',
TOOL_USE = 'tools'
}
/**
* Speculative-decoding draft sidecars (server spec-type draft-*).
* Filenames use the lowercase token, e.g. `mtp-<name>.gguf` or `-mtp` suffix.
*/
export enum ModelDraftSidecar {
/** DFlash block-diffusion draft (spec-type draft-dflash). */
DFLASH = 'dflash',
/** DSpark block-diffusion draft (spec-type draft-dspark). */
DSPARK = 'dspark',
/** EAGLE-3 speculative draft (spec-type draft-eagle3). */
EAGLE3 = 'eagle3',
/** Multi-token-prediction draft head (spec-type draft-mtp). */
MTP = 'mtp'
}
/**
* Non-draft sidecar file types. A sidecar is any auxiliary GGUF file
* accompanying the main model weights.
*/
export enum ModelAuxSidecar {
/** Importance-matrix data used to build imatrix quants; not loaded at serve time. */
IMATRIX = 'imatrix',
/** Multimodal projector: unlocks vision and/or audio input modalities. */
MMPROJ = 'mmproj'
}
/**
* Role of a selectable GGUF in the download options: the main weights, a
* speculative-decoding draft sidecar, or an auxiliary sidecar (mmproj).
*/
export enum ModelSelectableFileKind {
AUX = 'aux',
DRAFT = 'draft',
MAIN = 'main'
}
/**
* Why an in-flight download is being stopped, so the terminal `download_failed`
* feed event can be attributed: a user pause (resumable) or a user cancel
* (discard). Distinguishes these from a genuine download failure.
*/
export enum ModelDownloadStopRequest {
CANCEL = 'cancel',
PAUSE = 'pause'
}
/**
* Destructive download action the user is asked to confirm: stop and discard an
* in-flight download, or delete an already-downloaded model from disk. Both
* resolve through the same store removal call, differing only in the copy.
*/
export enum ModelDownloadConfirmAction {
CANCEL = 'cancel',
DELETE = 'delete'
}

Some files were not shown because too many files have changed in this diff Show More