Compare commits

...
Author SHA1 Message Date
Pascal e72982c13f graph: store the conv state of every rollback arch in one set_rows
The rollback slots of the causal conv history were written with one
copy per slot in qwen4exp, glm5-next, kimi-k3, bailingmoe3, lfm and
mamba2, while delta_net already wrote them with a single set_rows.
build_conv_state_store now does this for all of them: one copy without
rollback, otherwise one set_rows of the overlapping windows into the
min(n, n_rs_seq + 1) slots a rollback can read.

The slot order {n_rs_seq, ..., 0} is a constant of the recurrent
memory, kept in its own buffer next to the state, and each graph takes
a view of its tail. Its size no longer depends on the ubatch, so the
reserved graph covers every ubatch shape, and the graph input that
held it is gone.

A model that is not causal never rolls back, so the context clamps
n_rs_seq to 0 for it, like for an arch without rollback support.
2026-11-03 15:23:17 +01:00
Pascal 3b0861cf04 ggml: bound views by the bytes their strides reach
A view was checked against its source with the size of a contiguous
tensor of the same shape, computed before its strides were set. Views
whose rows overlap failed that check although they stay inside their
source, and views with wider strides passed it while reaching past
the end. The view functions now pass their strides to the tensor
constructor, which checks the bytes they actually reach, like
ggml_nbytes. The conv windows of delta_net are created with all their
rows at once instead of being widened after the check.
2026-11-03 15:23:17 +01:00
Prabhsimran Singh 28b812d6cb mtmd : add Nemotron 3.5 Transcribe support (#31471)
* mtmd : add Nemotron 3.5 Transcribe support

Assisted-by: Claude

* convert : fix vocab of draft models for Nemotron-H targets

Assisted-by: Claude

* convert : reject non-BF16 Transcribe checkpoints

Assisted-by: Claude

* mtmd : make speaker FIFO predictions local

Assisted-by: Claude

* mtmd : normalize the mel in encode_windows

Assisted-by: Claude

* convert : map the generated speaker kernel

Assisted-by: Claude

* mtmd : validate Nemotron Transcribe mmproj shapes

Assisted-by: Claude
2026-11-03 14:27:05 +01:00
Pascal 749db7f034 qwen4exp: write the conv state tail without an intermediate cont (#31443)
build_conv_state_at makes a contiguous copy of the conv input tail
before writing it into the recurrent state, so every GDN layer issues
two device copies per generated token where one is enough. On
Qwen3.8-Flash-Next (36 GDN layers) that is 72 copies per token, now 36.
Speculative decoding adds one more per rollback slot. ggml_cpy reads
the strided view directly, as the shared build_conv_state already does,
so the extra copy is dropped.
2026-11-03 13:47:23 +02:00
Aleksander Grygier 0943ca5472 ui: add Playwright harness for recording demo videos (#31318)
* ui: add Playwright harness for recording demo videos

Scripted walkthroughs under tools/ui/demo are recorded with Playwright and
exported as H.264 mp4 for pull requests (demo/pr) and docs (demo/docs).
Fixture MCP servers and seeded settings keep recordings deterministic, a
drawn cursor and captions make the flow readable, and DEMO_UI_DIR points
the recording at another checkout such as a PR worktree.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: record demo scripts from PR descriptions and a configurable storage

Demo scripts no longer live in the repo. `npm run demo:pr -- <number>`
records the `ts demo` block from a PR description against the PR head,
uploads the mp4, a GIF preview and the script to DEMO_STORAGE, and links
the preview in a `## Demo` section of the PR description.
`npm run demo:docs` records the docs scripts kept in DEMO_STORAGE.
DEMO_STORAGE (hf:// buckets or file://) and the public URL are read
from tools/ui/.env.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: import generated conversations into demo recordings

Demos can now start with existing chats: `test.use({ traces })` imports
llama.app JSONL traces through Settings > Import/Export before the video
starts. Built-in traces are generated deterministically, other names are
downloaded from DEMO_TRACES_DATASET, and `demo:traces` pushes the built-in
ones there. Adds `demo.scroll` for wheel scrolling.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: keep demo captions clear of the chat form and isolate concurrent runs

Captions move to the top of the frame, since at the bottom they covered
the chat form a demo is often about. Each recording run gets its own
Playwright output folder, so a run starting elsewhere no longer clears
another run's video before it is saved.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: record demos at 2x and 60 fps with human-like timing

Recording goes through Chromium's screencast instead of Playwright's
video, which encoded VP8 at about 1 Mbit/s at 1x. Frames are captured at
the device pixel ratio (DEMO_SCALE, default 2) and encoded into a 60 fps
H.264 mp4, starting once the page has settled.

The cursor now glides faster along a slight arc with Fitts-like
durations, settles briefly before each click, and typing and pauses vary
a little. The randomness is seeded per demo, so re-recording gives the
same motion.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: document the demo harness prerequisites

The demo README now lists the tools the harness needs and what each is
for, and walks through creating the Hugging Face bucket and the optional
traces dataset, with the matching .env settings.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: version linked demo URLs so re-recorded previews refresh

GitHub's image proxy caches a preview by URL, so a demo re-recorded at
the same PR head kept showing the old GIF. The Demo section now links
the files with a content hash in the query string.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: label PRs that feature a video demo

Adds a `video demo` label and a workflow that applies it when a PR
description has a demo written by the UI demo pipeline (the managed
demo:start/demo:end markers, or a preview linking to a demo video),
and removes it when that section is gone.

Assisted-by: Claude (Opus 5.5 Medium)

* ci: label video demos with gh instead of github-script

The label workflow now runs the gh CLI in a shell step, like
pr-draft-label.yml, so it needs no action outside the repository's
allowed list. The video link check only counts videos uploaded for the
PR itself, so a description linking other PRs' demos isn't labeled.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: zoom demos in on details with a two-finger pinch

demo.zoom pinches in on a control or a point, holds, and pinches back out
to 1x, so a detail too small to read in the frame can be shown up close.
It drives Chromium's own pinch gesture, so the page's fixed bars stay put,
and works at desktop and phone sizes alike.

The pinch is evened out where Chromium misses: one anchored near the top
does not zoom at all, so it starts lower, and one near the bottom
overshoots its ratio, so a small pinch from the middle corrects it. The
way out reads the real scale until the page is back at 1x, which the next
gesture needs.

The overlay also draws a fingertip per touch, so a pinch on a touch
device shows as two fingers.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: wait out a cold dev server before a demo starts

A fresh checkout's dev server optimizes its dependencies on the first
load and reloads the page, which can take well past an action's timeout,
so a demo failed before it started. The demo now waits up to 90 seconds
for the app; the recording only starts once it is ready.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: record a video per test in a PR demo, shown in a table

A large PR can split its demo into several shorter videos: each test()
in the ts demo block is recorded as its own video (<sha>-1.mp4, ...),
and the Demo section lays their previews out in a table, each column
headed by its test's title.

GIF previews now keep their longer side at DEMO_PREVIEW_WIDTH and shrink
until they are under 5 MB, since GitHub's image proxy refuses anything
larger and shows a broken image instead.

Importing traces works on a phone viewport too, where Settings sits in
the sidebar under the page until the menu key slides it aside.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: fix demo trace imports on desktop and lay landscape demos out two to a row

Importing traces failed on a desktop whose collapsed sidebar sits under
the page: it now expands the sidebar from the page's menu key on a phone
or from the sidebar's logo on a desktop, whichever makes Settings
reachable. Afterwards it forgets the expanded sidebar, so a demo starts
with it collapsed, as on a first visit.

A PR demo with several videos shows up to four portrait (phone) videos
to a row, or two landscape ones, so each preview stays readable.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: only record other people's PR demos at a reviewed head commit

demo:pr runs the PR's code and demo script on this machine, with the
gh and hf logins, so a PR by someone else now needs --trust <sha>
naming the head commit that was read, and is refused again after new
pushes. Recordings also launch Chromium with its sandbox on, which
Playwright turns off by default.

Assisted-by: Claude (Opus 5.5 Medium)
2026-11-03 12:30:49 +01:00
Tarek Dakhran f9448cddc4 model : record layer inputs in LFM3 for spec dec (#31598) 2026-11-03 13:29:10 +02:00
Jasmine-tim 0978a6e902 ggml: avoid signed overflow in ggml_roll bounds check (#30375)
ggml_roll validated each shift with abs(shift), but abs(INT_MIN) is signed
integer overflow UB evaluated before the bounds check runs. Compare with a
signed-safe test instead.
2026-11-03 13:26:29 +02:00
Daniel Bevenius 0ef27309b7 conversion : use original model tensor names in _flush_nvfp4_experts (#31427)
This commit updates the _flush_nvfp4_experts to take a new parameter
which is a dictionary mapping the key to the original model tensor name.

The motivation for this is that I noticed that the tensor names being
looked up were not the original model tensor names, for example:
```console
  model.layers.1.mlp.experts.up_proj.weight
```
But _nvfp4_layer_algo uses the original per-expert names:
```console
(Pdb) p self._nvfp4_layer_algo
{
'backbone.layers.1.mixer.experts.0.up_proj': 'W4A16_NVFP4',
...
```
This means that the lookup will never match for experts and fall back to
_nvfp4_global_algo. If the global algo is W4A16_NVFP4 then this would
go unnoticed, but for a model like
nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4 which is
MIXED_PRECISION, the expert tensors will not get prec_a4=false so they
would use the native W4A4 path on Blackwell even though the original
model tensors declare them as W4A16_NVFP4.
2026-11-03 11:40:51 +01:00
mmalpe-nvandGaurav Garg 8fc0376721 cuda: potential fix for Q8_1 correction sum (#31247)
* cuda: potential fix for Q8_1 correction sum

* Update ggml/src/ggml-cuda/quantize.cu

Co-authored-by: Gaurav Garg <gaugarg@nvidia.com>

* addressed review comment to change the quantize_mmq_q8_1 flow as well

---------

Co-authored-by: Gaurav Garg <gaugarg@nvidia.com>
2026-11-03 15:54:42 +05:30
Bhargav Krish 740251a920 cuda : gather batched tensor reads into mapped host memory (PR to master) (#31391)
* cuda: gather batched tensor reads into mapped host memory

Assisted-by: Codex

* add comments

Assisted-by: Codex

* simplify the implementation based on checks that exist earlier in the flow

* simplify batched tensor gather indexing

* add conditional dispatch

* address comments - make host registration raii, better test mindful of ci time, assert instead of return false

* cuda : gather small reads and use pinned DMA for large reads

* cuda : use std::vector for host gather descriptors

* cuda : add 8-byte gather copies, generalize tensor test and log registration failures

* common buffer for descriptors

* simplify stream sync + increase to 128k descriptor pinned buf
2026-11-03 15:31:50 +05:30
zyang-dev 71efc559a0 Add nccl support for multi-node tensor parallelism (#28967)
* Add a backend interface for collectives across ranks running in separate processes, implement it in the CUDA backend using NCCL, and use it in the RPC server when available.

* Drop ggml_backend_comm_allreduce_rank_t and reuse ggml_backend_comm_allreduce_tensor for per-rank communicators

* reuse ggml_backend_cuda_comm_allreduce_nccl for per-rank communicators. Remove the helper function backend_comm_proc_address

* Change ggml_backend_comm_get_unique_id_t and ggml_backend_comm_init_rank_t to more generic multi-process backend communicator interfaces

* set maxCTAs in ncclConfig_t to 8 for dgx spark in ggml_backend_cuda_comm_init_rank
2026-11-03 14:56:19 +05:30
Nidhi 19e8a86fe8 tools : reject mismatched shards in GGUF split merge (#31579) 2026-11-03 11:11:05 +02:00
Aman GuptaandPratik Narola 259d91709d meta: support GLM-5.3 next sm tensor (#30499)
* meta: support GLM-5.3 next sm tensor

* glm5-next : cont q_absorbed for -sm tensor with non-unified KV

* meta : keep GGML_OP_ARANGE in ggml.h order

* glm5-next : split MLA heads in pairs for -sm tensor

The MLA cache has a single KV head, so the heads on a device are its GQA
ratio. The HIP tile FA kernel for D=512 needs an even GQA ratio; with 3
devices the 16 heads of the test model were split 5/5/6 and it aborted.

* meta : keep the split for views inside one slab of the split axis

With a non-unified KV cache, build_attn_mha splits q into streams with a
view. For a permuted q, e.g. the absorbed query of glm5-next, that view
aborted with "view of permuted tensor not implemented". The split is now
kept if every other dim of the source and the view stays inside one slab
of the split axis. The per-device strides are only rescaled above it.

From tetherto/qvac-fabric-llm.cpp#353.

Co-authored-by: Pratik Narola <pratik.narola@tether.io>

* glm5-next : drop the q_absorbed cont

The meta backend can now split the per-stream view of the permuted q, so
the cont is not needed for -sm tensor with a non-unified KV cache. With
the cont, the q of flash attention was not contiguous, and when FA ran on
the CPU (WebGPU) the meta get_tensor aborted.

---------

Co-authored-by: Pratik Narola <pratik.narola@tether.io>
2026-11-03 10:51:10 +02:00
Georgi Gerganov 36ee3caf7a metal : fix cpy kernel for dst non-contiguous in dim 0 (#31581)
kernel_cpy_t_t indexed the dst innermost dim with an implicit element-size
stride instead of args.nb0, so cpy into a permuted view (nb0 != type size)
wrote the wrong addresses. caught by the new test-backend-ops cpy case with
permute_dst = {1, 0, 2, 3} from #31446. addressing is unchanged when dst is
contiguous in dim 0.

Assisted-by: dsh:llama.cpp/ggml-org/GLM-5.3-Flash-GGUF (Mac Studio, M5 Ultra, 256GB)
2026-11-03 10:41:24 +02:00
FamousMonsterrandFamousMonsterr fac3a894fc vulkan: skip maxStorageBufferRange pre-check for non-compute tensors (#31114)
* vulkan: skip maxStorageBufferRange pre-check for non-compute tensors

Pre-allocated leaf tensors and views (GGML_OP_NONE / GGML_OP_VIEW) are never
executed by the backend, but were still rejected by the descriptor-size
pre-check in ggml_backend_vk_device_supports_op when larger than
maxStorageBufferRange on devices without shader_64b_indexing. The scheduler
then aborts with 'pre-allocated tensor (...) in a buffer (Vulkan0) that cannot
run the operation (NONE)' - e.g. the MoE expert cache banks (llama-moe-cache)
of a 256-expert model with --moe-cache-mib on GCN GPUs, where a whole bank
(2.4 GB) exceeds the limit while the consuming ops only access small
per-step views.

Skip the size pre-check for non-compute ops (NONE and VIEW); the buffer type
allocation limit still bounds the actual allocation, and compute ops keep
validating their sources.

Tested on AMD Radeon Pro VII (gfx906, no shader_64b_indexing):
- before: GGML_ABORT 'pre-allocated tensor (moe_cache.0.blk.0.ffn_down_exps.weight) ... cannot run the operation (NONE)' with llama-cli -ngl 99 --cpu-moe --moe-cache-mib 6144 (Cyber-Tiel-Coder-35B-A3B, qwen35moe, 256 experts per layer)
- after: loads and generates at 8.1 t/s (vs 0.6 t/s without the cache)
- regression: -ncmoe 16 path unchanged (19.2 t/s), qwen3moe/qwen35moe without cache unchanged

Fixes #30697

* vulkan: use ggml_op_is_empty for the non-compute size-check skip

---------

Co-authored-by: FamousMonsterr <FamousMonsterr@users.noreply.github.com>
2026-11-03 08:53:04 +01:00
Aman GuptaandPascal b74298c109 delta_net_base: refactor conv state (#30878)
* delta_net: access conv state via views

* fix sm tensor

* delta_net: fill the conv slot index on the host

The rollback slot index of the conv windows is a constant per graph
shape. It is now an I32 input of the recurrent memory filled on the
host, instead of an arange and an argsort computed on the device, and
the meta backend no longer needs a mirrored split for ARANGE.

---------

Co-authored-by: Pascal <admin@serveurperso.com>
2026-11-03 08:28:35 +01:00
Dongsheng LuoandDongsheng Luo 37b0f9c36a vulkan: fix out-of-bounds NVFP4 UE4M3 scale lookup (#31079)
* vulkan : fix out-of-bounds NVFP4 UE4M3 scale lookup

On the non-OCP path, ue4m3_fp32_lut has 128 entries and is indexed with the raw scale byte. ggml_validate_row_data accepts every byte and ggml_ue4m3_to_fp32 ignores bit 7, so a byte >= 0x80 reads past the shared array. Vulkan robustness does not cover shared memory.

Size the table, both init loops, and the two host shared-memory budgets to 256 entries. ue4m3_to_fp32_build() already masks the exponent and treats only 0 and 0x7F as special, so entries 128-255 match the CPU. A 256-entry table keeps 0xFF distinct from 0x7F: the CPU decodes 0xFF as a non-zero value and 0x7F as zero.

Each NVFP4 pipeline uses 512 more bytes of shared memory. ggml_fp32_to_ue4m3 never sets bit 7, so models quantized by llama.cpp are unaffected.

Assisted-by: Claude
Assisted-by: Codex

* vulkan : fix out-of-bounds NVFP4 UE4M3 scale lookup

Assisted-by: Codex

---------

Co-authored-by: Dongsheng Luo <7137760+flyingdoog@users.noreply.github.com>
2026-11-03 07:58:56 +01:00
59 changed files with 3733 additions and 362 deletions
+37
View File
@@ -0,0 +1,37 @@
name: "PR video demo label"
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
cache-mode: none
permissions:
pull-requests: write
issues: write
jobs:
video-demo-label:
runs-on: ubuntu-slim
steps:
- name: Add or remove the video demo label
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# passed through env, so the body is only matched, never run, and is safe to read from a fork
BODY: ${{ github.event.pull_request.body }}
HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'video demo') }}
run: |
# sections written by demo:pr (see tools/ui/demo/cli.ts): the managed comment
# markers, or a preview linking to a demo video uploaded for this PR
has_demo=false
if { grep -qF '<!-- demo:start -->' <<< "$BODY" && grep -qF '<!-- demo:end -->' <<< "$BODY"; } ||
grep -qE "/resolve/videos/pr/$PR_NUMBER/[0-9a-f]{10}(-[0-9]+)?\.mp4" <<< "$BODY"; then
has_demo=true
fi
if [ "$has_demo" = true ] && [ "$HAS_LABEL" = false ]; then
gh pr edit "$PR_URL" --add-label 'video demo'
elif [ "$has_demo" = false ] && [ "$HAS_LABEL" = true ]; then
gh pr edit "$PR_URL" --remove-label 'video demo'
fi
+2
View File
@@ -223,6 +223,7 @@ TEXT_MODEL_MAP: dict[str, str] = {
"ModernBertForSequenceClassification": "bert",
"ModernBertModel": "bert",
"NanbeigeForCausalLM": "nanbeige",
"Nemotron3_5TranscribeForConditionalGeneration": "nemotron",
"NemotronForCausalLM": "nemotron",
"NemotronHForCausalLM": "nemotron",
"NemotronHPuzzleForCausalLM": "nemotron",
@@ -380,6 +381,7 @@ MMPROJ_MODEL_MAP: dict[str, str] = {
"MiniCPMOcrForConditionalGeneration": "minicpm",
"Mistral3ForConditionalGeneration": "llava",
"Mistral3Model": "llava",
"Nemotron3_5TranscribeForConditionalGeneration": "nemotron",
"NemotronH_Omni_Reasoning_V3": "nemotron",
"NemotronH_Nano_VL_V2": "nemotron",
"MuseGlimmerForConditionalGeneration": "muse_glimmer",
+7 -5
View File
@@ -1277,6 +1277,7 @@ class ModelBase:
expert_scales: dict[tuple[int, str], list[tuple[int, float]]] = {}
expert_input_scales: dict[tuple[int, str], list[tuple[int, float]]] = {}
expert_shapes: dict[tuple[int, str], list[int]] = {}
expert_names: dict[tuple[int, str], str] = {}
n_experts = self.find_hparam(["num_local_experts", "num_experts", "n_routed_experts"], optional=True) or 0
consumed: list[str] = []
@@ -1325,6 +1326,7 @@ class ModelBase:
expert_scales[key] = []
expert_input_scales[key] = []
expert_shapes[key] = shape
expert_names[key] = name
expert_blocks[key].append((expert_id, raw.copy()))
# Collect per-expert scale2 (scalar per expert)
expert_scales[key].append((expert_id, float(scale2.float().sum())))
@@ -1333,13 +1335,13 @@ class ModelBase:
# Flush when all experts for this (layer, proj) are collected
if n_experts > 0 and len(expert_blocks[key]) >= n_experts:
self._flush_nvfp4_experts(key, expert_blocks, expert_scales, expert_input_scales, expert_shapes, bid, proj_type)
self._flush_nvfp4_experts(key, expert_blocks, expert_scales, expert_input_scales, expert_shapes, expert_names, bid, proj_type)
else:
self._repack_nvfp4(name, weight, scale, scale2, input_scale)
# Flush any remaining experts (fallback if n_experts was unknown)
for bid, proj_type in list(expert_blocks.keys()):
self._flush_nvfp4_experts((bid, proj_type), expert_blocks, expert_scales, expert_input_scales, expert_shapes, bid, proj_type)
self._flush_nvfp4_experts((bid, proj_type), expert_blocks, expert_scales, expert_input_scales, expert_shapes, expert_names, bid, proj_type)
# Remove consumed tensors so get_tensors/modify_tensors won't see them
for name in consumed:
@@ -1350,7 +1352,7 @@ class ModelBase:
if name.endswith((".k_scale", ".v_scale")):
del self.model_tensors[name]
def _flush_nvfp4_experts(self, key, expert_blocks, expert_scales, expert_input_scales, expert_shapes, bid, proj_type):
def _flush_nvfp4_experts(self, key, expert_blocks, expert_scales, expert_input_scales, expert_shapes, expert_names, bid, proj_type):
experts = expert_blocks.pop(key)
scales = expert_scales.pop(key)
input_scales = expert_input_scales.pop(key)
@@ -1362,7 +1364,7 @@ class ModelBase:
new_name = self.map_tensor_name(merged_name)
logger.info(f"Repacked {new_name} with shape [{len(experts)}, {shape[0]}, {shape[1]}] and quantization NVFP4")
self.gguf_writer.add_tensor(new_name, merged, raw_dtype=gguf.GGMLQuantizationType.NVFP4)
self._tag_prec_a4(merged_name, new_name)
self._tag_prec_a4(expert_names.pop(key), new_name)
scales.sort(key=lambda x: x[0])
self._write_scales_tensor(new_name.replace(".weight", ".scale"), [s[1] for s in scales])
@@ -3384,7 +3386,7 @@ def get_model_architecture(hparams: dict[str, Any], model_type: ModelType) -> st
# Kimi-K3's text_config reports "KimiLinearForCausalLM", which is the older
# Kimi-Linear-48B architecture and cannot load K3 (no attention residuals,
# latent MoE, situ, ...). Route on the top-level architecture instead.
if model_type == ModelType.TEXT and arch in ("NEOChatModel", "StepVLForConditionalGeneration", "Sarashina2VisionForCausalLM", "Exaone4_5_ForConditionalGeneration", "Step3p7ForConditionalGeneration", "KimiK3ForConditionalGeneration"):
if model_type == ModelType.TEXT and arch in ("NEOChatModel", "StepVLForConditionalGeneration", "Sarashina2VisionForCausalLM", "Exaone4_5_ForConditionalGeneration", "Step3p7ForConditionalGeneration", "KimiK3ForConditionalGeneration", "Nemotron3_5TranscribeForConditionalGeneration"):
return arch
# if "architectures" is found in the sub-config, use that instead
+147 -2
View File
@@ -428,6 +428,9 @@ class NemotronHModel(GraniteHybridModel):
is_moe: bool = False
supports_mtp_export = True
_experts: list[dict[str, Tensor]] | None = None
# tokens past the embedding rows (left out of the vocab), and special tokens that are printed as text
_vocab_skip: tuple[str, ...] = ()
_user_defined_tokens: re.Pattern[str] | None = None
_SSM_LAYER_TYPES = {"mamba", "linear_attention"}
_ATTN_LAYER_TYPES = {"attention", "full_attention"}
@@ -616,7 +619,11 @@ class NemotronHModel(GraniteHybridModel):
vocab_size = -(vocab_size // -pad_vocab) * pad_vocab
self.hparams["vocab_size"] = vocab_size
assert max(tokenizer.vocab.values()) < vocab_size # ty: ignore[unresolved-attribute]
# draft-model converters (DFlash, LiLiCorr) call this with their own instance, which has neither hook
vocab_skip = getattr(self, "_vocab_skip", ())
user_defined_tokens = getattr(self, "_user_defined_tokens", None)
assert max(id_ for tok, id_ in tokenizer.vocab.items() if tok not in vocab_skip) < vocab_size # ty: ignore[unresolved-attribute]
tokpre = self.get_vocab_base_pre(tokenizer)
@@ -638,7 +645,8 @@ class NemotronHModel(GraniteHybridModel):
if previous_token != token:
logger.info(f"{repr(previous_token)} is encoded and decoded back to {repr(token)} using AutoTokenizer")
if added_tokens_decoder[i].special or self.does_token_look_special(token):
printed = user_defined_tokens is not None and user_defined_tokens.fullmatch(token)
if (added_tokens_decoder[i].special or self.does_token_look_special(token)) and not printed:
toktypes.append(gguf.TokenType.CONTROL)
else:
token = token.replace(b"\xe2\x96\x81".decode("utf-8"), " ") # pre-normalize user-defined spaces
@@ -834,3 +842,140 @@ class NemotronHPuzzleModel(NemotronHModel):
if item[0].startswith("mtp."):
return None
return super().filter_tensors(item)
# NeMo speechlm2 SALM checkpoint: a Nemotron-H decoder under llm.*, the speech encoder under perception.*
@ModelBase.register("Nemotron3_5TranscribeForConditionalGeneration")
@ModelBase.example("nvidia/NVIDIA-Nemotron-3.5-Transcribe-30B-A4B")
class NemotronTranscribeModel(NemotronHModel):
model_arch = gguf.MODEL_ARCH.NEMOTRON_H
# the audio placeholder (id vocab_size) has no embedding row; speaker, event and style tags print with the transcript
_vocab_skip = ("<|audio|>",)
_user_defined_tokens = re.compile(r"<spk:\d+>|<ev:[a-z_]+>|<sty:(?:stt|end):[a-z_]+>")
_renames = {
"llm.model.embed_tokens.weight": "backbone.embeddings.weight",
"llm.model.norm.weight": "backbone.norm_f.weight",
"llm.lm_head.weight": "lm_head.weight",
}
# grouped experts: [n_expert, n_in, n_out] -> [n_expert, n_out, n_in]
_grouped_experts = {"gate_and_up_projs": "up_proj", "down_projs": "down_proj"}
@classmethod
def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None:
name, gen = item
if name.startswith(("perception.", "ctc_timestamp.")) or "_extra_state" in name:
return None
if not name.startswith("llm."):
# the FP8 and NVFP4 releases store the decoder as backbone.*
raise ValueError(f"unexpected tensor {name}: only the BF16 checkpoint layout (llm.*) is supported")
if name in cls._renames:
name = cls._renames[name]
elif name.startswith("llm.model.layers."):
name = "backbone.layers." + name[len("llm.model.layers."):]
elif name.startswith("llm.mtp.layers."):
name = "mtp.layers." + name[len("llm.mtp.layers."):]
else:
raise ValueError(f"unexpected decoder tensor {name}")
return super().filter_tensors((name.replace("._fp32_params.", "."), gen))
def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
leaf = name.rsplit(".", 1)[-1]
if ".mixer.experts." in name and leaf in self._grouped_experts:
if name.startswith("mtp."):
assert self._mtp_bid is not None, f"{name}: MTP head is not exported"
bid = self._mtp_bid
assert bid is not None
data_torch = data_torch.permute(0, 2, 1).contiguous()
name = f"model.layers.{bid}.mlp.experts.{self._grouped_experts[leaf]}.weight"
yield from ModelBase.modify_tensors(self, data_torch, name, bid)
return
yield from super().modify_tensors(data_torch, name, bid)
@ModelBase.register("Nemotron3_5TranscribeForConditionalGeneration")
class NemotronTranscribeAudioModel(MmprojModel):
# NeMo ParallelExpertEncoder: ASR and speaker transformers, speaker activity fused into the ASR states
has_vision_encoder = False
has_audio_encoder = True
def get_audio_config(self) -> dict[str, Any] | None:
asr = self.global_config["pe_encoder_config"]["asr_encoder_cfg"]
return {
"hidden_size": asr["d_model"],
"intermediate_size": int(float(asr.get("ff_expansion", 4)) * asr["d_model"]),
"num_hidden_layers": asr["n_layers"],
"num_attention_heads": asr["n_heads"],
}
@staticmethod
def _rope_dims(cfg: dict[str, Any]) -> int:
assert cfg.get("self_attention_model") == "rope" and cfg.get("subsampling", "feature_stacking") == "feature_stacking"
assert cfg.get("attn_mode", "full") == "full" and not cfg.get("xscaling", False) and cfg.get("pre_block_norm", True)
assert float(cfg.get("rope_base", 10000.0)) == 10000.0
n_rot = int(cfg["d_model"] // cfg["n_heads"] * float(cfg.get("rotary_fraction", 1.0)))
assert n_rot >= 2 and n_rot % 2 == 0
return n_rot
def set_gguf_parameters(self):
super().set_gguf_parameters()
pe = self.global_config["pe_encoder_config"]
asr = pe["asr_encoder_cfg"]
diar = pe["diarization_model_cfg"]
spk = diar["encoder"]
assert pe.get("asr_encoder_type") == "transformer" and pe.get("speaker_feature_mode", "thresholded") == "thresholded"
assert pe.get("asr_normalize_type") == "per_feature" and pe.get("diar_normalize_type") in (None, "NA")
assert asr["subsampling_factor"] == spk["subsampling_factor"] == 8 and asr["feat_in"] == spk["feat_in"]
assert self._rope_dims(spk) == spk["d_model"] // spk["n_heads"] and not spk.get("qkv_bias", False)
assert diar.get("high_resolution", False)
# constants in clip: the ParallelExpertEncoder online settings and the Sortformer streaming config
assert (pe.get("online_inference_length", 500), pe.get("chunk_left_context", 50), pe.get("chunk_right_context", 50),
pe.get("diar_spkcache_len", 188), pe.get("diar_fifo_len", 40), pe.get("diar_spkcache_update_period", 300),
pe.get("speaker_activity_threshold", 0.5)) == (500, 50, 50, 188, 40, 300, 0.5), \
"streaming settings differ from the constants in tools/mtmd/models/nemotron-transcribe.cpp"
sm = diar["sortformer_modules"]
assert (sm["spkcache_sil_frames_per_spk"], sm["pred_score_threshold"], sm["scores_boost_latest"], sm["strong_boost_rate"],
sm["weak_boost_rate"], sm["min_pos_scores_rate"], sm["use_learnable_sil_emb"]) == (1, 0.25, 0.05, 0.75, 1.5, 0.5, True), \
"speaker cache settings differ from the constants in tools/mtmd/models/nemotron-transcribe.cpp"
self.gguf_writer.add_clip_projector_type(gguf.VisionProjectorType.NEMOTRON_TRANSCRIBE)
self.gguf_writer.add_audio_num_mel_bins(asr["feat_in"])
self.gguf_writer.add_audio_attention_layernorm_eps(1e-5)
self.gguf_writer.add_audio_subsampling_factor(asr["subsampling_factor"])
self.gguf_writer.add_audio_rope_dims(self._rope_dims(asr))
self.gguf_writer.add_audio_spk_block_count(spk["n_layers"])
self.gguf_writer.add_audio_spk_head_count(spk["n_heads"])
def generate_extra_tensors(self) -> Iterable[tuple[str, Tensor]]:
# ParallelExpertEncoder._build_sinusoid_position_encoding
pe = self.global_config["pe_encoder_config"]
n_spk = int(pe["diarization_model_cfg"]["sortformer_modules"]["num_spks"])
d = int(pe["asr_encoder_cfg"]["d_model"])
scale = float(pe.get("spk_kernel_scale", 1.0))
pos = torch.arange(n_spk, dtype=torch.float32)[:, None]
div = torch.exp(torch.arange(0, d, 2, dtype=torch.float32) * -(np.log(10000.0) / d))
kernel = torch.zeros(n_spk, d, dtype=torch.float32)
kernel[:, 0::2] = torch.sin(pos * div)
kernel[:, 1::2] = torch.cos(pos * div)
# stored as [d, n_spk]: the graph multiplies it with the speaker activity directly
yield self.format_tensor_name(gguf.MODEL_TENSOR.A_FUSION_SPK_KERNEL, suffix=""), (kernel * scale).T.contiguous()
def tensor_force_quant(self, name, new_name, bid, n_dims):
if self.match_model_tensor_name(new_name, gguf.MODEL_TENSOR.A_SPK_HEAD_UPSAMPLE, None):
return gguf.GGMLQuantizationType.F16 # ggml_conv_1d kernel
return super().tensor_force_quant(name, new_name, bid, n_dims)
@classmethod
def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None:
name, gen = item
if not name.startswith("perception.") or "_extra_state" in name:
return None
# training-only head; the speaker model's unused front end
if name.startswith(("perception.encoder.diarization_model.sortformer_modules.hidden_to_spks.",
"perception.encoder.diarization_model.preprocessor.")):
return None
return name, gen
def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
if name == "perception.preprocessor.featurizer.fb":
data_torch = data_torch.squeeze(0)
yield from super().modify_tensors(data_torch, name, bid)
+12
View File
@@ -211,6 +211,18 @@ extern "C" {
// Context management and operations for faster communication between backends, used for tensor parallelism (meta backend)
typedef void * (*ggml_backend_comm_init_t)(ggml_backend_t * backends, size_t n_backends);
// Builds the communicator for the case where each rank is a separate process, so a process
// owns one backend of the communicator rather than all of them.
//
// The caller provides bootstrap_callback, which moves bytes between the ranks before the
// communicator exists. Each rank passes size bytes in send_buf, and recv_buf receives
// world_size * size bytes in rank order. The backend decides what to exchange and how many
// times to call it, and every rank must make the same calls in the same order.
// comm_init_rank blocks until all world_size ranks have joined.
typedef bool (*ggml_backend_comm_bootstrap_callback)(void * user_data, const void * send_buf, void * recv_buf, size_t size);
typedef void * (*ggml_backend_comm_init_rank_t)(ggml_backend_t backend, int rank, int world_size, ggml_backend_comm_bootstrap_callback bootstrap_callback, void * bootstrap_callback_data);
typedef void (*ggml_backend_comm_free_t)(void * comm_ctx);
typedef bool (*ggml_backend_comm_allreduce_tensor_t)(void * comm_ctx, struct ggml_tensor ** tensors);
+63 -2
View File
@@ -489,6 +489,32 @@ static struct ggml_tensor * ggml_backend_meta_buffer_simple_tensor(const struct
return it->second[index];
}
// bytes spanned by all dims except skip_dim
static size_t ggml_backend_meta_inner_extent(const struct ggml_tensor * tensor, int skip_dim) {
size_t extent = 0;
for (int i = 0; i < GGML_MAX_DIMS; i++) {
if (i != skip_dim && tensor->ne[i] > 1) {
extent += (size_t) (tensor->ne[i] - 1) * tensor->nb[i];
}
}
return extent;
}
// the view dim that holds the split axis of src, or -1
// all other dims of src and view must stay inside one slab of that axis
static int ggml_backend_meta_view_slab_dim(const struct ggml_tensor * view, const struct ggml_tensor * src, int axis) {
const size_t slab = src->nb[axis];
if (ggml_backend_meta_inner_extent(src, axis) >= slab) {
return -1;
}
for (int dim = 0; dim < GGML_MAX_DIMS; dim++) {
if (view->ne[dim] == src->ne[axis] && view->nb[dim] == slab) {
return ggml_backend_meta_inner_extent(view, dim) < slab ? dim : -1;
}
}
return -1;
}
static struct ggml_backend_meta_split_state ggml_backend_meta_get_split_state(const struct ggml_tensor * tensor, bool assume_sync);
static struct ggml_backend_meta_split_state ggml_backend_meta_get_split_state(
@@ -700,9 +726,39 @@ static struct ggml_backend_meta_split_state ggml_backend_meta_get_split_state(
}
GGML_ABORT("fatal error");
}
// permuted view: split the view dim that spans the src split axis, other dims must be inside one element of it or outside the span
if (ggml_is_permuted(tensor) && !ggml_is_permuted(tensor->src[0]) && axis >= 0 && axis < GGML_MAX_DIMS-1) {
const size_t nb_axis = tensor->src[0]->nb[axis];
const size_t nb_next = tensor->src[0]->nb[axis+1];
for (int dim = 0; dim < GGML_MAX_DIMS; dim++) {
if (tensor->ne[dim] == 1 || tensor->nb[dim] < nb_axis || tensor->nb[dim]*tensor->ne[dim] != nb_next) {
continue;
}
bool others_ok = true;
for (int i = 0; i < GGML_MAX_DIMS; i++) {
if (i == dim || tensor->ne[i] == 1) {
continue;
}
others_ok = others_ok && (tensor->nb[i]*tensor->ne[i] <= tensor->nb[dim] || tensor->nb[i] % nb_next == 0);
}
if (others_ok) {
return {ggml_backend_meta_split_axis(dim), {0}, {1}, 1};
}
}
}
if (src_ss[0].axis == GGML_BACKEND_SPLIT_AXIS_MIRRORED || src_ss[0].axis == GGML_BACKEND_SPLIT_AXIS_PARTIAL) {
return src_ss[0];
}
// a view that only regroups dims inside one slab of the split axis keeps the split,
// the per-device strides are only rescaled above nb[axis] (e.g. the KV streams of a permuted q)
if (axis >= 0 && axis < GGML_MAX_DIMS && tensor->view_offs == tensor->src[0]->view_offs) {
const int dim = ggml_backend_meta_view_slab_dim(tensor, tensor->src[0], axis);
if (dim >= 0) {
ggml_backend_meta_split_state ret = src_ss[0];
ret.axis = ggml_backend_meta_split_axis(dim);
return ret;
}
}
GGML_ABORT("view of permuted tensor not implemented");
//return {GGML_BACKEND_SPLIT_AXIS_UNKNOWN, {0}, {1}, 1};
};
@@ -1000,8 +1056,13 @@ static struct ggml_backend_meta_split_state ggml_backend_meta_get_split_state(
split_state = handle_pad(src_ss);
} break;
case GGML_OP_PAD_REFLECT_1D:
case GGML_OP_ROLL:
case GGML_OP_ARANGE:
case GGML_OP_ROLL: {
split_state = handle_generic(src_ss, /*scalar_only =*/ true);
} break;
case GGML_OP_ARANGE: {
// no sources, every device computes the full range
split_state = {GGML_BACKEND_SPLIT_AXIS_MIRRORED, {0}, {1}, 1};
} break;
case GGML_OP_TIMESTEP_EMBEDDING: {
split_state = handle_generic(src_ss, /*scalar_only =*/ true);
} break;
+198
View File
@@ -0,0 +1,198 @@
#include "batch-get.cuh"
#include "common.cuh"
#include "ggml-cpp.h"
#include <climits>
#if !defined(GGML_USE_HIP) && !defined(GGML_USE_MUSA)
struct ggml_cuda_batch_get_descriptor {
const unsigned char * src;
unsigned char * dst;
size_t size;
};
static __global__ void ggml_cuda_batch_get_kernel(const ggml_cuda_batch_get_descriptor * copies, size_t count) {
// Blocks along y cooperate on each descriptor.
const size_t index = blockIdx.x;
if (index >= count) {
return;
}
const size_t first = (size_t) blockIdx.y * blockDim.x + threadIdx.x;
const size_t stride = (size_t) gridDim.y * blockDim.x;
const auto copy = copies[index];
// Use 16-, 8-, 4-, or 2-byte copies when both pointers meet that alignment; threads copy separate chunks.
const uintptr_t alignment = (uintptr_t) copy.src | (uintptr_t) copy.dst;
size_t tail = 0;
if (alignment % 16 == 0) {
for (size_t i = first; i < copy.size / 16; i += stride) {
ggml_cuda_memcpy_1<16>(copy.dst + 16*i, copy.src + 16*i);
}
tail = copy.size / 16 * 16;
} else if (alignment % 8 == 0) {
for (size_t i = first; i < copy.size / 8; i += stride) {
ggml_cuda_memcpy_1<8>(copy.dst + 8*i, copy.src + 8*i);
}
tail = copy.size / 8 * 8;
} else if (alignment % 4 == 0) {
for (size_t i = first; i < copy.size / 4; i += stride) {
ggml_cuda_memcpy_1<4>(copy.dst + 4*i, copy.src + 4*i);
}
tail = copy.size / 4 * 4;
} else if (alignment % 2 == 0) {
for (size_t i = first; i < copy.size / 2; i += stride) {
ggml_cuda_memcpy_1<2>(copy.dst + 2*i, copy.src + 2*i);
}
tail = copy.size / 2 * 2;
}
// Copy trailing bytes, or the entire range when no wider alignment is available.
for (size_t i = tail + first; i < copy.size; i += stride) {
ggml_cuda_memcpy_1<1>(copy.dst + i, copy.src + i);
}
}
struct ggml_cuda_batch_get_mapping {
void * host = nullptr;
unsigned char * device = nullptr;
ggml_cuda_batch_get_mapping(void * host, size_t size) {
const cudaError_t err = cudaHostRegister(host, size, cudaHostRegisterMapped | cudaHostRegisterPortable);
if (err != cudaSuccess) {
(void) cudaGetLastError();
GGML_LOG_DEBUG("%s: failed to register %.2f MiB of pinned memory: %s\n", __func__,
size / 1024.0 / 1024.0, cudaGetErrorString(err));
return;
}
this->host = host;
if (cudaHostGetDevicePointer((void **) &device, host, 0) != cudaSuccess) {
(void) cudaGetLastError();
device = nullptr;
return;
}
}
ggml_cuda_batch_get_mapping(const ggml_cuda_batch_get_mapping &) = delete;
ggml_cuda_batch_get_mapping & operator=(const ggml_cuda_batch_get_mapping &) = delete;
~ggml_cuda_batch_get_mapping() {
if (host) {
// Release only the registration acquired by this object.
CUDA_CHECK(cudaHostUnregister(host));
}
}
};
#endif
bool ggml_cuda_get_tensor_n(int device,
const ggml_tensor * const * tensors, void * const * data, const size_t * offsets,
const size_t * sizes, size_t n_tensors) {
#if defined(GGML_USE_HIP) || defined(GGML_USE_MUSA)
GGML_UNUSED(device);
GGML_UNUSED(tensors);
GGML_UNUSED(data);
GGML_UNUSED(offsets);
GGML_UNUSED(sizes);
GGML_UNUSED(n_tensors);
return false;
#else
// Provisional dispatch values based on experiments.
constexpr size_t min_batch_reads = 128;
constexpr size_t max_gather_read_bytes = 128 * 1024;
// Collect non-empty CPU destination ranges.
std::vector<std::pair<uintptr_t, uintptr_t>> ranges;
ranges.reserve(n_tensors);
size_t payload_bytes = 0;
// Number of non-empty reads whose size is at most max_gather_read_bytes.
size_t n_reads_small = 0;
for (size_t i = 0; i < n_tensors; ++i) {
if (sizes[i] == 0) {
continue;
}
const uintptr_t address = (uintptr_t) data[i];
GGML_ASSERT(address <= UINTPTR_MAX - sizes[i]);
GGML_ASSERT(payload_bytes <= SIZE_MAX - sizes[i]);
payload_bytes += sizes[i];
n_reads_small += sizes[i] <= max_gather_read_bytes;
ranges.emplace_back(address, address + sizes[i]);
}
if (ranges.empty()) {
return true;
}
// Total number of non-empty reads.
const size_t n_reads = ranges.size();
if (n_reads > SIZE_MAX / sizeof(ggml_cuda_batch_get_descriptor) || n_reads > INT_MAX) {
return false;
}
if (n_reads < 2 || (n_reads < min_batch_reads && n_reads_small == n_reads)) {
return false;
}
std::sort(ranges.begin(), ranges.end());
for (size_t i = 1; i < ranges.size(); ++i) {
// Preserve the ordered copy path when CPU destinations overlap.
if (ranges[i].first < ranges[i - 1].second) {
return false;
}
}
// Find the CPU span to register, including gaps between destinations.
const uintptr_t begin = ranges.front().first;
const uintptr_t end = ranges.back().second;
if (end - begin - payload_bytes > payload_bytes) {
return false;
}
// Register and map the existing CPU destination so the kernel can write to it directly.
ggml_cuda_batch_get_mapping mapping((void *) begin, end - begin);
if (!mapping.host) {
return false;
}
// Reads selected for gather; zero means all reads use DMA.
size_t n_reads_gather = mapping.device && n_reads_small >= min_batch_reads ? n_reads_small : 0;
// Keep descriptor storage alive until the final stream synchronization.
constexpr size_t max_batch_descriptors = 128 * 1024;
const size_t bytes = std::min(n_reads_gather, max_batch_descriptors) * sizeof(ggml_cuda_batch_get_descriptor);
ggml_backend_buffer_ptr descriptors(n_reads_gather ? ggml_backend_buft_alloc_buffer(ggml_backend_cuda_buffer_type(device), bytes) : nullptr);
if (!descriptors) {
n_reads_gather = 0;
}
const auto host_type = ggml_backend_cuda_host_buffer_type();
ggml_backend_buffer_ptr host_descriptors(n_reads_gather ? ggml_backend_buft_alloc_buffer(host_type, bytes) : nullptr);
if (!host_descriptors || ggml_backend_buffer_get_type(host_descriptors.get()) != host_type) {
n_reads_gather = 0;
}
if (n_reads_gather) {
auto * copies = static_cast<ggml_cuda_batch_get_descriptor *>(ggml_backend_buffer_get_base(host_descriptors.get()));
auto * device_copies = static_cast<ggml_cuda_batch_get_descriptor *>(ggml_backend_buffer_get_base(descriptors.get()));
const int threads = 256;
const size_t nsm = ggml_cuda_info().devices[device].nsm;
size_t i = 0;
for (size_t first = 0; first < n_reads_gather; first += max_batch_descriptors) {
const size_t n_batch = std::min(n_reads_gather - first, max_batch_descriptors);
size_t count = 0;
for (; i < n_tensors && count < n_batch; ++i) {
if (sizes[i] == 0 || sizes[i] > max_gather_read_bytes) {
continue;
}
copies[count++] = {(const unsigned char *) tensors[i]->data + offsets[i],
mapping.device + ((uintptr_t) data[i] - begin), sizes[i]};
}
const size_t blocks_per_copy = std::min<size_t>(UINT16_MAX, std::max<size_t>(1, (nsm + n_batch - 1) / n_batch));
const dim3 blocks((unsigned int) n_batch, (unsigned int) blocks_per_copy);
// Stream ordering keeps each kernel ahead of the next descriptor overwrite.
CUDA_CHECK(cudaMemcpyAsync(device_copies, copies, n_batch * sizeof(copies[0]), cudaMemcpyHostToDevice, cudaStreamPerThread));
ggml_cuda_batch_get_kernel<<<blocks, threads, 0, cudaStreamPerThread>>>(device_copies, n_batch);
CUDA_CHECK(cudaGetLastError());
if (first + n_batch < n_reads_gather) {
// Wait before refilling the pinned host descriptor buffer.
CUDA_CHECK(cudaStreamSynchronize(cudaStreamPerThread));
}
}
}
// Keep the destination pinned for DMA too; overlapping destinations already declined above.
for (size_t i = 0; i < n_tensors; ++i) {
if (sizes[i] && (!n_reads_gather || sizes[i] > max_gather_read_bytes)) {
CUDA_CHECK(cudaMemcpyAsync(data[i], (const char *) tensors[i]->data + offsets[i], sizes[i], cudaMemcpyDeviceToHost, cudaStreamPerThread));
}
}
// Wait for GPU writes before releasing the CPU mapping and descriptor buffers.
CUDA_CHECK(cudaStreamSynchronize(cudaStreamPerThread));
return true;
#endif
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include "ggml-backend.h"
bool ggml_cuda_get_tensor_n(int device,
const ggml_tensor * const * tensors, void * const * data, const size_t * offsets,
const size_t * sizes, size_t n_tensors);
+109 -1
View File
@@ -9,6 +9,7 @@
#include "ggml-cuda/arange.cuh"
#include "ggml-cuda/argmax.cuh"
#include "ggml-cuda/argsort.cuh"
#include "ggml-cuda/batch-get.cuh"
#include "ggml-cuda/binbcast.cuh"
#include "ggml-cuda/clamp.cuh"
#include "ggml-cuda/col2im-1d.cuh"
@@ -886,6 +887,11 @@ static void ggml_backend_cuda_buffer_get_tensor_n(ggml_backend_buffer_t buffer,
ggml_cuda_set_device(ctx->device);
if (!ggml_cuda_pinned_memory_disabled() &&
ggml_cuda_get_tensor_n(ctx->device, tensors, data, offsets, sizes, n_tensors)) {
return;
}
for (size_t i = 0; i < n_tensors; i++) {
if (sizes[i] == 0) {
continue;
@@ -1117,6 +1123,18 @@ struct ggml_backend_cuda_comm_context {
ggml_cuda_ar_pipeline * ar_pipeline = nullptr;
// Number of ranks in the communicator when each rank is a separate process. Zero unless
// comm_init_rank created this context.
int world_size = 0;
// Number of ranks taking part in the reduction. For a communicator from comm_init_rank this is
// world_size. Otherwise it is the number of backends this process holds. The NCCL reduction
// uses it to choose the tensor size below which it reduces in F32 rather than converting to
// BF16 first.
size_t n_ranks() const {
return world_size > 0 ? (size_t) world_size : backends.size();
}
#ifdef GGML_USE_NCCL
std::vector<ncclComm_t> comms;
#endif // GGML_USE_NCCL
@@ -1144,6 +1162,7 @@ static bool ggml_backend_cuda_comm_allreduce_nccl(
}
const size_t n_backends = comm_ctx->backends.size();
const size_t n_ranks = comm_ctx->n_ranks();
for (size_t i = 0; i < n_backends; ++i) {
GGML_ASSERT(tensors[i] != nullptr);
@@ -1153,7 +1172,7 @@ static bool ggml_backend_cuda_comm_allreduce_nccl(
// For small tensors, simply reduce them as FP32.
// The following heuristic for how "small" a tensor should be is based on RTX 4090s connected via 16x PCIe 4.0.
if ((n_backends <= 2 && ne < 32768) || (n_backends == 3 && ne < 131072) || (n_backends >= 4 && ne < 262144)) {
if ((n_ranks <= 2 && ne < 32768) || (n_ranks == 3 && ne < 131072) || (n_ranks >= 4 && ne < 262144)) {
for (size_t i = 0; i < n_backends; ++i) {
if ((tensors[i]->flags & GGML_TENSOR_FLAG_COMPUTE) == 0) {
ggml_backend_cuda_context * cuda_ctx = (ggml_backend_cuda_context *) comm_ctx->backends[i]->context;
@@ -1384,6 +1403,90 @@ static void * ggml_backend_cuda_comm_init(ggml_backend_t * backends, size_t n_ba
return ret;
}
#ifdef GGML_USE_NCCL
// Builds the communicator with ncclCommInitRank, for the case where each rank is its own
// process. Blocks until all world_size ranks have joined.
static void * ggml_backend_cuda_comm_init_rank(ggml_backend_t backend, int rank, int world_size,
ggml_backend_comm_bootstrap_callback bootstrap_callback, void * bootstrap_callback_data) {
if (world_size < 1 || rank < 0 || rank >= world_size) {
GGML_LOG_ERROR("%s: invalid rank %d for world size %d\n", __func__, rank, world_size);
return nullptr;
}
// Every rank sends whether its local checks passed, and rank 0 also sends the NCCL unique id.
// So all ranks either join together or fail together, and no rank waits in ncclCommInitRank alone.
struct bootstrap_msg {
uint8_t ok;
ncclUniqueId id;
};
bootstrap_msg local;
memset(&local, 0, sizeof(local));
local.ok = 1;
if (!ggml_backend_is_cuda(backend)) {
GGML_LOG_ERROR("%s: backend is not a CUDA backend\n", __func__);
local.ok = 0;
} else {
// NCCL requires one distinct physical GPU per rank, see ggml_backend_cuda_comm_init_nccl.
const ggml_cuda_device_info & info = ggml_cuda_info();
if (info.device_count > info.physical_device_count) {
GGML_LOG_ERROR("%s: CUDA virtual devices are in use, which NCCL does not support\n", __func__);
local.ok = 0;
}
}
if (local.ok && rank == 0) {
const ncclResult_t rc = ncclGetUniqueId(&local.id);
if (rc != ncclSuccess) {
GGML_LOG_ERROR("%s: ncclGetUniqueId failed (%s)\n", __func__, ncclGetErrorString(rc));
local.ok = 0;
}
}
std::vector<bootstrap_msg> all(world_size);
if (!bootstrap_callback(bootstrap_callback_data, &local, all.data(), sizeof(local))) {
GGML_LOG_ERROR("%s: bootstrap exchange failed on rank %d\n", __func__, rank);
return nullptr;
}
for (int i = 0; i < world_size; i++) {
if (!all[i].ok) {
GGML_LOG_ERROR("%s: rank %d failed its setup checks\n", __func__, i);
return nullptr;
}
}
ggml_backend_cuda_context * cuda_ctx = (ggml_backend_cuda_context *) backend->context;
auto * ret = new ggml_backend_cuda_comm_context;
ret->backends.push_back(backend);
ret->dev_ids.push_back(cuda_ctx->device);
ret->world_size = world_size;
ret->comms.resize(1);
ret->try_allreduce = ggml_backend_cuda_comm_try_allreduce_nccl;
// On DGX Spark, NCCL picks 64 channels by default, which is slower and uses more memory than 8.
// NCCL_MAX_CTAS still overrides this.
ncclConfig_t config = NCCL_CONFIG_INITIALIZER;
const int cc = ggml_cuda_info().devices[cuda_ctx->device].cc;
if (GGML_CUDA_CC_IS_NVIDIA(cc) && cc == GGML_CUDA_CC_DGX_SPARK) {
config.maxCTAs = 8;
}
ggml_cuda_set_device(cuda_ctx->device);
const ncclResult_t rc = ncclCommInitRankConfig(&ret->comms[0], world_size, all[0].id, rank, &config);
if (rc != ncclSuccess) {
GGML_LOG_ERROR("%s: ncclCommInitRankConfig failed for rank %d/%d (%s)\n",
__func__, rank, world_size, ncclGetErrorString(rc));
ret->comms.clear();
delete ret;
return nullptr;
}
GGML_LOG_INFO("%s: joined NCCL communicator as rank %d/%d on device %d\n",
__func__, rank, world_size, cuda_ctx->device);
return ret;
}
#endif // GGML_USE_NCCL
// Top-level dispatch -- calls the function pointer chosen by comm_init.
// Returns false to let the meta-backend's butterfly run.
static bool ggml_backend_cuda_comm_allreduce_tensor(void * comm_ctx_v, struct ggml_tensor ** tensors) {
@@ -6384,6 +6487,11 @@ static void * ggml_backend_cuda_reg_get_proc_address(ggml_backend_reg_t reg, con
if (strcmp(name, "ggml_backend_comm_allreduce_tensor") == 0) {
return (void *)ggml_backend_cuda_comm_allreduce_tensor;
}
#ifdef GGML_USE_NCCL
if (strcmp(name, "ggml_backend_comm_init_rank") == 0) {
return (void *)ggml_backend_cuda_comm_init_rank;
}
#endif // GGML_USE_NCCL
if (strcmp(name, "ggml_backend_register_host_buffer") == 0) {
return (void *)ggml_backend_cuda_register_host_buffer;
}
+16 -14
View File
@@ -83,21 +83,21 @@ static __global__ void quantize_q8_1(
ggml_cuda_pdl_sync();
const float xi = i0 < ne00 ? x[i03*s03 + i02*s02 + i01*s01 + i00] : 0.0f;
float amax = fabsf(xi);
float sum = xi;
amax = warp_reduce_max<QK8_1>(amax);
sum = warp_reduce_sum<QK8_1>(sum);
const float d = amax / 127.0f;
const int8_t q = amax == 0.0f ? 0 : roundf(xi / d);
const int sum = warp_reduce_sum<QK8_1>((int) q);
y[ib].qs[iqs] = q;
if (iqs > 0) {
return;
}
y[ib].ds = make_half2(d, sum);
y[ib].ds = make_half2(d, d * sum);
}
__device__ __forceinline__ uint8_t compute_e8m0_scale(float amax) {
@@ -500,17 +500,6 @@ static __global__ void quantize_mmq_q8_1(
amax = fmaxf(amax, __shfl_xor_sync(0xFFFFFFFF, amax, offset, WARP_SIZE));
}
float sum;
if (ds_layout != MMQ_Q8_1_DS_LAYOUT_D4) {
sum = xi.x + xi.y + xi.z + xi.w;
// Calculate sums across vals_per_sum/4 threads.
#pragma unroll
for (int offset = vals_per_sum/8; offset > 0; offset >>= 1) {
sum += __shfl_xor_sync(0xFFFFFFFF, sum, offset, WARP_SIZE);
}
}
const float d_inv = 127.0f / amax;
char4 q;
q.x = roundf(xi.x*d_inv);
@@ -519,6 +508,19 @@ static __global__ void quantize_mmq_q8_1(
q.w = roundf(xi.w*d_inv);
const float d = 1.0f / d_inv;
float sum = 0.0f;
if (ds_layout != MMQ_Q8_1_DS_LAYOUT_D4) {
int sum_q = int(q.x) + int(q.y) + int(q.z) + int(q.w);
// Calculate sums across vals_per_sum/4 threads.
#pragma unroll
for (int offset = vals_per_sum/8; offset > 0; offset >>= 1) {
sum_q += __shfl_xor_sync(0xFFFFFFFF, sum_q, offset, WARP_SIZE);
}
sum = d * float(sum_q);
}
// write the block once (normal) or to each of the token's compact rows (scatter)
const int nwrite = scatter ? n_expert_used : 1;
#pragma unroll
+2 -2
View File
@@ -26,11 +26,11 @@ kernel void kernel_cpy_t_t(
const int32_t i1 = (n - i3*args.ne2*args.ne1*args.ne0 - i2*args.ne1*args.ne0)/args.ne0;
const int32_t i0 = (n - i3*args.ne2*args.ne1*args.ne0 - i2*args.ne1*args.ne0 - i1*args.ne0);
device T1 * dst_data = (device T1 *) (dst + i3*args.nb3 + i2*args.nb2 + i1*args.nb1 + i0*args.nb0);
device char * dst_data = dst + i3*args.nb3 + i2*args.nb2 + i1*args.nb1 + i0*args.nb0;
for (int32_t i00 = iw0*ntg[0] + tpitg.x; i00 < args.ne00;) {
device const T0 * src = (device T0 *)(src0 + i03*args.nb03 + i02*args.nb02 + i01*args.nb01 + i00*args.nb00);
dst_data[i00] = (T1) src[0];
*(device T1 *)(dst_data + i00*args.nb0) = (T1) src[0];
break;
}
}
+218 -56
View File
@@ -1430,6 +1430,9 @@ public:
private:
void sync_all_backends();
void comm_free_device(uint32_t dev_id);
static bool comm_allreduce_pairwise(void * comm_ctx_v, ggml_tensor ** tensors);
static bool comm_bootstrap_exchange(void * user_data, const void * send_buf, void * recv_buf, size_t size);
bool get_cached_file(uint64_t hash, std::vector<uint8_t> & data);
ggml_tensor * deserialize_tensor(struct ggml_context * ctx, const rpc_tensor * tensor);
ggml_tensor * create_node(uint64_t id,
@@ -1438,15 +1441,26 @@ private:
std::unordered_map<uint64_t, struct ggml_tensor*> & tensor_map);
// pairwise allreduce over a direct connection to the peer server
// Per-device state for one communicator.
struct comm_state {
socket_ptr peer;
// Sockets to the other servers. Rank 0 holds one per peer in rank order, so peers[i] is
// rank i + 1. Every other rank holds one to rank 0. The pairwise path reduces over peers[0].
std::vector<socket_ptr> peers;
uint32_t rank = 0;
uint32_t world = 0;
ggml_backend_t pairwise_backend = nullptr;
ggml_backend_buffer_ptr scratch;
size_t scratch_size = 0;
std::vector<uint8_t> send_buf;
std::vector<uint8_t> recv_buf;
// The backend's communicator. Null on the pairwise path, which has none.
void * backend_comm_ctx = nullptr;
// The chosen reduction, set once a path is agreed. Receives backend_comm_ctx, or
// comm_state on the pairwise path. A communicator covers one device, so the caller
// passes a one-element tensor array.
ggml_backend_comm_allreduce_tensor_t allreduce_fn = nullptr;
};
std::vector<ggml_backend_t> backends;
@@ -2225,78 +2239,188 @@ void rpc_server::sync_all_backends() {
}
}
// The comm link between two servers uses the same caps negotiation as the client HELLO,
void rpc_server::comm_free_device(uint32_t dev_id) {
comm_state & state = comm_states[dev_id];
if (state.backend_comm_ctx != nullptr) {
auto comm_free = (ggml_backend_comm_free_t) ggml_backend_reg_get_proc_address(
ggml_backend_dev_backend_reg(ggml_backend_get_device(backends[dev_id])), "ggml_backend_comm_free");
if (comm_free != nullptr) {
comm_free(state.backend_comm_ctx);
}
}
state = comm_state();
}
// Exchange RPC transport capabilities over a peer connection. recv_first selects which side
// receives first. Both sides receiving first would deadlock.
static bool comm_caps_handshake(const socket_ptr & peer, bool recv_first) {
uint8_t local_caps[RPC_CONN_CAPS_SIZE] = {};
uint8_t remote_caps[RPC_CONN_CAPS_SIZE] = {};
peer->get_caps(local_caps);
if (recv_first) {
if (!peer->recv_data(remote_caps, sizeof(remote_caps)) ||
!peer->send_data(local_caps, sizeof(local_caps))) {
return false;
}
} else {
if (!peer->send_data(local_caps, sizeof(local_caps)) ||
!peer->recv_data(remote_caps, sizeof(remote_caps))) {
return false;
}
}
peer->update_caps(remote_caps);
return true;
}
// The comm link between servers uses the same caps negotiation as the client HELLO,
// so it gets the same transport upgrades (e.g. RDMA).
bool rpc_server::comm_init(const rpc_msg_comm_init_req & request, rpc_msg_comm_init_rsp & response) {
response.ok = 0;
if (request.device >= backends.size() || request.world != 2 || request.rank >= request.world) {
if (request.device >= backends.size() || request.world < 2 || request.rank >= request.world) {
return true;
}
comm_state & state = comm_states[request.device];
if (state.peer != nullptr) {
const uint32_t dev_id = request.device;
comm_state & state = comm_states[dev_id];
if (state.allreduce_fn != nullptr) {
response.ok = 1;
return true;
}
uint8_t local_caps[RPC_CONN_CAPS_SIZE] = {};
uint8_t remote_caps[RPC_CONN_CAPS_SIZE] = {};
// Joining a communicator blocks until every rank arrives, so drain outstanding work first.
sync_all_backends();
// Rank 0 listens and accepts world-1 peers; every other rank connects back to it.
if (request.rank == 0) {
socket_ptr srv = socket_t::create_server("0.0.0.0", request.port);
if (srv == nullptr) {
LOG_ERROR("[%s] failed to listen on comm port %u\n", __func__, request.port);
return true;
}
state.peer = srv->accept();
if (state.peer == nullptr) {
return true;
// Store peers[i] as rank i + 1, as comm_bootstrap_exchange needs.
// Each peer sends its rank because peers may connect in any order.
state.peers.resize(request.world - 1);
for (uint32_t i = 1; i < request.world; i++) {
socket_ptr peer = srv->accept();
uint32_t peer_rank = 0;
if (peer == nullptr || !comm_caps_handshake(peer, /*recv_first =*/ true) ||
!peer->recv_data(&peer_rank, sizeof(peer_rank)) ||
peer_rank == 0 || peer_rank >= request.world || state.peers[peer_rank - 1] != nullptr) {
LOG_ERROR("[%s] bad peer connection (rank %u)\n", __func__, peer_rank);
state.peers.clear();
return true;
}
state.peers[peer_rank - 1] = std::move(peer);
}
if (!state.peer->recv_data(remote_caps, sizeof(remote_caps))) {
state.peer = nullptr;
return true;
}
state.peer->get_caps(local_caps);
if (!state.peer->send_data(local_caps, sizeof(local_caps))) {
state.peer = nullptr;
return true;
}
state.peer->update_caps(remote_caps);
} else {
const std::string host(request.host, strnlen(request.host, sizeof(request.host)));
socket_ptr peer;
// rank 0 may not be listening yet, retry for a few seconds
for (int i = 0; i < 100 && state.peer == nullptr; i++) {
state.peer = socket_t::connect(host.c_str(), request.port);
if (state.peer == nullptr) {
for (int i = 0; i < 100 && peer == nullptr; i++) {
peer = socket_t::connect(host.c_str(), request.port);
if (peer == nullptr) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
}
if (state.peer == nullptr) {
if (peer == nullptr) {
LOG_ERROR("[%s] failed to connect to peer %s:%u\n", __func__, host.c_str(), request.port);
return true;
}
state.peer->get_caps(local_caps);
if (!state.peer->send_data(local_caps, sizeof(local_caps)) ||
!state.peer->recv_data(remote_caps, sizeof(remote_caps))) {
state.peer = nullptr;
if (!comm_caps_handshake(peer, /*recv_first =*/ false) ||
!peer->send_data(&request.rank, sizeof(request.rank)) || !peer->flush()) {
return true;
}
state.peer->update_caps(remote_caps);
state.peers.push_back(std::move(peer));
}
state.rank = request.rank;
state.world = request.world;
LOG_INFO("[%s] device %u joined pairwise comm as rank %u\n", __func__, request.device, request.rank);
// Forces the pairwise path, for comparing the two on one build. Setting it on any one rank
// is enough, because the negotiation spreads the decision.
const bool no_backend_communicator = std::getenv("GGML_RPC_NO_BACKEND_COMMUNICATOR") != nullptr;
ggml_backend_reg_t reg = ggml_backend_dev_backend_reg(ggml_backend_get_device(backends[dev_id]));
auto init_rank = (ggml_backend_comm_init_rank_t)
ggml_backend_reg_get_proc_address(reg, "ggml_backend_comm_init_rank");
auto allreduce_fn = (ggml_backend_comm_allreduce_tensor_t)
ggml_backend_reg_get_proc_address(reg, "ggml_backend_comm_allreduce_tensor");
// The ranks must agree on backend communicator or pairwise. Every rank shares whether its
// backend provides a communicator, and all ranks use it only if every rank can.
const uint8_t self_can_use_backend_comm = !no_backend_communicator && init_rank != nullptr && allreduce_fn != nullptr;
std::vector<uint8_t> all_can_use_backend_comm(request.world);
if (!comm_bootstrap_exchange(&state, &self_can_use_backend_comm, all_can_use_backend_comm.data(),
sizeof(self_can_use_backend_comm))) {
state.peers.clear();
return true;
}
const bool use_backend_communicator = std::all_of(all_can_use_backend_comm.begin(), all_can_use_backend_comm.end(),
[](uint8_t c) { return c != 0; });
if (use_backend_communicator) {
void * backend_ctx = init_rank(backends[dev_id], (int) request.rank, (int) request.world, comm_bootstrap_exchange, &state);
if (backend_ctx == nullptr) {
LOG_ERROR("[%s] failed to join communicator as rank %u/%u\n", __func__, request.rank, request.world);
state.peers.clear();
return true;
}
state.backend_comm_ctx = backend_ctx;
state.allreduce_fn = allreduce_fn;
// The sockets have done their job; the collective runs between the backends from here on.
state.peers.clear();
LOG_INFO("[%s] device %u using backend communicator, rank %u/%u\n", __func__, dev_id, request.rank, request.world);
response.ok = 1;
return true;
}
// No backend communicator, so fall back to the pairwise exchange, which only covers two ranks.
if (request.world != 2) {
LOG_ERROR("[%s] world size %u requires a backend communicator\n", __func__, request.world);
state.peers.clear();
return true;
}
state.pairwise_backend = backends[dev_id];
state.allreduce_fn = comm_allreduce_pairwise;
LOG_INFO("[%s] device %u using pairwise communicator, rank %u\n", __func__, dev_id, request.rank);
response.ok = 1;
return true;
}
bool rpc_server::comm_allreduce(const rpc_msg_comm_allreduce_req & request) {
if (request.device >= backends.size()) {
return false;
// Bootstrap callback for ggml_backend_comm_init_rank_t, over the peer sockets. Rank 0 collects
// size bytes from every rank in rank order, then sends the whole array back to every rank.
// user_data is the comm_state.
bool rpc_server::comm_bootstrap_exchange(void * user_data, const void * send_buf, void * recv_buf, size_t size) {
comm_state & state = *(comm_state *) user_data;
uint8_t * recv = (uint8_t *) recv_buf;
const size_t total = (size_t) state.world * size;
if (state.rank == 0) {
memcpy(recv, send_buf, size);
for (size_t i = 0; i < state.peers.size(); i++) {
if (!state.peers[i]->recv_data(recv + (i + 1) * size, size)) {
return false;
}
}
for (const socket_ptr & peer : state.peers) {
if (!peer->send_data(recv, total) || !peer->flush()) {
return false;
}
}
return true;
}
comm_state & state = comm_states[request.device];
if (state.peer == nullptr) {
LOG_ERROR("[%s] no communicator for device %u\n", __func__, request.device);
return false;
}
ggml_backend_t backend = backends[request.device];
return state.peers[0]->send_data(send_buf, size) && state.peers[0]->flush() &&
state.peers[0]->recv_data(recv, total);
}
// Reduces this rank's contribution in place, exchanging partials with the peer over peers[0].
// Used when the backend provides no communicator. comm_ctx_v is the comm_state.
bool rpc_server::comm_allreduce_pairwise(void * comm_ctx_v, ggml_tensor ** tensors) {
comm_state & state = *(comm_state *) comm_ctx_v;
ggml_tensor * t_dst = tensors[0];
ggml_backend_t backend = state.pairwise_backend;
const size_t nbytes = ggml_nbytes(t_dst);
const int64_t ne = ggml_nelements(t_dst);
size_t ctx_size = 16*ggml_tensor_overhead() + 2*ggml_graph_overhead_custom(8, false);
struct ggml_init_params params = {
@@ -2307,16 +2431,7 @@ bool rpc_server::comm_allreduce(const rpc_msg_comm_allreduce_req & request) {
ggml_context_ptr ctx_ptr { ggml_init(params) };
GGML_ASSERT(ctx_ptr != nullptr);
ggml_context * ctx = ctx_ptr.get();
ggml_tensor * t_dst = deserialize_tensor(ctx, &request.tensor);
if (t_dst == nullptr || t_dst->buffer == nullptr) {
LOG_ERROR("[%s] error deserializing tensor\n", __func__);
return false;
}
const size_t nbytes = ggml_nbytes(t_dst);
const int64_t ne = ggml_nelements(t_dst);
if (nbytes == 0) {
return true;
}
// reduce large partials in bf16 to halve the wire bytes; small (decode-sized) ones
// stay f32 since the extra casts and sync cost more than the bytes saved
const bool wire_bf16 = t_dst->type == GGML_TYPE_F32 && ne >= 32768;
@@ -2372,13 +2487,13 @@ bool rpc_server::comm_allreduce(const rpc_msg_comm_allreduce_req & request) {
// rank 0 sends first, rank 1 receives first, so large payloads cannot deadlock
if (state.rank == 0) {
if (!state.peer->send_data(state.send_buf.data(), wire_bytes) || !state.peer->flush() ||
!state.peer->recv_data(state.recv_buf.data(), wire_bytes)) {
if (!state.peers[0]->send_data(state.send_buf.data(), wire_bytes) || !state.peers[0]->flush() ||
!state.peers[0]->recv_data(state.recv_buf.data(), wire_bytes)) {
return false;
}
} else {
if (!state.peer->recv_data(state.recv_buf.data(), wire_bytes) ||
!state.peer->send_data(state.send_buf.data(), wire_bytes) || !state.peer->flush()) {
if (!state.peers[0]->recv_data(state.recv_buf.data(), wire_bytes) ||
!state.peers[0]->send_data(state.send_buf.data(), wire_bytes) || !state.peers[0]->flush()) {
return false;
}
}
@@ -2408,11 +2523,50 @@ bool rpc_server::comm_allreduce(const rpc_msg_comm_allreduce_req & request) {
return true;
}
bool rpc_server::comm_allreduce(const rpc_msg_comm_allreduce_req & request) {
if (request.device >= backends.size()) {
return false;
}
comm_state & state = comm_states[request.device];
if (state.allreduce_fn == nullptr) {
LOG_ERROR("[%s] no communicator for device %u\n", __func__, request.device);
return false;
}
struct ggml_init_params params = {
/*.mem_size =*/ ggml_tensor_overhead(),
/*.mem_buffer =*/ NULL,
/*.no_alloc =*/ true,
};
ggml_context_ptr ctx_ptr { ggml_init(params) };
GGML_ASSERT(ctx_ptr != nullptr);
ggml_tensor * t_dst = deserialize_tensor(ctx_ptr.get(), &request.tensor);
if (t_dst == nullptr || t_dst->buffer == nullptr) {
LOG_ERROR("[%s] error deserializing tensor\n", __func__);
return false;
}
if (ggml_nbytes(t_dst) == 0) {
return true;
}
// Neither path needs a wait here. The pairwise path waits internally, and the backend orders
// its reduction after the work that produced the partial sum.
void * comm_ctx = state.backend_comm_ctx != nullptr ? state.backend_comm_ctx : &state;
if (!state.allreduce_fn(comm_ctx, &t_dst)) {
LOG_ERROR("[%s] allreduce failed for tensor %s\n", __func__, t_dst->name);
return false;
}
return true;
}
bool rpc_server::comm_free(const rpc_msg_comm_free_req & request) {
if (request.device >= backends.size()) {
return false;
}
comm_states[request.device] = comm_state();
// Wait for outstanding work. Destroying a communicator while its kernels are still running
// is unsafe.
sync_all_backends();
comm_free_device(request.device);
return true;
}
@@ -2432,6 +2586,14 @@ bool rpc_server::get_device_memory(const rpc_msg_get_device_memory_req & request
}
rpc_server::~rpc_server() {
// Wait for outstanding work. Destroying a communicator while its kernels are still running
// is unsafe.
sync_all_backends();
// Free any communicator still open. A client can disconnect without sending COMM_FREE.
for (uint32_t dev_id = 0; dev_id < comm_states.size(); dev_id++) {
comm_free_device(dev_id);
}
for (auto buffer : buffers) {
ggml_backend_buffer_free(buffer);
}
@@ -2997,7 +3159,7 @@ static void ggml_backend_rpc_comm_free(void * comm_ctx_v) {
}
static void * ggml_backend_rpc_comm_init(ggml_backend_t * backends, size_t n_backends) {
if (n_backends != 2 || std::getenv("GGML_RPC_NO_COMM") != nullptr) {
if (n_backends < 2 || std::getenv("GGML_RPC_NO_COMM") != nullptr) {
return nullptr;
}
std::vector<ggml_backend_rpc_comm_context::rank_info> ranks;
+7 -6
View File
@@ -1457,8 +1457,8 @@ static bool ggml_vk_matmul_shmem_support(const vk_device& device, const std::vec
lut_size = 4*16;
break;
case GGML_TYPE_NVFP4:
// Same kvalues budget as MXFP4 plus ue4m3_fp32_lut[128] (types.glsl, DATA_A_NVFP4).
lut_size = 4*16 + 128u * (uint32_t)sizeof(float);
// Same kvalues budget as MXFP4 plus ue4m3_fp32_lut[256] (types.glsl, DATA_A_NVFP4).
lut_size = 4*16 + 256u * (uint32_t)sizeof(float);
break;
default:
break;
@@ -1592,7 +1592,7 @@ static bool ggml_vk_matmul_cm1_int_shmem_support(const vk_device& device, const
total += 16u * (uint32_t)sizeof(int8_t); // cm1_kvalues[16]
}
if (src0_type == GGML_TYPE_NVFP4 && !device->ocp_fp4) {
total += 128u * (uint32_t)sizeof(float); // ue4m3_fp32_lut[128]
total += 256u * (uint32_t)sizeof(float); // ue4m3_fp32_lut[256]
}
if (mul_mat_id) {
total += BN * 2u * (uint32_t)sizeof(uint16_t); // row_ids[BN] (u16vec2)
@@ -15550,13 +15550,14 @@ static bool ggml_backend_vk_device_supports_op(ggml_backend_dev_t dev, const ggm
}
return true;
};
// reject any tensors larger than the max buffer size
// non-compute ops are never executed here; their size is bounded by the buffer allocation limit
const bool non_compute = ggml_op_is_empty(op->op);
for (int i = 0; i < GGML_MAX_SRC; i++) {
if (op->src[i] && !tensor_size_supported(ggml_nbytes(op->src[i]))) {
if (op->src[i] && !non_compute && !tensor_size_supported(ggml_nbytes(op->src[i]))) {
return false;
}
}
if (!tensor_size_supported(ggml_nbytes(op))) {
if (!non_compute && !tensor_size_supported(ggml_nbytes(op))) {
return false;
}
@@ -173,7 +173,7 @@ void main() {
cm1_kvalues[gl_LocalInvocationIndex] = kvalues_mxfp4_const[gl_LocalInvocationIndex];
}
#if !defined(USE_OCP_FP4)
for (uint i = gl_LocalInvocationIndex; i < 128u; i += BLOCK_SIZE) {
for (uint i = gl_LocalInvocationIndex; i < 256u; i += BLOCK_SIZE) {
ue4m3_fp32_lut[i] = ue4m3_to_fp32_build(i);
}
#endif
@@ -1921,8 +1921,9 @@ shared int8_t kvalues_mxfp4[16];
#endif
#if defined(DATA_A_NVFP4) && !defined(USE_OCP_FP4)
// UE4M3 scale in NVFP4 blocks use only 7 bits; sign (bit 7) is always zero.
shared float ue4m3_fp32_lut[128];
// Cover all scale bytes accepted by ggml_validate_row_data.
// ue4m3_to_fp32_build() masks the exponent and mantissa, but checks the full byte for 0 and 0x7F.
shared float ue4m3_fp32_lut[256];
float ue4m3_to_fp32_build(uint u) {
if (u == 0u || u == 127u) {
@@ -1947,7 +1948,7 @@ void init_iq_shmem(uvec3 wgsize)
kvalues_mxfp4[i] = kvalues_mxfp4_const[i];
}
#if defined(DATA_A_NVFP4)
for (uint i = gl_LocalInvocationIndex.x; i < 128u; i += wgsize.x) {
for (uint i = gl_LocalInvocationIndex.x; i < 256u; i += wgsize.x) {
ue4m3_fp32_lut[i] = ue4m3_to_fp32_build(i);
}
#endif
+37 -39
View File
@@ -1788,6 +1788,7 @@ static struct ggml_tensor * ggml_new_tensor_impl(
enum ggml_type type,
int n_dims,
const int64_t * ne,
const size_t * nb,
struct ggml_tensor * view_src,
size_t view_offs) {
@@ -1814,8 +1815,6 @@ static struct ggml_tensor * ggml_new_tensor_impl(
data_size *= ne[i];
}
GGML_ASSERT(view_src == NULL || data_size == 0 || data_size + view_offs <= ggml_nbytes(view_src));
void * data = view_src != NULL ? view_src->data : NULL;
if (data != NULL) {
data = (char *) data + view_offs;
@@ -1859,12 +1858,23 @@ static struct ggml_tensor * ggml_new_tensor_impl(
result->ne[i] = ne[i];
}
result->nb[0] = ggml_type_size(type);
result->nb[1] = result->nb[0]*(result->ne[0]/ggml_blck_size(type));
for (int i = 2; i < GGML_MAX_DIMS; i++) {
result->nb[i] = result->nb[i - 1]*result->ne[i - 1];
// a view built with strides takes them from the caller, any other tensor is contiguous
if (nb != NULL) {
for (int i = 0; i < GGML_MAX_DIMS; i++) {
result->nb[i] = nb[i];
}
} else {
result->nb[0] = ggml_type_size(type);
result->nb[1] = result->nb[0]*(result->ne[0]/ggml_blck_size(type));
for (int i = 2; i < GGML_MAX_DIMS; i++) {
result->nb[i] = result->nb[i - 1]*result->ne[i - 1];
}
}
// a view stays inside its source, over the bytes its strides actually reach
const size_t view_size = ggml_nbytes(result);
GGML_ASSERT(view_src == NULL || view_size == 0 || view_size + view_offs <= ggml_nbytes(view_src));
ctx->n_objects++;
return result;
@@ -1875,7 +1885,7 @@ struct ggml_tensor * ggml_new_tensor(
enum ggml_type type,
int n_dims,
const int64_t * ne) {
return ggml_new_tensor_impl(ctx, type, n_dims, ne, NULL, 0);
return ggml_new_tensor_impl(ctx, type, n_dims, ne, NULL, NULL, 0);
}
struct ggml_tensor * ggml_new_tensor_1d(
@@ -1992,13 +2002,9 @@ struct ggml_tensor * ggml_format_name(struct ggml_tensor * tensor, const char *
struct ggml_tensor * ggml_view_tensor(
struct ggml_context * ctx,
struct ggml_tensor * src) {
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, src->type, GGML_MAX_DIMS, src->ne, src, 0);
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, src->type, GGML_MAX_DIMS, src->ne, src->nb, src, 0);
ggml_format_name(result, "%s (view)", src->name);
for (int i = 0; i < GGML_MAX_DIMS; i++) {
result->nb[i] = src->nb[i];
}
return result;
}
@@ -2948,7 +2954,7 @@ static struct ggml_tensor * ggml_glu_impl(
}
int64_t ne[GGML_MAX_DIMS] = { a->ne[0] / 2 }; for (int i = 1; i < GGML_MAX_DIMS; i++) ne[i] = a->ne[i];
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, GGML_MAX_DIMS, b ? a->ne : ne, NULL, 0);
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, GGML_MAX_DIMS, b ? a->ne : ne, NULL, NULL, 0);
ggml_set_op_params_i32(result, 0, (int32_t) op);
ggml_set_op_params_i32(result, 1, (int32_t) swapped);
@@ -3757,7 +3763,7 @@ struct ggml_tensor * ggml_reshape(
// as only the shape of b is relevant, and not its memory layout, b is allowed to be non contiguous.
GGML_ASSERT(ggml_nelements(a) == ggml_nelements(b));
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, GGML_MAX_DIMS, b->ne, a, 0);
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, GGML_MAX_DIMS, b->ne, NULL, a, 0);
ggml_format_name(result, "%s (reshaped)", a->name);
result->op = GGML_OP_RESHAPE;
@@ -3774,7 +3780,7 @@ struct ggml_tensor * ggml_reshape_1d(
GGML_ASSERT(ggml_nelements(a) == ne0);
const int64_t ne[1] = { ne0 };
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, 1, ne, a, 0);
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, 1, ne, NULL, a, 0);
ggml_format_name(result, "%s (reshaped)", a->name);
result->op = GGML_OP_RESHAPE;
@@ -3792,7 +3798,7 @@ struct ggml_tensor * ggml_reshape_2d(
GGML_ASSERT(ggml_nelements(a) == ne0*ne1);
const int64_t ne[2] = { ne0, ne1 };
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, 2, ne, a, 0);
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, 2, ne, NULL, a, 0);
ggml_format_name(result, "%s (reshaped)", a->name);
result->op = GGML_OP_RESHAPE;
@@ -3811,7 +3817,7 @@ struct ggml_tensor * ggml_reshape_3d(
GGML_ASSERT(ggml_nelements(a) == ne0*ne1*ne2);
const int64_t ne[3] = { ne0, ne1, ne2 };
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, 3, ne, a, 0);
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, 3, ne, NULL, a, 0);
ggml_format_name(result, "%s (reshaped)", a->name);
result->op = GGML_OP_RESHAPE;
@@ -3831,7 +3837,7 @@ struct ggml_tensor * ggml_reshape_4d(
GGML_ASSERT(ggml_nelements(a) == ne0*ne1*ne2*ne3);
const int64_t ne[4] = { ne0, ne1, ne2, ne3 };
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, 4, ne, a, 0);
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, 4, ne, NULL, a, 0);
ggml_format_name(result, "%s (reshaped)", a->name);
result->op = GGML_OP_RESHAPE;
@@ -3845,8 +3851,9 @@ static struct ggml_tensor * ggml_view_impl(
struct ggml_tensor * a,
int n_dims,
const int64_t * ne,
const size_t * nb,
size_t offset) {
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, n_dims, ne, a, offset);
struct ggml_tensor * result = ggml_new_tensor_impl(ctx, a->type, n_dims, ne, nb, a, offset);
ggml_format_name(result, "%s (view)", a->name);
ggml_set_op_params(result, &offset, sizeof(offset));
@@ -3864,7 +3871,7 @@ struct ggml_tensor * ggml_view_1d(
struct ggml_tensor * a,
int64_t ne0,
size_t offset) {
struct ggml_tensor * result = ggml_view_impl(ctx, a, 1, &ne0, offset);
struct ggml_tensor * result = ggml_view_impl(ctx, a, 1, &ne0, NULL, offset);
return result;
}
@@ -3879,12 +3886,9 @@ struct ggml_tensor * ggml_view_2d(
size_t nb1,
size_t offset) {
const int64_t ne[2] = { ne0, ne1 };
const size_t nb[4] = { ggml_type_size(a->type), nb1, nb1*ne1, nb1*ne1 };
struct ggml_tensor * result = ggml_view_impl(ctx, a, 2, ne, offset);
result->nb[1] = nb1;
result->nb[2] = result->nb[1]*ne1;
result->nb[3] = result->nb[2];
struct ggml_tensor * result = ggml_view_impl(ctx, a, 2, ne, nb, offset);
return result;
}
@@ -3901,12 +3905,9 @@ struct ggml_tensor * ggml_view_3d(
size_t nb2,
size_t offset) {
const int64_t ne[3] = { ne0, ne1, ne2 };
const size_t nb[4] = { ggml_type_size(a->type), nb1, nb2, nb2*ne2 };
struct ggml_tensor * result = ggml_view_impl(ctx, a, 3, ne, offset);
result->nb[1] = nb1;
result->nb[2] = nb2;
result->nb[3] = result->nb[2]*ne2;
struct ggml_tensor * result = ggml_view_impl(ctx, a, 3, ne, nb, offset);
return result;
}
@@ -3925,12 +3926,9 @@ struct ggml_tensor * ggml_view_4d(
size_t nb3,
size_t offset) {
const int64_t ne[4] = { ne0, ne1, ne2, ne3 };
const size_t nb[4] = { ggml_type_size(a->type), nb1, nb2, nb3 };
struct ggml_tensor * result = ggml_view_impl(ctx, a, 4, ne, offset);
result->nb[1] = nb1;
result->nb[2] = nb2;
result->nb[3] = nb3;
struct ggml_tensor * result = ggml_view_impl(ctx, a, 4, ne, nb, offset);
return result;
}
@@ -5389,10 +5387,10 @@ struct ggml_tensor * ggml_roll(
int shift2,
int shift3) {
GGML_ASSERT(a->nb[0] == ggml_type_size(a->type));
GGML_ASSERT(abs(shift0) < a->ne[0]);
GGML_ASSERT(abs(shift1) < a->ne[1]);
GGML_ASSERT(abs(shift2) < a->ne[2]);
GGML_ASSERT(abs(shift3) < a->ne[3]);
GGML_ASSERT(shift0 > -a->ne[0] && shift0 < a->ne[0]);
GGML_ASSERT(shift1 > -a->ne[1] && shift1 < a->ne[1]);
GGML_ASSERT(shift2 > -a->ne[2] && shift2 < a->ne[2]);
GGML_ASSERT(shift3 > -a->ne[3] && shift3 < a->ne[3]);
struct ggml_tensor * result = ggml_dup_tensor(ctx, a);
+60
View File
@@ -506,6 +506,11 @@ class Keys:
WINDOW_SIZE = "clip.audio.window_size"
LOCAL_BLOCK_COUNT = "clip.audio.local_block_count" # mimo-v2.5: input_local_transformer layer count
LOCAL_GROUP_SIZE = "clip.audio.local_group_size" # mimo-v2.5: input_local_transformer grouping size
ROPE_DIMS = "clip.audio.rope_dims" # nemotron-transcribe
class Speaker: # nemotron-transcribe
BLOCK_COUNT = "clip.audio.spk.block_count"
HEAD_COUNT = "clip.audio.spk.head_count"
class Attention:
HEAD_COUNT = "clip.audio.attention.head_count"
@@ -1411,6 +1416,24 @@ class MODEL_TENSOR(IntEnum):
A_ENC_CONV_NORM_VAR = auto() # parakeet
A_ENC_MEL_FILTERS = auto() # parakeet
A_ENC_WINDOW = auto() # parakeet
A_ENC_ATTN_QKV = auto() # nemotron-transcribe
A_SPK_INP_PROJ = auto() # nemotron-transcribe
A_SPK_PRE_NORM = auto() # nemotron-transcribe
A_SPK_POST_NORM = auto() # nemotron-transcribe
A_SPK_ATTN_NORM = auto() # nemotron-transcribe
A_SPK_ATTN_QKV = auto() # nemotron-transcribe
A_SPK_ATTN_OUT = auto() # nemotron-transcribe
A_SPK_FFN_NORM = auto() # nemotron-transcribe
A_SPK_FFN_UP = auto() # nemotron-transcribe
A_SPK_FFN_DOWN = auto() # nemotron-transcribe
A_SPK_HEAD_PROJ = auto() # nemotron-transcribe
A_SPK_HEAD_UPSAMPLE = auto() # nemotron-transcribe
A_SPK_HEAD_FC1 = auto() # nemotron-transcribe
A_SPK_HEAD_FC2 = auto() # nemotron-transcribe
A_SPK_SIL_EMB = auto() # nemotron-transcribe
A_FUSION_ASR_NORM = auto() # nemotron-transcribe
A_FUSION_SPK_NORM = auto() # nemotron-transcribe
A_FUSION_SPK_KERNEL = auto() # nemotron-transcribe
A_CTC_OUT = auto()
A_CTC_OUT_MID = auto()
A_ENC_ATTN_REL_POS_EMB = auto()
@@ -2219,6 +2242,24 @@ TENSOR_NAMES: dict[MODEL_TENSOR, str] = {
MODEL_TENSOR.A_ENC_CONV_NORM_VAR: "a.blk.{bid}.conv_norm_var",
MODEL_TENSOR.A_ENC_MEL_FILTERS: "a.mel_filters",
MODEL_TENSOR.A_ENC_WINDOW: "a.window",
MODEL_TENSOR.A_ENC_ATTN_QKV: "a.blk.{bid}.attn_qkv",
MODEL_TENSOR.A_SPK_INP_PROJ: "a.spk.input_projection",
MODEL_TENSOR.A_SPK_PRE_NORM: "a.spk.pre_ln",
MODEL_TENSOR.A_SPK_POST_NORM: "a.spk.post_ln",
MODEL_TENSOR.A_SPK_ATTN_NORM: "a.spk.blk.{bid}.ln1",
MODEL_TENSOR.A_SPK_ATTN_QKV: "a.spk.blk.{bid}.attn_qkv",
MODEL_TENSOR.A_SPK_ATTN_OUT: "a.spk.blk.{bid}.attn_out",
MODEL_TENSOR.A_SPK_FFN_NORM: "a.spk.blk.{bid}.ln2",
MODEL_TENSOR.A_SPK_FFN_UP: "a.spk.blk.{bid}.ffn_up",
MODEL_TENSOR.A_SPK_FFN_DOWN: "a.spk.blk.{bid}.ffn_down",
MODEL_TENSOR.A_SPK_HEAD_PROJ: "a.spk.head.proj",
MODEL_TENSOR.A_SPK_HEAD_UPSAMPLE: "a.spk.head.upsample",
MODEL_TENSOR.A_SPK_HEAD_FC1: "a.spk.head.fc1",
MODEL_TENSOR.A_SPK_HEAD_FC2: "a.spk.head.fc2",
MODEL_TENSOR.A_SPK_SIL_EMB: "a.spk.head.sil_emb",
MODEL_TENSOR.A_FUSION_ASR_NORM: "a.fusion.asr_norm",
MODEL_TENSOR.A_FUSION_SPK_NORM: "a.fusion.spk_norm",
MODEL_TENSOR.A_FUSION_SPK_KERNEL: "a.fusion.spk_kernel",
MODEL_TENSOR.A_CTC_OUT: "a.enc_ctc_out",
MODEL_TENSOR.A_CTC_OUT_MID: "a.enc_ctc_out_mid",
MODEL_TENSOR.A_ENC_ATTN_REL_POS_EMB: "a.blk.{bid}.attn_rel_pos_emb",
@@ -2587,6 +2628,24 @@ MODEL_TENSORS: dict[MODEL_ARCH, list[MODEL_TENSOR]] = {
MODEL_TENSOR.A_ENC_CONV_NORM_VAR,
MODEL_TENSOR.A_ENC_MEL_FILTERS,
MODEL_TENSOR.A_ENC_WINDOW,
MODEL_TENSOR.A_ENC_ATTN_QKV,
MODEL_TENSOR.A_SPK_INP_PROJ,
MODEL_TENSOR.A_SPK_PRE_NORM,
MODEL_TENSOR.A_SPK_POST_NORM,
MODEL_TENSOR.A_SPK_ATTN_NORM,
MODEL_TENSOR.A_SPK_ATTN_QKV,
MODEL_TENSOR.A_SPK_ATTN_OUT,
MODEL_TENSOR.A_SPK_FFN_NORM,
MODEL_TENSOR.A_SPK_FFN_UP,
MODEL_TENSOR.A_SPK_FFN_DOWN,
MODEL_TENSOR.A_SPK_HEAD_PROJ,
MODEL_TENSOR.A_SPK_HEAD_UPSAMPLE,
MODEL_TENSOR.A_SPK_HEAD_FC1,
MODEL_TENSOR.A_SPK_HEAD_FC2,
MODEL_TENSOR.A_SPK_SIL_EMB,
MODEL_TENSOR.A_FUSION_ASR_NORM,
MODEL_TENSOR.A_FUSION_SPK_NORM,
MODEL_TENSOR.A_FUSION_SPK_KERNEL,
MODEL_TENSOR.A_MM_INP_PROJ,
MODEL_TENSOR.A_MM_SOFT_EMB_NORM,
MODEL_TENSOR.A_MM_EMBEDDING,
@@ -6740,6 +6799,7 @@ class VisionProjectorType:
MINICPMV4_7 = "minicpmv4_7"
MINICPM_OCR_1_0 = "minicpm_ocr_1_0"
GRANITE_SPEECH = "granite_speech" # audio
NEMOTRON_TRANSCRIBE = "nemotron_transcribe" # audio
MIMOVL = "mimovl"
MIMO_AUDIO = "mimo_audio"
GRANITE4_VISION = "granite4_vision"
+9
View File
@@ -1712,6 +1712,15 @@ class GGUFWriter:
def add_audio_projector_head_count(self, value: int) -> None:
self.add_uint32(Keys.ClipAudio.Projector.HEAD_COUNT, value)
def add_audio_rope_dims(self, value: int) -> None:
self.add_uint32(Keys.ClipAudio.ROPE_DIMS, value)
def add_audio_spk_block_count(self, value: int) -> None:
self.add_uint32(Keys.ClipAudio.Speaker.BLOCK_COUNT, value)
def add_audio_spk_head_count(self, value: int) -> None:
self.add_uint32(Keys.ClipAudio.Speaker.HEAD_COUNT, value)
# audio generation (mmproj)
def add_clip_gen_audio_projector_type(self, value: str) -> None:
+86 -1
View File
@@ -2532,6 +2532,7 @@ class TensorNameMap:
MODEL_TENSOR.A_ENC_INP_PROJ: (
"conformer.subsample_conv_projection.input_proj_linear", # gemma4
"perception.encoder.asr_encoder.pre_encode.proj", # nemotron-transcribe (feature stacking)
"encoder.input_linear",
),
@@ -2546,13 +2547,16 @@ class TensorNameMap:
"speaker_encoder.mfa.conv", # qwen3tts speaker encoder: multi-layer feature aggregation
),
MODEL_TENSOR.A_PRE_NORM: (),
MODEL_TENSOR.A_PRE_NORM: (
"perception.encoder.asr_encoder.embed_norm", # nemotron-transcribe
),
MODEL_TENSOR.A_POST_NORM: (
"audio_tower.layer_norm", # ultravox
"audio_tower.norm", # voxtral realtime
"audio_encoder.dots_encoder.speech_encoder.layer_norm", # dots3note
"audio_tower.ln_post", # qwen2omni
"perception.encoder.asr_encoder.final_norm", # nemotron-transcribe
"encoder.layer_norm", # mimo-audio-tokenizer
"encoder.lfm.lfm.embedding_norm", # lfm3a
),
@@ -2595,10 +2599,12 @@ class TensorNameMap:
MODEL_TENSOR.A_ENC_ATTN_Q_NORM: (
"encoder.lfm.lfm.layers.{bid}.self_attn.q_layernorm", # lfm3a
"perception.encoder.asr_encoder.layers.{bid}.attn.q_norm", # nemotron-transcribe
),
MODEL_TENSOR.A_ENC_ATTN_K_NORM: (
"encoder.lfm.lfm.layers.{bid}.self_attn.k_layernorm", # lfm3a
"perception.encoder.asr_encoder.layers.{bid}.attn.k_norm", # nemotron-transcribe
),
MODEL_TENSOR.A_ENC_ATTN_K_REL: (
@@ -2629,6 +2635,7 @@ class TensorNameMap:
"encoder.lfm.lfm.layers.{bid}.operator_norm", # lfm3a
"conformer.layers.{bid}.attention.pre_attn_norm", # gemma3n
"sound_encoder.encoder.layers.{bid}.norm_self_att", # parakeet
"perception.encoder.asr_encoder.layers.{bid}.norm1", # nemotron-transcribe
"encoder.layers.{bid}.attn.pre_norm", # granite_speech
"encoder.layers.{bid}.self_attn_layer_norm", # mimo-audio-tokenizer
),
@@ -2642,6 +2649,7 @@ class TensorNameMap:
"conformer.layers.{bid}.attention.post", # gemma3n
"conformer.layers.{bid}.self_attn.post", # gemma4
"sound_encoder.encoder.layers.{bid}.self_attn.o_proj", # parakeet
"perception.encoder.asr_encoder.layers.{bid}.attn.out_proj", # nemotron-transcribe
"encoder.layers.{bid}.attn.to_out", # granite_speech
"encoder.layers.{bid}.self_attn.out_proj", # mimo-audio-tokenizer
),
@@ -2653,6 +2661,7 @@ class TensorNameMap:
"encoder.lfm.lfm.layers.{bid}.ffn_norm", # lfm3a
"conformer.layers.{bid}.attention.post_norm", # gemma3n
"sound_encoder.encoder.layers.{bid}.norm_out", # parakeet
"perception.encoder.asr_encoder.layers.{bid}.norm2", # nemotron-transcribe
"encoder.layers.{bid}.post_norm", # granite_speech
"encoder.layers.{bid}.final_layer_norm", # mimo-audio-tokenizer
),
@@ -2683,6 +2692,7 @@ class TensorNameMap:
"conformer.layers.{bid}.ffw_layer_start.ffw_layer_1", # gemma3n
"conformer.layers.{bid}.feed_forward1.ffw_layer_1", # gemma4
"sound_encoder.encoder.layers.{bid}.feed_forward1.linear1", # parakeet
"perception.encoder.asr_encoder.layers.{bid}.ffn.net.0", # nemotron-transcribe
"encoder.layers.{bid}.ff1.up_proj", # granite_speech
"encoder.layers.{bid}.fc1", # mimo-audio-tokenizer
),
@@ -2702,6 +2712,7 @@ class TensorNameMap:
"conformer.layers.{bid}.ffw_layer_start.ffw_layer_2", # gemma3n
"conformer.layers.{bid}.feed_forward1.ffw_layer_2", # gemma4
"sound_encoder.encoder.layers.{bid}.feed_forward1.linear2", # parakeet
"perception.encoder.asr_encoder.layers.{bid}.ffn.net.3", # nemotron-transcribe
"encoder.layers.{bid}.ff1.down_proj", # granite_speech
"encoder.layers.{bid}.fc2", # mimo-audio-tokenizer
),
@@ -2792,6 +2803,7 @@ class TensorNameMap:
"audio_tower.proj", # qwen2omni
"model.audio_tower.output_proj", # gemma4
"speaker_encoder.fc", # qwen3tts speaker encoder: final speaker embedding projection
"perception.proj", # nemotron-transcribe
),
MODEL_TENSOR.A_MM_NORM_PRE: (
@@ -2914,12 +2926,85 @@ class TensorNameMap:
MODEL_TENSOR.A_ENC_MEL_FILTERS: (
"sound_encoder.encoder.feature_extractor.featurizer.fb", # parakeet
"perception.preprocessor.featurizer.fb", # nemotron-transcribe
),
MODEL_TENSOR.A_ENC_WINDOW: (
"sound_encoder.encoder.feature_extractor.featurizer.window", # parakeet
"perception.preprocessor.featurizer.window", # nemotron-transcribe
),
MODEL_TENSOR.A_ENC_ATTN_QKV: (
"perception.encoder.asr_encoder.layers.{bid}.attn.w_qkv", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_INP_PROJ: (
"perception.encoder.diarization_model.encoder.pre_encode.proj", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_PRE_NORM: (
"perception.encoder.diarization_model.encoder.embed_norm", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_POST_NORM: (
"perception.encoder.diarization_model.encoder.final_norm", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_ATTN_NORM: (
"perception.encoder.diarization_model.encoder.layers.{bid}.norm1", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_ATTN_QKV: (
"perception.encoder.diarization_model.encoder.layers.{bid}.attn.w_qkv", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_ATTN_OUT: (
"perception.encoder.diarization_model.encoder.layers.{bid}.attn.out_proj", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_FFN_NORM: (
"perception.encoder.diarization_model.encoder.layers.{bid}.norm2", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_FFN_UP: (
"perception.encoder.diarization_model.encoder.layers.{bid}.ffn.net.0", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_FFN_DOWN: (
"perception.encoder.diarization_model.encoder.layers.{bid}.ffn.net.3", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_HEAD_PROJ: (
"perception.encoder.diarization_model.sortformer_modules.encoder_proj", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_HEAD_UPSAMPLE: (
"perception.encoder.diarization_model.sortformer_modules.subpixel_upsample", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_HEAD_FC1: (
"perception.encoder.diarization_model.sortformer_modules.first_hidden_to_hidden", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_HEAD_FC2: (
"perception.encoder.diarization_model.sortformer_modules.single_hidden_to_spks", # nemotron-transcribe
),
MODEL_TENSOR.A_SPK_SIL_EMB: (
"perception.encoder.diarization_model.sortformer_modules.learnable_sil_emb", # nemotron-transcribe
),
MODEL_TENSOR.A_FUSION_ASR_NORM: (
"perception.encoder.asr_norm", # nemotron-transcribe
),
MODEL_TENSOR.A_FUSION_SPK_NORM: (
"perception.encoder.diar_norm", # nemotron-transcribe
),
# generated in conversion code (nemotron-transcribe)
MODEL_TENSOR.A_FUSION_SPK_KERNEL: (),
MODEL_TENSOR.A_MM_EMBEDDING: (
"model.embed_audio.embedding", # gemma3n
),
-1
View File
@@ -1390,7 +1390,6 @@ bool llm_arch_supports_sm_tensor(const llm_arch & arch) {
case LLM_ARCH_KIMI_LINEAR:
case LLM_ARCH_BAILINGMOE3:
case LLM_ARCH_KIMI_K3:
case LLM_ARCH_GLM5_NEXT:
case LLM_ARCH_QWEN3TTS:
case LLM_ARCH_LIMITE:
return false;
+1 -1
View File
@@ -201,7 +201,7 @@ llama_context::llama_context(
}
cparams.n_rs_seq = params.n_rs_seq;
if (cparams.n_rs_seq > 0 && !llm_arch_supports_rs_rollback(model.arch)) {
if (cparams.n_rs_seq > 0 && (!llm_arch_supports_rs_rollback(model.arch) || !hparams.causal_attn)) {
LLAMA_LOG_DEBUG("%s: n_rs_seq=%u requested but model does not support recurrent partial rollback; clamping to 0\n",
__func__, cparams.n_rs_seq);
cparams.n_rs_seq = 0;
+57
View File
@@ -3994,6 +3994,63 @@ ggml_tensor * llm_graph_context::build_rs(
get_state_rows);
}
void llm_graph_context::build_conv_state_store(
llm_graph_input_rs * inp,
ggml_tensor * s,
ggml_tensor * conv_x,
int64_t n_cols,
size_t offs,
int il) const {
const auto * mctx_cur = inp->mctx;
const int64_t n = conv_x->ne[0] - n_cols; // n_seq_tokens
const int64_t channels = conv_x->ne[1];
const int64_t n_seqs = conv_x->ne[2];
const size_t row_size = s->nb[1];
const size_t cell_offs = mctx_cur->get_head()*row_size + offs;
if (cparams.n_rs_seq == 0) {
ggml_tensor * last = ggml_view_3d(ctx0, conv_x,
n_cols, channels, n_seqs,
conv_x->nb[1], conv_x->nb[2],
ggml_row_size(conv_x->type, n));
ggml_tensor * cell = ggml_view_2d(ctx0, s,
n_cols*channels, n_seqs,
row_size, cell_offs);
ggml_build_forward_expand(gf, ggml_cpy(ctx0, last, cell));
return;
}
// [TAG_RECURRENT_ROLLBACK_SPLITS]
// this logic assumes that the last (n_rs_seq + 1) tokens of a sequence in a batch are inside
// the same ubatch, which `split_equal()` guarantees via its n_keep_tail argument
// a rollback of k tokens reads slot k, and the memory allows k < n only (see rs_max), so slots from n on are left unwritten
const int64_t K = (int64_t) cparams.n_rs_seq + 1;
const int64_t Kw = std::min(n, K);
// {Kw-1, ..., 0}, the tail of the slot order the memory keeps for every ubatch
ggml_tensor * slot_order = mctx_cur->get_slot_l(il);
slot_order = ggml_view_1d(ctx0, slot_order, Kw, ggml_row_size(slot_order->type, K - Kw));
// window w starts at column n - (Kw - 1) + w (ascending time) and is rollback slot Kw - 1 - w,
// consecutive windows start one element apart, so they overlap
ggml_tensor * windows = ggml_view_4d(ctx0, conv_x,
n_cols, Kw, channels, n_seqs,
ggml_element_size(conv_x), conv_x->nb[1], conv_x->nb[2],
ggml_row_size(conv_x->type, n - (Kw - 1)));
// [n_cols, slot, channel, seq] view of the cells of this ubatch
ggml_tensor * slots = ggml_view_4d(ctx0, s,
n_cols, K, channels, n_seqs,
(size_t) mctx_cur->get_size()*row_size, ggml_row_size(s->type, n_cols), row_size,
cell_offs);
ggml_build_forward_expand(gf, ggml_set_rows(ctx0, slots, windows, slot_order));
}
ggml_tensor * llm_graph_context::build_rwkv_token_shift_load(
llm_graph_input_rs * inp,
const llama_ubatch & ubatch,
+10
View File
@@ -1433,6 +1433,16 @@ struct llm_graph_context {
int32_t n_seqs,
const llm_graph_get_rows_fn & get_state_rows = nullptr) const;
// store the conv history of the ubatch cells, the last n_cols columns of conv_x {n_cols + n_seq_tokens, channels, n_seqs},
// at byte offset offs of each row of s, and with n_rs_seq > 0 once per rollback slot of this ubatch
void build_conv_state_store(
llm_graph_input_rs * inp,
ggml_tensor * s,
ggml_tensor * conv_x,
int64_t n_cols,
size_t offs,
int il) const;
ggml_tensor * build_rwkv_token_shift_load(
llm_graph_input_rs * inp,
const llama_ubatch & ubatch,
+51 -2
View File
@@ -26,7 +26,7 @@ llama_memory_recurrent::llama_memory_recurrent(
uint32_t mem_size,
uint32_t n_seq_max,
uint32_t n_rs_seq,
const layer_filter_cb & filter) : hparams(model.hparams), n_seq_max(n_seq_max), ctxs_bufs(no_alloc) {
const layer_filter_cb & filter) : hparams(model.hparams), n_seq_max(n_seq_max), ctxs_bufs(no_alloc), slot_bufs(no_alloc) {
const int32_t n_layer = hparams.n_layer();
head = 0;
@@ -46,6 +46,7 @@ llama_memory_recurrent::llama_memory_recurrent(
}
};
std::map<ggml_backend_buffer_type_t, ggml_context_ptr, ggml_backend_buft_comparator> ctx_map;
std::map<ggml_backend_buffer_type_t, ggml_context_ptr, ggml_backend_buft_comparator> slot_ctx_map;
// create a context for each buffer type
auto ctx_for_buft = [&](ggml_backend_buffer_type_t buft) -> ggml_context * {
@@ -74,6 +75,7 @@ llama_memory_recurrent::llama_memory_recurrent(
r_l.resize(n_layer);
s_l.resize(n_layer);
p_l.resize(n_layer);
slot_l.resize(n_layer);
for (int i = 0; i < n_layer; i++) {
if (filter && !filter(i)) {
@@ -113,6 +115,28 @@ llama_memory_recurrent::llama_memory_recurrent(
ggml_format_name(p, "cache_ple_r_l%d", i);
p_l[i] = p;
}
if (n_rs_seq > 0) {
auto it = slot_ctx_map.find(buft);
if (it == slot_ctx_map.end()) {
ggml_init_params params = {
/*.mem_size =*/ size_t(n_layer*ggml_tensor_overhead()),
/*.mem_buffer =*/ NULL,
/*.no_alloc =*/ true,
};
ggml_context * slot_ctx = ggml_init(params);
if (!slot_ctx) {
throw std::runtime_error("failed to create ggml context for rs slots");
}
it = slot_ctx_map.emplace(buft, slot_ctx).first;
}
ggml_tensor * slot = ggml_new_tensor_1d(it->second.get(), GGML_TYPE_I32, n_rs_seq + 1);
ggml_format_name(slot, "cache_rs_slot_l%d", i);
slot_l[i] = slot;
}
}
// allocate the tensors and initialize the buffers to avoid NaNs in the padding
@@ -122,6 +146,23 @@ llama_memory_recurrent::llama_memory_recurrent(
LLAMA_LOG_INFO("%s: %10s RS buffer size = %8.2f MiB\n", __func__, ggml_backend_buffer_name(buf), ggml_backend_buffer_get_size(buf)/1024.0/1024.0);
}
for (auto & [buft, ctx] : slot_ctx_map) {
slot_bufs.emplace(std::move(ctx), buft, "rs slots");
}
if (!slot_bufs.no_alloc) {
std::vector<int32_t> order(n_rs_seq + 1);
for (uint32_t i = 0; i <= n_rs_seq; ++i) {
order[i] = (int32_t) (n_rs_seq - i);
}
for (ggml_tensor * slot : slot_l) {
if (slot != nullptr) {
ggml_backend_tensor_set(slot, order.data(), 0, ggml_nbytes(slot));
}
}
}
if (is_empty()) {
if (n_rs_seq > 0) {
n_rs_seq = 0;
@@ -432,7 +473,11 @@ void llama_memory_recurrent::set_rs_idx(llama_seq_id seq_id, uint32_t idx) {
}
std::map<ggml_backend_buffer_type_t, size_t> llama_memory_recurrent::memory_breakdown() const {
return ctxs_bufs.memory_breakdown();
auto ret = ctxs_bufs.memory_breakdown();
for (const auto & [buft, size] : slot_bufs.memory_breakdown()) {
ret[buft] += size;
}
return ret;
}
llama_memory_context_ptr llama_memory_recurrent::init_batch(llama_batch_allocr & balloc, uint32_t n_ubatch, bool embd_all) {
@@ -1360,6 +1405,10 @@ ggml_tensor * llama_memory_recurrent_context::get_p_l(int32_t il) const {
return mem->p_l[il];
}
ggml_tensor * llama_memory_recurrent_context::get_slot_l(int32_t il) const {
return mem->slot_l[il];
}
int32_t llama_memory_recurrent_context::s_copy(int i) const {
const uint32_t cell_idx = i + mem->head;
const int32_t src0 = mem->cells[cell_idx].src0;
+6
View File
@@ -118,6 +118,8 @@ public:
std::vector<ggml_tensor *> s_l;
// a second conv history that must stay replicated across devices, so it cannot share the r row
std::vector<ggml_tensor *> p_l;
// I32 [n_rs_seq + 1] = {n_rs_seq, ..., 0}, the rollback slot of each conv window, the same for every ubatch
std::vector<ggml_tensor *> slot_l;
private:
//const llama_model & model;
@@ -128,6 +130,9 @@ private:
// ggml contexts for the RS cache along with the allocated backend buffers:
llama_memory_bufs ctxs_bufs;
// the constants of the cache, kept out of the state buffers so that clearing or restoring the state leaves them intact
llama_memory_bufs slot_bufs;
// true if no layers - can happen if the layer filter removes all layers
bool is_empty() const;
@@ -184,6 +189,7 @@ public:
ggml_tensor * get_r_l(int32_t il) const;
ggml_tensor * get_s_l(int32_t il) const;
ggml_tensor * get_p_l(int32_t il) const;
ggml_tensor * get_slot_l(int32_t il) const;
int32_t s_copy(int i) const;
+62 -1
View File
@@ -410,6 +410,7 @@ struct ggml_backend_meta_split_state llama_meta_device_get_split_state(const str
const std::string tensor_name = tensor->name;
const bool is_dsv4 = ud->model->arch == LLM_ARCH_DEEPSEEK4 || ud->model->arch == LLM_ARCH_DEEPSEEK41 ||
(ud->model->arch == LLM_ARCH_DFLASH && hparams.dsv4_hc_mult > 0);
const bool is_glm5_next = ud->model->arch == LLM_ARCH_GLM5_NEXT;
static const std::regex pattern_q_weight ("blk\\.\\d*\\.attn_q.weight");
static const std::regex pattern_kv_weight ("blk\\.\\d*\\.attn_(k|v).weight");
@@ -429,6 +430,7 @@ struct ggml_backend_meta_split_state llama_meta_device_get_split_state(const str
static const std::regex pattern_attn_out_a_weight("blk\\.\\d*\\.attn_output_a\\.weight");
static const std::regex pattern_attn_out_b_weight("blk\\.\\d*\\.attn_output_b\\.weight");
static const std::regex pattern_attn_q_b_weight ("blk\\.\\d*\\.attn_q_b\\.weight");
static const std::regex pattern_attn_kv_b_weight("blk\\.\\d*\\.attn_(k|v)_b\\.weight");
static const std::regex pattern_attn_gate_weight("blk\\.\\d*\\.attn_gate.weight");
static const std::regex pattern_ssm_dt ("blk\\.\\d*\\.ssm_dt.bias");
@@ -441,6 +443,8 @@ struct ggml_backend_meta_split_state llama_meta_device_get_split_state(const str
static const std::regex pattern_s_cache ("cache_s_l\\d*");
static const std::regex pattern_ssm_conv1d ("blk\\.\\d*\\.ssm_conv1d.weight");
static const std::regex pattern_ssm_out_weight ("blk\\.\\d*\\.ssm_out.weight");
static const std::regex pattern_kda_conv1d ("blk\\.\\d*\\.ssm_conv1d_(q|k|v)\\.weight");
static const std::regex pattern_kda_gate_b ("blk\\.\\d*\\.ssm_(f|g)_b\\.weight");
static const std::regex pattern_ffn_up_weight ("blk\\.\\d*\\.ffn_up(_exps)?.weight");
static const std::regex pattern_ffn_up_bias ("blk\\.\\d*\\.ffn_up(_exps)?.bias");
@@ -532,6 +536,25 @@ struct ggml_backend_meta_split_state llama_meta_device_get_split_state(const str
if (std::regex_match(tensor_name, pattern_attn_out_b_weight)) {
return get_tensor_config_impl(GGML_BACKEND_SPLIT_AXIS_0);
}
}
if (is_glm5_next) {
// split the KDA and MLA heads, the MLA latent cache and the indexer stay mirrored
if (std::regex_match(tensor_name, pattern_kv_cache)) {
return get_tensor_config_impl(GGML_BACKEND_SPLIT_AXIS_MIRRORED);
}
if (std::regex_match(tensor_name, pattern_attn_q_b_weight) || std::regex_match(tensor_name, pattern_kda_gate_b) ||
std::regex_match(tensor_name, pattern_ssm_beta)) {
return get_tensor_config_impl(GGML_BACKEND_SPLIT_AXIS_1, "attn_output.weight");
}
if (std::regex_match(tensor_name, pattern_attn_kv_b_weight) || std::regex_match(tensor_name, pattern_kda_conv1d)) {
return get_tensor_config_impl(GGML_BACKEND_SPLIT_AXIS_2, "attn_output.weight");
}
if (std::regex_match(tensor_name, pattern_ssm_dt) || std::regex_match(tensor_name, pattern_ssm_a) ||
std::regex_match(tensor_name, pattern_r_cache) || std::regex_match(tensor_name, pattern_s_cache)) {
return get_tensor_config_impl(GGML_BACKEND_SPLIT_AXIS_0, "attn_output.weight");
}
}
if (is_dsv4 || is_glm5_next) {
if (std::regex_match(tensor_name, pattern_ffn_up_shexp_weight) ||
std::regex_match(tensor_name, pattern_ffn_gate_shexp_weight)) {
return get_tensor_config_impl(GGML_BACKEND_SPLIT_AXIS_1, "ffn_down_shexp.weight");
@@ -645,6 +668,12 @@ struct ggml_backend_meta_split_state llama_meta_device_get_split_state(const str
// if a model has fused tensors they need to be separated into "segments", see the comments on ggml_backend_meta_split_state struct
auto get_split_segments = [&](int axis, uint32_t il) -> std::vector<std::pair<int64_t, uint32_t>> {
if (is_glm5_next && std::regex_match(tensor_name, pattern_r_cache)) {
// the conv state holds the q, k and v windows one after the other
const int64_t conv_state_size = (hparams.ssm_d_conv - 1) * hparams.n_head(il) * hparams.n_embd_head_kda;
GGML_ASSERT(tensor->ne[axis] == 3*conv_state_size);
return {{conv_state_size, 3}};
}
// TODO: deduplicate condition [TAG_SPLIT_QGATE_QWEN]
if (ud->model->arch == LLM_ARCH_QWEN3NEXT || ud->model->arch == LLM_ARCH_QWEN35 || ud->model->arch == LLM_ARCH_QWEN35MOE || ud->model->arch == LLM_ARCH_CLEF || ud->model->arch == LLM_ARCH_CLOVER_MOE ||
ud->model->arch == LLM_ARCH_QWEN4EXP) {
@@ -736,7 +765,39 @@ struct ggml_backend_meta_split_state llama_meta_device_get_split_state(const str
auto get_split_granularity = [&](int64_t blck_size, uint32_t il, const std::vector<std::pair<int64_t, uint32_t>> & segments) -> std::vector<int64_t> {
// for better performance it may make sense to round up blck_size to a higher power of 2 so that more efficient kernels can be used
if (hparams.is_recr(il)) {
if (is_glm5_next && hparams.is_recr(il)) {
// KDA, each device holds whole heads
const int64_t head_dim = hparams.n_embd_head_kda;
const int64_t blck_size_perf = std::lcm(blck_size, 128);
const int64_t granularity_qkv = std::lcm(blck_size_perf, head_dim);
if (std::regex_match(tensor_name, pattern_q_weight) || std::regex_match(tensor_name, pattern_kv_weight) ||
std::regex_match(tensor_name, pattern_kda_conv1d) || std::regex_match(tensor_name, pattern_kda_gate_b) ||
std::regex_match(tensor_name, pattern_ssm_dt) || std::regex_match(tensor_name, pattern_attn_out_weight)) {
return {granularity_qkv};
}
if (std::regex_match(tensor_name, pattern_ssm_a) || std::regex_match(tensor_name, pattern_ssm_beta)) {
return {granularity_qkv / head_dim};
}
if (std::regex_match(tensor_name, pattern_r_cache)) {
return std::vector<int64_t>(segments.size(), granularity_qkv * (hparams.ssm_d_conv - 1));
}
if (std::regex_match(tensor_name, pattern_s_cache)) {
return {granularity_qkv * head_dim};
}
} else if (is_glm5_next) {
// MLA, each device holds whole heads
// an even number of heads per device, the HIP tile FA kernel for D=512 needs an even GQA ratio
const int64_t granularity_head = std::lcm(std::lcm(hparams.n_embd_head_v_mla(), blck_size) / hparams.n_embd_head_v_mla(), int64_t(2));
if (std::regex_match(tensor_name, pattern_attn_q_b_weight)) {
return {granularity_head * hparams.n_embd_head_k_mla()};
}
if (std::regex_match(tensor_name, pattern_attn_kv_b_weight)) {
return {granularity_head};
}
if (std::regex_match(tensor_name, pattern_attn_out_weight)) {
return {granularity_head * hparams.n_embd_head_v_mla()};
}
} else if (hparams.is_recr(il)) {
// linear attention
const int64_t head_dim = hparams.ssm_d_state;
const int64_t blck_size_perf = std::lcm(blck_size, 128);
+17 -43
View File
@@ -160,25 +160,11 @@ std::unique_ptr<llm_graph_context> llama_model_bailingmoe3::build_arch_graph(con
return std::make_unique<graph>(*this, params);
}
static ggml_tensor * bailingmoe3_causal_conv1d(
ggml_cgraph * gf,
ggml_context * ctx0,
ggml_tensor * conv_states_all,
ggml_tensor * conv_state_all,
int64_t qkv,
ggml_tensor * x,
ggml_tensor * proj_w,
ggml_tensor * conv_w,
int64_t d_conv,
int64_t head_dim,
int64_t n_head,
int64_t n_seq_tokens,
int64_t n_seqs,
int64_t n_tokens,
int64_t cache_head,
uint32_t mem_size,
uint32_t n_rs_seq) {
const int64_t d_inner = head_dim * n_head;
ggml_tensor * llama_model_bailingmoe3::graph::build_causal_conv1d(
llm_graph_input_rs * inp_rs, ggml_tensor * conv_states_all, ggml_tensor * conv_state_all,
int64_t qkv, ggml_tensor * x, ggml_tensor * proj_w, ggml_tensor * conv_w,
int64_t d_conv, int64_t head_dim, int64_t n_head_kda, int64_t n_seq_tokens, int64_t n_seqs, int il) {
const int64_t d_inner = head_dim * n_head_kda;
const int64_t conv_state_size = (d_conv - 1) * d_inner;
const int64_t total_state_size = 3 * conv_state_size;
@@ -191,23 +177,13 @@ static ggml_tensor * bailingmoe3_causal_conv1d(
x_proj = ggml_reshape_3d(ctx0, x_proj, d_inner, n_seq_tokens, n_seqs);
ggml_tensor * conv_x = ggml_concat(ctx0, conv_state, ggml_transpose(ctx0, x_proj), 0);
const int64_t K = (int64_t) n_rs_seq + 1;
const int64_t n_written = std::min<int64_t>(n_seq_tokens, K);
for (int64_t slot = 0; slot < n_written; ++slot) {
ggml_tensor * conv_snap = ggml_view_3d(ctx0, conv_x, d_conv - 1, d_inner, n_seqs,
conv_x->nb[1], conv_x->nb[2], (conv_x->ne[0] - (d_conv - 1) - slot) * conv_x->nb[0]);
ggml_build_forward_expand(gf, ggml_cpy(ctx0, conv_snap,
ggml_view_3d(ctx0, conv_states_all, d_conv - 1, d_inner, n_seqs,
(d_conv - 1) * ggml_element_size(conv_states_all),
total_state_size * ggml_element_size(conv_states_all),
((slot * mem_size + cache_head) * total_state_size + qkv * conv_state_size) * ggml_element_size(conv_states_all))));
}
build_conv_state_store(inp_rs, conv_states_all, conv_x, d_conv - 1,
ggml_row_size(conv_states_all->type, qkv * conv_state_size), il);
ggml_tensor * conv_weight = ggml_reshape_2d(ctx0, conv_w, d_conv, d_inner);
ggml_tensor * out = ggml_ssm_conv(ctx0, conv_x, conv_weight);
out = ggml_silu(ctx0, ggml_reshape_2d(ctx0, out, d_inner, n_tokens));
return ggml_reshape_4d(ctx0, out, head_dim, n_head, n_seq_tokens, n_seqs);
return ggml_reshape_4d(ctx0, out, head_dim, n_head_kda, n_seq_tokens, n_seqs);
}
llama_model_bailingmoe3::graph::graph(const llama_model & model, const llm_graph_params & params) :
@@ -253,20 +229,18 @@ llama_model_bailingmoe3::graph::graph(const llama_model & model, const llm_graph
if (hparams.is_recr(il)) {
const auto * mctx_cur = inp_rs->mctx;
const auto cache_head = mctx_cur->get_head();
const auto mem_size = mctx_cur->get_size();
ggml_tensor * conv_states_all = mctx_cur->get_r_l(il);
ggml_tensor * conv_state_all = build_rs(inp_rs, conv_states_all, hparams.n_embd_r(), n_seqs);
ggml_tensor * q = bailingmoe3_causal_conv1d(
gf, ctx0, conv_states_all, conv_state_all, 0, cur, layer.wq, layer.ssm_q_conv,
d_conv, head_dim, n_head, n_seq_tokens, n_seqs, n_tokens, cache_head, mem_size, cparams.n_rs_seq);
ggml_tensor * k = bailingmoe3_causal_conv1d(
gf, ctx0, conv_states_all, conv_state_all, 1, cur, layer.wk, layer.ssm_k_conv,
d_conv, head_dim, n_head, n_seq_tokens, n_seqs, n_tokens, cache_head, mem_size, cparams.n_rs_seq);
ggml_tensor * v = bailingmoe3_causal_conv1d(
gf, ctx0, conv_states_all, conv_state_all, 2, cur, layer.wv, layer.ssm_v_conv,
d_conv, head_dim, n_head, n_seq_tokens, n_seqs, n_tokens, cache_head, mem_size, cparams.n_rs_seq);
ggml_tensor * q = build_causal_conv1d(
inp_rs, conv_states_all, conv_state_all, 0, cur, layer.wq, layer.ssm_q_conv,
d_conv, head_dim, n_head, n_seq_tokens, n_seqs, il);
ggml_tensor * k = build_causal_conv1d(
inp_rs, conv_states_all, conv_state_all, 1, cur, layer.wk, layer.ssm_k_conv,
d_conv, head_dim, n_head, n_seq_tokens, n_seqs, il);
ggml_tensor * v = build_causal_conv1d(
inp_rs, conv_states_all, conv_state_all, 2, cur, layer.wv, layer.ssm_v_conv,
d_conv, head_dim, n_head, n_seq_tokens, n_seqs, il);
ggml_tensor * gate = ggml_mul_mat(ctx0, layer.ssm_f_a, cur);
gate = ggml_add(ctx0, gate, layer.ssm_dt_b);
+1 -53
View File
@@ -450,11 +450,6 @@ ggml_tensor * llm_build_delta_net_base::build_conv_state(
int64_t conv_kernel_size,
int64_t conv_channels,
int il) {
const auto * mctx_cur = inp->mctx;
const auto kv_head = mctx_cur->get_head();
const auto mem_size = mctx_cur->get_size();
const int64_t n_seqs = ubatch.n_seqs;
ggml_tensor * conv_states = build_rs(inp, conv_states_all, hparams.n_embd_r(), n_seqs);
@@ -469,54 +464,7 @@ ggml_tensor * llm_build_delta_net_base::build_conv_state(
ggml_tensor * conv_input = ggml_concat(ctx0, conv_states, qkv_mixed, 0);
cb(conv_input, "conv_input", il);
const int64_t row_count = (conv_kernel_size - 1) * conv_channels;
const size_t row_size = ggml_row_size(conv_states_all->type, row_count);
if (cparams.n_rs_seq == 0) {
const int64_t s_idx = conv_input->ne[0] - conv_states->ne[0];
const int64_t s_slot = 0;
ggml_tensor * conv_state_last =
ggml_view_3d(ctx0, conv_input,
conv_kernel_size - 1, conv_channels, n_seqs,
conv_input->nb[1], conv_input->nb[2],
ggml_row_size(conv_input->type, s_idx));
cb(conv_state_last, "conv_state_last", il);
ggml_tensor * conv_state_update =
ggml_view_2d(ctx0, conv_states_all,
row_count, n_seqs, conv_states_all->nb[1],
(s_slot * mem_size + kv_head) * row_size);
cb(conv_state_update, "conv_state_update", il);
ggml_build_forward_expand(gf, ggml_cpy(ctx0, conv_state_last, conv_state_update));
} else {
// [TAG_RECURRENT_ROLLBACK_SPLITS]
// this logic assumes that the last (n_rs_seq + 1) tokens of a sequence in a batch are inside
// the same ubatch, which `split_equal()` guarantees via its n_keep_tail argument
const int64_t K = (int64_t) cparams.n_rs_seq + 1;
for (int64_t t = 1; t <= K; ++t) {
const int64_t s_idx = std::max<int64_t>(0, conv_input->ne[0] - conv_states->ne[0] - K + t);
const int64_t s_slot = K - t;
ggml_tensor * conv_state_last =
ggml_view_3d(ctx0, conv_input,
conv_kernel_size - 1, conv_channels, n_seqs,
conv_input->nb[1], conv_input->nb[2],
ggml_row_size(conv_input->type, s_idx));
ggml_tensor * conv_state_update =
ggml_view_2d(ctx0,
conv_states_all, row_count, n_seqs,
conv_states_all->nb[1],
(s_slot * mem_size + kv_head) * row_size);
ggml_build_forward_expand(gf, ggml_cpy(ctx0, conv_state_last, conv_state_update));
}
}
build_conv_state_store(inp, conv_states_all, conv_input, conv_kernel_size - 1, 0, il);
return conv_input;
}
+11 -28
View File
@@ -191,13 +191,11 @@ std::unique_ptr<llm_graph_context> llama_model_glm5_next::build_arch_graph(const
}
// Causal conv1d over one of Q/K/V
static ggml_tensor * glm5_conv1d(ggml_cgraph * gf, ggml_context * ctx0,
ggml_tensor * conv_states_all, ggml_tensor * conv_state_all,
int64_t qkv, ggml_tensor * x, ggml_tensor * proj_w, ggml_tensor * conv_w,
int64_t d_conv, int64_t head_dim, int64_t n_head,
int64_t n_seq_tokens, int64_t n_seqs, int64_t n_tokens, int64_t kv_head,
int64_t mem_size, int64_t K_rs) {
const int64_t d_inner = head_dim * n_head;
ggml_tensor * llama_model_glm5_next::graph::build_kda_conv1d(
llm_graph_input_rs * inp_rs, ggml_tensor * conv_states_all, ggml_tensor * conv_state_all,
int64_t qkv, ggml_tensor * x, ggml_tensor * proj_w, ggml_tensor * conv_w,
int64_t d_conv, int64_t head_dim, int64_t n_head_kda, int64_t n_seq_tokens, int64_t n_seqs, int il) {
const int64_t d_inner = head_dim * n_head_kda;
const int64_t conv_state_size = (d_conv - 1) * d_inner;
const int64_t n_embd_r_total = 3 * conv_state_size;
@@ -210,26 +208,15 @@ static ggml_tensor * glm5_conv1d(ggml_cgraph * gf, ggml_context * ctx0,
ggml_tensor * x_3d = ggml_reshape_3d(ctx0, x_proj, d_inner, n_seq_tokens, n_seqs);
ggml_tensor * conv_x = ggml_concat(ctx0, conv_state_x, ggml_transpose(ctx0, x_3d), 0);
// group s holds the conv window s tokens back.
// [TAG_RECURRENT_ROLLBACK_SPLITS]: the last K_rs tokens must share one ubatch.
for (int64_t s = 0; s < K_rs; ++s) {
const int64_t s_idx = std::max<int64_t>(0, n_seq_tokens - s);
ggml_tensor * conv_x_s = ggml_view_3d(ctx0, conv_x, d_conv - 1, d_inner, n_seqs,
conv_x->nb[1], conv_x->nb[2], s_idx * conv_x->nb[0]);
ggml_build_forward_expand(gf,
ggml_cpy(ctx0, conv_x_s,
ggml_view_3d(ctx0, conv_states_all, d_conv - 1, d_inner, n_seqs,
(d_conv - 1) * ggml_element_size(conv_states_all),
n_embd_r_total * ggml_element_size(conv_states_all),
((s * mem_size + kv_head) * n_embd_r_total + qkv * conv_state_size) * ggml_element_size(conv_states_all))));
}
build_conv_state_store(inp_rs, conv_states_all, conv_x, d_conv - 1,
ggml_row_size(conv_states_all->type, qkv * conv_state_size), il);
ggml_tensor * conv_weight = ggml_reshape_2d(ctx0, conv_w, d_conv, d_inner);
ggml_tensor * Xcur = ggml_ssm_conv(ctx0, conv_x, conv_weight);
Xcur = ggml_reshape_2d(ctx0, Xcur, d_inner, n_tokens);
Xcur = ggml_silu(ctx0, Xcur);
return ggml_reshape_4d(ctx0, Xcur, head_dim, n_head, n_seq_tokens, n_seqs);
return ggml_reshape_4d(ctx0, Xcur, head_dim, n_head_kda, n_seq_tokens, n_seqs);
}
@@ -812,17 +799,13 @@ ggml_tensor * llama_model_glm5_next::graph::build_kda_layer(
int64_t d_inner, int64_t n_seq_tokens, int64_t n_seqs, int il) {
const auto * mctx_cur = inp_rs->mctx;
const auto kv_head = mctx_cur->get_head();
ggml_tensor * conv_states_all = mctx_cur->get_r_l(il);
ggml_tensor * conv_state_all = build_rs(inp_rs, conv_states_all, hparams.n_embd_r(), n_seqs);
const int64_t mem_size = mctx_cur->get_size();
const int64_t K_rs = (int64_t) cparams.n_rs_seq + 1;
ggml_tensor * Qcur = glm5_conv1d(gf, ctx0, conv_states_all, conv_state_all, 0, cur, layer.wq, layer.ssm_q_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, n_tokens, kv_head, mem_size, K_rs);
ggml_tensor * Kcur = glm5_conv1d(gf, ctx0, conv_states_all, conv_state_all, 1, cur, layer.wk, layer.ssm_k_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, n_tokens, kv_head, mem_size, K_rs);
ggml_tensor * Vcur = glm5_conv1d(gf, ctx0, conv_states_all, conv_state_all, 2, cur, layer.wv, layer.ssm_v_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, n_tokens, kv_head, mem_size, K_rs);
ggml_tensor * Qcur = build_kda_conv1d(inp_rs, conv_states_all, conv_state_all, 0, cur, layer.wq, layer.ssm_q_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, il);
ggml_tensor * Kcur = build_kda_conv1d(inp_rs, conv_states_all, conv_state_all, 1, cur, layer.wk, layer.ssm_k_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, il);
ggml_tensor * Vcur = build_kda_conv1d(inp_rs, conv_states_all, conv_state_all, 2, cur, layer.wv, layer.ssm_v_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, il);
cb(Qcur, "kda_q_conv", il);
cb(Kcur, "kda_k_conv", il);
cb(Vcur, "kda_v_conv", il);
+11 -29
View File
@@ -1,6 +1,5 @@
#include "models.h"
#include <algorithm>
#include "llama-memory-recurrent.h"
//
@@ -353,13 +352,11 @@ llama_model_kimi_k3::graph::graph(const llama_model & model, const llm_graph_par
//
// causal conv1d over one of Q/K/V. `qkv` selects which third of the conv state to use
static ggml_tensor * kimi_k3_conv1d(ggml_cgraph * gf, ggml_context * ctx0,
ggml_tensor * conv_states_all, ggml_tensor * conv_state_all,
int64_t qkv, ggml_tensor * x, ggml_tensor * proj_w, ggml_tensor * conv_w,
int64_t d_conv, int64_t head_dim, int64_t n_head,
int64_t n_seq_tokens, int64_t n_seqs, int64_t n_tokens, int64_t kv_head,
int64_t mem_size, int64_t K_rs) {
const int64_t d_inner = head_dim * n_head;
ggml_tensor * llama_model_kimi_k3::graph::build_kda_conv1d(
llm_graph_input_rs * inp_rs, ggml_tensor * conv_states_all, ggml_tensor * conv_state_all,
int64_t qkv, ggml_tensor * x, ggml_tensor * proj_w, ggml_tensor * conv_w,
int64_t d_conv, int64_t head_dim, int64_t n_head_kda, int64_t n_seq_tokens, int64_t n_seqs, int il) {
const int64_t d_inner = head_dim * n_head_kda;
const int64_t conv_state_size = (d_conv - 1) * d_inner;
const int64_t n_embd_r_total = 3 * conv_state_size;
@@ -372,26 +369,15 @@ static ggml_tensor * kimi_k3_conv1d(ggml_cgraph * gf, ggml_context * ctx0,
ggml_tensor * x_3d = ggml_reshape_3d(ctx0, x_proj, d_inner, n_seq_tokens, n_seqs);
ggml_tensor * conv_x = ggml_concat(ctx0, conv_state_x, ggml_transpose(ctx0, x_3d), 0);
// group s holds the conv window s tokens back.
// [TAG_RECURRENT_ROLLBACK_SPLITS]: the last K_rs tokens must share one ubatch.
for (int64_t s = 0; s < K_rs; ++s) {
const int64_t s_idx = std::max<int64_t>(0, n_seq_tokens - s);
ggml_tensor * conv_x_s = ggml_view_3d(ctx0, conv_x, d_conv - 1, d_inner, n_seqs,
conv_x->nb[1], conv_x->nb[2], s_idx * conv_x->nb[0]);
ggml_build_forward_expand(gf,
ggml_cpy(ctx0, conv_x_s,
ggml_view_3d(ctx0, conv_states_all, d_conv - 1, d_inner, n_seqs,
(d_conv - 1) * ggml_element_size(conv_states_all),
n_embd_r_total * ggml_element_size(conv_states_all),
((s * mem_size + kv_head) * n_embd_r_total + qkv * conv_state_size) * ggml_element_size(conv_states_all))));
}
build_conv_state_store(inp_rs, conv_states_all, conv_x, d_conv - 1,
ggml_row_size(conv_states_all->type, qkv * conv_state_size), il);
ggml_tensor * conv_weight = ggml_reshape_2d(ctx0, conv_w, d_conv, d_inner);
ggml_tensor * Xcur = ggml_ssm_conv(ctx0, conv_x, conv_weight);
Xcur = ggml_reshape_2d(ctx0, Xcur, d_inner, n_tokens);
Xcur = ggml_silu(ctx0, Xcur);
return ggml_reshape_4d(ctx0, Xcur, head_dim, n_head, n_seq_tokens, n_seqs);
return ggml_reshape_4d(ctx0, Xcur, head_dim, n_head_kda, n_seq_tokens, n_seqs);
}
ggml_tensor * llama_model_kimi_k3::graph::build_kda_layer(
@@ -400,17 +386,13 @@ ggml_tensor * llama_model_kimi_k3::graph::build_kda_layer(
int64_t d_inner, int64_t n_seq_tokens, int64_t n_seqs, int il) {
const auto * mctx_cur = inp_rs->mctx;
const auto kv_head = mctx_cur->get_head();
ggml_tensor * conv_states_all = mctx_cur->get_r_l(il);
ggml_tensor * conv_state_all = build_rs(inp_rs, conv_states_all, hparams.n_embd_r(), n_seqs);
const int64_t mem_size = mctx_cur->get_size();
const int64_t K_rs = (int64_t) cparams.n_rs_seq + 1;
ggml_tensor * Qcur = kimi_k3_conv1d(gf, ctx0, conv_states_all, conv_state_all, 0, cur, layer.wq, layer.ssm_q_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, n_tokens, kv_head, mem_size, K_rs);
ggml_tensor * Kcur = kimi_k3_conv1d(gf, ctx0, conv_states_all, conv_state_all, 1, cur, layer.wk, layer.ssm_k_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, n_tokens, kv_head, mem_size, K_rs);
ggml_tensor * Vcur = kimi_k3_conv1d(gf, ctx0, conv_states_all, conv_state_all, 2, cur, layer.wv, layer.ssm_v_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, n_tokens, kv_head, mem_size, K_rs);
ggml_tensor * Qcur = build_kda_conv1d(inp_rs, conv_states_all, conv_state_all, 0, cur, layer.wq, layer.ssm_q_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, il);
ggml_tensor * Kcur = build_kda_conv1d(inp_rs, conv_states_all, conv_state_all, 1, cur, layer.wk, layer.ssm_k_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, il);
ggml_tensor * Vcur = build_kda_conv1d(inp_rs, conv_states_all, conv_state_all, 2, cur, layer.wv, layer.ssm_v_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, il);
cb(Qcur, "kda_q_conv", il);
cb(Kcur, "kda_k_conv", il);
cb(Vcur, "kda_v_conv", il);
+1 -17
View File
@@ -2,8 +2,6 @@
#include "llama-memory-recurrent.h"
#include <algorithm>
llm_build_lfm_base::llm_build_lfm_base(const llm_graph_params & params) : llm_graph_context(params) {}
ggml_tensor * llm_build_lfm_base::build_lfm_shortconv(const llama_model & model,
@@ -11,7 +9,6 @@ ggml_tensor * llm_build_lfm_base::build_lfm_shortconv(const llama_model & model,
llm_graph_input_rs * inp_recr,
ggml_tensor * cur,
int il) {
const uint32_t kv_head = mctx_recr->get_head();
const int64_t n_seq_tokens = ubatch.n_seq_tokens;
const int64_t n_seqs = ubatch.n_seqs;
GGML_ASSERT(n_seqs != 0);
@@ -55,20 +52,7 @@ ggml_tensor * llm_build_lfm_base::build_lfm_shortconv(const llama_model & model,
}
GGML_ASSERT(bx->ne[0] > conv->ne[0]);
// write conv states: slot 0 = the final state, slot s = the state s tokens back (partial rollback)
const int64_t K = hparams.causal_attn && cparams.n_rs_seq > 0 ? (int64_t) cparams.n_rs_seq + 1 : 1;
const int64_t n_written = std::min<int64_t>(n_seq_tokens, K);
const auto mem_size = mctx_recr->get_size();
const size_t row_size = ggml_row_size(conv_state->type, (int64_t) d_conv * n_embd);
for (int64_t slot = 0; slot < n_written; ++slot) {
auto * conv_snap = ggml_view_3d(ctx0, bx, d_conv, bx->ne[1], bx->ne[2], bx->nb[1], bx->nb[2],
(bx->ne[0] - d_conv - slot) * ggml_element_size(bx));
ggml_build_forward_expand(gf, ggml_cpy(ctx0, conv_snap,
ggml_view_2d(ctx0, conv_state, (int64_t) d_conv * n_embd, n_seqs,
conv_state->nb[1],
((size_t) slot * mem_size + kv_head) * row_size)));
}
build_conv_state_store(inp_recr, conv_state, bx, d_conv, 0, il);
auto * conv_kernel = model.layers[il].shortconv.conv;
auto * conv_out = ggml_ssm_conv(ctx0, bx, conv_kernel);
+2
View File
@@ -157,6 +157,8 @@ llama_model_lfm3::graph<decision>::graph(const llama_model & model, const llm_gr
ggml_tensor * inp_out_ids = build_inp_out_ids();
for (int il = 0; il < n_layer; ++il) {
res->t_layer_inp[il] = cur;
auto * prev_cur = cur;
cur = build_norm(cur, model.layers[il].attn_norm, NULL, LLM_NORM_RMS, il);
+1 -13
View File
@@ -206,19 +206,7 @@ ggml_tensor * llm_build_mamba_base::build_mamba2_layer(llm_graph_input_rs * inp,
// => {d_conv - 1 + n_seq_tokens, d_inner + 2*n_group*d_state, n_seqs}
ggml_tensor * conv_x = ggml_concat(ctx0, conv, ggml_transpose(ctx0, xBC), 0);
const int64_t row_count = (d_conv - 1) * (d_inner + 2 * n_group * d_state);
const size_t row_size = ggml_row_size(conv_states_all->type, row_count);
const int64_t n_written = std::min<int64_t>(n_seq_tokens, K);
for (int64_t slot = 0; slot < n_written; ++slot) {
ggml_tensor * last_conv = ggml_view_3d(ctx0, conv_x, d_conv - 1, d_inner + 2 * n_group * d_state, n_seqs,
conv_x->nb[1], conv_x->nb[2], (n_seq_tokens - slot) * conv_x->nb[0]);
ggml_build_forward_expand(gf, ggml_cpy(ctx0, last_conv,
ggml_view_2d(ctx0, conv_states_all, row_count, n_seqs,
conv_states_all->nb[1],
((size_t) slot * mem_size + kv_head) * row_size)));
}
build_conv_state_store(inp, conv_states_all, conv_x, d_conv - 1, 0, il);
// 1D convolution
// The equivalent is to make a self-overlapping view of conv_x
+15
View File
@@ -2143,6 +2143,11 @@ struct llama_model_bailingmoe3 : public llama_model_base {
graph(const llama_model & model, const llm_graph_params & params);
const llama_model & model;
// causal conv1d over one of Q/K/V, qkv selects which third of the conv state row is used
ggml_tensor * build_causal_conv1d(llm_graph_input_rs * inp_rs, ggml_tensor * conv_states_all, ggml_tensor * conv_state_all,
int64_t qkv, ggml_tensor * x, ggml_tensor * proj_w, ggml_tensor * conv_w,
int64_t d_conv, int64_t head_dim, int64_t n_head_kda, int64_t n_seq_tokens, int64_t n_seqs, int il);
};
struct graph_mtp : public llm_graph_context {
@@ -3024,6 +3029,11 @@ struct llama_model_kimi_k3 : public llama_model_base {
int64_t d_conv, int64_t head_dim, int64_t n_head_kda,
int64_t d_inner, int64_t n_seq_tokens, int64_t n_seqs, int il);
// causal conv1d over one of Q/K/V, qkv selects which third of the conv state row is used
ggml_tensor * build_kda_conv1d(llm_graph_input_rs * inp_rs, ggml_tensor * conv_states_all, ggml_tensor * conv_state_all,
int64_t qkv, ggml_tensor * x, ggml_tensor * proj_w, ggml_tensor * conv_w,
int64_t d_conv, int64_t head_dim, int64_t n_head_kda, int64_t n_seq_tokens, int64_t n_seqs, int il);
ggml_tensor * build_mla_layer(ggml_tensor * cur, const llama_layer & layer,
llm_graph_input_attn_k * inp_attn_k,
llm_graph_input_attn_kv * inp_attn_kv,
@@ -3096,6 +3106,11 @@ struct llama_model_glm5_next : public llama_model_base {
int64_t d_conv, int64_t head_dim, int64_t n_head_kda,
int64_t d_inner, int64_t n_seq_tokens, int64_t n_seqs, int il);
// causal conv1d over one of Q/K/V, qkv selects which third of the conv state row is used
ggml_tensor * build_kda_conv1d(llm_graph_input_rs * inp_rs, ggml_tensor * conv_states_all, ggml_tensor * conv_state_all,
int64_t qkv, ggml_tensor * x, ggml_tensor * proj_w, ggml_tensor * conv_w,
int64_t d_conv, int64_t head_dim, int64_t n_head_kda, int64_t n_seq_tokens, int64_t n_seqs, int il);
ggml_tensor * build_kpool_select(ggml_tensor * cur, ggml_tensor * qr, const llama_layer & layer,
const llama_memory_hybrid_idx_context * mctx_hyb, llm_graph_input_kpool * inp_kpool, int il);
+1 -26
View File
@@ -1402,10 +1402,6 @@ ggml_tensor * llama_model_qwen4exp::graph::build_conv_state_at(
int64_t state_cols,
int64_t channels,
int il) {
const auto * mctx_cur = inp->mctx;
const auto kv_head = mctx_cur->get_head();
const int64_t n_seqs = ubatch.n_seqs;
const int64_t row_total = conv_states_all->ne[0];
@@ -1423,28 +1419,7 @@ ggml_tensor * llama_model_qwen4exp::graph::build_conv_state_at(
ggml_tensor * conv_input = ggml_concat(ctx0, state, ggml_transpose(ctx0, x), 0);
// [TAG_RECURRENT_ROLLBACK_SPLITS] keep the last state_cols columns once per rollback slot,
// slot s ending s tokens earlier so a rollback of s tokens reads a history that never saw them
const size_t row_size = ggml_row_size(conv_states_all->type, row_total);
const uint32_t mem_size = mctx_cur->get_size();
const int64_t n_slots = (int64_t) cparams.n_rs_seq + 1;
for (int64_t slot = 0; slot < n_slots; ++slot) {
const int64_t s_idx = std::max<int64_t>(0, conv_input->ne[0] - state_cols - slot);
ggml_tensor * tail = ggml_view_3d(ctx0, conv_input,
state_cols, channels, n_seqs,
conv_input->nb[1], conv_input->nb[2],
ggml_row_size(conv_input->type, s_idx));
ggml_tensor * dst = ggml_view_2d(ctx0, conv_states_all,
state_cols * channels, n_seqs,
conv_states_all->nb[1],
(slot * mem_size + kv_head) * row_size);
ggml_build_forward_expand(gf, ggml_cpy(ctx0, ggml_cont(ctx0, tail), dst));
}
build_conv_state_store(inp, conv_states_all, conv_input, state_cols, 0, il);
return conv_input;
}
+22
View File
@@ -148,6 +148,28 @@ static bool test_tensor_n(ggml_backend_buffer_type_t buft) {
}
}
{
uint8_t input[24], expected[128], out[128] = {};
for (size_t i = 0; i < sizeof(input); i++) {
input[i] = (uint8_t) (i + 1);
}
ggml_backend_tensor_set(t_big, input, 0, sizeof(input));
// Verify scattered byte reads, including repeated source offsets.
std::vector<ggml_tensor *> tensors(128, t_big);
std::vector<size_t> offsets(128), sizes(128, 1), data_offsets(128);
for (size_t i = 0; i < tensors.size(); i++) {
offsets[i] = (i * 5) % sizeof(input);
data_offsets[i] = i;
expected[i] = input[offsets[i]];
}
ggml_backend_tensor_get_n(tensors.data(), out, offsets.data(), sizes.data(), data_offsets.data(), tensors.size());
if (memcmp(out, expected, sizeof(out)) != 0) {
return false;
}
}
return true;
}
+9 -1
View File
@@ -279,7 +279,6 @@ static gguf_context_ptr get_gguf_ctx(const llm_arch arch, const bool moe) {
|| arch == LLM_ARCH_KIMI_LINEAR
|| arch == LLM_ARCH_BAILINGMOE3
|| arch == LLM_ARCH_KIMI_K3
|| arch == LLM_ARCH_GLM5_NEXT
|| arch == LLM_ARCH_MISTRAL4
|| arch == LLM_ARCH_MISTRAL_LARGE4
|| arch == LLM_ARCH_HY_V4) {
@@ -289,6 +288,11 @@ static gguf_context_ptr get_gguf_ctx(const llm_arch arch, const bool moe) {
if (arch == LLM_ARCH_MISTRAL_LARGE4) {
n_layer = 4; // cover NoPE, shared indexer and KV sharing layers
}
} else if (arch == LLM_ARCH_GLM5_NEXT) {
// several heads so that -sm tensor can split them and the MLA flash attention has GQA > 1
n_embd = 128;
n_head = 16;
n_ff = 192;
} else if (arch == LLM_ARCH_NEMOTRON_H || arch == LLM_ARCH_NEMOTRON_H_MOE) {
n_layer = 3;
} else if (arch == LLM_ARCH_CHAMELEON) {
@@ -357,6 +361,10 @@ static gguf_context_ptr get_gguf_ctx(const llm_arch arch, const bool moe) {
// GLM5 next KDA heads come from the uniform head count, only head_count_kv is per layer.
if (arch == LLM_ARCH_GLM5_NEXT) {
ms.add_kv(LLM_KV_ATTENTION_HEAD_COUNT, n_head);
// the MLA cache holds one latent head
for (uint32_t & n : n_head_per_layer) {
n = std::min(n, uint32_t(1));
}
} else {
ms.add_kv(LLM_KV_ATTENTION_HEAD_COUNT, n_head_per_layer);
}
+19
View File
@@ -454,6 +454,17 @@ static void gguf_merge(const split_params & split_params) {
ctx_ggufs.push_back(ctx_gguf);
ctx_metas.push_back(ctx_meta);
// Verify that this split belongs to the same set as the first one
if (i_split > 0) {
auto key_split_no = gguf_find_key(ctx_gguf, LLM_KV_SPLIT_NO);
auto key_split_count = gguf_find_key(ctx_gguf, LLM_KV_SPLIT_COUNT);
if (key_split_no < 0 || gguf_get_val_u16(ctx_gguf, key_split_no) != i_split ||
key_split_count < 0 || gguf_get_val_u16(ctx_gguf, key_split_count) != n_split) {
fprintf(stderr, "\n%s: %s is not split %d of %d\n", __func__, split_path, i_split + 1, n_split);
exit(EXIT_FAILURE);
}
}
if (i_split == 0) {
auto key_n_split = gguf_find_key(ctx_gguf, LLM_KV_SPLIT_COUNT);
if (key_n_split < 0) {
@@ -508,6 +519,14 @@ static void gguf_merge(const split_params & split_params) {
fprintf(stderr, "\033[3Ddone\n");
}
// Verify that no tensor is missing
auto key_n_tensors = gguf_find_key(ctx_ggufs[0], LLM_KV_SPLIT_TENSORS_COUNT);
if (key_n_tensors < 0 || gguf_get_val_i32(ctx_ggufs[0], key_n_tensors) != total_tensors) {
fprintf(stderr, "%s: splits contain %d tensors, which does not match %s\n", __func__, total_tensors, LLM_KV_SPLIT_TENSORS_COUNT);
exit(EXIT_FAILURE);
}
std::ofstream fout;
if (!split_params.dry_run) {
fout.open(split_params.output.c_str(), std::ios::binary);
+1
View File
@@ -47,6 +47,7 @@ add_library(mtmd
models/kimik25.cpp
models/nemotron-v2-vl.cpp
models/nemotron-omni.cpp
models/nemotron-transcribe.cpp
models/muse-glimmer.cpp
models/muse-spark-audio.cpp
models/llama4.cpp
+19
View File
@@ -113,6 +113,11 @@
#define KEY_GEN_AUDIO_PENALTY_LAST_N "clip.gen.audio.sampling.penalty_last_n"
#define KEY_AUDIO_SUBSMPL_FACTOR "clip.audio.subsampling_factor"
// nemotron transcribe
#define KEY_A_ROPE_DIMS "clip.audio.rope_dims"
#define KEY_A_SPK_N_BLOCK "clip.audio.spk.block_count"
#define KEY_A_SPK_N_HEAD "clip.audio.spk.head_count"
//
// tensor name constants
//
@@ -469,6 +474,13 @@
#define TN_CONV_NORM_MEAN "%s.blk.%d.conv_norm_mean"
#define TN_CONV_NORM_VAR "%s.blk.%d.conv_norm_var"
// nemotron transcribe
#define TN_A_SPK_INP_PROJ "a.spk.input_projection.%s"
#define TN_A_SPK_HEAD "a.spk.head.%s.%s" // proj, upsample, fc1, fc2
#define TN_A_SPK_SIL_EMB "a.spk.head.sil_emb"
#define TN_A_FUSION_NORM "a.fusion.%s.%s" // asr_norm, spk_norm
#define TN_A_FUSION_KERNEL "a.fusion.spk_kernel"
// align x to upper multiple of n
#define CLIP_ALIGN(x, n) ((((x) + (n) - 1) / (n)) * (n))
@@ -487,6 +499,11 @@ struct clip_enc_state {
// lfm3a: same order as list_lfm3a_state_slots(), F32 [ne0, ne1], updated in place
std::vector<ggml_tensor *> slots;
// nemotron transcribe: the window's normalized log-mel, cache and FIFO rows in; its speaker rows and predictions out
std::vector<float> mel_norm;
std::vector<float> spk_prev;
std::vector<float> spk_chunk, spk_preds;
};
// per-sequence state of audio generation, see clip_gen_state_init()
@@ -567,6 +584,7 @@ enum projector_type {
PROJECTOR_TYPE_MINICPMV4_7,
PROJECTOR_TYPE_MINICPM_OCR_1_0,
PROJECTOR_TYPE_GRANITE_SPEECH,
PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE,
PROJECTOR_TYPE_MIMOVL,
PROJECTOR_TYPE_MINIMAX_M3,
PROJECTOR_TYPE_GRANITE4_VISION,
@@ -647,6 +665,7 @@ static std::map<projector_type, std::string> PROJECTOR_TYPE_NAMES = {
{ PROJECTOR_TYPE_MINICPMV4_7, "minicpmv4_7"},
{ PROJECTOR_TYPE_MINICPM_OCR_1_0, "minicpm_ocr_1_0"},
{ PROJECTOR_TYPE_GRANITE_SPEECH, "granite_speech"},
{ PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE, "nemotron_transcribe"},
{ PROJECTOR_TYPE_MIMOVL, "mimovl"},
{ PROJECTOR_TYPE_MINIMAX_M3, "minimax_m3"},
{ PROJECTOR_TYPE_GRANITE4_VISION, "granite4_vision"},
+30
View File
@@ -165,6 +165,12 @@ struct clip_hparams {
std::vector<float> mel_filters;
std::vector<float> window;
// nemotron transcribe
int32_t audio_rope_dims = 0;
int32_t spk_n_embd = 0;
int32_t spk_n_layer = 0;
int32_t spk_n_head = 0;
// mimo-audio-tokenizer: residual vector quantizer
int32_t rvq_num_quantizers = 0;
std::vector<int32_t> rvq_codebook_size; // per-quantizer bin count (ragged, e.g. 1024/1024/256/128x17)
@@ -292,6 +298,8 @@ struct clip_layer {
ggml_tensor * k_norm = nullptr;
ggml_tensor * q_norm = nullptr;
ggml_tensor * k_norm_b = nullptr;
ggml_tensor * q_norm_b = nullptr;
// lfm3: per-head sigmoid gate on the attention output
ggml_tensor * attn_gate_w = nullptr;
@@ -926,6 +934,28 @@ struct clip_model {
std::vector<clip_layer> sam_layers;
// nemotron transcribe: speaker expert, Sortformer head, fusion
std::vector<clip_layer> spk_layers;
ggml_tensor * spk_inp_proj_w = nullptr;
ggml_tensor * spk_pre_ln_w = nullptr;
ggml_tensor * spk_pre_ln_b = nullptr;
ggml_tensor * spk_post_ln_w = nullptr;
ggml_tensor * spk_post_ln_b = nullptr;
ggml_tensor * spk_head_proj_w = nullptr;
ggml_tensor * spk_head_proj_b = nullptr;
ggml_tensor * spk_head_up_w = nullptr;
ggml_tensor * spk_head_up_b = nullptr;
ggml_tensor * spk_head_fc1_w = nullptr;
ggml_tensor * spk_head_fc1_b = nullptr;
ggml_tensor * spk_head_fc2_w = nullptr;
ggml_tensor * spk_head_fc2_b = nullptr;
ggml_tensor * fusion_asr_norm_w = nullptr;
ggml_tensor * fusion_asr_norm_b = nullptr;
ggml_tensor * fusion_spk_norm_w = nullptr;
ggml_tensor * fusion_spk_norm_b = nullptr;
ggml_tensor * fusion_spk_kernel = nullptr;
std::vector<float> spk_sil_emb;
// deepseek-ocr-2
ggml_tensor * resample_query_768 = nullptr;
ggml_tensor * resample_query_1024 = nullptr;
+140 -6
View File
@@ -405,13 +405,13 @@ ggml_tensor * clip_graph::build_vit(
if (layer.q_norm) {
GGML_ASSERT(layer.q_norm->ne[0] == Qcur->ne[0]);
Qcur = build_norm(Qcur, layer.q_norm, NULL, norm_t, eps, il);
Qcur = build_norm(Qcur, layer.q_norm, layer.q_norm_b, norm_t, eps, il);
cb(Qcur, "Qcur_norm", il);
}
if (layer.k_norm) {
GGML_ASSERT(layer.k_norm->ne[0] == Kcur->ne[0]);
Kcur = build_norm(Kcur, layer.k_norm, NULL, norm_t, eps, il);
Kcur = build_norm(Kcur, layer.k_norm, layer.k_norm_b, norm_t, eps, il);
cb(Kcur, "Kcur_norm", il);
}
@@ -437,11 +437,11 @@ ggml_tensor * clip_graph::build_vit(
if (!norm_per_head) {
if (layer.q_norm) {
Qcur = build_norm(Qcur, layer.q_norm, NULL, norm_t, eps, il);
Qcur = build_norm(Qcur, layer.q_norm, layer.q_norm_b, norm_t, eps, il);
cb(Qcur, "Qcur_norm", il);
}
if (layer.k_norm) {
Kcur = build_norm(Kcur, layer.k_norm, NULL, norm_t, eps, il);
Kcur = build_norm(Kcur, layer.k_norm, layer.k_norm_b, norm_t, eps, il);
cb(Kcur, "Kcur_norm", il);
}
}
@@ -452,11 +452,11 @@ ggml_tensor * clip_graph::build_vit(
if (norm_per_head) {
if (layer.q_norm) {
Qcur = build_norm(Qcur, layer.q_norm, NULL, norm_t, eps, il);
Qcur = build_norm(Qcur, layer.q_norm, layer.q_norm_b, norm_t, eps, il);
cb(Qcur, "Qcur_norm_per_head", il);
}
if (layer.k_norm) {
Kcur = build_norm(Kcur, layer.k_norm, NULL, norm_t, eps, il);
Kcur = build_norm(Kcur, layer.k_norm, layer.k_norm_b, norm_t, eps, il);
cb(Kcur, "Kcur_norm_per_head", il);
}
}
@@ -1242,6 +1242,10 @@ static std::unique_ptr<clip_graph> clip_get_graph_builder(clip_ctx * ctx, const
{
builder = std::make_unique<clip_graph_parakeet>(ctx, img);
} break;
case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
{
builder = std::make_unique<clip_graph_nemotron_transcribe>(ctx, img, params ? params->enc_state : nullptr);
} break;
case PROJECTOR_TYPE_GRANITE4_VISION:
{
builder = std::make_unique<clip_graph_granite4_vision>(ctx, img);
@@ -1623,6 +1627,27 @@ struct clip_model_loader {
hparams.audio_window_len = 400;
hparams.audio_hop_len = 160;
} break;
case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
{
get_u32(KEY_AUDIO_SUBSMPL_FACTOR, hparams.subsampling_factor);
get_u32(KEY_A_ROPE_DIMS, hparams.audio_rope_dims);
get_u32(KEY_A_SPK_N_BLOCK, hparams.spk_n_layer);
get_u32(KEY_A_SPK_N_HEAD, hparams.spk_n_head);
if (hparams.subsampling_factor != 8) {
throw std::runtime_error(string_format("%s: unsupported subsampling factor %d\n", __func__, hparams.subsampling_factor));
}
if (hparams.spk_n_layer <= 0 || hparams.spk_n_layer > (int) gguf_get_n_tensors(ctx_gguf.get())) {
throw std::runtime_error(string_format("%s: invalid speaker block count %d\n", __func__, hparams.spk_n_layer));
}
hparams.audio_chunk_len = 0;
hparams.audio_sample_rate = 16000;
hparams.audio_n_fft = 512;
hparams.audio_window_len = 400;
hparams.audio_hop_len = 160;
// one full window
using nt = clip_graph_nemotron_transcribe;
hparams.warmup_audio_size = (nt::window_left + nt::window_core + nt::window_right) * hparams.subsampling_factor;
} break;
case PROJECTOR_TYPE_IDEFICS3:
{
// use default llava-uhd preprocessing params
@@ -4073,6 +4098,87 @@ struct clip_model_loader {
model.mm_model_mlp_2_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 1, "weight"));
model.mm_model_mlp_3_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 3, "weight"));
} break;
case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
{
hparams.mel_filters = get_vector(TN_MEL_FILTERS);
hparams.window = get_vector(TN_WINDOW);
// ASR expert: blocks, pre_ln and post_ln come from the generic loader
model.inp_proj_w = get_tensor(string_format(TN_INP_PROJ, "weight"));
for (int il = 0; il < hparams.n_layer; ++il) {
auto & layer = model.layers[il];
layer.q_norm_b = get_tensor(string_format(TN_ATTN_Q_NORM, prefix, il, "bias"));
layer.k_norm_b = get_tensor(string_format(TN_ATTN_K_NORM, prefix, il, "bias"));
}
// speaker expert
const char * sp = "a.spk";
model.spk_inp_proj_w = get_tensor(string_format(TN_A_SPK_INP_PROJ, "weight"));
model.spk_pre_ln_w = get_tensor(string_format(TN_LN_PRE, sp, "weight"));
model.spk_pre_ln_b = get_tensor(string_format(TN_LN_PRE, sp, "bias"));
model.spk_post_ln_w = get_tensor(string_format(TN_LN_POST, sp, "weight"));
model.spk_post_ln_b = get_tensor(string_format(TN_LN_POST, sp, "bias"));
model.spk_layers.resize(hparams.spk_n_layer);
for (int il = 0; il < hparams.spk_n_layer; ++il) {
auto & layer = model.spk_layers[il];
layer.ln_1_w = get_tensor(string_format(TN_LN_1, sp, il, "weight"));
layer.ln_1_b = get_tensor(string_format(TN_LN_1, sp, il, "bias"));
layer.qkv_w = get_tensor(string_format(TN_ATTN_QKV, sp, il, "weight"));
layer.o_w = get_tensor(string_format(TN_ATTN_OUTPUT, sp, il, "weight"));
layer.o_b = get_tensor(string_format(TN_ATTN_OUTPUT, sp, il, "bias"));
layer.ln_2_w = get_tensor(string_format(TN_LN_2, sp, il, "weight"));
layer.ln_2_b = get_tensor(string_format(TN_LN_2, sp, il, "bias"));
layer.ff_up_w = get_tensor(string_format(TN_FFN_UP, sp, il, "weight"));
layer.ff_up_b = get_tensor(string_format(TN_FFN_UP, sp, il, "bias"));
layer.ff_down_w = get_tensor(string_format(TN_FFN_DOWN, sp, il, "weight"));
layer.ff_down_b = get_tensor(string_format(TN_FFN_DOWN, sp, il, "bias"));
}
// Sortformer head, fusion
model.spk_head_proj_w = get_tensor(string_format(TN_A_SPK_HEAD, "proj", "weight"));
model.spk_head_proj_b = get_tensor(string_format(TN_A_SPK_HEAD, "proj", "bias"));
model.spk_head_up_w = get_tensor(string_format(TN_A_SPK_HEAD, "upsample", "weight"));
model.spk_head_up_b = get_tensor(string_format(TN_A_SPK_HEAD, "upsample", "bias"));
model.spk_head_fc1_w = get_tensor(string_format(TN_A_SPK_HEAD, "fc1", "weight"));
model.spk_head_fc1_b = get_tensor(string_format(TN_A_SPK_HEAD, "fc1", "bias"));
model.spk_head_fc2_w = get_tensor(string_format(TN_A_SPK_HEAD, "fc2", "weight"));
model.spk_head_fc2_b = get_tensor(string_format(TN_A_SPK_HEAD, "fc2", "bias"));
model.fusion_asr_norm_w = get_tensor(string_format(TN_A_FUSION_NORM, "asr_norm", "weight"));
model.fusion_asr_norm_b = get_tensor(string_format(TN_A_FUSION_NORM, "asr_norm", "bias"));
model.fusion_spk_norm_w = get_tensor(string_format(TN_A_FUSION_NORM, "spk_norm", "weight"));
model.fusion_spk_norm_b = get_tensor(string_format(TN_A_FUSION_NORM, "spk_norm", "bias"));
model.fusion_spk_kernel = get_tensor(TN_A_FUSION_KERNEL);
model.spk_sil_emb = get_vector(TN_A_SPK_SIL_EMB);
// the head layouts and the widths the graph and the speaker state use must match the tensors
hparams.spk_n_embd = model.spk_inp_proj_w->ne[1];
const int64_t d_head = hparams.n_head > 0 ? hparams.n_embd / hparams.n_head : 0;
const int64_t d_head_spk = hparams.spk_n_head > 0 ? hparams.spk_n_embd / hparams.spk_n_head : 0;
const int64_t n_spk = model.spk_head_fc2_w->ne[1];
// the graph splits each fused QKV into three views of the layer width
bool qkv_ok = true;
for (const auto & layer : model.layers) {
qkv_ok = qkv_ok && layer.qkv_w && layer.qkv_w->ne[0] == hparams.n_embd && layer.qkv_w->ne[1] == 3 * hparams.n_embd;
}
for (const auto & layer : model.spk_layers) {
qkv_ok = qkv_ok && layer.qkv_w->ne[0] == hparams.spk_n_embd && layer.qkv_w->ne[1] == 3 * hparams.spk_n_embd;
}
if (!qkv_ok || model.inp_proj_w->ne[1] != hparams.n_embd || d_head <= 0 || d_head * hparams.n_head != hparams.n_embd ||
(hparams.n_embd_head > 0 && hparams.n_embd_head != d_head) ||
hparams.audio_rope_dims <= 0 || hparams.audio_rope_dims % 2 != 0 || hparams.audio_rope_dims > d_head ||
d_head_spk <= 0 || d_head_spk % 2 != 0 || d_head_spk * hparams.spk_n_head != hparams.spk_n_embd ||
(int64_t) model.spk_sil_emb.size() != hparams.spk_n_embd || n_spk <= 0 ||
model.fusion_spk_kernel->ne[0] != n_spk || model.fusion_spk_kernel->ne[1] != hparams.n_embd ||
hparams.mel_filters.size() != (size_t) hparams.n_mel_bins * (hparams.audio_n_fft / 2 + 1) ||
hparams.window.size() != (size_t) hparams.audio_window_len) {
throw std::runtime_error(string_format("%s: hparams do not match the tensor shapes\n", __func__));
}
// projection to the text model
model.mm_fc_w = get_tensor(string_format(TN_MM_AUDIO_FC, "weight"));
model.mm_fc_b = get_tensor(string_format(TN_MM_AUDIO_FC, "bias"));
} break;
case PROJECTOR_TYPE_GRANITE_SPEECH:
{
model.inp_proj_w = get_tensor(string_format(TN_INP_PROJ, "weight"));
@@ -5013,6 +5119,7 @@ int clip_n_output_tokens(const clip_ctx * ctx, const clip_image_f32 * img) {
}
n_patches = n;
} break;
case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
case PROJECTOR_TYPE_PARAKEET:
{
n_patches = (img->nx() + (params.subsampling_factor - 1)) / params.subsampling_factor;
@@ -5100,6 +5207,9 @@ bool clip_image_batch_encode(clip_ctx * ctx, int n_threads, const clip_image_f32
params.n_threads = n_threads;
params.out_embd = &out_batch_embd;
if (ctx->proj_type() == PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE) {
return clip_graph_nemotron_transcribe::encode_windows(ctx, ctx->model, &params);
}
return clip_encode(ctx, &params);
}
@@ -6593,6 +6703,20 @@ bool clip_encode(struct clip_ctx * ctx, struct clip_encode_params * params) {
set_lfm3a_window_inputs(base, n_pos, hparams.attn_window_size);
} break;
case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
{
GGML_ASSERT(imgs.entries.size() == 1);
std::vector<int32_t> positions(ggml_nelements(get_inp_tensor("positions")));
for (size_t i = 0; i < positions.size(); i++) {
positions[i] = (int32_t) i;
}
set_input_i32("positions", positions);
GGML_ASSERT(params->enc_state != nullptr);
set_input_f32("inp_norm", params->enc_state->mel_norm);
if (ggml_graph_get_tensor(gf, "spk_prev") != nullptr) {
set_input_f32("spk_prev", params->enc_state->spk_prev);
}
} break;
case PROJECTOR_TYPE_PARAKEET:
{
GGML_ASSERT(imgs.entries.size() == 1);
@@ -6899,6 +7023,15 @@ bool clip_encode(struct clip_ctx * ctx, struct clip_encode_params * params) {
GGML_ASSERT(t != nullptr);
copy_gen_state_out(t, slots[i++]);
}
} else if (params->enc_state != nullptr && model.proj_type == PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE) {
clip_enc_state * st = params->enc_state;
ggml_tensor * chunk = ggml_graph_get_tensor(gf, "spk_chunk");
ggml_tensor * preds = ggml_graph_get_tensor(gf, "spk_preds");
GGML_ASSERT(chunk != nullptr && preds != nullptr);
st->spk_chunk.resize(ggml_nelements(chunk));
st->spk_preds.resize(ggml_nelements(preds));
ggml_backend_tensor_get(chunk, st->spk_chunk.data(), 0, ggml_nbytes(chunk));
ggml_backend_tensor_get(preds, st->spk_preds.data(), 0, ggml_nbytes(preds));
} else if (params->enc_state != nullptr) {
clip_enc_state * st = params->enc_state;
ggml_tensor * tail = ggml_graph_get_tensor(gf, "out_stem_tail");
@@ -7057,6 +7190,7 @@ int clip_n_mmproj_embd(const struct clip_ctx * ctx) {
case PROJECTOR_TYPE_LLAMA4:
return ctx->model.mm_model_proj->ne[1];
case PROJECTOR_TYPE_QWEN2A:
case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
return ctx->model.mm_fc_w->ne[1];
case PROJECTOR_TYPE_QWEN3A:
return ctx->model.mm_2_w->ne[1];
+16
View File
@@ -30,6 +30,22 @@ struct clip_graph_gemma4uv : clip_graph {
ggml_cgraph * build() override;
};
struct clip_graph_nemotron_transcribe : clip_graph {
const clip_enc_state * state; // nullptr during warmup
clip_graph_nemotron_transcribe(clip_ctx * ctx, const clip_image_f32 & img, const clip_enc_state * state)
: clip_graph(ctx, img), state(state) {}
ggml_cgraph * build() override;
ggml_tensor * build_spk_encoder(ggml_tensor * cur, ggml_tensor * pos);
// one recording: encodes its windows in turn, carrying the speaker state between them
static bool encode_windows(clip_ctx * ctx, const clip_model & model, clip_encode_params * params);
// NeMo ParallelExpertEncoder online defaults, in output frames: 40 s windows with 4 s of context on each side
static constexpr int window_core = 500;
static constexpr int window_left = 50;
static constexpr int window_right = 50;
};
struct clip_graph_pixtral : clip_graph {
clip_graph_pixtral(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
ggml_cgraph * build() override;
+343
View File
@@ -0,0 +1,343 @@
#include "models.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <numeric>
// NeMo ParallelExpertEncoder: ASR and speaker (Sortformer) transformers, speaker activity fused into the ASR states
// a speaker is active in an 80 ms frame where its prediction exceeds this (speaker_activity_threshold)
static constexpr float SPK_ACTIVITY_THR = 0.5f;
// NeMo streaming Sortformer: PEE online defaults and the checkpoint's sortformer_modules config
static constexpr int SPK_FIFO_LEN = 40;
static constexpr int SPK_CACHE_LEN = 188;
static constexpr int SPK_SIL_FRAMES = 1;
static constexpr float SPK_PRED_SCORE_THR = 0.25f;
static constexpr float SPK_BOOST_LATEST = 0.05f;
static constexpr float SPK_STRONG_BOOST_RATE = 0.75f;
static constexpr float SPK_WEAK_BOOST_RATE = 1.5f;
static constexpr float SPK_MIN_POS_RATE = 0.5f;
// _compress_spkcache runs here on exp(score): its additive log terms become factors and -inf becomes 0
static constexpr float SPK_SCORE_OFFSET = 2.0f; // -log(0.5) in _get_log_pred_scores
static constexpr float SPK_STRONG_BOOST = 4.0f; // _boost_topk_scores, scale_factor 2
static constexpr float SPK_WEAK_BOOST = 2.0f; // _boost_topk_scores, scale_factor 1
static constexpr float SPK_SPEECH_THR = 0.5f; // _disable_low_scores: a speaker's frames at or below it are not cached
// the speaker expert has its own layers, width and head count, which build_vit does not take; full rotary
ggml_tensor * clip_graph_nemotron_transcribe::build_spk_encoder(ggml_tensor * cur, ggml_tensor * pos) {
const int n_head = hparams.spk_n_head;
const int64_t n_embd = cur->ne[0];
const int64_t n_pos = cur->ne[1];
const int64_t d_head = n_embd / n_head;
cur = build_norm(cur, model.spk_pre_ln_w, model.spk_pre_ln_b, NORM_TYPE_NORMAL, eps, -1);
for (int il = 0; il < (int) model.spk_layers.size(); il++) {
const auto & layer = model.spk_layers[il];
ggml_tensor * x = build_norm(cur, layer.ln_1_w, layer.ln_1_b, NORM_TYPE_NORMAL, eps, il);
ggml_tensor * qkv = build_mm(layer.qkv_w, x);
const size_t es = ggml_element_size(qkv);
ggml_tensor * q = ggml_view_3d(ctx0, qkv, d_head, n_head, n_pos, d_head * es, qkv->nb[1], 0);
ggml_tensor * k = ggml_view_3d(ctx0, qkv, d_head, n_head, n_pos, d_head * es, qkv->nb[1], n_embd * es);
ggml_tensor * v = ggml_view_3d(ctx0, qkv, d_head, n_head, n_pos, d_head * es, qkv->nb[1], 2 * n_embd * es);
q = ggml_rope_ext(ctx0, q, pos, nullptr, d_head, GGML_ROPE_TYPE_NEOX, 0, 10000.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f);
k = ggml_rope_ext(ctx0, k, pos, nullptr, d_head, GGML_ROPE_TYPE_NEOX, 0, 10000.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f);
x = build_attn(layer.o_w, layer.o_b, q, k, v, nullptr, 1.0f / sqrtf((float) d_head), il);
cur = ggml_add(ctx0, cur, x);
x = build_norm(cur, layer.ln_2_w, layer.ln_2_b, NORM_TYPE_NORMAL, eps, il);
x = build_ffn(x, layer.ff_up_w, layer.ff_up_b, nullptr, nullptr, layer.ff_down_w, layer.ff_down_b, FFN_GELU_ERF, il);
cur = ggml_add(ctx0, cur, x);
}
return build_norm(cur, model.spk_post_ln_w, model.spk_post_ln_b, NORM_TYPE_NORMAL, eps, -1);
}
ggml_cgraph * clip_graph_nemotron_transcribe::build() {
const int sub = hparams.subsampling_factor;
const int64_t n_mel = hparams.n_mel_bins;
const int64_t n_mel_frames = img.nx();
const int64_t n_pos = (n_mel_frames + sub - 1) / sub;
GGML_ASSERT(img.ny() == n_mel);
// warmup: the largest graph, with a full speaker cache and FIFO
const int64_t n_prev = state == nullptr ? SPK_CACHE_LEN + SPK_FIFO_LEN
: (int64_t) state->spk_prev.size() / hparams.spk_n_embd;
// [n_frames, n_mel] log-mel: raw for the speaker expert, normalized over the recording (encode_windows) for the ASR one
ggml_tensor * inp = build_inp_raw(1);
ggml_tensor * inp_norm = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, n_mel_frames, n_mel);
ggml_set_name(inp_norm, "inp_norm");
ggml_set_input(inp_norm);
auto stack_mel = [&](ggml_tensor * mel) {
return build_stack(ggml_cont(ctx0, ggml_transpose(ctx0, mel)), sub, n_mel);
};
// positions over [cache | FIFO | window]; the ASR expert takes the first n_pos
ggml_tensor * pos = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, n_prev + n_pos);
ggml_set_name(pos, "positions");
ggml_set_input(pos);
ggml_tensor * spk_prev = nullptr;
if (n_prev > 0) {
spk_prev = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, hparams.spk_n_embd, n_prev);
ggml_set_name(spk_prev, "spk_prev");
ggml_set_input(spk_prev);
}
ggml_tensor * asr_pos = ggml_view_1d(ctx0, pos, n_pos, 0);
auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
return ggml_rope_ext(ctx0, cur, asr_pos, nullptr, hparams.audio_rope_dims, GGML_ROPE_TYPE_NEOX, 0, 10000.0f, 1.0f,
0.0f, 1.0f, 0.0f, 0.0f);
};
ggml_tensor * asr = build_vit(build_mm(model.inp_proj_w, stack_mel(inp_norm)), n_pos, NORM_TYPE_NORMAL, FFN_GELU_ERF, nullptr,
add_pos);
cb(asr, "asr_out", -1);
// speaker expert over [cache | FIFO | window]; the window's rows and predictions feed the next window's state
ggml_tensor * chunk = build_mm(model.spk_inp_proj_w, stack_mel(inp));
ggml_set_name(chunk, "spk_chunk");
ggml_set_output(chunk);
ggml_tensor * spk = spk_prev ? ggml_concat(ctx0, spk_prev, chunk, 1) : chunk;
const int64_t n_spk_pos = n_prev + n_pos;
spk = build_spk_encoder(spk, pos);
cb(spk, "spk_out", -1);
// Sortformer head, 10 ms frames
ggml_tensor * cur = ggml_add(ctx0, build_mm(model.spk_head_proj_w, spk), model.spk_head_proj_b);
const int64_t d_tf = cur->ne[0];
cur = ggml_cont(ctx0, ggml_transpose(ctx0, cur));
cur = ggml_conv_1d(ctx0, model.spk_head_up_w, cur, 1, 1, 1); // [n_spk_pos, sub*d_tf]
cur = ggml_add(ctx0, cur, ggml_reshape_2d(ctx0, model.spk_head_up_b, 1, sub * d_tf));
cur = ggml_cont(ctx0, ggml_transpose(ctx0, cur));
cur = ggml_reshape_2d(ctx0, cur, d_tf, sub * n_spk_pos);
cur = ggml_add(ctx0, build_mm(model.spk_head_fc1_w, ggml_relu(ctx0, cur)), model.spk_head_fc1_b);
cur = ggml_add(ctx0, build_mm(model.spk_head_fc2_w, ggml_relu(ctx0, cur)), model.spk_head_fc2_b);
ggml_tensor * probs = ggml_sigmoid(ctx0, cur); // [n_spk, sub*n_spk_pos]
cb(probs, "spk_probs", -1);
// 80 ms predictions
ggml_tensor * preds = ggml_pool_2d(ctx0, probs, GGML_OP_POOL_AVG, 1, sub, 1, sub, 0, 0); // [n_spk, n_spk_pos]
ggml_set_name(preds, "spk_preds");
ggml_set_output(preds);
// fusion, on the window's rows
preds = ggml_view_2d(ctx0, preds, preds->ne[0], n_pos, preds->nb[1], n_prev * preds->nb[1]);
ggml_tensor * act = ggml_step(ctx0, ggml_scale_bias(ctx0, preds, 1.0f, -SPK_ACTIVITY_THR));
act = build_norm(act, model.fusion_spk_norm_w, model.fusion_spk_norm_b, NORM_TYPE_NORMAL, eps, -1);
cur = build_norm(asr, model.fusion_asr_norm_w, model.fusion_asr_norm_b, NORM_TYPE_NORMAL, eps, -1);
cur = ggml_add(ctx0, cur, build_mm(model.fusion_spk_kernel, act));
cb(cur, "fused", -1);
cur = ggml_add(ctx0, build_mm(model.mm_fc_w, cur), model.mm_fc_b);
cb(cur, "projected", -1);
ggml_build_forward_expand(gf, cur);
return gf;
}
// indices of the k largest of v[0, n), ties to the lower index
static std::vector<int> spk_topk(const float * v, int n, int k) {
std::vector<int> idx(n);
std::iota(idx.begin(), idx.end(), 0);
k = std::clamp(k, 0, n);
std::partial_sort(idx.begin(), idx.begin() + k, idx.end(), [&](int a, int b) {
return v[a] > v[b] || (v[a] == v[b] && a < b);
});
idx.resize(k);
return idx;
}
// NeMo SortformerModules._compress_spkcache: keep the SPK_CACHE_LEN highest-scoring rows, speaker by speaker
static void spk_compress_cache(std::vector<float> & emb, std::vector<float> & preds, int n, int d, int n_spk,
const std::vector<float> & sil_emb) {
const int per_spk = std::max(0, SPK_CACHE_LEN / n_spk - SPK_SIL_FRAMES);
const int strong = (int) std::floor(per_spk * SPK_STRONG_BOOST_RATE);
const int weak = (int) std::floor(per_spk * SPK_WEAK_BOOST_RATE);
const int min_pos = (int) std::floor(per_spk * SPK_MIN_POS_RATE);
const float disabled = 0.0f;
const float latest = std::exp(SPK_BOOST_LATEST);
// speaker-major: each speaker's n rows, then its silence rows (+inf)
const int nt = n + SPK_SIL_FRAMES;
std::vector<float> scores((size_t) n_spk * nt, std::numeric_limits<float>::infinity());
for (int s = 0; s < n_spk; s++) {
float * sc = scores.data() + (size_t) s * nt;
int n_pos = 0;
for (int t = 0; t < n; t++) {
const float * pt = preds.data() + (size_t) t * n_spk;
float prod = 1.0f;
for (int j = 0; j < n_spk; j++) {
if (j != s) {
prod *= std::max(1.0f - pt[j], SPK_PRED_SCORE_THR);
}
}
prod *= std::max(pt[s], SPK_PRED_SCORE_THR);
sc[t] = pt[s] > SPK_SPEECH_THR ? prod * SPK_SCORE_OFFSET : disabled;
n_pos += sc[t] > 1.0f;
}
if (n_pos >= min_pos) {
for (int t = 0; t < n; t++) {
if (!(sc[t] > 1.0f)) {
sc[t] = disabled;
}
}
}
for (int t = SPK_CACHE_LEN; t < n; t++) {
sc[t] *= latest;
}
for (int t : spk_topk(sc, n, strong)) {
sc[t] *= SPK_STRONG_BOOST;
}
for (int t : spk_topk(sc, n, weak)) {
sc[t] *= SPK_WEAK_BOOST;
}
}
std::vector<int> pick = spk_topk(scores.data(), (int) scores.size(), SPK_CACHE_LEN);
const int placeholder = (int) scores.size();
for (int & i : pick) {
if (scores[i] == disabled) {
i = placeholder;
}
}
std::sort(pick.begin(), pick.end());
std::vector<float> out_emb((size_t) SPK_CACHE_LEN * d);
std::vector<float> out_preds((size_t) SPK_CACHE_LEN * n_spk, 0.0f);
for (int i = 0; i < SPK_CACHE_LEN; i++) {
const int t = pick[i] == placeholder ? -1 : pick[i] % nt;
float * e = out_emb.data() + (size_t) i * d;
if (t < 0 || t >= n) {
std::copy(sil_emb.begin(), sil_emb.end(), e);
} else {
std::copy_n(emb.data() + (size_t) t * d, d, e);
std::copy_n(preds.data() + (size_t) t * n_spk, n_spk, out_preds.data() + (size_t) i * n_spk);
}
}
emb.swap(out_emb);
preds.swap(out_preds);
}
// speaker cache, with its predictions, and FIFO of one recording
struct spk_stream {
std::vector<float> cache, cache_preds;
std::vector<float> fifo;
};
// every update has a full window, which overflows the FIFO and the cache (NeMo's update period of 300 never applies)
static_assert(clip_graph_nemotron_transcribe::window_core - SPK_FIFO_LEN > SPK_CACHE_LEN);
// NeMo SortformerModules.streaming_update: core rows join the FIFO, its overflow moves to the cache, which is compressed
static void update_spk_state(spk_stream & ss, const clip_enc_state & st, const clip_model & model, int64_t core_begin,
int64_t n_core) {
const int64_t d = model.hparams.spk_n_embd;
const int64_t n_spk = model.spk_head_fc2_w->ne[1];
const int64_t n_cache = (int64_t) ss.cache.size() / d;
const int64_t n_fifo = (int64_t) ss.fifo.size() / d;
const int64_t n_rows = (int64_t) st.spk_chunk.size() / d;
GGML_ASSERT((n_cache + n_fifo + n_rows) * n_spk == (int64_t) st.spk_preds.size() && core_begin + n_core <= n_rows);
const float * preds = st.spk_preds.data();
const float * core_preds = preds + (n_cache + n_fifo + core_begin) * n_spk;
std::vector<float> fifo_preds(preds + n_cache * n_spk, preds + (n_cache + n_fifo) * n_spk);
ss.fifo.insert(ss.fifo.end(), st.spk_chunk.begin() + core_begin * d, st.spk_chunk.begin() + (core_begin + n_core) * d);
fifo_preds.insert(fifo_preds.end(), core_preds, core_preds + n_core * n_spk);
const int64_t n_pop = n_fifo + n_core - SPK_FIFO_LEN;
ss.cache.insert(ss.cache.end(), ss.fifo.begin(), ss.fifo.begin() + n_pop * d);
ss.cache_preds.insert(ss.cache_preds.end(), fifo_preds.begin(), fifo_preds.begin() + n_pop * n_spk);
ss.fifo.erase(ss.fifo.begin(), ss.fifo.begin() + n_pop * d);
spk_compress_cache(ss.cache, ss.cache_preds, (int) (ss.cache.size() / d), (int) d, (int) n_spk, model.spk_sil_emb);
}
// NeMo ParallelExpertEncoder._forward_online: windows with left/right context, one graph each, the speaker state carried
bool clip_graph_nemotron_transcribe::encode_windows(clip_ctx * ctx, const clip_model & model, clip_encode_params * params) {
GGML_ASSERT(params->imgs->entries.size() == 1 && params->out_embd != nullptr);
const clip_image_f32 & rec = params->imgs->entries[0];
const auto & buf = rec.get_ro_buf();
const int64_t n_frames = rec.nx();
const int64_t n_rows = rec.ny();
const int64_t n_embd = clip_n_mmproj_embd(ctx);
const int sub = model.hparams.subsampling_factor;
const int64_t core = (int64_t) window_core * sub;
const int64_t left = (int64_t) window_left * sub;
const int64_t right = (int64_t) window_right * sub;
const int64_t n_win = std::max<int64_t>(1, (n_frames + core - 1) / core);
GGML_ASSERT((int64_t) buf.size() == n_frames * n_rows);
auto & out = *params->out_embd;
GGML_ASSERT(out.size() == (size_t) n_embd * clip_n_output_tokens(ctx, &rec));
// the ASR expert's input: per-feature normalization over the whole recording (NeMo normalize_batch, per_feature)
std::vector<double> mean(n_rows), denom(n_rows);
for (int64_t r = 0; r < n_rows; r++) {
const float * row = buf.data() + r * n_frames;
double sum = 0.0;
double sq_diff_sum = 0.0;
for (int64_t t = 0; t < n_frames; t++) {
sum += (double) row[t];
}
mean[r] = sum / n_frames;
for (int64_t t = 0; t < n_frames; t++) {
const double diff = (double) row[t] - mean[r];
sq_diff_sum += diff * diff;
}
denom[r] = std::sqrt(sq_diff_sum / (n_frames - 1.0)) + 1e-5;
}
spk_stream ss;
clip_enc_state st;
size_t n_out = 0;
for (int64_t w = 0; w < n_win; w++) {
const int64_t core_begin = w * core;
const int64_t core_end = std::min(core_begin + core, n_frames);
const int64_t win_begin = std::max<int64_t>(core_begin - left, 0);
const int64_t win_end = std::min(core_end + right, n_frames);
const int64_t win_len = win_end - win_begin;
std::vector<float> data((size_t) win_len * n_rows);
st.mel_norm.resize(data.size());
for (int64_t r = 0; r < n_rows; r++) {
const float * src = buf.data() + r * n_frames + win_begin;
std::copy_n(src, win_len, data.data() + r * win_len);
for (int64_t t = 0; t < win_len; t++) {
st.mel_norm[r * win_len + t] = (float) ((src[t] - mean[r]) / denom[r]);
}
}
clip_image_f32_batch batch;
batch.is_audio = true;
batch.entries.emplace_back();
batch.entries[0].set_size({(int) win_len, (int) n_rows}, false, true);
batch.entries[0].cpy_buf(data);
std::vector<float> embd((size_t) n_embd * clip_n_output_tokens(ctx, &batch.entries[0]));
clip_encode_params wp = *params;
wp.imgs = &batch;
wp.out_embd = &embd;
wp.enc_state = &st;
if (!clip_encode(ctx, &wp)) {
return false;
}
// output frames of the core, rounded up as NeMo's _asr_output_frame_boundary
const int64_t n_left = (core_begin - win_begin) / sub;
const int64_t n_core = (core_end - core_begin + sub - 1) / sub;
GGML_ASSERT((n_out + n_core) * n_embd <= out.size());
std::copy_n(embd.data() + n_left * n_embd, n_core * n_embd, out.data() + n_out * n_embd);
n_out += n_core;
if (w + 1 < n_win) {
update_spk_state(ss, st, model, n_left, n_core);
st.spk_prev = ss.cache;
st.spk_prev.insert(st.spk_prev.end(), ss.fifo.begin(), ss.fifo.end());
}
}
GGML_ASSERT(n_out * n_embd == out.size());
return true;
}
+40 -9
View File
@@ -1385,13 +1385,8 @@ void mtmd_audio_preprocessor_parakeet::initialize() {
cache.hann_window = hparams.window;
}
bool mtmd_audio_preprocessor_parakeet::preprocess(const float * samples,
size_t n_samples_in,
std::vector<mtmd_audio_mel> & output) const {
if (n_samples_in == 0) {
return false;
}
// log-mel without normalization
void mtmd_audio_preprocessor_parakeet::log_mel(const float * samples, size_t n_samples_in, mtmd_audio_mel & out_full) const {
filter_params params;
params.n_mel = hparams.n_mel_bins;
params.n_fft_bins = 1 + (hparams.audio_n_fft / 2);
@@ -1422,7 +1417,6 @@ bool mtmd_audio_preprocessor_parakeet::preprocess(const float * samples,
std::vector<float> samples_padded(n_samples_in + 2 * pad, 0.0f);
std::copy(samples_preprocessed.begin(), samples_preprocessed.end(), samples_padded.begin() + pad);
mtmd_audio_mel out_full;
out_full.n_mel = params.n_mel;
out_full.n_len = (samples_padded.size() - frame_size) / frame_step + 1;
out_full.n_len_org = out_full.n_len;
@@ -1461,11 +1455,22 @@ bool mtmd_audio_preprocessor_parakeet::preprocess(const float * samples,
for (int iw = 0; iw < n_threads - 1; ++iw) {
workers[iw].join();
}
}
bool mtmd_audio_preprocessor_parakeet::preprocess(const float * samples,
size_t n_samples_in,
std::vector<mtmd_audio_mel> & output) const {
if (n_samples_in == 0) {
return false;
}
mtmd_audio_mel out_full;
log_mel(samples, n_samples_in, out_full);
// Per-feature normalization (only on valid frames)
{
const double eps = 1e-5;
int valid_frames = n_samples_in / frame_step;
int valid_frames = n_samples_in / hparams.audio_hop_len;
for (int j = 0; j < out_full.n_mel; j++) {
double sum = 0.0;
@@ -1497,6 +1502,32 @@ bool mtmd_audio_preprocessor_parakeet::preprocess(const float * samples,
return true;
}
//
// mtmd_audio_preprocessor_nemotron_transcribe implementation
//
bool mtmd_audio_preprocessor_nemotron_transcribe::preprocess(const float * samples,
size_t n_samples,
std::vector<mtmd_audio_mel> & output) const {
if (n_samples < (size_t) hparams.audio_n_fft) {
return false;
}
mtmd_audio_mel mel;
log_mel(samples, n_samples, mel);
// frames past n_samples / hop are padding (NeMo get_seq_len); kept raw, encode_windows normalizes them for ASR
const int64_t n_frames = n_samples / hparams.audio_hop_len;
for (int64_t j = 1; j < mel.n_mel; j++) {
std::copy_n(mel.data.begin() + j * mel.n_len, n_frames, mel.data.begin() + j * n_frames);
}
mel.n_len = n_frames;
mel.n_len_org = n_frames;
mel.data.resize(mel.n_mel * n_frames);
output.push_back(std::move(mel));
return true;
}
// mtmd_audio_preprocessor_gemma4ua
//
+9
View File
@@ -176,6 +176,9 @@ struct mtmd_audio_preprocessor_parakeet : mtmd_audio_preprocessor {
void initialize() override;
bool preprocess(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output) const override;
protected:
void log_mel(const float * samples, size_t n_samples, mtmd_audio_mel & out) const;
private:
mtmd_audio_cache cache;
@@ -186,6 +189,12 @@ struct mtmd_audio_preprocessor_parakeet : mtmd_audio_preprocessor {
const mtmd_audio_cache & cache, mtmd_audio_mel & mel);
};
// one chunk per recording: the raw log-mel of its valid frames
struct mtmd_audio_preprocessor_nemotron_transcribe : mtmd_audio_preprocessor_parakeet {
mtmd_audio_preprocessor_nemotron_transcribe(clip_ctx * ctx) : mtmd_audio_preprocessor_parakeet(ctx) { }
bool preprocess(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output) const override;
};
//
// streaming ISTFT - converts spectrogram frames back to audio one frame at a time
//
+4
View File
@@ -1153,6 +1153,10 @@ struct mtmd_context {
{
audio_preproc = std::make_unique<mtmd_audio_preprocessor_parakeet>(ctx_a);
} break;
case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
{
audio_preproc = std::make_unique<mtmd_audio_preprocessor_nemotron_transcribe>(ctx_a);
} break;
case PROJECTOR_TYPE_GEMMA4UA:
{
aud_beg = "<|audio>";
+18
View File
@@ -1,3 +1,21 @@
VITE_PUBLIC_APP_NAME='llama-ui'
VITE_DEBUG='true'
VITE_PUBLIC_SERVER_ORIGIN='http://localhost:8033'
# Demo videos (npm run demo:pr / demo:docs), see demo/README.md
# Where demo scripts and videos are stored; unset keeps videos in demo/videos
# DEMO_STORAGE='hf://buckets/<owner>/<bucket>'
# DEMO_STORAGE='file:///path/to/dir'
# Public base URL of DEMO_STORAGE, used to link videos in PR descriptions
# DEMO_STORAGE_PUBLIC_URL='https://...'
# HF dataset for demo conversations (llama.app traces): `demo:traces` pushes the
# built-in ones there, and demos can import any <name>.jsonl from it
# DEMO_TRACES_DATASET='<owner>/<dataset>'
# DEMO_GITHUB_REPO='ggml-org/llama.cpp'
# DEMO_SERVER_ORIGIN='http://localhost:9931'
# DEMO_LLAMA_SERVER='../../build/bin/llama-server'
# DEMO_COLOR_SCHEME='light'
# Device pixel ratio demos render and record at (1440x900 viewport)
# DEMO_SCALE='2'
# Longer side of the GIF preview, in pixels; it shrinks further to stay under 5 MB
# DEMO_PREVIEW_WIDTH='800'
+5
View File
@@ -0,0 +1,5 @@
.runs/
test-results/
videos/
*.demo.ts
/traces/
+133
View File
@@ -0,0 +1,133 @@
# Demo videos
Scripted Playwright walkthroughs of the UI, exported as mp4 with an animated GIF preview, for pull requests and documentation. The harness lives here; the demo scripts don't:
- **PR demos** live in the PR description, as a `ts demo` code block.
- **Docs demos** live in the storage target, under `scripts/docs/`.
```
demo/
├── cli.ts demo:pr, demo:docs and demo:traces
├── storage.ts storage targets (hf://, file://)
├── helpers.ts `test` with a `demo` fixture: cursor, captions, human-like timing
├── recorder.ts screencast recording and mp4 export
├── fixtures/ fixture MCP servers, seeded settings and demo conversations
└── videos/ output (git-ignored)
```
## Setup
### Tools
| Tool | Used for |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Node 22.18+ | runs `demo/cli.ts` directly (TypeScript type stripping) |
| `npx playwright install chromium` | the browser demos record in, with its sandbox on |
| `ffmpeg` | mp4 encoding and the GIF preview |
| `gh`, logged in | reads the PR description and writes the `## Demo` section, so you need edit rights on the PR |
| `hf`, logged in with a write token (`hf auth login`) | uploads to the bucket and the traces dataset |
| `llama-server` | `build/bin/llama-server`, or one already running at `DEMO_SERVER_ORIGIN` (default `:9931`). It runs in router mode, so demos can pick any model in the local cache; scripts that send messages need at least one. |
`gh` is only needed for `demo:pr`, and `hf` only with the bucket or dataset below. Without them, the video stays in `demo/videos/`.
Videos are recorded through Chromium's screencast at `DEMO_SCALE` (2x by default, so 2880x1800) and 60 fps, starting once the page has settled.
### Hugging Face bucket (videos, previews, scripts)
The bucket must be public, since GitHub loads the preview from it:
```bash
hf buckets create <owner>/llama-ui-demos
```
Then set it in `tools/ui/.env` (see `.env.example`):
```bash
DEMO_STORAGE='hf://buckets/<owner>/llama-ui-demos'
DEMO_STORAGE_PUBLIC_URL='https://huggingface.co/buckets/<owner>/llama-ui-demos/resolve'
```
Layout: `videos/pr/<number>/<sha>.{mp4,gif,demo.ts}`, `videos/docs/<name>.{mp4,gif}` and `scripts/docs/*.demo.ts` (the docs scripts). Without `DEMO_STORAGE_PUBLIC_URL`, videos are uploaded but not linked in the PR. `DEMO_STORAGE='file:///some/dir'` stores them in a local folder instead.
### Hugging Face dataset (conversations, optional)
Only needed for demos that import conversations other than the built-in ones, or to publish the built-in ones with `npm run demo:traces`. It can be private: it is read with your `hf` login.
```bash
hf repos create <owner>/llama-ui-demo-traces --repo-type dataset --private
```
```bash
DEMO_TRACES_DATASET='<owner>/llama-ui-demo-traces'
```
### Other storage
Only Hugging Face buckets and `file://` folders are supported for now. S3, R2 or similar would be one more class in `storage.ts` (`upload` and `downloadDir`) plus the matching `DEMO_STORAGE_PUBLIC_URL`.
## Recording a pull request
Add the script to the PR description:
````md
<details>
<summary>Demo script</summary>
```ts demo
test('demo', async ({ demo, page }) => {
await demo.click(page.getByRole('button', { exact: true, name: 'Settings' }));
await demo.caption('What this shows');
});
```
</details>
````
Then run:
```bash
npm run demo:pr -- 31303
```
A PR by someone else runs its code and demo script on your machine, with your `gh` and `hf` logins, so `demo:pr` refuses it until you've read its diff and demo block and pass the head commit you read: `npm run demo:pr -- 31303 --trust <sha>`. If the PR gets new commits, it refuses again.
It checks out the PR head in a temporary worktree, records the script against it, uploads `videos/pr/<number>/<sha>.{mp4,gif,demo.ts}` and adds or updates a `## Demo` section in the PR description with the preview linking to the video.
A large PR can show its changes in several shorter videos: each `test()` in the block is recorded as its own video (`<sha>-1.mp4`, `<sha>-2.mp4`, ...), and the `## Demo` section shows their previews in a table, each headed by its test's title: up to four portrait (phone) videos to a row, or two landscape ones. Helpers and `test.use` at the top of the block are shared by all of them.
````ts
test('Header and safe areas', async ({ demo, page }) => {
// ...
});
test('Sidebar gestures', async ({ demo, page }) => {
// ...
});
``` GitHub has no API for attachments, so for an inline player drag `demo/videos/pr-<number>.mp4` into the description instead.
## Recording docs
```bash
npm run demo:docs
````
Records every `scripts/docs/*.demo.ts` from the storage target and uploads `videos/docs/<name>.{mp4,gif}`.
## Writing a demo
Scripts get `test` and `expect` from `helpers.ts` without importing them. `demo.click`, `demo.hover` and `demo.type` glide a visible cursor to the target before acting, with timing varied like a person's (seeded per demo, so re-takes match); `demo.scroll` turns the mouse wheel over a target; `demo.zoom` pinches in on a control or a point to show a detail too small to read in the frame, holds, and pinches back out (with the fingertips drawn on a touch device); `demo.caption` shows a caption at the top of the frame. Use `test.use({ settings, startPath })` to seed other settings or start on another route.
## Conversations
Demos that need existing chats import them with `test.use({ traces: ['long-chat'] })`, through Settings > Import/Export before the video starts. Traces use the export format (llama.app JSONL), so the Hub shows them as Llama traces:
- `fixtures/traces.ts` generates the built-in ones: `long-chat` (36 tall exchanges) and `agentic-debugging` (long reasoning, shell output and code blocks).
- Any other name is downloaded as `<name>.jsonl` from `DEMO_TRACES_DATASET`, e.g. an exported conversation uploaded there.
`npm run demo:traces` writes the built-in traces to `demo/traces/` and pushes them to `DEMO_TRACES_DATASET`.
## Trying a script locally
To try a script locally, put it in `demo/` as `<name>.demo.ts`, importing from `./helpers.ts`, and run `npm run demo:record -- demo/<name>`. `npm run demo:codegen` opens Playwright codegen against `:5180` for a first draft.
The config starts, or reuses when already running, the fixture MCP servers on `:8765`, `llama-server` on `:9931` (router mode, `--tools all`) and the UI's vite dev server.
+411
View File
@@ -0,0 +1,411 @@
/**
* Records demo videos for pull requests and docs.
*
* node demo/cli.ts pr <number> record the ```ts demo block from a PR description, a video per test
* (--trust <sha> to record someone else's PR at that head commit)
* node demo/cli.ts docs record every docs/*.demo.ts script in DEMO_STORAGE
* node demo/cli.ts traces write the built-in traces, and push them to DEMO_TRACES_DATASET
*
* Settings come from tools/ui/.env (see .env.example).
*/
import { DEMO_TRACES, generateTrace } from './fixtures/traces.ts';
import { type DemoStorage, getDemoStorage, getPublicUrl } from './storage.ts';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { basename, join, relative, resolve } from 'node:path';
const DEMO_DIR = import.meta.dirname;
const UI_DIR = resolve(DEMO_DIR, '..');
const RUNS_DIR = join(DEMO_DIR, '.runs');
const VIDEOS_DIR = join(DEMO_DIR, 'videos');
const TRACES_DIR = join(DEMO_DIR, 'traces');
const DEMO_BLOCK = /```(?:ts|typescript)[ \t]+demo[ \t]*\r?\n([\s\S]*?)```/;
const HELPERS_IMPORT = /^import\s[^;]*from\s+['"][^'"]*helpers(?:\.ts)?['"];?\s*$/gm;
const SECTION_START = '<!-- demo:start -->';
const SECTION_END = '<!-- demo:end -->';
/** GitHub's image proxy refuses larger images, so a bigger preview shows as a broken image. */
const PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
const envFile = join(UI_DIR, '.env');
if (existsSync(envFile)) process.loadEnvFile(envFile);
const REPO = process.env.DEMO_GITHUB_REPO ?? 'ggml-org/llama.cpp';
function run(
cmd: string,
args: string[],
opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}
): string {
return execFileSync(cmd, args, {
cwd: opts.cwd ?? UI_DIR,
encoding: 'utf8',
env: opts.env
}).trim();
}
/** Writes a demo script into .runs/, importing the harness helpers. */
function writeDemoScript(name: string, source: string): string {
const file = join(RUNS_DIR, `${name}.demo.ts`);
const helpers = relative(RUNS_DIR, join(DEMO_DIR, 'helpers.ts'));
const body = source.replace(HELPERS_IMPORT, '').trim();
mkdirSync(RUNS_DIR, { recursive: true });
writeFileSync(file, `import { expect, test } from './${helpers}';\n\n${body}\n`);
return file;
}
async function freePort(): Promise<number> {
return new Promise((done, fail) => {
const server = createServer();
server.once('error', fail);
server.listen(0, () => {
const { port } = server.address() as { port: number };
server.close(() => done(port));
});
});
}
/** A video recorded from one `test()` of a script. */
interface Video {
mp4: string;
title: string;
}
/** Records the given scripts; returns a video for each of their tests, in order. */
async function record(scripts: string[], uiDir = UI_DIR): Promise<Video[]> {
const port = await freePort();
const manifests = scripts.map((s) => join(VIDEOS_DIR, `${basename(s, '.demo.ts')}.videos.jsonl`));
for (const manifest of manifests) rmSync(manifest, { force: true });
execFileSync(
'npx',
[
'playwright',
'test',
'--config=demo/playwright.config.ts',
...scripts.map((s) => relative(UI_DIR, s))
],
{
cwd: UI_DIR,
env: { ...process.env, DEMO_UI_DIR: uiDir, DEMO_UI_PORT: String(port) },
stdio: 'inherit'
}
);
// helpers.ts lists each video it encodes in the script's manifest
return manifests.flatMap((manifest) =>
readFileSync(manifest, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line) as Video)
);
}
/**
* Animated GIF preview that GitHub renders inline, its longer side DEMO_PREVIEW_WIDTH (so a
* phone's portrait video is that tall), shrunk until it fits under PREVIEW_MAX_BYTES.
*/
function makePreview(mp4: string): string {
const gif = mp4.replace(/\.mp4$/, '.gif');
for (let side = Number(process.env.DEMO_PREVIEW_WIDTH ?? 800); ; side = Math.round(side * 0.8)) {
execFileSync('ffmpeg', [
'-y',
'-loglevel',
'error',
'-i',
mp4,
'-vf',
`fps=10,scale='if(gt(iw,ih),${side},-2)':'if(gt(iw,ih),-2,${side})':flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`,
'-loop',
'0',
gif
]);
if (statSync(gif).size <= PREVIEW_MAX_BYTES || side < 320) return gif;
}
}
function upload(storage: DemoStorage, files: Record<string, string>): void {
for (const [key, file] of Object.entries(files)) {
storage.upload(file, key);
console.log(`uploaded ${key}`);
}
}
/**
* Public URL with a content hash, so a re-recorded file at the same key gets a new
* URL: GitHub's image proxy caches previews by URL.
*/
function versionedUrl(key: string, file: string): null | string {
const url = getPublicUrl(key);
const version = createHash('sha1').update(readFileSync(file)).digest('hex').slice(0, 10);
return url && `${url}?v=${version}`;
}
/** Whether a video is taller than wide, like a phone's. */
function isPortrait(mp4: string): boolean {
const [width, height] = run('ffprobe', [
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=width,height',
'-of',
'csv=p=0',
mp4
])
.split(',')
.map(Number);
return height > width;
}
/**
* One preview, or a table of them when the script has several tests, each headed by its
* test's title: up to four portrait videos to a row, or two landscape ones, so each stays
* large enough to read.
*/
function demoSection(
links: { gif: string; mp4: string; title: string }[],
sha: string,
columns: number
): string {
const preview = ({ gif, mp4, title }: (typeof links)[number]) =>
`[![${title.replace(/[[\]|]/g, '')}](${gif})](${mp4})`;
const cell = (text: string) => text.replace(/\|/g, '\\|');
const rows = Array.from({ length: Math.ceil(links.length / columns) }, (_, i) =>
links.slice(i * columns, (i + 1) * columns)
);
const pad = (cells: string[]) => [...cells, ...Array(columns - cells.length).fill(' ')];
const body =
links.length === 1
? [preview({ ...links[0], title: 'Demo' })]
: rows.flatMap((row, i) => [
// a table has one header row, so later rows title their videos in bold
`| ${pad(row.map((l) => (i === 0 ? cell(l.title) : `**${cell(l.title)}**`))).join(' | ')} |`,
...(i === 0 ? [`|${Array(columns).fill('---').join('|')}|`] : []),
`| ${pad(row.map(preview)).join(' | ')} |`
]);
const note =
links.length === 1 ? 'the preview for the full video' : 'a preview for its full video';
return [
SECTION_START,
...body,
'',
`Recorded at ${sha.slice(0, 10)}; click ${note}.`,
SECTION_END
].join('\n');
}
/** Replaces the managed demo section, or adds a `## Demo` section before the details. */
function withDemoSection(body: string, section: string): string {
const start = body.indexOf(SECTION_START);
const end = body.indexOf(SECTION_END);
if (start !== -1 && end > start) {
return body.slice(0, start) + section + body.slice(end + SECTION_END.length);
}
const block = `## Demo\n\n${section}\n\n`;
const anchor = body.search(/^## (Additional information|Requirements)\b/m);
return anchor === -1
? `${body.trimEnd()}\n\n${block}`
: body.slice(0, anchor) + block + body.slice(anchor);
}
async function recordPr(number: string, trustedSha?: string): Promise<void> {
const pr = JSON.parse(
run('gh', ['pr', 'view', number, '--repo', REPO, '--json', 'author,body,headRefOid'])
) as { author: { login: string }; body: string; headRefOid: string };
const source = DEMO_BLOCK.exec(pr.body)?.[1];
if (!source) throw new Error(`PR #${number} has no \`\`\`ts demo block in its description`);
const sha = pr.headRefOid;
// the PR's code and demo script run on this machine, with the gh and hf logins,
// so someone else's PR only runs at a head commit you've read and passed to --trust
if (pr.author.login !== run('gh', ['api', 'user', '--jq', '.login'])) {
if (!trustedSha) {
throw new Error(
`PR #${number} is by ${pr.author.login}, and recording it runs its code here: ` +
`read its diff and demo block, then run again with --trust ${sha.slice(0, 10)}`
);
}
if (trustedSha.length < 7 || !sha.startsWith(trustedSha)) {
throw new Error(`PR #${number} head is ${sha.slice(0, 10)}, not the trusted ${trustedSha}`);
}
}
const worktree = join(tmpdir(), `llama-ui-demo-pr-${number}`);
run('git', ['fetch', '--no-tags', `https://github.com/${REPO}.git`, `pull/${number}/head`]);
if (run('git', ['rev-parse', 'FETCH_HEAD']) !== sha) {
throw new Error(`PR #${number} head moved while fetching, run again`);
}
rmSync(worktree, { force: true, recursive: true });
run('git', ['worktree', 'prune']);
run('git', ['worktree', 'add', '--detach', worktree, sha]);
symlinkSync(join(UI_DIR, 'node_modules'), join(worktree, 'tools/ui/node_modules'));
const name = `pr-${number}`;
const script = writeDemoScript(name, source);
try {
const videos = (await record([script], join(worktree, 'tools/ui'))).map((video) => ({
...video,
gif: makePreview(video.mp4)
}));
const storage = getDemoStorage();
for (const { gif, mp4 } of videos) console.log(`\nvideo: ${mp4}\npreview: ${gif}`);
if (!storage) {
console.log('DEMO_STORAGE is not set: drag the mp4 into the PR description.');
return;
}
const prefix = `videos/pr/${number}/${sha.slice(0, 10)}`;
// a script with several tests gets <sha>-1.mp4, <sha>-2.mp4 and so on
const keys = videos.map((_, i) => (videos.length === 1 ? prefix : `${prefix}-${i + 1}`));
upload(storage, { [`${prefix}.demo.ts`]: script });
videos.forEach(({ gif, mp4 }, i) => {
upload(storage, { [`${keys[i]}.gif`]: gif, [`${keys[i]}.mp4`]: mp4 });
});
if (!getPublicUrl(prefix)) {
console.log('DEMO_STORAGE_PUBLIC_URL is not set: not linking the video in the PR.');
return;
}
const links = videos.map(({ gif, mp4, title }, i) => ({
gif: versionedUrl(`${keys[i]}.gif`, gif) ?? '',
mp4: versionedUrl(`${keys[i]}.mp4`, mp4) ?? '',
title
}));
execFileSync('gh', ['pr', 'edit', number, '--repo', REPO, '--body-file', '-'], {
cwd: UI_DIR,
input: withDemoSection(
pr.body,
demoSection(links, sha, Math.min(links.length, isPortrait(videos[0].mp4) ? 4 : 2))
),
stdio: ['pipe', 'inherit', 'inherit']
});
console.log(`linked the demo in https://github.com/${REPO}/pull/${number}`);
} finally {
rmSync(script, { force: true });
run('git', ['worktree', 'remove', '--force', worktree]);
}
}
async function recordDocs(): Promise<void> {
const storage = getDemoStorage();
if (!storage) throw new Error('DEMO_STORAGE is not set: docs demo scripts are read from it');
const downloaded = join(RUNS_DIR, 'docs-source');
rmSync(downloaded, { force: true, recursive: true });
if (!storage.downloadDir('scripts/docs', downloaded)) {
throw new Error('no scripts/docs/*.demo.ts in DEMO_STORAGE');
}
const scripts = readdirSync(downloaded)
.filter((f) => f.endsWith('.demo.ts'))
.map((f) =>
writeDemoScript(basename(f, '.demo.ts'), readFileSync(join(downloaded, f), 'utf8'))
);
try {
const videos = await record(scripts);
for (const { mp4 } of videos) {
const name = basename(mp4, '.mp4');
upload(storage, {
[`videos/docs/${name}.gif`]: makePreview(mp4),
[`videos/docs/${name}.mp4`]: mp4
});
}
} finally {
rmSync(RUNS_DIR, { force: true, recursive: true });
}
}
/** Writes the built-in traces to demo/traces/ and uploads them to the traces dataset. */
function writeTraces(): void {
rmSync(TRACES_DIR, { force: true, recursive: true });
mkdirSync(TRACES_DIR, { recursive: true });
for (const name of Object.keys(DEMO_TRACES)) {
writeFileSync(join(TRACES_DIR, `${name}.jsonl`), `${generateTrace(name)}\n`);
console.log(`wrote demo/traces/${name}.jsonl`);
}
const dataset = process.env.DEMO_TRACES_DATASET;
if (!dataset) {
console.log('DEMO_TRACES_DATASET is not set: not uploading.');
return;
}
execFileSync(
'hf',
[
'upload',
dataset,
TRACES_DIR,
'.',
'--repo-type',
'dataset',
'--commit-message',
'Update demo traces'
],
{ stdio: 'inherit' }
);
}
const [command, arg, ...flags] = process.argv.slice(2);
const trustIndex = flags.indexOf('--trust');
if (command === 'pr' && arg) {
await recordPr(arg, trustIndex === -1 ? undefined : flags[trustIndex + 1]);
} else if (command === 'docs') {
await recordDocs();
} else if (command === 'traces') {
writeTraces();
} else {
console.error('usage: node demo/cli.ts pr <number> [--trust <sha>] | docs | traces');
process.exit(1);
}
+49
View File
@@ -0,0 +1,49 @@
/** Shared fixture data for demo recordings (used by both Node and the browser seed). */
export const DEMO_MCP_PORT = 8765;
export interface DemoMcpServer {
id: string;
name: string;
title: string;
tools: { name: string; description: string; result: string }[];
}
export const DEMO_MCP_SERVERS: DemoMcpServer[] = [
{
id: 'weather',
name: 'weather',
title: 'Weather',
tools: [
{
description: 'Get the weather forecast for a city',
name: 'get_forecast',
result: 'Sunny, 22°C'
},
{
description: 'List active weather alerts for a region',
name: 'get_alerts',
result: 'No active alerts'
}
]
},
{
id: 'notes',
name: 'notes',
title: 'Notes',
tools: [
{ description: 'Search notes by keyword', name: 'search_notes', result: 'No notes found' },
{ description: 'Create a new note', name: 'create_note', result: 'Note created' },
{ description: 'List recent notes', name: 'list_notes', result: 'No notes yet' }
]
}
];
/** `mcpServers` setting value pointing the UI at the fixture servers. */
export function demoMcpServerSettings(): object[] {
return DEMO_MCP_SERVERS.map((server) => ({
enabled: true,
id: `demo-${server.id}`,
url: `http://localhost:${DEMO_MCP_PORT}/${server.id}/mcp`
}));
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Fixture MCP servers for demo recordings.
*
* Serves a few stateless Streamable HTTP MCP servers with canned tools, so
* demos show MCP groups without depending on real third-party servers.
*
* Usage: node demo/fixtures/mcp-servers.ts
*/
import { DEMO_MCP_PORT, DEMO_MCP_SERVERS, type DemoMcpServer } from './demo-config.ts';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { z } from 'zod';
function buildServer(fixture: DemoMcpServer): McpServer {
const server = new McpServer({ name: fixture.name, title: fixture.title, version: '1.0.0' });
for (const tool of fixture.tools) {
server.registerTool(
tool.name,
{ description: tool.description, inputSchema: { query: z.string().optional() } },
async () => ({ content: [{ text: tool.result, type: 'text' }] })
);
}
return server;
}
function setCorsHeaders(res: ServerResponse): void {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id, mcp-protocol-version');
// the UI runs with COEP require-corp
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
}
async function readBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const raw = Buffer.concat(chunks).toString();
return raw ? JSON.parse(raw) : undefined;
}
const httpServer = createServer(async (req, res) => {
setCorsHeaders(res);
if (req.method === 'OPTIONS') {
res.writeHead(204).end();
return;
}
const path = new URL(req.url ?? '/', 'http://localhost').pathname;
const fixture = DEMO_MCP_SERVERS.find((s) => path === `/${s.id}/mcp`);
if (!fixture) {
res.writeHead(path === '/health' ? 200 : 404).end();
return;
}
if (req.method !== 'POST') {
// stateless: no standalone SSE stream or session teardown
res.writeHead(405).end();
return;
}
const server = buildServer(fixture);
const transport = new StreamableHTTPServerTransport({
enableJsonResponse: true,
sessionIdGenerator: undefined
});
res.on('close', () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, await readBody(req));
});
httpServer.listen(DEMO_MCP_PORT, () => {
console.log(`demo MCP servers listening on http://localhost:${DEMO_MCP_PORT}`);
});
+312
View File
@@ -0,0 +1,312 @@
/**
* Synthetic conversations for demos, in the UI's export format (llama.app JSONL
* traces): a `session` record followed by one `message` record per message.
* Deterministic, so a trace and the demos recorded from it don't drift. Messages
* carry no model, so the UI doesn't flag one the server doesn't have.
*/
import { createHash } from 'node:crypto';
const HARNESS = 'llama.app';
const START = Date.UTC(2026, 9, 1, 9, 0, 0);
interface ToolCall {
function: { arguments: string; name: string };
id: string;
type: 'function';
}
interface TraceMessage {
children: string[];
content: string;
convId: string;
id: string;
parent: null | string;
reasoningContent?: string;
role: 'assistant' | 'system' | 'tool' | 'user';
timestamp: number;
toolCallId?: string;
toolCalls?: ToolCall[];
type: 'root' | 'text';
}
/** Builds one linear conversation, message by message. */
class Trace {
private calls = 0;
private readonly id: string;
private readonly messages: TraceMessage[] = [];
private readonly name: string;
assistant(content: string, reasoning?: string): this {
return this.push({ content, reasoningContent: reasoning, role: 'assistant' });
}
constructor(name: string) {
this.name = name;
this.id = uuid(name, 'conv');
this.push({ content: '', role: 'system', type: 'root' });
}
toJsonl(): string {
const last = this.messages.at(-1)!;
const session = {
currNode: last.id,
harness: HARNESS,
id: this.id,
lastModified: last.timestamp,
name: this.name,
type: 'session'
};
return [session, ...this.messages.map((message) => ({ message, type: 'message' }))]
.map((record) => JSON.stringify(record))
.join('\n');
}
/** An assistant turn calling one tool, followed by the tool's result. */
tool(name: string, args: Record<string, unknown>, result: string, reasoning?: string): this {
const id = uuid(this.name, `call-${this.calls++}`).replaceAll('-', '');
this.push({
content: '',
reasoningContent: reasoning,
role: 'assistant',
toolCalls: [{ function: { arguments: JSON.stringify(args), name }, id, type: 'function' }]
});
return this.push({ content: result, role: 'tool', toolCallId: id });
}
user(content: string): this {
return this.push({ content, role: 'user' });
}
private push(message: Partial<TraceMessage> & Pick<TraceMessage, 'content' | 'role'>): this {
const parent = this.messages.at(-1);
const index = this.messages.length;
const entry: TraceMessage = {
children: [],
convId: this.id,
id: uuid(this.name, `msg-${index}`),
parent: parent?.id ?? null,
timestamp: START + index * 20_000,
type: 'text',
...message
};
parent?.children.push(entry.id);
this.messages.push(entry);
return this;
}
}
function uuid(...parts: string[]): string {
const hex = createHash('sha1').update(parts.join(':')).digest('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
}
const STEPS = [
['set up the project', 'cargo new forecast && cd forecast', 'Cargo.toml', 'toml'],
['parse command line arguments', 'cargo add clap --features derive', 'src/cli.rs', 'rust'],
['call the weather API', 'cargo add reqwest --features json,blocking', 'src/api.rs', 'rust'],
['deserialize the response', 'cargo add serde --features derive', 'src/model.rs', 'rust'],
['cache responses on disk', 'cargo add directories', 'src/cache.rs', 'rust'],
['print a table', 'cargo add comfy-table', 'src/table.rs', 'rust'],
['handle errors', 'cargo add anyhow', 'src/main.rs', 'rust'],
['add colors', 'cargo add owo-colors', 'src/style.rs', 'rust'],
['read a config file', 'cargo add toml', 'src/config.rs', 'rust'],
['write tests', 'cargo add --dev assert_cmd predicates', 'tests/cli.rs', 'rust'],
['set up CI', 'mkdir -p .github/workflows', '.github/workflows/ci.yml', 'yaml'],
['publish a release', 'cargo install cargo-dist', 'dist-workspace.toml', 'toml']
] as const;
function codeFor(step: number, file: string, lang: string): string {
const lines =
lang === 'rust'
? [
`// ${file}`,
'use anyhow::{Context, Result};',
'',
`pub fn step_${step}(input: &str) -> Result<Vec<String>> {`,
' let mut out = Vec::new();',
' for (i, line) in input.lines().enumerate() {',
' let trimmed = line.trim();',
' if trimmed.is_empty() {',
' continue;',
' }',
' out.push(format!("{i:>3}: {trimmed}"));',
' }',
` Ok(out).context("step ${step} failed")`,
'}'
]
: lang === 'yaml'
? [
`# ${file}`,
'on: [push, pull_request]',
'jobs:',
' test:',
' runs-on: ubuntu-latest'
]
: [`# ${file}`, `name = "forecast"`, `version = "0.${step}.0"`, 'edition = "2024"'];
return `\`\`\`${lang}\n${lines.join('\n')}\n\`\`\``;
}
/** A long chat with tall messages, for scrolling and landing demos. */
function longChat(): Trace {
const trace = new Trace('Building a weather CLI in Rust');
for (let i = 0; i < 36; i++) {
const [goal, command, file, lang] = STEPS[i % STEPS.length];
const round = Math.floor(i / STEPS.length);
const ask =
round === 0 ? `How do I ${goal}?` : `Can we revisit how we ${goal}? (pass ${round + 1})`;
trace.user(ask);
trace.assistant(
[
`To ${goal}, start from a clean working tree so the change stays easy to review.`,
'',
'```sh',
command,
'```',
'',
`Then add \`${file}\`:`,
'',
codeFor(i + 1, file, lang),
'',
'A few things to keep in mind:',
'',
`- Keep \`${file}\` focused on one job, so the next step only touches what it needs.`,
'- Run `cargo clippy` after each step; it catches most mistakes early.',
'- Commit once it builds, so you can always go back to a working version.',
'',
i % 3 === 0
? `Next we can look at how to ${STEPS[(i + 1) % STEPS.length][0]}.`
: 'Let me know if anything here does not build.'
].join('\n'),
`The user wants to ${goal}. Give the command, the file and a short checklist.`
);
}
return trace;
}
function testLog(failing: boolean): string {
const lines = ['running 48 tests'];
for (let i = 1; i <= 48; i++) {
const ok = !failing || (i !== 17 && i !== 31);
lines.push(
`test cache::tests::entry_${String(i).padStart(2, '0')} ... ${ok ? 'ok' : 'FAILED'}`
);
}
if (failing) {
lines.push(
'',
'failures:',
'',
'---- cache::tests::entry_17 stdout ----',
"thread 'cache::tests::entry_17' panicked at src/cache.rs:88:9:",
'assertion `left == right` failed: entry expired too early',
' left: Some(1759312800)',
' right: Some(1759316400)',
'',
'---- cache::tests::entry_31 stdout ----',
"thread 'cache::tests::entry_31' panicked at src/cache.rs:88:9:",
'assertion `left == right` failed: entry expired too early',
'',
'test result: FAILED. 46 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out'
);
} else {
lines.push('', 'test result: ok. 48 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out');
}
return `${lines.join('\n')}\n\n[exit code: ${failing ? 101 : 0}]`;
}
function reasoning(topic: string, paragraphs: number): string {
const out: string[] = [];
for (let i = 0; i < paragraphs; i++) {
out.push(
`${i === 0 ? `Looking at ${topic}.` : 'Another angle:'} The cache stores an expiry timestamp per entry, ` +
'and the failing tests are the ones created right at an hour boundary. If the expiry is computed ' +
'from local time while the check uses UTC, entries created near the boundary would expire an hour ' +
`early. Step ${i + 1}: confirm by reading how \`expires_at\` is built, then compare with \`is_fresh\`.`
);
}
return out.join('\n\n');
}
/** An agentic session with long reasoning, shell output and code, for nested scroller demos. */
function agenticDebugging(): Trace {
const trace = new Trace('Fix the flaky cache tests');
const fixed = Array.from({ length: 60 }, (_, i) =>
i === 0
? 'use chrono::{DateTime, Duration, Utc};'
: i === 30
? ' let expires_at = Utc::now() + self.ttl; // was Local::now()'
: ` // cache.rs line ${i + 1}: entry handling stays the same`
).join('\n');
trace.user('The cache tests fail on CI every few runs. Can you find out why and fix it?');
for (let round = 0; round < 3; round++) {
trace.tool(
'exec_shell_command',
{ command: 'cargo test cache -- --test-threads=1' },
testLog(true),
reasoning('the failing cache tests', 4 + round)
);
trace.tool(
'exec_shell_command',
{ command: "grep -n 'expires_at\\|is_fresh' src/cache.rs" },
Array.from(
{ length: 30 },
(_, i) => `${80 + i}: let expires_at = Local::now() + self.ttl; // entry ${i}`
).join('\n') + '\n\n[exit code: 0]',
reasoning('where the expiry is computed', 3)
);
trace.assistant(
[
`Round ${round + 1}: the expiry uses local time while the freshness check uses UTC.`,
'',
'```rust',
fixed,
'```',
'',
'I switched the expiry to `Utc::now()` so both sides use the same clock.'
].join('\n'),
reasoning('the fix', 5)
);
trace.user(round < 2 ? 'Still flaky on CI, can you check again?' : 'Run the tests once more.');
}
trace.tool(
'exec_shell_command',
{ command: 'cargo test cache' },
testLog(false),
reasoning('the final test run', 2)
);
trace.assistant(
'All 48 cache tests pass now, including the two that failed at the hour boundary.'
);
return trace;
}
/** Built-in traces, by name. */
export const DEMO_TRACES: Record<string, () => Trace> = {
'agentic-debugging': agenticDebugging,
'long-chat': longChat
};
export function generateTrace(name: string): null | string {
return DEMO_TRACES[name]?.().toJsonl() ?? null;
}
+500
View File
@@ -0,0 +1,500 @@
import { demoMcpServerSettings } from './fixtures/demo-config.ts';
import { generateTrace } from './fixtures/traces.ts';
import { ScreencastRecorder } from './recorder.ts';
import { type CDPSession, type Locator, type Page, test as base } from '@playwright/test';
import { execFileSync } from 'node:child_process';
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
const VIDEOS_DIR = resolve(import.meta.dirname, 'videos');
/** How long a demo waits for the app to be ready before it starts recording. */
const APP_READY_TIMEOUT_MS = 90000;
/** How far below the viewport's top a pinch has to start for Chromium to zoom at all. */
const ZOOM_TOP_INSET_PX = 64;
/**
* Draws a fake cursor and click ripples, since recorded videos don't include
* the OS pointer, and a fingertip per touch, so a pinch shows as two fingers.
* Runs in the page via addInitScript.
*/
function installCursorOverlay(): void {
const mount = () => {
const style = document.createElement('style');
style.textContent = `
#demo-cursor { position: fixed; top: 0; left: 0; z-index: 2147483647; pointer-events: none;
width: 22px; height: 22px; margin: -3px 0 0 -3px; transition: transform 80ms ease-out; }
#demo-cursor.down { transform: scale(0.85); }
.demo-ripple { position: fixed; z-index: 2147483646; pointer-events: none; width: 36px; height: 36px;
margin: -18px 0 0 -18px; border-radius: 50%; background: rgba(59, 130, 246, 0.35);
animation: demo-ripple 450ms ease-out forwards; }
@keyframes demo-ripple { from { transform: scale(0.3); opacity: 1; } to { transform: scale(1.4); opacity: 0; } }
#demo-caption { position: fixed; left: 50%; top: 72px; z-index: 2147483647; pointer-events: none;
transform: translate(-50%, -8px); opacity: 0; transition: opacity 200ms ease, transform 200ms ease;
max-width: 80%; padding: 10px 18px; border-radius: 10px; background: rgba(17, 17, 17, 0.88);
color: #fff; font: 500 18px/1.4 system-ui, sans-serif; text-align: center; }
#demo-caption.visible { opacity: 1; transform: translate(-50%, 0); }
.demo-finger { position: fixed; z-index: 2147483646; pointer-events: none; width: 40px; height: 40px;
margin: -20px 0 0 -20px; border-radius: 50%; background: rgba(59, 130, 246, 0.3);
border: 2px solid rgba(59, 130, 246, 0.55); transition: opacity 200ms ease; }
`;
document.head.appendChild(style);
const cursor = document.createElement('div');
cursor.id = 'demo-cursor';
cursor.innerHTML = `<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 2l16 10.5-7 1.5-3.5 6.5z"
fill="#111" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
document.documentElement.appendChild(cursor);
const caption = document.createElement('div');
caption.id = 'demo-caption';
document.documentElement.appendChild(caption);
document.addEventListener(
'mousemove',
(e) => (cursor.style.translate = `${e.clientX}px ${e.clientY}px`),
true
);
document.addEventListener(
'mousedown',
(e) => {
cursor.classList.add('down');
const ripple = document.createElement('div');
ripple.className = 'demo-ripple';
ripple.style.left = `${e.clientX}px`;
ripple.style.top = `${e.clientY}px`;
document.documentElement.appendChild(ripple);
setTimeout(() => ripple.remove(), 500);
},
true
);
document.addEventListener('mouseup', () => cursor.classList.remove('down'), true);
const fingers: HTMLDivElement[] = [];
const placeFingers = (e: TouchEvent) => {
Array.from(e.touches).forEach((touch, i) => {
const finger = (fingers[i] ??= Object.assign(document.createElement('div'), {
className: 'demo-finger'
}));
finger.style.left = `${touch.clientX}px`;
finger.style.top = `${touch.clientY}px`;
finger.style.opacity = '1';
document.documentElement.appendChild(finger);
});
fingers.slice(e.touches.length).forEach((finger) => (finger.style.opacity = '0'));
};
for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel']) {
document.addEventListener(type, placeFingers as EventListener, {
capture: true,
passive: true
});
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount);
} else {
mount();
}
}
/** Seeds UI settings once per demo, so later reloads keep what the demo changed. */
function seedSettings(config: Record<string, unknown>): void {
const key = 'LlamaUi.config';
if (localStorage.getItem(key) === null) {
localStorage.setItem(key, JSON.stringify(config));
}
}
/** Seeded randomness, so a demo moves the same way on every recording. */
function createRandom(seed: string): () => number {
let state = [...seed].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 2654435761), 1);
return () => {
state = (state + 0x6d2b79f5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
/**
* Drives the page like a person: the cursor glides along a slight arc with
* Fitts-like durations, settles briefly before pressing, and typing and pauses
* vary a little.
*/
export class Demo {
private cdp: CDPSession | undefined;
private readonly page: Page;
private readonly random: () => number;
private x = 0;
private y = 0;
/** Shows a caption at the top of the frame for `ms`, clear of the chat form. */
async caption(text: string, ms = 2200): Promise<void> {
await this.page.evaluate((t) => {
const el = document.getElementById('demo-caption');
if (!el) return;
el.textContent = t;
el.classList.add('visible');
}, text);
await this.page.waitForTimeout(ms);
await this.page.evaluate(() =>
document.getElementById('demo-caption')?.classList.remove('visible')
);
await this.page.waitForTimeout(250);
}
async click(target: Locator, ms = 400): Promise<void> {
await this.moveTo(target);
await this.page.waitForTimeout(this.between(80, 160));
await this.page.mouse.down();
await this.page.waitForTimeout(this.between(50, 90));
await this.page.mouse.up();
await this.pause(ms);
}
constructor(page: Page, seed: string) {
this.page = page;
this.random = createRandom(seed);
const viewport = page.viewportSize();
this.x = (viewport?.width ?? 0) / 2;
this.y = (viewport?.height ?? 0) / 2;
}
async hover(target: Locator, ms = 350): Promise<void> {
await this.moveTo(target);
await this.pause(ms);
}
/** Glides the cursor to a point near the center of `target`. */
async moveTo(target: Locator): Promise<void> {
await target.scrollIntoViewIfNeeded();
const box = await target.boundingBox();
if (!box) throw new Error(`demo: ${target} has no bounding box`);
const x = box.x + box.width * this.between(0.4, 0.6);
const y = box.y + box.height * this.between(0.4, 0.6);
const dx = x - this.x;
const dy = y - this.y;
const distance = Math.hypot(dx, dy);
const duration = 110 + 55 * Math.log2(distance / 40 + 1);
// bow the path a little to one side, like a wrist turning
const bow = distance * this.between(-0.08, 0.08);
const fromX = this.x;
const fromY = this.y;
const started = Date.now();
for (;;) {
const t = Math.min((Date.now() - started) / duration, 1);
const p = easeInOut(t);
const arc = Math.sin(Math.PI * p) * bow;
this.x = fromX + dx * p - (dy / (distance || 1)) * arc;
this.y = fromY + dy * p + (dx / (distance || 1)) * arc;
await this.page.mouse.move(this.x, this.y);
if (t === 1) break;
await this.page.waitForTimeout(8);
}
}
/** Waits about `ms`, varied by up to 15% so pauses don't feel metronomic. */
async pause(ms = 800): Promise<void> {
await this.page.waitForTimeout(ms * this.between(0.85, 1.15));
}
async press(key: string, ms = 600): Promise<void> {
await this.page.keyboard.press(key);
await this.pause(ms);
}
/** Scrolls the wheel over `target` by `dy` pixels (negative scrolls up), in small steps like a trackpad. */
async scroll(target: Locator, dy: number, ms = 500): Promise<void> {
await this.moveTo(target);
const step = Math.sign(dy) * 40;
for (let done = 0; Math.abs(done) < Math.abs(dy); done += step) {
await this.page.mouse.wheel(0, step);
await this.page.waitForTimeout(16);
}
await this.pause(ms);
}
/** Types quickly with uneven key timing, a little slower after spaces and punctuation. */
async type(target: Locator, text: string, ms = 500): Promise<void> {
await this.click(target, 200);
for (const char of text) {
await this.page.keyboard.type(char);
await this.page.waitForTimeout(
this.between(15, 45) + (/[\s.,!?]/.test(char) ? this.between(20, 60) : 0)
);
}
await this.pause(ms);
}
/**
* Pinches in on `target`, a control or a point in the viewport, holds about `ms`, and pinches
* back out to 1x: a two-finger zoom onto a detail too small to read in the frame. On a touch
* device the fingertips show.
*/
async zoom(target: Locator | { x: number; y: number }, scale = 2.5, ms = 1400): Promise<void> {
const point = 'x' in target ? target : await this.center(target);
// a pinch anchored in the viewport's top band does not zoom at all
const at = { x: point.x, y: Math.max(point.y, ZOOM_TOP_INSET_PX) };
const viewport = this.page.viewportSize() ?? { height: 0, width: 0 };
const middle = { x: viewport.width / 2, y: viewport.height / 2 };
await this.pinch(at, scale, 1400);
// One near the bottom edge overshoots its ratio, and a pinch there again misses too. A small
// pinch from the middle evens it out, drawing the detail toward the middle, so it stays in view
const reached = await this.pageScale();
if (Math.abs(reached / scale - 1) > 0.03) await this.pinch(middle, scale / reached, 1400);
await this.liftFingers();
await this.pause(ms);
// back to exactly 1x, which the next gesture needs, in one quick pinch from the middle of
// the viewport, and another for what is left when one lands short
for (let tries = 0; ; tries++) {
const current = await this.pageScale();
if (current <= 1.001) break;
if (tries === 4) throw new Error(`demo: zoom stuck at ${current.toFixed(3)}x`);
await this.pinch(middle, 1 / current, 1800);
}
await this.liftFingers();
await this.pause(200);
}
private between(min: number, max: number): number {
return min + (max - min) * this.random();
}
private async center(target: Locator): Promise<{ x: number; y: number }> {
const box = await target.boundingBox();
if (!box) throw new Error(`demo: ${target} has no bounding box`);
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/** A pinch that reaches the screen's edge can end without its last touchend reaching the page. */
private async liftFingers(): Promise<void> {
await this.page.evaluate(() =>
document
.querySelectorAll<HTMLElement>('.demo-finger')
.forEach((finger) => (finger.style.opacity = '0'))
);
}
private pageScale(): Promise<number> {
return this.page.evaluate(() => window.visualViewport?.scale ?? 1);
}
/**
* A two-finger pinch by `factor` around `at`, in viewport pixels. While zoomed in, the gesture
* reads its position in page-scale units, so it is divided by the scale the page is at.
*/
private async pinch(at: { x: number; y: number }, factor: number, speed: number): Promise<void> {
const cdp = await this.session();
const current = await this.pageScale();
await cdp.send('Input.synthesizePinchGesture', {
gestureSourceType: 'touch',
relativeSpeed: speed,
scaleFactor: factor,
x: at.x / current,
y: at.y / current
});
}
private async session(): Promise<CDPSession> {
this.cdp ??= await this.page.context().newCDPSession(this.page);
return this.cdp;
}
}
interface DemoOptions {
/** Path the demo starts from. */
startPath: string;
/** Extra UI settings merged into the seeded config. */
settings: Record<string, unknown>;
/**
* Conversations imported before the demo starts: built-in traces from
* fixtures/traces.ts, or `<name>.jsonl` files from DEMO_TRACES_DATASET.
*/
traces: string[];
}
/** Reads a trace by name, generating built-in ones and downloading the rest. */
function loadTrace(name: string, dir: string): string {
const generated = generateTrace(name);
if (generated) return generated;
const dataset = process.env.DEMO_TRACES_DATASET;
if (!dataset)
throw new Error(`demo: no built-in trace "${name}" and DEMO_TRACES_DATASET is not set`);
execFileSync(
'hf',
['download', dataset, `${name}.jsonl`, '--repo-type', 'dataset', '--local-dir', dir],
{ stdio: 'inherit' }
);
return readFileSync(join(dir, `${name}.jsonl`), 'utf8');
}
/** Imports conversations through Settings > Import/Export, like a user would. */
async function importTraces(page: Page, file: string): Promise<void> {
// the expanded sidebar adds the shortcut to the name
const settings = page.getByRole('button', { name: /^Settings(\s|$)/ }).first();
const reachable = (target: Locator) =>
target.click({ timeout: 2000, trial: true }).then(
() => true,
() => false
);
// a collapsed sidebar can sit under the page: on a phone the page's menu key slides it aside,
// on a desktop its logo expands it
const keys = [
page.locator('xpath=//button[@aria-label="Expand navigation"][not(ancestor::aside)]'),
page.locator('aside button[aria-label="Expand navigation"]')
];
for (const key of keys) {
if (await reachable(settings)) break;
if ((await key.count()) > 0 && (await reachable(key.first()))) await key.first().click();
}
await settings.click();
const dialog = page.getByRole('dialog');
await dialog.getByText('Import/Export', { exact: true }).filter({ visible: true }).click();
const chooser = page.waitForEvent('filechooser');
await dialog.getByRole('button', { name: 'Import conversations' }).click();
await (await chooser).setFiles(file);
await page.getByRole('button', { name: /^Import \(\d+\)$/ }).click();
await page.getByRole('heading', { name: /^Imported \d+ conversation/ }).waitFor();
// the app remembers the sidebar the import expanded; a demo starts with it collapsed, as on a
// first visit
await page.evaluate(() => {
for (const key of Object.keys(localStorage)) {
if (key.endsWith('.sidebarExpanded')) localStorage.removeItem(key);
}
});
}
export const test = base.extend<DemoOptions & { demo: Demo }>({
demo: async ({ page }, use, testInfo) => {
await use(new Demo(page, `${testInfo.file}:${testInfo.title}`));
},
page: async ({ page, settings, startPath, traces }, use, testInfo) => {
await page.addInitScript(installCursorOverlay);
await page.addInitScript(seedSettings, {
mcpServers: JSON.stringify(demoMcpServerSettings()),
...settings
});
if (traces.length > 0) {
const dir = testInfo.outputPath('traces');
const file = join(dir, 'import.jsonl');
mkdirSync(dir, { recursive: true });
writeFileSync(file, traces.map((name) => loadTrace(name, dir)).join('\n'));
await page.goto('/');
await page
.getByRole('textbox', { name: 'Type a message...' })
.waitFor({ timeout: APP_READY_TIMEOUT_MS });
await importTraces(page, file);
}
await page.goto(startPath);
// a hash route after the import doesn't reload, which the conversations list needs
if (traces.length > 0) await page.reload();
// a fresh checkout's dev server optimizes its dependencies on the first load and reloads the
// page, which can take well past an action's timeout; the recording has not started yet
await page
.getByRole('textbox', { name: 'Type a message...' })
.waitFor({ timeout: APP_READY_TIMEOUT_MS });
const viewport = page.viewportSize();
if (viewport) await page.mouse.move(viewport.width / 2, viewport.height / 2);
// let MCP connections and fonts settle before the recording starts
await page.waitForTimeout(1500);
const recorder = new ScreencastRecorder(testInfo.outputPath('frames'));
await recorder.start(page);
await use(page);
await recorder.stop();
await page.close();
if (testInfo.status !== testInfo.expectedStatus) return;
const file = basename(testInfo.file).replace(/\.demo\.ts$/, '');
const name = testInfo.title === 'demo' ? file : `${file}--${slugify(testInfo.title)}`;
const mp4 = resolve(VIDEOS_DIR, `${name}.mp4`);
mkdirSync(VIDEOS_DIR, { recursive: true });
recorder.encode(mp4);
// the CLI reads which videos a script made, in order, with their titles
appendFileSync(
resolve(VIDEOS_DIR, `${file}.videos.jsonl`),
`${JSON.stringify({ mp4, title: testInfo.title })}\n`
);
console.log(`demo video: ${mp4}`);
},
settings: [{}, { option: true }],
startPath: ['/', { option: true }],
traces: [[], { option: true }]
});
export { expect } from '@playwright/test';
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
+87
View File
@@ -0,0 +1,87 @@
import { DEMO_MCP_PORT } from './fixtures/demo-config.ts';
import { defineConfig, devices } from '@playwright/test';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
const ENV_FILE = resolve(import.meta.dirname, '../.env');
if (existsSync(ENV_FILE)) process.loadEnvFile(ENV_FILE);
/**
* Demo video recordings.
*
* Env (also read from tools/ui/.env):
* DEMO_UI_DIR tools/ui checkout to record (default: this one), e.g. a PR worktree
* DEMO_UI_PORT port for that checkout's vite dev server (default: 5180)
* DEMO_SERVER_ORIGIN llama-server the UI proxies to (default: http://localhost:9931)
* DEMO_LLAMA_SERVER llama-server binary started when none runs there (default: build/bin)
* DEMO_COLOR_SCHEME light | dark (default: light)
*/
// Playwright clears outputDir when a run starts, so concurrent recordings each get
// their own; workers inherit the env, so they resolve the same directory
process.env.DEMO_RUN_ID ??= String(process.pid);
const UI_DIR = resolve(process.env.DEMO_UI_DIR ?? resolve(import.meta.dirname, '..'));
const UI_PORT = Number(process.env.DEMO_UI_PORT ?? 5180);
const SERVER_ORIGIN = process.env.DEMO_SERVER_ORIGIN ?? 'http://localhost:9931';
const SERVER_PORT = Number(new URL(SERVER_ORIGIN).port || 80);
const LLAMA_SERVER =
process.env.DEMO_LLAMA_SERVER ?? resolve(import.meta.dirname, '../../../build/bin/llama-server');
export const VIEWPORT = { height: 900, width: 1440 };
/** Device pixel ratio the demos render and record at. */
const SCALE = Number(process.env.DEMO_SCALE ?? 2);
export default defineConfig({
expect: {
timeout: 10000
},
fullyParallel: false,
outputDir: `test-results/${process.env.DEMO_RUN_ID}`,
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], deviceScaleFactor: SCALE, viewport: VIEWPORT }
}
],
reporter: 'line',
retries: 0,
testDir: '.',
testMatch: ['**/*.demo.ts'],
timeout: 120000,
use: {
actionTimeout: 10000,
baseURL: `http://localhost:${UI_PORT}`,
colorScheme: process.env.DEMO_COLOR_SCHEME === 'dark' ? 'dark' : 'light',
// headless Chromium screencasts at 1x unless the scale is forced at launch; the sandbox
// is on since a PR demo runs that PR's code (Playwright turns it off by default). Not the
// installed Chrome: its headless mode screencasts a window that ignores the viewport
launchOptions: { args: [`--force-device-scale-factor=${SCALE}`], chromiumSandbox: true },
// recorded through the screencast in recorder.ts, at the device pixel ratio
video: 'off',
viewport: VIEWPORT
},
webServer: [
{
command: `node ${resolve(import.meta.dirname, 'fixtures/mcp-servers.ts')}`,
reuseExistingServer: true,
url: `http://localhost:${DEMO_MCP_PORT}/health`
},
{
command: `${LLAMA_SERVER} --host 127.0.0.1 --port ${SERVER_PORT} --tools all`,
reuseExistingServer: true,
timeout: 60000,
url: `${SERVER_ORIGIN}/health`
},
{
command: `npx vite dev --port ${UI_PORT} --strictPort`,
cwd: UI_DIR,
env: { VITE_PUBLIC_SERVER_ORIGIN: SERVER_ORIGIN },
reuseExistingServer: true,
timeout: 120000,
url: `http://localhost:${UI_PORT}`
}
],
workers: 1
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Records a page through Chromium's screencast instead of Playwright's video,
* which encodes VP8 at about 1 Mbit/s at 1x scale. Frames arrive at the page's
* device pixel ratio, each with its timestamp, and are encoded once into an
* H.264 mp4 (what GitHub accepts in PR descriptions).
*/
import type { CDPSession, Page } from '@playwright/test';
import { execFileSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const FPS = 60;
const JPEG_QUALITY = 92;
interface Frame {
file: string;
/** Seconds, on the same clock as Date.now(). */
time: number;
}
export class ScreencastRecorder {
private readonly dir: string;
private readonly frames: Frame[] = [];
private readonly pending: Promise<void>[] = [];
private session: CDPSession | undefined;
private stoppedAt = 0;
constructor(dir: string) {
this.dir = dir;
}
/** Encodes the frames, each held until the next one arrived (the screencast only sends changes). */
encode(mp4: string): void {
if (this.frames.length === 0) throw new Error('demo: the screencast captured no frames');
const list = this.frames.flatMap((frame, i) => {
const end = this.frames[i + 1]?.time ?? this.stoppedAt;
return [`file '${frame.file}'`, `duration ${Math.max(end - frame.time, 1 / FPS).toFixed(4)}`];
});
const listFile = join(this.dir, 'frames.txt');
// the concat demuxer ignores the last entry's duration unless the file is repeated
writeFileSync(listFile, [...list, `file '${this.frames.at(-1)!.file}'`, ''].join('\n'));
execFileSync(
'ffmpeg',
[
'-y',
'-loglevel',
'error',
'-f',
'concat',
'-safe',
'0',
'-i',
listFile,
'-vf',
`fps=${FPS},scale=trunc(iw/2)*2:trunc(ih/2)*2`,
'-c:v',
'libx264',
'-preset',
'slow',
'-crf',
'18',
'-pix_fmt',
'yuv420p',
'-movflags',
'+faststart',
mp4
],
{ stdio: 'inherit' }
);
rmSync(this.dir, { force: true, recursive: true });
}
async start(page: Page): Promise<void> {
const size = page.viewportSize();
const scale = await page.evaluate(() => window.devicePixelRatio);
rmSync(this.dir, { force: true, recursive: true });
mkdirSync(this.dir, { recursive: true });
this.session = await page.context().newCDPSession(page);
this.session.on('Page.screencastFrame', ({ data, metadata, sessionId }) => {
const file = join(this.dir, `${String(this.frames.length).padStart(6, '0')}.jpg`);
// acknowledged first: Chromium sends the next frame only after the ack
this.session?.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
this.frames.push({ file, time: metadata.timestamp ?? Date.now() / 1000 });
this.pending.push(writeFile(file, Buffer.from(data, 'base64')));
});
await this.session.send('Page.startScreencast', {
everyNthFrame: 1,
format: 'jpeg',
maxHeight: size ? size.height * scale : undefined,
maxWidth: size ? size.width * scale : undefined,
quality: JPEG_QUALITY
});
}
async stop(): Promise<void> {
this.stoppedAt = Date.now() / 1000;
await this.session?.send('Page.stopScreencast').catch(() => {});
await this.session?.detach().catch(() => {});
await Promise.all(this.pending);
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Storage targets for demo scripts and videos, picked by the scheme of the
* DEMO_STORAGE URI:
*
* hf://buckets/<owner>/<bucket>[/prefix] Hugging Face bucket, via the `hf` CLI login
* file:///some/dir local or mounted directory
*/
import { execFileSync } from 'node:child_process';
import { copyFileSync, cpSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export interface DemoStorage {
/** Copies every file under remote `dir` into local `target`; returns false when it has none. */
downloadDir(dir: string, target: string): boolean;
/** Copies local `file` to remote `key`. */
upload(file: string, key: string): void;
}
function joinKey(base: string, key: string): string {
return `${base.replace(/\/+$/, '')}/${key}`;
}
// plain fields: the CLI runs under Node's type stripping, which has no parameter properties
class HfStorage implements DemoStorage {
private readonly base: string;
constructor(base: string) {
this.base = base;
}
downloadDir(dir: string, target: string): boolean {
mkdirSync(target, { recursive: true });
try {
execFileSync('hf', ['buckets', 'sync', joinKey(this.base, dir), target], {
stdio: 'inherit'
});
} catch {
return false;
}
return true;
}
upload(file: string, key: string): void {
execFileSync('hf', ['buckets', 'cp', file, joinKey(this.base, key)], { stdio: 'inherit' });
}
}
class FileStorage implements DemoStorage {
private readonly root: string;
constructor(root: string) {
this.root = root;
}
downloadDir(dir: string, target: string): boolean {
const source = join(this.root, dir);
if (!existsSync(source)) return false;
cpSync(source, target, { recursive: true });
return true;
}
upload(file: string, key: string): void {
const target = join(this.root, key);
mkdirSync(dirname(target), { recursive: true });
copyFileSync(file, target);
}
}
export function getDemoStorage(uri = process.env.DEMO_STORAGE): DemoStorage | null {
if (!uri) return null;
if (uri.startsWith('hf://')) return new HfStorage(uri);
if (uri.startsWith('file://')) return new FileStorage(fileURLToPath(uri));
throw new Error(`DEMO_STORAGE: unsupported scheme in "${uri}" (use hf:// or file://)`);
}
/** Public URL of a stored key, when DEMO_STORAGE_PUBLIC_URL is set. */
export function getPublicUrl(key: string): string | null {
const base = process.env.DEMO_STORAGE_PUBLIC_URL;
return base ? joinKey(base, key) : null;
}
+5
View File
@@ -18,6 +18,11 @@
"test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test",
"test:e2e:pwa": "playwright test tests/e2e/pwa.e2e.ts",
"demo:codegen": "playwright codegen --target=playwright-test --viewport-size=1440,900 --output=demo/recording.demo.ts http://localhost:5180/",
"demo:docs": "node demo/cli.ts docs",
"demo:pr": "node demo/cli.ts pr",
"demo:record": "playwright test --config=demo/playwright.config.ts",
"demo:traces": "node demo/cli.ts traces",
"test:client": "vitest --project=client",
"test:unit": "vitest --project=unit",
"test:unit:pwa": "vitest --project=unit --run tests/unit/pwa.spec.ts",