diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c38d98f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,24 @@ +# House rule: all text is stored and checked out with LF. +# (Batch wrappers are the only exception -- cmd.exe needs CRLF.) +* text=auto eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binaries: never touch line endings, never treat as text +*.so binary +*.a binary +*.dll binary +*.exe binary +*.png binary +*.jpg binary +*.jpeg binary +*.ico binary +*.icns binary +*.jar binary +*.zip binary +*.bin binary +*.gguf binary +*.safetensors binary +*.e0b binary +# repo-specific addition: the technical report lives at paper/main.pdf +*.pdf binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c3a3ad..78cfea2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ jobs: test-macos: name: Unit tests (macOS + MLX) runs-on: macos-14 + defaults: + run: + working-directory: python steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -22,6 +25,9 @@ jobs: hygiene: name: Repo hygiene (pure Python, no MLX) runs-on: ubuntu-latest + defaults: + run: + working-directory: python steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/.gitignore b/.gitignore index c3b7432..b2587d4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,21 @@ dist/ # Trained adapter artifacts (never committed; see README) artifacts/ +# Downloaded model checkpoints (never committed; see README quick start). +# Anchored so the source package python/src/edge0/models/ is not matched. +/models/ +/python/models/ + +# Platform build areas materialized by the engine/vendor scripts (never commit): +# isolated patch-replay worktrees, the pinned llama.cpp checkout, and the +# local engine-depot bootstrap fallback (see windows/scripts/vendor-build.ps1). +/wt/ +/vendor/ +.edge0/ + # macOS .DS_Store # Bench / runtime logs *.log -scripts/alignment_results.json +python/scripts/alignment_results.json diff --git a/NOTICE b/NOTICE index ddee8a9..2b044cc 100644 --- a/NOTICE +++ b/NOTICE @@ -4,7 +4,7 @@ edge0 This project bundles or adapts the following third-party works: mlx-lm (MIT License) - - `src/edge0/backends/mlx/_impl/qwen3_next.py` is vendored from mlx-lm + - `python/src/edge0/backends/mlx/_impl/qwen3_next.py` is vendored from mlx-lm 0.31.0 (https://github.com/ml-explore/mlx-lm), with additive hooks for prerouter routing and layer callbacks. Non-prerouter paths are kept byte-identical to the upstream file. @@ -12,10 +12,24 @@ mlx-lm (MIT License) Copyright (c) 2023-2024 Apple Inc. and the mlx-lm contributors. bailing_hybrid (Apache License 2.0) - - `src/edge0/backends/mlx/_impl/bailing_hybrid.py` is vendored from the + - `python/src/edge0/backends/mlx/_impl/bailing_hybrid.py` is vendored from the Ling 3.0 MLX implementation (rapid-mlx / Ling-3.0-tiny, Apache-2.0), with prerouter hook identifiers renamed for consistency with the framework. Copyright (c) the bailing / Ling MLX contributors. +llama.cpp (MIT License) + - The Android and Windows engines build their inference core from a + pristine upstream checkout of llama.cpp + (https://github.com/ggml-org/llama.cpp), pinned at commit 7ab4ee7 + (release b11100) via `vendor.llama.pin` and materialized into the + gitignored `vendor/llama.cpp` by the platform build scripts. + - It is never patched in place: all modifications ship as replayable + patch bands under `patches/llama.cpp/{common,android,windows}` and are + replayed into an isolated build worktree at compile time. + - ggml (Apache License 2.0) is developed within the same upstream tree. + - Not vendored as files in this repo; built from source by the consumer + (see `android/NOTICE` and `windows/NOTICE` for the per-platform detail). + Copyright (c) 2023-2024 The ggml authors. + All other files are Copyright (c) 2025 edge0 contributors, licensed under the Apache License, Version 2.0 (see LICENSE). diff --git a/README.md b/README.md index 5e0240a..c335726 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ **An open-source streaming MoE inference framework — SSD expert offload + Recover-LoRA + prerouter routing prediction.** +**Python** · **macOS** · **iOS** · **Android** — one recipe, every device. + [![Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Edge0--35B--A3B--preview-yellow?style=for-the-badge)](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) [![Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Edge0--8B--A1B--preview-yellow?style=for-the-badge)](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) [![ModelScope](https://img.shields.io/badge/ModelScope-Edge0--35B--A3B--preview-624AFF?style=for-the-badge)](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) @@ -14,16 +16,52 @@ [![GitHub](https://img.shields.io/badge/GitHub-Edge0--AI%2FEdge0-black?style=for-the-badge&logo=github)](https://github.com/Edge0-AI/Edge0) [![License](https://img.shields.io/badge/License-Apache%202.0-blue?style=for-the-badge)](LICENSE) -English | [中文](README_zh.md) +English | [中文](README_zh.md) | [日本語](README_ja.md) | [Español](README_es.md) | [Français](README_fr.md) +## News + +- **[2026-09-30]** We released the **edge0 inference engines for four platforms — iOS, macOS, Android and Windows** — so users get the best inference experience across architectures and platforms. The source is open-sourced in this repo ([`ios/`](ios/) · [`macos/`](macos/) · [`android/`](android/) · [`windows/`](windows/)) — see each directory's README for details. The **unified inference framework** follows in **Q4 2026**; see the [Roadmap](#roadmap). +- **[2026-09-20]** `--prefill-ondemand` lands for `edge0 demo / chat / serve` ([#112](https://github.com/Edge0-AI/Edge0/pull/112)). +- **[2026-09-16]** Our technical report is on arXiv: [The Other Half of the Memory Wall: Serving 35B MoEs from SSD with Trained Routing Prediction](https://arxiv.org/abs/2609.18063). +- **[2026-09-08]** Initial open-source release of **edge0**, together with both model tiers — [`Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) and [`Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) — on Hugging Face and ModelScope. + +## About + **edge0** is an open-source streaming MoE inference framework. It generalizes the production-proven recipe — **SSD expert offload + Recover-LoRA + prerouter routing prediction** — into an extensible -framework. The backend is isolated by design: the current MLX backend -runs on Apple Silicon, and additional platforms (CUDA, …) plug into the -same core abstractions. +framework that runs large sparse-MoE models on consumer hardware: peak +memory is bounded by the *active* expert set, not the parameter count. + +### Core mechanisms + +- **SSD expert offload**: expert weights are streamed from storage on + demand; peak memory is bounded by the active set, not the parameter + count. +- **Prerouter**: a trained head predicts expert routing one step + ahead, so expert loads overlap the forward pass instead of stalling + it — **up to +59%** decode throughput; the gain grows with storage + latency, model size, and routed width *K*. +- **Recover-LoRA**: the int4 base is frozen and LoRA adapters are + trained by distillation from the FP teacher, recovering most of the + quantization loss at 4-bit (see [Quality](#quality)). Adapters stay + unmerged: one read-only base serves multiple adapter sets. + +### Platforms + +One repo, one recipe, per-platform runtimes: + +| Platform | Directory | Stack | Status | +|---|---|---|---| +| **Python** (macOS · Apple Silicon) | [`python/`](python/README.md) | Python + MLX | ✅ Available now | +| **macOS** app & CLI | [`macos/`](macos/README.md) | Rust | ✅ Open-sourced (2026-09-30) | +| **iOS** app | [`ios/`](ios/README.md) | Swift + MLX Swift | ✅ Open-sourced (2026-09-30) | +| **Android** app & engine | [`android/`](android/README.md) | Kotlin + native engine | ✅ Open-sourced (2026-09-30) | +| **Windows** app & engine | [`windows/`](windows/README.md) | C++ + Vulkan | ✅ Open-sourced (2026-09-30) | + +### Models Two model tiers ship with the framework. Each tier is an end-to-end release: the released checkpoint, the trained LoRA adapters, and the @@ -40,32 +78,19 @@ LoRA and prerouter training done for this framework — the adapter files are co-located with each checkpoint and load automatically, so `edge0 serve ` runs the trained pipeline out of the box. -## Requirements - -- **OS / hardware**: the MLX backend runs on macOS with Apple Silicon - (M1/M2/M3/M4). The CUDA backend is on the roadmap — no other - platforms are supported yet. -- **Python**: 3.10+ (3.12 recommended). -- **MLX**: `mlx==0.30.6` / `mlx-metal==0.30.6` with `mlx-lm==0.31.0` (see - `pyproject.toml`). Garbled, mixed-language output on Apple A18 / A18 Pro - means an older `mlx`: `pip install 'mlx==0.30.6' 'mlx-metal==0.30.6'` - ([#8](https://github.com/Edge0-AI/Edge0/issues/8)). -- **Memory**: ~2.9 GB peak active memory for `edge0-35b`, ~1.0 GB for - `edge0-8b` (short contexts; see [Benchmark](#benchmark)). Add - headroom for the OS, tokenizer, and long-context KV growth. -- **Disk**: the 4-bit checkpoints are ~23 GB (`edge0-35b`) and ~4.2 GB - (`edge0-8b`); expert weights are mmapped and read on demand, they are - not loaded into RAM up front. - -## Design +### Design - **transformers-style usage**: `AutoModel` / `AutoConfig` / `AutoEngine` resolve the tier from the model name; -- **Backend isolation**: all MLX code lives under `edge0/backends/mlx/`; - the core logic (model specs, prerouter, streaming expert pool, server) - depends only on the backend facade (`edge0/backends/base.py`), so a new - backend implements the same facade (`backends/cuda/` is a reserved - slot) with zero changes to core code; +- **Backend isolation (Python framework)**: within the Python framework, + all MLX code lives under `python/src/edge0/backends/mlx/`; the core + logic (model specs, prerouter, streaming expert pool, server) depends + only on the backend facade (`backends/base.py`), so a new backend + implements the same facade (`backends/cuda/` is a reserved slot) with + zero changes to core code. The iOS / macOS / Android engines ship + platform-native stacks today — bringing every platform under one + access layer is exactly what the unified inference framework + (see [Roadmap](#roadmap)) will deliver; - **Adapters as safetensors**: LoRA and prerouter weights are `.safetensors` files with provenance metadata (source, version, owner layers), resolved from the model directory or `artifacts/`; @@ -74,34 +99,79 @@ are co-located with each checkpoint and load automatically, so and that model's adapters; upgrading adapters swaps adapter files only — the base stays read-only and is never merged. -## Core mechanisms +### Quality -- **SSD expert offload**: expert weights are streamed from storage on - demand; peak memory is bounded by the active set, not the parameter - count. -- **Prerouter**: a trained head predicts expert routing one step - ahead, so expert loads overlap the forward pass instead of stalling - it — **up to +59%** decode throughput; the gain grows with storage - latency, model size, and routed width *K*. -- **Recover-LoRA**: the int4 base is frozen and LoRA adapters are - trained by distillation from the FP teacher, recovering most of the - quantization loss at 4-bit (see [Quality](#quality)). Adapters stay - unmerged: one read-only base serves multiple adapter sets. +All benchmarks were run by us with [OpenCompass](https://github.com/open-compass/opencompass) +under identical settings and parameters for both the edge0 models (int4 + +trained adapters + prerouter routing) and the original fp16 base models. +The loss of the edge0 pipeline is small: **3.9 points on average for +edge0-35b, 2.8 for edge0-8b** (MMLU-Pro is even above the base). Max 100: + +| Benchmark | edge0-35b (int4) | Qwen3.6-35B-A3B (fp16) | edge0-8b (int4) | Ling 3.0 tiny (fp16) | +|---|---:|---:|---:|---:| +| AIME 2026 | 86.6 | 92.7 | 63.3 | 73.3 | +| HumanEval | 90.9 | 95.1 | 91.5 | 92.7 | +| GPQA-Diamond | 79.8 | 81.8 | 70.7 | 71.2 | +| MMLU-Pro | 81.0 | 84.6 | 70.1 | 65.8 | +| IFBench | 57.9 | 61.7 | 53.9 | 60.6 | +| **Average** | **79.2** | **83.2** | **69.9** | **72.7** | + +### Benchmark + +Measured with `python/examples/bench.py` (3.3k-token prompt prefill → 10 +sampled warmup steps → 200 timed sampled decode tokens, 2 runs per tier): + +| Tier | Decode speed | Prefill throughput (cold / warm)* | Peak active memory | Test machine | +|---|---|---|---|---| +| `edge0-35b` | 14.9–17.7 tok/s | 113 / 140 tok/s | 2.9 GiB | Mac mini M4 Pro, 24 GB | +| `edge0-8b` | 23.9–25.3 tok/s | 500 / 1428 tok/s | 1.0 GiB | Mac mini M4 Pro, 24 GB | + +*Cold = first request after process start (expert weights fault in from +SSD); warm = subsequent requests (page cache resident). Prefill numbers +are throughput over a ~3.3k-token prompt (`BENCH_LONG=1`).* + +Reproduce: + +```bash +cd python +python examples/bench.py edge0-35b # via $EDGE0_35B_MODEL +python examples/bench.py edge0-8b # via $EDGE0_8B_MODEL +``` -## Quick start +## Getting Started + +### Python (macOS · Apple Silicon) + +#### Requirements + +- **OS / hardware**: the MLX backend runs on macOS with Apple Silicon + (M1/M2/M3/M4). The CUDA backend is on the roadmap — no other + platforms are supported by the Python framework yet. +- **Python**: 3.10+ (3.12 recommended). +- **MLX**: `mlx==0.30.6` / `mlx-metal==0.30.6` with `mlx-lm==0.31.0` (see + `python/pyproject.toml`). Garbled, mixed-language output on Apple A18 / + A18 Pro means an older `mlx`: `pip install 'mlx==0.30.6' + 'mlx-metal==0.30.6'` ([#8](https://github.com/Edge0-AI/Edge0/issues/8)). +- **Memory**: ~2.9 GB peak active memory for `edge0-35b`, ~1.0 GB for + `edge0-8b` (short contexts; see [Benchmark](#benchmark)). Add + headroom for the OS, tokenizer, and long-context KV growth. +- **Disk**: the 4-bit checkpoints are ~23 GB (`edge0-35b`) and ~4.2 GB + (`edge0-8b`); expert weights are mmapped and read on demand, they are + not loaded into RAM up front. -### 1) Install +#### 1) Install ```bash +cd python # Python >= 3.10; the MLX backend requires macOS with Apple Silicon python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev,fetch]' ``` -### 2) Download a model +#### 2) Download a model -The two tiers are published on Hugging Face and ModelScope — each repo bundles the -base checkpoint and the trained LoRA + prerouter adapters in **one -directory**, so a single download is a ready-to-run model: +The two tiers are published on Hugging Face and ModelScope — each repo +bundles the base checkpoint and the trained LoRA + prerouter adapters in +**one directory**, so a single download is a ready-to-run model: - [`Edge0/Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) (~23 GB) · [ModelScope mirror](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) - [`Edge0/Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) (~4.2 GB) · [ModelScope mirror](https://www.modelscope.cn/models/Edge0/Edge0-8B-A1B-preview) @@ -125,7 +195,7 @@ models/edge0-35b/ └── prerouter_edge0_35b.safetensors # trained prerouter heads ``` -### 3) Point edge0 at it +#### 3) Point edge0 at it Tier names resolve to local directories via environment variables (where you put the download is up to you): @@ -143,7 +213,7 @@ edge0 demo models/edge0-35b edge0 serve models/edge0-8b ``` -### 4) Run +#### 4) Run ```bash # quick demo @@ -156,7 +226,7 @@ edge0 serve edge0-35b ```bash curl http://127.0.0.1:8000/v1/chat/completions \ -H 'Content-Type: application/json' \ - -d '{"messages":[{"role":"user","content":"Hello!"}],"max_tokens":32}' + -d '{"model":"edge0-35b","messages":[{"role":"user","content":"Hello!"}],"max_tokens":32}' # 5) One-shot chat (pass --max-new to cap length; add --show-thinking to # print the model's reasoning block too) @@ -165,7 +235,7 @@ edge0 chat edge0-35b --prompt "Explain streaming inference in one sentence." `python -m edge0 ...` is equivalent to `edge0 ...`. -### Python API +#### Python API ```python from edge0 import AutoEngine @@ -182,10 +252,10 @@ print(engine._tok.decode(tokens)) engine.close() # release mmaps / expert cache ``` -`examples/demo.py` is the same minimal walkthrough (`edge0 demo` runs -this exact path). +`python/examples/demo.py` is the same minimal walkthrough (`edge0 demo` +runs this exact path). -### Models and adapters +#### Models and adapters - **Checkpoint**: the original model directory (`config.json`, `model*.safetensors`, tokenizer). `edge0 serve ` / @@ -195,72 +265,133 @@ this exact path). location automatically: - the model directory (recommended): side by side with the base, e.g. `lora_edge0_35b.safetensors` + `prerouter_edge0_35b.safetensors`; - - `artifacts/` (repo root, gitignored): convert once from - training-side npz exports via `edge0 convert-adapters --npz-dir ...`. + - `artifacts/` at the Python project root (gitignored): an optional + fallback cache for adapter safetensors not co-located with the model. - The published model repos bundle both the base checkpoint and the current default adapter release, so `scripts/fetch_models.py` produces - a ready-to-run model directory. Check each model's doc page for its + a ready-to-run model directory. Check each model's doc page for its adapter provenance (training data, owner-layer layout). - Both adapters are required for the prerouter + LoRA pipeline; if a file is missing, `edge0` fails with a clear message (or pass `--no-prerouter` / `--no-lora` to run the plain base model). -## Quality -All benchmarks were run by us with [OpenCompass](https://github.com/open-compass/opencompass) -under identical settings and parameters for both the edge0 models (int4 + -trained adapters + prerouter routing) and the original fp16 base models. -The loss of the edge0 pipeline is small: **3.9 points on average for -edge0-35b, 2.8 for edge0-8b** (MMLU-Pro is even above the base). Max 100: +#### Documentation -| Benchmark | edge0-35b (int4) | Qwen3.6-35B-A3B (fp16) | edge0-8b (int4) | Ling 3.0 tiny (fp16) | -|---|---:|---:|---:|---:| -| AIME 2026 | 86.6 | 92.7 | 63.3 | 73.3 | -| HumanEval | 90.9 | 95.1 | 91.5 | 92.7 | -| GPQA-Diamond | 79.8 | 81.8 | 70.7 | 71.2 | -| MMLU-Pro | 81.0 | 84.6 | 70.1 | 65.8 | -| IFBench | 57.9 | 61.7 | 53.9 | 60.6 | -| **Average** | **79.2** | **83.2** | **69.9** | **72.7** | +- [Architecture](docs/architecture.md) +- [Attention](docs/attention.md) / [MoE](docs/moe.md) / [SSD streaming](docs/streaming.md) / [prerouter](docs/prerouter.md) +- [Adding a model](docs/adding-a-model.md) +- [edge0-35b](docs/models/edge0-35b.md) / [edge0-8b](docs/models/edge0-8b.md) +- Technical report: [The Other Half of the Memory Wall](https://arxiv.org/abs/2609.18063) ([PDF](paper/main.pdf)) -## Benchmark +### macOS / iOS / Android / Windows -Measured with `examples/bench.py` (3.3k-token prompt prefill → 10 sampled -warmup steps → 200 timed sampled decode tokens, 2 runs per tier): +The four platform engines are open-sourced in this repo — more details +in each directory's README: -| Tier | Decode speed | Prefill throughput (cold / warm)* | Peak active memory | Test machine | -|---|---|---|---|---| -| `edge0-35b` | 14.9–17.7 tok/s | 113 / 140 tok/s | 2.9 GiB | Mac mini M4 Pro, 24 GB | -| `edge0-8b` | 23.9–25.3 tok/s | 500 / 1428 tok/s | 1.0 GiB | Mac mini M4 Pro, 24 GB | +- **macOS**: local CLI / daemon / desktop app (Rust) — see [`macos/README.md`](macos/README.md) +- **iOS**: on-device iPhone app (Swift + MLX Swift) — see [`ios/README.md`](ios/README.md) +- **Android**: on-device app + native engine (Kotlin) — see [`android/README.md`](android/README.md) +- **Windows**: desktop app + native engine (C++ + Vulkan) — see [`windows/README.md`](windows/README.md) -*Cold = first request after process start (expert weights fault in from -SSD); warm = subsequent requests (page cache resident). Prefill numbers -are throughput over a ~3.3k-token prompt (`BENCH_LONG=1`).* +The **unified inference framework** — one access layer, runtime +auto-adapting to iOS / macOS / Android / Windows / Python — arrives in +**Q4 2026**; see the [Roadmap](#roadmap). -Reproduce: +## Roadmap -```bash -python examples/bench.py edge0-35b # via $EDGE0_35B_MODEL -python examples/bench.py edge0-8b # via $EDGE0_8B_MODEL -``` +### Q4 2026 + +**Platforms & systems** + +- **edge0 unified inference framework** — we will + open-source a unified inference framework: **one unified access + layer** (a single API across chat / serve / on-device use), with the + **runtime automatically adapting to the hardware platform** — iOS, + macOS, Android, Windows and Python. It builds on the platform + engines already open-sourced in this repo (`ios/` · `macos/` · + `android/` · `windows/`). +- **CUDA backend** for the Python framework — reserved slot at + `python/src/edge0/backends/cuda/`, core code needs zero changes. + +**Models & algorithms** + +Q4 works two fronts: bringing a next-generation architecture into the +framework, and turning latent reasoning into a real latency saving rather +than just an arithmetic one. + +- **Next-gen architecture support (Qwen3.8-Flash class)** — run hybrid + linear attention (GDN + QSA), gated multi-branch residual, and N-gram + embedding topologies on edge0. These designs suit SSD streaming offload + naturally: O(1)-state attention keeps long thinking from becoming a + KV-cache problem, and lookup-only N-gram tables stream on demand. Goal: + the tier runs on a single device and benchmarks within an acceptable + gap of the fp16 base. +- **Latent thinking + batched expert pre-prediction** — make latent + reasoning a *latency* saving, not only an arithmetic one. The core + engineering problem: move expert routing from per-position to **once + per block**, so one prediction covers every position and round of a + block and **expert load volume decouples from the reasoning loop + count** — plus cross-block prefetch that loads the next block's experts + inside the current block's compute window. Progress is measured as + **end-to-end thinking-phase time at matched accuracy** (never tokens/s). +- More model tiers and adapter releases on the existing pipeline. -## Tests +## Contributing + +Contributions are welcome — issues, PRs, benchmark reports and model +ports all count. + +**Python framework** (available now): ```bash +cd python +python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev,fetch]' + pytest # unit tests (no real weights) EDGE0_8B_MODEL=/path/to/edge0-8b pytest -m slow -q - # real-weight generation; missing tiers are skipped + # real-weight generation; missing tiers are skipped .venv/bin/python scripts/e2e_smoke.py \ --qwen-dir /path/to/edge0-35b --ling-dir /path/to/edge0-8b - # staged vs exact consistency + generation smoke + # staged vs exact consistency + generation smoke scripts/generate_example.py # full-pipeline API example -examples/demo.py # minimal API walkthrough +examples/demo.py # minimal API walkthrough ``` -## Documentation +CI runs unit tests (macOS + MLX) and a repo-hygiene suite (no hardcoded +paths, backend-boundary and secret checks) on every PR. -- [Architecture](docs/architecture.md) -- [Attention](docs/attention.md) / [MoE](docs/moe.md) / [SSD streaming](docs/streaming.md) / [prerouter](docs/prerouter.md) -- [Adding a model](docs/adding-a-model.md) -- [edge0-35b](docs/models/edge0-35b.md) / [edge0-8b](docs/models/edge0-8b.md) +**Platform runtimes** (macOS / iOS / Android / Windows): each platform +directory ships its own build guide and tests — see the directory +READMEs. + +Workflow: fork → feature branch → PR against `main`. Please keep the +hygiene suite green and add tests for new behavior. + +## Citation + +If you find edge0 useful, please cite our technical report: + +```bibtex +@article{lin2026other, + title = {The Other Half of the Memory Wall: Serving 35B MoEs from SSD + with Trained Routing Prediction}, + author = {Lin, Yu and Wang, Yiming and Cai, Runyuan and Liu, Hanze and + Zeng, Xiaodong}, + journal = {arXiv preprint arXiv:2609.18063}, + year = {2026}, + url = {https://arxiv.org/abs/2609.18063} +} +``` + +## Contact Us + +Community and support channels are coming soon — this section will list +the official ways to reach us: + +- **Email**: samuel@edge0.ai + +For bugs and feature requests, please use +[GitHub Issues](https://github.com/Edge0-AI/Edge0/issues). ## License diff --git a/README_es.md b/README_es.md new file mode 100644 index 0000000..c5f8dcf --- /dev/null +++ b/README_es.md @@ -0,0 +1,419 @@ +
+ +edge0 + +# edge0 + +**Un framework de inferencia MoE en streaming de código abierto — SSD expert offload + Recover-LoRA + predicción de enrutamiento del prerouter.** + +**Python** · **macOS** · **iOS** · **Android** — una receta, todos los dispositivos. + +[![Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Edge0--35B--A3B--preview-yellow?style=for-the-badge)](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) +[![Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Edge0--8B--A1B--preview-yellow?style=for-the-badge)](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) +[![ModelScope](https://img.shields.io/badge/ModelScope-Edge0--35B--A3B--preview-624AFF?style=for-the-badge)](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) +[![ModelScope](https://img.shields.io/badge/ModelScope-Edge0--8B--A1B--preview-624AFF?style=for-the-badge)](https://www.modelscope.cn/models/Edge0/Edge0-8B-A1B-preview) +[![arXiv](https://img.shields.io/badge/arXiv-2609.18063-B31B1B?style=for-the-badge&logo=arxiv&logoColor=white)](https://arxiv.org/abs/2609.18063) +[![GitHub](https://img.shields.io/badge/GitHub-Edge0--AI%2FEdge0-black?style=for-the-badge&logo=github)](https://github.com/Edge0-AI/Edge0) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue?style=for-the-badge)](LICENSE) + +[English](README.md) | [中文](README_zh.md) | [日本語](README_ja.md) | Español | [Français](README_fr.md) + +
+ +## Novedades + +- **[2026-09-30]** Lanzamos los **motores de inferencia de edge0 para cuatro plataformas — iOS, macOS, Android y Windows** — de modo que los usuarios obtienen la mejor experiencia de inferencia en todas las arquitecturas y plataformas. El código fuente se ha liberado en este repositorio ([`ios/`](ios/) · [`macos/`](macos/) · [`android/`](android/) · [`windows/`](windows/)) — consulta el README de cada directorio para más detalles. El **framework de inferencia unificado** llegará en **Q4 2026**; consulta la [Hoja de ruta](#hoja-de-ruta). +- **[2026-09-20]** `--prefill-ondemand` llega a `edge0 demo / chat / serve` ([#112](https://github.com/Edge0-AI/Edge0/pull/112)). +- **[2026-09-16]** Nuestro informe técnico está en arXiv: [The Other Half of the Memory Wall: Serving 35B MoEs from SSD with Trained Routing Prediction](https://arxiv.org/abs/2609.18063). +- **[2026-09-08]** Primera versión de código abierto de **edge0**, junto con los dos niveles de modelos — [`Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) y [`Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) — en Hugging Face y ModelScope. + +## Acerca de + +**edge0** es un framework de inferencia MoE en streaming de código abierto. +Generaliza la receta probada en producción — **SSD expert offload + +Recover-LoRA + predicción de enrutamiento del prerouter** — en un framework +extensible que ejecuta grandes modelos sparse-MoE en hardware de consumo: la +memoria pico está limitada por el conjunto de expertos *activos*, no por el +número de parámetros. + +### Mecanismos principales + +- **SSD expert offload**: los pesos de los expertos se transmiten desde el + almacenamiento bajo demanda; la memoria pico está limitada por el conjunto + activo, no por el número de parámetros. +- **Prerouter**: una cabeza entrenada predice el enrutamiento de expertos un + paso por delante, de modo que las cargas de expertos se solapan con el + forward pass en lugar de bloquearlo — **hasta +59%** de throughput de + decode; la ganancia crece con la latencia del almacenamiento, el tamaño del + modelo y el ancho de enrutamiento *K*. +- **Recover-LoRA**: la base int4 se mantiene congelada y los adaptadores LoRA + se entrenan por destilación desde el teacher FP, recuperando la mayor parte + de la pérdida de cuantización a 4 bits (consulta [Calidad](#calidad)). Los + adaptadores permanecen sin fusionar: una única base de solo lectura sirve a + varios conjuntos de adaptadores. + +### Plataformas + +Un repositorio, una receta, runtimes por plataforma: + +| Plataforma | Directorio | Stack | Estado | +|---|---|---|---| +| **Python** (macOS · Apple Silicon) | [`python/`](python/README_es.md) | Python + MLX | ✅ Disponible ahora | +| Aplicación y CLI para **macOS** | [`macos/`](macos/README_es.md) | Rust | ✅ Código abierto (2026-09-30) | +| Aplicación para **iOS** | [`ios/`](ios/README_es.md) | Swift + MLX Swift | ✅ Código abierto (2026-09-30) | +| Aplicación y motor para **Android** | [`android/`](android/README_es.md) | Kotlin + motor nativo | ✅ Código abierto (2026-09-30) | +| Aplicación y motor para **Windows** | [`windows/`](windows/README_es.md) | C++ + Vulkan | ✅ Código abierto (2026-09-30) | + +### Modelos + +El framework incluye dos niveles de modelos. Cada nivel es una versión de +extremo a extremo: el checkpoint publicado, los adaptadores LoRA entrenados y +las cabezas prerouter entrenadas funcionan en conjunto como una sola unidad. + +| Nivel | Checkpoint publicado | Perfil de inferencia | +|---|---|---| +| `edge0-35b` | [`Edge0/Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) · [ModelScope](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) | 4 bits, 40 capas, 256 expertos, prerouter K=4 | +| `edge0-8b` | [`Edge0/Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) · [ModelScope](https://www.modelscope.cn/models/Edge0/Edge0-8B-A1B-preview) | 4 bits, 24 capas, 128 expertos, prerouter K=8 | + +Ambos checkpoints se construyen sobre modelos base sparse-MoE abiertos +(Qwen3.6-35B-A3B y el híbrido bailing Ling 3.0, respectivamente) e incluyen +el entrenamiento de LoRA y prerouter realizado para este framework — los +archivos de adaptadores están junto a cada checkpoint y se cargan +automáticamente, de modo que `edge0 serve ` ejecuta el pipeline +entrenado sin ninguna configuración adicional. + +### Diseño + +- **Uso al estilo transformers**: `AutoModel` / `AutoConfig` / `AutoEngine` + resuelven el nivel a partir del nombre del modelo; +- **Aislamiento de backends (framework de Python)**: dentro del framework de + Python, todo el código MLX vive en `python/src/edge0/backends/mlx/`; la + lógica principal (especificaciones de modelos, prerouter, pool de expertos + en streaming, servidor) depende solo de la fachada del backend + (`backends/base.py`), de modo que un nuevo backend implementa la misma + fachada (`backends/cuda/` es un espacio reservado) sin ningún cambio en el + código principal. Los motores de iOS / macOS / Android incluyen hoy stacks + nativos de cada plataforma — llevar todas las plataformas bajo una única + capa de acceso es exactamente lo que entregará el framework de inferencia + unificado (consulta la [Hoja de ruta](#hoja-de-ruta)); +- **Adaptadores como safetensors**: los pesos de LoRA y del prerouter son + archivos `.safetensors` con metadatos de procedencia (fuente, versión, capas + propietarias), resueltos desde el directorio del modelo o `artifacts/`; +- **Modelo + adaptadores en un solo directorio**: un directorio de modelo + contiene tanto el checkpoint base (`config.json` / `model*.safetensors` / + tokenizer) como los adaptadores de ese modelo; actualizar los adaptadores + solo reemplaza los archivos de adaptadores — la base permanece de solo + lectura y nunca se fusiona. + +### Calidad + +Todos los benchmarks fueron ejecutados por nosotros con +[OpenCompass](https://github.com/open-compass/opencompass) bajo configuraciones +y parámetros idénticos tanto para los modelos edge0 (int4 + adaptadores +entrenados + enrutamiento prerouter) como para los modelos base fp16 +originales. La pérdida del pipeline edge0 es pequeña: **3.9 puntos de media +para edge0-35b, 2.8 para edge0-8b** (MMLU-Pro incluso supera a la base). +Máximo 100: + +| Benchmark | edge0-35b (int4) | Qwen3.6-35B-A3B (fp16) | edge0-8b (int4) | Ling 3.0 tiny (fp16) | +|---|---:|---:|---:|---:| +| AIME 2026 | 86.6 | 92.7 | 63.3 | 73.3 | +| HumanEval | 90.9 | 95.1 | 91.5 | 92.7 | +| GPQA-Diamond | 79.8 | 81.8 | 70.7 | 71.2 | +| MMLU-Pro | 81.0 | 84.6 | 70.1 | 65.8 | +| IFBench | 57.9 | 61.7 | 53.9 | 60.6 | +| **Media** | **79.2** | **83.2** | **69.9** | **72.7** | + +### Benchmark + +Medido con `python/examples/bench.py` (prefill de un prompt de 3.3k tokens → +10 pasos de calentamiento muestreados → 200 tokens de decode muestreados y +cronometrados, 2 ejecuciones por nivel): + +| Nivel | Velocidad de decode | Throughput de prefill (frío / caliente)* | Memoria activa pico | Máquina de pruebas | +|---|---|---|---|---| +| `edge0-35b` | 14.9–17.7 tok/s | 113 / 140 tok/s | 2.9 GiB | Mac mini M4 Pro, 24 GB | +| `edge0-8b` | 23.9–25.3 tok/s | 500 / 1428 tok/s | 1.0 GiB | Mac mini M4 Pro, 24 GB | + +*Frío = primera petición tras iniciar el proceso (los pesos de expertos entran +por fallos de página desde el SSD); caliente = peticiones posteriores +(residentes en la caché de páginas). Las cifras de prefill son throughput sobre +un prompt de ~3.3k tokens (`BENCH_LONG=1`).* + +Reproducir: + +```bash +cd python +python examples/bench.py edge0-35b # via $EDGE0_35B_MODEL +python examples/bench.py edge0-8b # via $EDGE0_8B_MODEL +``` + +## Primeros pasos + +### Python (macOS · Apple Silicon) + +#### Requisitos + +- **SO / hardware**: el backend MLX se ejecuta en macOS con Apple Silicon + (M1/M2/M3/M4). El backend CUDA está en la hoja de ruta — el framework de + Python aún no admite otras plataformas. +- **Python**: 3.10+ (se recomienda 3.12). +- **MLX**: `mlx==0.30.6` / `mlx-metal==0.30.6` con `mlx-lm==0.31.0` (consulta + `python/pyproject.toml`). Una salida ilegible o mezclada de idiomas en Apple + A18 / A18 Pro indica un `mlx` antiguo: `pip install 'mlx==0.30.6' + 'mlx-metal==0.30.6'` ([#8](https://github.com/Edge0-AI/Edge0/issues/8)). +- **Memoria**: ~2.9 GB de memoria activa pico para `edge0-35b`, ~1.0 GB para + `edge0-8b` (contextos cortos; consulta [Benchmark](#benchmark)). Añade + margen para el SO, el tokenizer y el crecimiento de la KV cache en contextos + largos. +- **Disco**: los checkpoints de 4 bits ocupan ~23 GB (`edge0-35b`) y ~4.2 GB + (`edge0-8b`); los pesos de los expertos se mapean con mmap y se leen bajo + demanda, no se cargan en RAM por adelantado. + +#### 1) Instalación + +```bash +cd python +# Python >= 3.10; the MLX backend requires macOS with Apple Silicon +python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev,fetch]' +``` + +#### 2) Descargar un modelo + +Los dos niveles están publicados en Hugging Face y ModelScope — cada +repositorio incluye el checkpoint base y los adaptadores entrenados de LoRA + +prerouter en **un solo directorio**, de modo que una única descarga es un +modelo listo para ejecutar: + +- [`Edge0/Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) (~23 GB) · [mirror en ModelScope](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) +- [`Edge0/Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) (~4.2 GB) · [mirror en ModelScope](https://www.modelscope.cn/models/Edge0/Edge0-8B-A1B-preview) + +```bash +# with the repo's helper (defaults to the two repos above): +.venv/bin/python scripts/fetch_models.py --tier edge0-35b --target-dir models +.venv/bin/python scripts/fetch_models.py --tier edge0-8b --target-dir models + +# or directly with the CLI: +.venv/bin/huggingface-cli download Edge0/Edge0-35B-A3B-preview --local-dir models/edge0-35b +.venv/bin/huggingface-cli download Edge0/Edge0-8B-A1B-preview --local-dir models/edge0-8b +``` + +De cualquier forma obtendrás un directorio así: + +``` +models/edge0-35b/ +├── config.json, model-*.safetensors, tokenizer files # base checkpoint +├── lora_edge0_35b.safetensors # trained LoRA adapters +└── prerouter_edge0_35b.safetensors # trained prerouter heads +``` + +#### 3) Apuntar edge0 al modelo + +Los nombres de nivel se resuelven a directorios locales mediante variables de +entorno (dónde guardes la descarga depende de ti): + +```bash +export EDGE0_35B_MODEL=$PWD/models/edge0-35b +export EDGE0_8B_MODEL=$PWD/models/edge0-8b +``` + +O sáltate las variables de entorno y pasa el directorio directamente — el +nivel se detecta automáticamente desde el `config.json` del checkpoint: + +```bash +edge0 demo models/edge0-35b +edge0 serve models/edge0-8b +``` + +#### 4) Ejecutar + +```bash +# quick demo +edge0 demo edge0-35b + +# serve (OpenAI-compatible /v1/chat/completions) +edge0 serve edge0-35b +``` + +```bash +curl http://127.0.0.1:8000/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{"model":"edge0-35b","messages":[{"role":"user","content":"Hello!"}],"max_tokens":32}' + +# 5) One-shot chat (pass --max-new to cap length; add --show-thinking to +# print the model's reasoning block too) +edge0 chat edge0-35b --prompt "Explain streaming inference in one sentence." +``` + +`python -m edge0 ...` es equivalente a `edge0 ...`. + +#### API de Python + +```python +from edge0 import AutoEngine +from edge0.server.chat import ChatMessage, ChatRequest, ChatSession + +engine = AutoEngine.from_pretrained("/path/to/model") # tier auto-detected +req = ChatRequest( + model=engine.name, + messages=[ChatMessage(role="user", content="Hello!")], + max_tokens=64, +) +tokens, meta = ChatSession(engine, req).run() +print(engine._tok.decode(tokens)) +engine.close() # release mmaps / expert cache +``` + +`python/examples/demo.py` es el mismo recorrido mínimo (`edge0 demo` ejecuta +exactamente esta ruta). + +#### Modelos y adaptadores + +- **Checkpoint**: el directorio original del modelo (`config.json`, + `model*.safetensors`, tokenizer). `edge0 serve ` / + `AutoEngine.from_pretrained()` detectan el nivel desde `config.json`. +- Los **adaptadores** (LoRA + prerouter, safetensors) se resuelven + automáticamente desde cualquiera de estas ubicaciones: + - el directorio del modelo (recomendado): junto a la base, p. ej. + `lora_edge0_35b.safetensors` + `prerouter_edge0_35b.safetensors`; + - `artifacts/` en la raíz del proyecto Python (ignorado por git): una caché + alternativa opcional para los safetensors de adaptadores que no estén + junto al modelo. +- Los repositorios de modelos publicados incluyen tanto el checkpoint base + como la versión actual por defecto de los adaptadores, de modo que + `scripts/fetch_models.py` produce un directorio de modelo listo para + ejecutar. Consulta la página de documentación de cada modelo para conocer la + procedencia de sus adaptadores (datos de entrenamiento, disposición de las + capas propietarias). +- Ambos adaptadores son necesarios para el pipeline prerouter + LoRA; si falta + un archivo, `edge0` falla con un mensaje claro (o pasa `--no-prerouter` / + `--no-lora` para ejecutar el modelo base simple). + +#### Documentación + +- [Arquitectura](docs/architecture.md) +- [Atención](docs/attention.md) / [MoE](docs/moe.md) / [Streaming SSD](docs/streaming.md) / [prerouter](docs/prerouter.md) +- [Añadir un modelo](docs/adding-a-model.md) +- [edge0-35b](docs/models/edge0-35b.md) / [edge0-8b](docs/models/edge0-8b.md) +- Informe técnico: [The Other Half of the Memory Wall](https://arxiv.org/abs/2609.18063) ([PDF](paper/main.pdf)) + +### macOS / iOS / Android / Windows + +Los cuatro motores de plataforma son de código abierto en este repositorio — +más detalles en el README de cada directorio: + +- **macOS**: CLI / demonio / aplicación de escritorio local (Rust) — consulta [`macos/README_es.md`](macos/README_es.md) +- **iOS**: aplicación para iPhone en el dispositivo (Swift + MLX Swift) — consulta [`ios/README_es.md`](ios/README_es.md) +- **Android**: aplicación en el dispositivo + motor nativo (Kotlin) — consulta [`android/README_es.md`](android/README_es.md) +- **Windows**: aplicación de escritorio + motor nativo (C++ + Vulkan) — consulta [`windows/README_es.md`](windows/README_es.md) + +El **framework de inferencia unificado** — una única capa de acceso, con el +runtime adaptándose automáticamente a iOS / macOS / Android / Windows / +Python — llegará en **Q4 2026**; consulta la [Hoja de ruta](#hoja-de-ruta). + +## Hoja de ruta + +### Q4 2026 + +**Plataformas y sistemas** + +- **Framework de inferencia unificado de edge0** — liberaremos + un framework de inferencia unificado: **una única capa de acceso + unificada** (una sola API para chat / serve / uso en el dispositivo), con el + **runtime adaptándose automáticamente a la plataforma de hardware** — iOS, + macOS, Android, Windows y Python. Se construye sobre los motores de + plataforma ya liberados en este repositorio (`ios/` · `macos/` · + `android/` · `windows/`). +- **Backend CUDA** para el framework de Python — espacio reservado en + `python/src/edge0/backends/cuda/`, el código principal no necesita ningún + cambio. + +**Modelos y algoritmos** + +Q4 trabaja en dos frentes: incorporar una arquitectura de próxima generación +al framework y convertir el razonamiento latente en un ahorro real de latencia +y no solo aritmético. + +- **Soporte de arquitectura de próxima generación (clase Qwen3.8-Flash)** — + ejecutar atención lineal híbrida (GDN + QSA), residual multirrama con + puertas y topologías de embedding N-gram en edge0. Estos diseños encajan de + forma natural con el streaming offload a SSD: la atención de estado O(1) + evita que el thinking prolongado se convierta en un problema de KV cache, y + las tablas N-gram de solo consulta se transmiten bajo demanda. Objetivo: que + el nivel se ejecute en un solo dispositivo y que los benchmarks queden a una + distancia aceptable de la base fp16. +- **Thinking latente + predicción anticipada de expertos por lotes** — + convertir el razonamiento latente en un ahorro de *latencia*, no solo + aritmético. El problema central de ingeniería: mover el enrutamiento de + expertos de por posición a **una vez por bloque**, de modo que una sola + predicción cubra cada posición y ronda de un bloque y **el volumen de carga + de expertos se desacople del número de bucles de razonamiento** — además de + prefetch entre bloques que carga los expertos del siguiente bloque dentro de + la ventana de cómputo del bloque actual. El progreso se mide como **tiempo + de la fase de thinking de extremo a extremo con la misma precisión** (nunca + tokens/s). +- Más niveles de modelos y versiones de adaptadores sobre el pipeline + existente. + +## Contribuir + +Las contribuciones son bienvenidas — issues, PRs, informes de benchmarks y +ports de modelos, todo cuenta. + +**Framework de Python** (disponible ahora): + +```bash +cd python +python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev,fetch]' + +pytest # unit tests (no real weights) +EDGE0_8B_MODEL=/path/to/edge0-8b pytest -m slow -q + # real-weight generation; missing tiers are skipped +.venv/bin/python scripts/e2e_smoke.py \ + --qwen-dir /path/to/edge0-35b --ling-dir /path/to/edge0-8b + # staged vs exact consistency + generation smoke +scripts/generate_example.py # full-pipeline API example +examples/demo.py # minimal API walkthrough +``` + +CI ejecuta pruebas unitarias (macOS + MLX) y una suite de higiene del +repositorio (sin rutas hardcodeadas, checks de límites de backend y de +secretos) en cada PR. + +**Runtimes de plataforma** (macOS / iOS / Android / Windows): cada +directorio de plataforma incluye su propia guía de compilación y pruebas +— consulta el README de cada directorio. + +Flujo de trabajo: fork → rama de feature → PR contra `main`. Mantén la suite +de higiene en verde y añade pruebas para los nuevos comportamientos. + +## Cita + +Si edge0 te resulta útil, cita nuestro informe técnico: + +```bibtex +@article{lin2026other, + title = {The Other Half of the Memory Wall: Serving 35B MoEs from SSD + with Trained Routing Prediction}, + author = {Lin, Yu and Wang, Yiming and Cai, Runyuan and Liu, Hanze and + Zeng, Xiaodong}, + journal = {arXiv preprint arXiv:2609.18063}, + year = {2026}, + url = {https://arxiv.org/abs/2609.18063} +} +``` + +## Contacto + +Los canales de comunidad y soporte llegarán pronto — esta sección enumerará +las vías oficiales para contactarnos: + +- **Email**: samuel@edge0.ai + +Para bugs y solicitudes de funcionalidades, usa +[GitHub Issues](https://github.com/Edge0-AI/Edge0/issues). + +## Licencia + +Apache-2.0, incluido el código de terceros incorporado (consulta +[NOTICE](NOTICE)). diff --git a/README_fr.md b/README_fr.md new file mode 100644 index 0000000..ade6b8d --- /dev/null +++ b/README_fr.md @@ -0,0 +1,430 @@ +
+ +edge0 + +# edge0 + +**Un framework open source d'inférence MoE en streaming — SSD expert offload + Recover-LoRA + prédiction de routage prerouter.** + +**Python** · **macOS** · **iOS** · **Android** — une seule recette, tous les appareils. + +[![Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Edge0--35B--A3B--preview-yellow?style=for-the-badge)](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) +[![Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Edge0--8B--A1B--preview-yellow?style=for-the-badge)](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) +[![ModelScope](https://img.shields.io/badge/ModelScope-Edge0--35B--A3B--preview-624AFF?style=for-the-badge)](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) +[![ModelScope](https://img.shields.io/badge/ModelScope-Edge0--8B--A1B--preview-624AFF?style=for-the-badge)](https://www.modelscope.cn/models/Edge0/Edge0-8B-A1B-preview) +[![arXiv](https://img.shields.io/badge/arXiv-2609.18063-B31B1B?style=for-the-badge&logo=arxiv&logoColor=white)](https://arxiv.org/abs/2609.18063) +[![GitHub](https://img.shields.io/badge/GitHub-Edge0--AI%2FEdge0-black?style=for-the-badge&logo=github)](https://github.com/Edge0-AI/Edge0) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue?style=for-the-badge)](LICENSE) + +[English](README.md) | [中文](README_zh.md) | [日本語](README_ja.md) | [Español](README_es.md) | Français + +
+ +## Actualités + +- **[2026-09-30]** Nous avons publié les **moteurs d'inférence edge0 pour quatre plateformes — iOS, macOS, Android et Windows** — afin d'offrir aux utilisateurs la meilleure expérience d'inférence à travers les architectures et les plateformes. Le code source est publié en open source dans ce dépôt ([`ios/`](ios/) · [`macos/`](macos/) · [`android/`](android/) · [`windows/`](windows/)) — consultez le README de chaque répertoire pour plus de détails. Le **framework d'inférence unifié** suivra au **T4 2026** ; voir la [Feuille de route](#feuille-de-route). +- **[2026-09-20]** `--prefill-ondemand` arrive pour `edge0 demo / chat / serve` ([#112](https://github.com/Edge0-AI/Edge0/pull/112)). +- **[2026-09-16]** Notre rapport technique est sur arXiv : [The Other Half of the Memory Wall: Serving 35B MoEs from SSD with Trained Routing Prediction](https://arxiv.org/abs/2609.18063). +- **[2026-09-08]** Première publication open source d'**edge0**, avec les deux gammes de modèles — [`Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) et [`Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) — sur Hugging Face et ModelScope. + +## À propos + +**edge0** est un framework open source d'inférence MoE en streaming. Il +généralise la recette éprouvée en production — **SSD expert offload + +Recover-LoRA + prédiction de routage prerouter** — en un framework +extensible qui exécute de grands modèles MoE creux (sparse-MoE) sur du +matériel grand public : la mémoire de pointe est limitée par l'ensemble +*actif* d'experts, et non par le nombre de paramètres. + +### Mécanismes fondamentaux + +- **SSD expert offload** : les poids des experts sont lus en streaming + depuis le stockage à la demande ; la mémoire de pointe est limitée par + l'ensemble actif, et non par le nombre de paramètres. +- **Prerouter** : une tête entraînée prédit le routage des experts un pas + à l'avance, de sorte que les chargements d'experts chevauchent la passe + avant au lieu de la bloquer — **jusqu'à +59 %** de débit de decode ; le + gain augmente avec la latence du stockage, la taille du modèle et la + largeur routée *K*. +- **Recover-LoRA** : la base int4 est gelée et les adaptateurs LoRA sont + entraînés par distillation depuis l'enseignant FP, récupérant + l'essentiel de la perte de quantification à 4 bits (voir + [Qualité](#qualité)). Les adaptateurs restent non fusionnés : une base + unique en lecture seule sert plusieurs jeux d'adaptateurs. + +### Plateformes + +Un dépôt, une recette, des runtimes par plateforme : + +| Plateforme | Répertoire | Stack | Statut | +|---|---|---|---| +| **Python** (macOS · Apple Silicon) | [`python/`](python/README_fr.md) | Python + MLX | ✅ Disponible maintenant | +| Application et CLI **macOS** | [`macos/`](macos/README_fr.md) | Rust | ✅ Open source (2026-09-30) | +| Application **iOS** | [`ios/`](ios/README_fr.md) | Swift + MLX Swift | ✅ Open source (2026-09-30) | +| Application et moteur **Android** | [`android/`](android/README_fr.md) | Kotlin + moteur natif | ✅ Open source (2026-09-30) | +| Application et moteur **Windows** | [`windows/`](windows/README_fr.md) | C++ + Vulkan | ✅ Open source (2026-09-30) | + +### Modèles + +Deux gammes de modèles sont fournies avec le framework. Chaque gamme est +une publication complète : le checkpoint publié, les adaptateurs LoRA +entraînés et les têtes prerouter entraînées fonctionnent ensemble comme +une seule unité. + +| Gamme | Checkpoint publié | Profil d'inférence | +|---|---|---| +| `edge0-35b` | [`Edge0/Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) · [ModelScope](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) | 4-bit, 40 couches, 256 experts, prerouter K=4 | +| `edge0-8b` | [`Edge0/Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) · [ModelScope](https://www.modelscope.cn/models/Edge0/Edge0-8B-A1B-preview) | 4-bit, 24 couches, 128 experts, prerouter K=8 | + +Les deux checkpoints sont construits à partir de modèles de base MoE creux +open source (respectivement Qwen3.6-35B-A3B et l'hybride bailing Ling 3.0) +et sont fournis avec l'entraînement LoRA et prerouter réalisé pour ce +framework — les fichiers d'adaptateurs sont colocalisés avec chaque +checkpoint et se chargent automatiquement, de sorte que +`edge0 serve ` exécute le pipeline entraîné prêt à l'emploi. + +### Conception + +- **Utilisation façon transformers** : `AutoModel` / `AutoConfig` / `AutoEngine` + résolvent la gamme à partir du nom du modèle ; +- **Isolation du backend (framework Python)** : dans le framework Python, + tout le code MLX se trouve sous `python/src/edge0/backends/mlx/` ; la + logique centrale (spécifications des modèles, prerouter, pool d'experts + en streaming, serveur) ne dépend que de la façade du backend + (`backends/base.py`), de sorte qu'un nouveau backend implémente la même + façade (`backends/cuda/` est un emplacement réservé) sans aucune + modification du code central. Les moteurs iOS / macOS / Android sont + aujourd'hui fournis avec des stacks natives par plateforme — regrouper + toutes les plateformes sous une couche d'accès unique est précisément ce + que fournira le framework d'inférence unifié (voir la + [Feuille de route](#feuille-de-route)) ; +- **Adaptateurs au format safetensors** : les poids LoRA et prerouter sont + des fichiers `.safetensors` avec métadonnées de provenance (source, + version, couches propriétaires), résolus depuis le répertoire du modèle + ou `artifacts/` ; +- **Modèle + adaptateurs dans un même répertoire** : un répertoire de + modèle contient à la fois le checkpoint de base (`config.json` / + `model*.safetensors` / tokenizer) et les adaptateurs de ce modèle ; la + mise à niveau des adaptateurs ne remplace que les fichiers d'adaptateurs + — la base reste en lecture seule et n'est jamais fusionnée. + +### Qualité + +Tous les benchmarks ont été exécutés par nos soins avec +[OpenCompass](https://github.com/open-compass/opencompass), dans des +conditions et avec des paramètres identiques, pour les modèles edge0 +(int4 + adaptateurs entraînés + routage prerouter) et pour les modèles de +base fp16 d'origine. La perte du pipeline edge0 est faible : **3,9 points +en moyenne pour edge0-35b, 2,8 pour edge0-8b** (MMLU-Pro est même +au-dessus de la base). Maximum 100 : + +| Benchmark | edge0-35b (int4) | Qwen3.6-35B-A3B (fp16) | edge0-8b (int4) | Ling 3.0 tiny (fp16) | +|---|---:|---:|---:|---:| +| AIME 2026 | 86.6 | 92.7 | 63.3 | 73.3 | +| HumanEval | 90.9 | 95.1 | 91.5 | 92.7 | +| GPQA-Diamond | 79.8 | 81.8 | 70.7 | 71.2 | +| MMLU-Pro | 81.0 | 84.6 | 70.1 | 65.8 | +| IFBench | 57.9 | 61.7 | 53.9 | 60.6 | +| **Moyenne** | **79.2** | **83.2** | **69.9** | **72.7** | + +### Benchmark + +Mesuré avec `python/examples/bench.py` (prefill d'un prompt de 3,3 k +tokens → 10 pas d'échauffement échantillonnés → 200 tokens de decode +échantillonnés et chronométrés, 2 exécutions par gamme) : + +| Gamme | Vitesse de decode | Débit de prefill (à froid / à chaud)* | Mémoire active de pointe | Machine de test | +|---|---|---|---|---| +| `edge0-35b` | 14.9–17.7 tok/s | 113 / 140 tok/s | 2.9 GiB | Mac mini M4 Pro, 24 GB | +| `edge0-8b` | 23.9–25.3 tok/s | 500 / 1428 tok/s | 1.0 GiB | Mac mini M4 Pro, 24 GB | + +*À froid = première requête après le démarrage du processus (les poids des +experts arrivent depuis le SSD par défaut de page) ; à chaud = requêtes +suivantes (page cache résident). Les chiffres de prefill sont des débits +mesurés sur un prompt d'environ 3,3 k tokens (`BENCH_LONG=1`).* + +Reproduire : + +```bash +cd python +python examples/bench.py edge0-35b # via $EDGE0_35B_MODEL +python examples/bench.py edge0-8b # via $EDGE0_8B_MODEL +``` + +## Démarrage rapide + +### Python (macOS · Apple Silicon) + +#### Prérequis + +- **OS / matériel** : le backend MLX fonctionne sur macOS avec Apple + Silicon (M1/M2/M3/M4). Le backend CUDA est sur la feuille de route — + aucune autre plateforme n'est encore prise en charge par le framework + Python. +- **Python** : 3.10+ (3.12 recommandé). +- **MLX** : `mlx==0.30.6` / `mlx-metal==0.30.6` avec `mlx-lm==0.31.0` (voir + `python/pyproject.toml`). Une sortie illisible ou mêlant plusieurs + langues sur Apple A18 / A18 Pro indique une version plus ancienne de + `mlx` : `pip install 'mlx==0.30.6' + 'mlx-metal==0.30.6'` ([#8](https://github.com/Edge0-AI/Edge0/issues/8)). +- **Mémoire** : environ 2,9 GB de mémoire active de pointe pour + `edge0-35b`, environ 1,0 GB pour `edge0-8b` (contextes courts ; voir + [Benchmark](#benchmark)). Prévoyez de la marge pour l'OS, le tokenizer + et la croissance de la KV cache en contexte long. +- **Disque** : les checkpoints 4-bit pèsent environ 23 GB (`edge0-35b`) et + environ 4,2 GB (`edge0-8b`) ; les poids des experts sont mmappés et lus + à la demande, ils ne sont pas chargés en RAM au démarrage. + +#### 1) Installer + +```bash +cd python +# Python >= 3.10; the MLX backend requires macOS with Apple Silicon +python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev,fetch]' +``` + +#### 2) Télécharger un modèle + +Les deux gammes sont publiées sur Hugging Face et ModelScope — chaque +dépôt regroupe le checkpoint de base et les adaptateurs LoRA + prerouter +entraînés dans **un seul répertoire**, de sorte qu'un unique +téléchargement constitue un modèle prêt à l'emploi : + +- [`Edge0/Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) (~23 GB) · [Miroir ModelScope](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) +- [`Edge0/Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) (~4,2 GB) · [Miroir ModelScope](https://www.modelscope.cn/models/Edge0/Edge0-8B-A1B-preview) + +```bash +# with the repo's helper (defaults to the two repos above): +.venv/bin/python scripts/fetch_models.py --tier edge0-35b --target-dir models +.venv/bin/python scripts/fetch_models.py --tier edge0-8b --target-dir models + +# or directly with the CLI: +.venv/bin/huggingface-cli download Edge0/Edge0-35B-A3B-preview --local-dir models/edge0-35b +.venv/bin/huggingface-cli download Edge0/Edge0-8B-A1B-preview --local-dir models/edge0-8b +``` + +Quelle que soit la méthode, vous obtenez un répertoire comme celui-ci : + +``` +models/edge0-35b/ +├── config.json, model-*.safetensors, tokenizer files # base checkpoint +├── lora_edge0_35b.safetensors # trained LoRA adapters +└── prerouter_edge0_35b.safetensors # trained prerouter heads +``` + +#### 3) Pointer edge0 vers le modèle + +Les noms de gamme sont résolus vers des répertoires locaux via des +variables d'environnement (l'emplacement du téléchargement est libre) : + +```bash +export EDGE0_35B_MODEL=$PWD/models/edge0-35b +export EDGE0_8B_MODEL=$PWD/models/edge0-8b +``` + +Ou ignorez complètement les variables d'environnement et passez +directement le répertoire — la gamme est détectée automatiquement depuis +le `config.json` du checkpoint : + +```bash +edge0 demo models/edge0-35b +edge0 serve models/edge0-8b +``` + +#### 4) Exécuter + +```bash +# quick demo +edge0 demo edge0-35b + +# serve (OpenAI-compatible /v1/chat/completions) +edge0 serve edge0-35b +``` + +```bash +curl http://127.0.0.1:8000/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{"model":"edge0-35b","messages":[{"role":"user","content":"Hello!"}],"max_tokens":32}' + +# 5) One-shot chat (pass --max-new to cap length; add --show-thinking to +# print the model's reasoning block too) +edge0 chat edge0-35b --prompt "Explain streaming inference in one sentence." +``` + +`python -m edge0 ...` est équivalent à `edge0 ...`. + +#### API Python + +```python +from edge0 import AutoEngine +from edge0.server.chat import ChatMessage, ChatRequest, ChatSession + +engine = AutoEngine.from_pretrained("/path/to/model") # tier auto-detected +req = ChatRequest( + model=engine.name, + messages=[ChatMessage(role="user", content="Hello!")], + max_tokens=64, +) +tokens, meta = ChatSession(engine, req).run() +print(engine._tok.decode(tokens)) +engine.close() # release mmaps / expert cache +``` + +`python/examples/demo.py` est le même parcours minimal (`edge0 demo` +exécute exactement ce chemin). + +#### Modèles et adaptateurs + +- **Checkpoint** : le répertoire du modèle d'origine (`config.json`, + `model*.safetensors`, tokenizer). `edge0 serve ` / + `AutoEngine.from_pretrained()` détectent la gamme depuis + `config.json`. +- Les **adaptateurs** (LoRA + prerouter, safetensors) sont résolus + automatiquement depuis l'un ou l'autre emplacement : + - le répertoire du modèle (recommandé) : à côté de la base, par exemple + `lora_edge0_35b.safetensors` + `prerouter_edge0_35b.safetensors` ; + - `artifacts/` à la racine du projet Python (ignoré par git) : un cache + de secours optionnel pour les safetensors d'adaptateurs non + colocalisés avec le modèle. +- Les dépôts de modèles publiés regroupent à la fois le checkpoint de base + et la version par défaut actuelle des adaptateurs, de sorte que + `scripts/fetch_models.py` produit un répertoire de modèle prêt à + l'emploi. Consultez la page de documentation de chaque modèle pour la + provenance de ses adaptateurs (données d'entraînement, disposition des + couches propriétaires). +- Les deux adaptateurs sont requis pour le pipeline prerouter + LoRA ; si + un fichier manque, `edge0` échoue avec un message clair (ou passez + `--no-prerouter` / `--no-lora` pour exécuter le modèle de base seul). + +#### Documentation + +- [Architecture](docs/architecture.md) +- [Attention](docs/attention.md) / [MoE](docs/moe.md) / [Streaming SSD](docs/streaming.md) / [prerouter](docs/prerouter.md) +- [Ajouter un modèle](docs/adding-a-model.md) +- [edge0-35b](docs/models/edge0-35b.md) / [edge0-8b](docs/models/edge0-8b.md) +- Rapport technique : [The Other Half of the Memory Wall](https://arxiv.org/abs/2609.18063) ([PDF](paper/main.pdf)) + +### macOS / iOS / Android / Windows + +Les quatre moteurs par plateforme sont publiés en open source dans ce +dépôt — plus de détails dans le README de chaque répertoire : + +- **macOS** : CLI locale / démon / application de bureau (Rust) — voir [`macos/README_fr.md`](macos/README_fr.md) +- **iOS** : application iPhone embarquée (Swift + MLX Swift) — voir [`ios/README_fr.md`](ios/README_fr.md) +- **Android** : application embarquée + moteur natif (Kotlin) — voir [`android/README_fr.md`](android/README_fr.md) +- **Windows** : application de bureau + moteur natif (C++ + Vulkan) — voir [`windows/README_fr.md`](windows/README_fr.md) + +Le **framework d'inférence unifié** — une couche d'accès unique, un +runtime s'adaptant automatiquement à iOS / macOS / Android / Windows / +Python — arrivera au **T4 2026** ; voir la +[Feuille de route](#feuille-de-route). + +## Feuille de route + +### T4 2026 + +**Plateformes et systèmes** + +- **Framework d'inférence unifié edge0** — nous publierons en open source + un framework d'inférence unifié : **une couche d'accès unifiée** (une + API unique pour le chat / le serving / l'utilisation embarquée), avec le + **runtime s'adaptant automatiquement à la plateforme matérielle** — iOS, + macOS, Android, Windows et Python. Il s'appuie sur les moteurs par + plateforme déjà publiés en open source dans ce dépôt (`ios/` · `macos/` + · `android/` · `windows/`). +- **Backend CUDA** pour le framework Python — emplacement réservé dans + `python/src/edge0/backends/cuda/`, le code central ne nécessite aucune + modification. + +**Modèles et algorithmes** + +Le T4 travaille sur deux fronts : intégrer une architecture de nouvelle +génération dans le framework, et transformer le raisonnement latent en un +véritable gain de latence et non seulement arithmétique. + +- **Prise en charge des architectures de nouvelle génération (classe + Qwen3.8-Flash)** — exécuter sur edge0 l'attention linéaire hybride + (GDN + QSA), les résidus multi-branches à portail et les topologies + d'embedding N-gram. Ces conceptions se prêtent naturellement au SSD + expert offload en streaming : l'attention à état O(1) évite que les + longues réflexions ne deviennent un problème de KV cache, et les tables + N-gram, consultables uniquement en lecture (lookup-only), se chargent en + streaming à la demande. Objectif : que la gamme s'exécute sur un seul + appareil et que les benchmarks se situent à un écart acceptable de la + base fp16. +- **Raisonnement latent + pré-prédiction d'experts par lots** — faire du + raisonnement latent une économie de *latence*, et pas seulement + arithmétique. Le problème d'ingénierie central : faire passer le routage + des experts d'une prédiction par position à **une prédiction par bloc**, + afin qu'une seule prédiction couvre toutes les positions et tous les + tours d'un bloc et que **le volume de chargement des experts soit + découplé du nombre de tours de la boucle de raisonnement** — plus un + préchargement inter-blocs qui charge les experts du bloc suivant dans la + fenêtre de calcul du bloc courant. Les progrès sont mesurés en **temps + de phase de réflexion de bout en bout à précision égale** (jamais en + tokens/s). +- Davantage de gammes de modèles et de versions d'adaptateurs sur le + pipeline existant. + +## Contribution + +Les contributions sont les bienvenues — issues, PR, rapports de benchmark +et portages de modèles, tout compte. + +**Framework Python** (disponible maintenant) : + +```bash +cd python +python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev,fetch]' + +pytest # unit tests (no real weights) +EDGE0_8B_MODEL=/path/to/edge0-8b pytest -m slow -q + # real-weight generation; missing tiers are skipped +.venv/bin/python scripts/e2e_smoke.py \ + --qwen-dir /path/to/edge0-35b --ling-dir /path/to/edge0-8b + # staged vs exact consistency + generation smoke +scripts/generate_example.py # full-pipeline API example +examples/demo.py # minimal API walkthrough +``` + +La CI exécute les tests unitaires (macOS + MLX) et une suite d'hygiène du +dépôt (pas de chemins codés en dur, vérification des frontières de backend +et des secrets) sur chaque PR. + +**Runtimes par plateforme** (macOS / iOS / Android / Windows) : chaque +répertoire de plateforme dispose de son propre guide de compilation et de +ses tests — voir le README de chaque répertoire. + +Workflow : fork → branche de fonctionnalité → PR vers `main`. Merci de +garder la suite d'hygiène au vert et d'ajouter des tests pour les nouveaux +comportements. + +## Citation + +Si vous trouvez edge0 utile, merci de citer notre rapport technique : + +```bibtex +@article{lin2026other, + title = {The Other Half of the Memory Wall: Serving 35B MoEs from SSD + with Trained Routing Prediction}, + author = {Lin, Yu and Wang, Yiming and Cai, Runyuan and Liu, Hanze and + Zeng, Xiaodong}, + journal = {arXiv preprint arXiv:2609.18063}, + year = {2026}, + url = {https://arxiv.org/abs/2609.18063} +} +``` + +## Nous contacter + +Les canaux communautaires et d'assistance arrivent bientôt — cette section +listera les moyens officiels de nous joindre : + +- **Email** : samuel@edge0.ai + +Pour les bugs et les demandes de fonctionnalités, merci d'utiliser +[GitHub Issues](https://github.com/Edge0-AI/Edge0/issues). + +## Licence + +Apache-2.0, y compris le code tiers intégré (voir [NOTICE](NOTICE)). diff --git a/README_ja.md b/README_ja.md new file mode 100644 index 0000000..d37cae2 --- /dev/null +++ b/README_ja.md @@ -0,0 +1,302 @@ +
+ +edge0 + +# edge0 + +**オープンソースのストリーミング MoE 推論フレームワーク — SSD expert offload + Recover-LoRA + prerouter ルーティング予測。** + +**Python** · **macOS** · **iOS** · **Android** — 1 つのレシピで、すべてのデバイスに対応。 + +[![Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Edge0--35B--A3B--preview-yellow?style=for-the-badge)](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) +[![Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Edge0--8B--A1B--preview-yellow?style=for-the-badge)](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) +[![ModelScope](https://img.shields.io/badge/ModelScope-Edge0--35B--A3B--preview-624AFF?style=for-the-badge)](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) +[![ModelScope](https://img.shields.io/badge/ModelScope-Edge0--8B--A1B--preview-624AFF?style=for-the-badge)](https://www.modelscope.cn/models/Edge0/Edge0-8B-A1B-preview) +[![arXiv](https://img.shields.io/badge/arXiv-2609.18063-B31B1B?style=for-the-badge&logo=arxiv&logoColor=white)](https://arxiv.org/abs/2609.18063) +[![GitHub](https://img.shields.io/badge/GitHub-Edge0--AI%2FEdge0-black?style=for-the-badge&logo=github)](https://github.com/Edge0-AI/Edge0) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue?style=for-the-badge)](LICENSE) + +[English](README.md) | [中文](README_zh.md) | 日本語 | [Español](README_es.md) | [Français](README_fr.md) + +
+ +## ニュース + +- **[2026-09-30]** **4 つのプラットフォーム — iOS、macOS、Android、Windows — 向けの edge0 推論エンジン**をリリースしました。ユーザーはアーキテクチャとプラットフォームをまたいで最適な推論体験を得られます。ソースはこのリポジトリでオープンソース化されています([`ios/`](ios/) · [`macos/`](macos/) · [`android/`](android/) · [`windows/`](windows/))— 詳細は各ディレクトリの README を参照してください。**統合推論フレームワーク**は **2026 年 Q4** に控えています。[ロードマップ](#ロードマップ)を参照。 +- **[2026-09-20]** `edge0 demo / chat / serve` に `--prefill-ondemand` が登場([#112](https://github.com/Edge0-AI/Edge0/pull/112))。 +- **[2026-09-16]** 技術レポートが arXiv に掲載: [The Other Half of the Memory Wall: Serving 35B MoEs from SSD with Trained Routing Prediction](https://arxiv.org/abs/2609.18063)。 +- **[2026-09-08]** **edge0** の初オープンソースリリース。両モデルティア — [`Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) と [`Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) — を Hugging Face と ModelScope で同時公開。 + +## 概要 + +**edge0** はオープンソースのストリーミング MoE 推論フレームワークです。本番で実証済みのレシピ — **SSD expert offload + Recover-LoRA + prerouter ルーティング予測** — を拡張可能なフレームワークへと一般化し、コンシューマーハードウェアで大規模スパース MoE モデルを実行します。ピークメモリは、パラメータ数ではなく*アクティブ*なエキスパート集合によって上限が決まります。 + +### コア機構 + +- **SSD expert offload**: エキスパートの重みは必要に応じてストレージからストリーミングされます。ピークメモリはパラメータ数ではなく、アクティブな集合によって上限が決まります。 +- **Prerouter**: 訓練済みヘッドがエキスパートルーティングを 1 ステップ先に予測するため、エキスパートのロードがフォワードパスを停滞させるのではなくオーバーラップします — デコードスループットは**最大 +59%**。ストレージレイテンシ、モデルサイズ、ルーティング幅 *K* が増すほど効果は大きくなります。 +- **Recover-LoRA**: int4 のベースは凍結され、FP 教師からの蒸留によって LoRA アダプタが訓練されることで、4-bit 時の量子化損失の大部分を回復します([品質](#品質)を参照)。アダプタはマージされません。1 つの読み取り専用ベースが複数のアダプタセットに対応します。 + +### プラットフォーム + +1 つのリポジトリ、1 つのレシピ、プラットフォームごとのランタイム: + +| プラットフォーム | ディレクトリ | スタック | ステータス | +|---|---|---|---| +| **Python**(macOS · Apple Silicon) | [`python/`](python/README_ja.md) | Python + MLX | ✅ 現在利用可能 | +| **macOS** アプリ & CLI | [`macos/`](macos/README_ja.md) | Rust | ✅ オープンソース化(2026-09-30) | +| **iOS** アプリ | [`ios/`](ios/README_ja.md) | Swift + MLX Swift | ✅ オープンソース化(2026-09-30) | +| **Android** アプリ & エンジン | [`android/`](android/README_ja.md) | Kotlin + ネイティブエンジン | ✅ オープンソース化(2026-09-30) | +| **Windows** アプリ & エンジン | [`windows/`](windows/README_ja.md) | C++ + Vulkan | ✅ オープンソース化(2026-09-30) | + +### モデル + +フレームワークには 2 つのモデルティアが付属します。各ティアはエンドツーエンドのリリースであり、公開されるチェックポイント、訓練済み LoRA アダプタ、訓練済み prerouter ヘッドが 1 つの単位として機能します。 + +| ティア | 公開チェックポイント | 推論プロファイル | +|---|---|---| +| `edge0-35b` | [`Edge0/Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) · [ModelScope](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) | 4-bit、40 層、256 エキスパート、prerouter K=4 | +| `edge0-8b` | [`Edge0/Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) · [ModelScope](https://www.modelscope.cn/models/Edge0/Edge0-8B-A1B-preview) | 4-bit、24 層、128 エキスパート、prerouter K=8 | + +両方のチェックポイントともオープンのスパース MoE ベースモデル(それぞれ Qwen3.6-35B-A3B と Ling 3.0 bailing hybrid)上に構築されており、本フレームワークのために実施された LoRA と prerouter の訓練成果物を同梱しています — アダプタファイルは各チェックポイントと同じ場所に配置され、自動的にロードされるため、`edge0 serve ` はすぐさま訓練済みパイプラインを実行できます。 + +### 設計 + +- **transformers スタイルの使い方**: `AutoModel` / `AutoConfig` / `AutoEngine` がモデル名からティアを解決します。 +- **バックエンド分離(Python フレームワーク)**: Python フレームワーク内では、すべての MLX コードは `python/src/edge0/backends/mlx/` 配下に置かれます。コアロジック(モデル仕様、prerouter、ストリーミングエキスパートプール、サーバー)はバックエンドファサード(`backends/base.py`)にのみ依存するため、新しいバックエンドは同じファサードを実装するだけで済み(`backends/cuda/` は予約スロット)、コアコードの変更はゼロです。iOS / macOS / Android エンジンは現在プラットフォームネイティブのスタックで提供されています — すべてのプラットフォームを 1 つのアクセス層の下にまとめることこそ、統合推論フレームワーク([ロードマップ](#ロードマップ)を参照)が実現するものです。 +- **safetensors 形式のアダプタ**: LoRA と prerouter の重みは、来歴メタデータ(source、version、owner layers)付きの `.safetensors` ファイルであり、モデルディレクトリまたは `artifacts/` から解決されます。 +- **モデル + アダプタを 1 つのディレクトリに**: モデルディレクトリには、ベースチェックポイント(`config.json` / `model*.safetensors` / トークナイザー)とそのモデルのアダプタの両方が格納されます。アダプタのアップグレードはアダプタファイルの交換のみ — ベースは読み取り専用のままで、決してマージされません。 + +### 品質 + +すべてのベンチマークは、edge0 モデル(int4 + 訓練済みアダプタ + prerouter ルーティング)とオリジナルの fp16 ベースモデルの両方について、同一の設定とパラメータで [OpenCompass](https://github.com/open-compass/opencompass) を使って私たち自身が実行しました。edge0 パイプラインの損失は小さく、**edge0-35b で平均 3.9 ポイント、edge0-8b で 2.8 ポイント**です(MMLU-Pro はベースを上回ります)。満点 100: + +| ベンチマーク | edge0-35b (int4) | Qwen3.6-35B-A3B (fp16) | edge0-8b (int4) | Ling 3.0 tiny (fp16) | +|---|---:|---:|---:|---:| +| AIME 2026 | 86.6 | 92.7 | 63.3 | 73.3 | +| HumanEval | 90.9 | 95.1 | 91.5 | 92.7 | +| GPQA-Diamond | 79.8 | 81.8 | 70.7 | 71.2 | +| MMLU-Pro | 81.0 | 84.6 | 70.1 | 65.8 | +| IFBench | 57.9 | 61.7 | 53.9 | 60.6 | +| **平均** | **79.2** | **83.2** | **69.9** | **72.7** | + +### ベンチマーク + +`python/examples/bench.py` で測定(3.3k トークンのプロンプトのプリフィル → サンプリングによるウォームアップ 10 ステップ → 計測付きのサンプリングデコード 200 トークン、ティアごとに 2 回実行): + +| ティア | デコード速度 | プリフィルスループット(コールド / ウォーム)* | ピークアクティブメモリ | テストマシン | +|---|---|---|---|---| +| `edge0-35b` | 14.9–17.7 tok/s | 113 / 140 tok/s | 2.9 GiB | Mac mini M4 Pro, 24 GB | +| `edge0-8b` | 23.9–25.3 tok/s | 500 / 1428 tok/s | 1.0 GiB | Mac mini M4 Pro, 24 GB | + +*コールド = プロセス起動後の最初のリクエスト(エキスパートの重みが SSD からフォールトインする);ウォーム = 以降のリクエスト(ページキャッシュに常駐)。プリフィルの数値は約 3.3k トークンのプロンプトに対するスループット(`BENCH_LONG=1`)。* + +再現手順: + +```bash +cd python +python examples/bench.py edge0-35b # via $EDGE0_35B_MODEL +python examples/bench.py edge0-8b # via $EDGE0_8B_MODEL +``` + +## はじめに + +### Python(macOS · Apple Silicon) + +#### 要件 + +- **OS / ハードウェア**: MLX バックエンドは Apple Silicon(M1/M2/M3/M4)搭載の macOS で動作します。CUDA バックエンドはロードマップ上にあります — Python フレームワークはまだ他のプラットフォームをサポートしていません。 +- **Python**: 3.10+(3.12 推奨)。 +- **MLX**: `mlx==0.30.6` / `mlx-metal==0.30.6` と `mlx-lm==0.31.0`(`python/pyproject.toml` を参照)。Apple A18 / A18 Pro で文字化けした言語混在の出力が発生する場合、古い `mlx` が原因です: `pip install 'mlx==0.30.6' 'mlx-metal==0.30.6'`([#8](https://github.com/Edge0-AI/Edge0/issues/8))。 +- **メモリ**: `edge0-35b` でピークアクティブメモリ約 2.9 GB、`edge0-8b` で約 1.0 GB(短いコンテキストの場合;[ベンチマーク](#ベンチマーク)を参照)。OS、トークナイザー、長いコンテキストでの KV キャッシュの増加のための余裕を上乗せしてください。 +- **ディスク**: 4-bit チェックポイントは約 23 GB(`edge0-35b`)と約 4.2 GB(`edge0-8b`)です。エキスパートの重みは mmap されてオンデマンドで読み取られ、事前に RAM へロードされることはありません。 + +#### 1) インストール + +```bash +cd python +# Python >= 3.10; the MLX backend requires macOS with Apple Silicon +python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev,fetch]' +``` + +#### 2) モデルのダウンロード + +2 つのティアは Hugging Face と ModelScope で公開されています — 各リポジトリは、ベースチェックポイントと訓練済み LoRA + prerouter アダプタを**1 つのディレクトリ**にまとめており、1 回のダウンロードですぐ実行できるモデルが揃います: + +- [`Edge0/Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview)(~23 GB)· [ModelScope ミラー](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) +- [`Edge0/Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview)(~4.2 GB)· [ModelScope ミラー](https://www.modelscope.cn/models/Edge0/Edge0-8B-A1B-preview) + +```bash +# with the repo's helper (defaults to the two repos above): +.venv/bin/python scripts/fetch_models.py --tier edge0-35b --target-dir models +.venv/bin/python scripts/fetch_models.py --tier edge0-8b --target-dir models + +# or directly with the CLI: +.venv/bin/huggingface-cli download Edge0/Edge0-35B-A3B-preview --local-dir models/edge0-35b +.venv/bin/huggingface-cli download Edge0/Edge0-8B-A1B-preview --local-dir models/edge0-8b +``` + +どちらの方法でも、次のようなディレクトリが得られます: + +``` +models/edge0-35b/ +├── config.json, model-*.safetensors, tokenizer files # base checkpoint +├── lora_edge0_35b.safetensors # trained LoRA adapters +└── prerouter_edge0_35b.safetensors # trained prerouter heads +``` + +#### 3) edge0 にモデルを指定する + +ティア名は環境変数を通じてローカルディレクトリに解決されます(ダウンロード先は自由です): + +```bash +export EDGE0_35B_MODEL=$PWD/models/edge0-35b +export EDGE0_8B_MODEL=$PWD/models/edge0-8b +``` + +環境変数を完全に省略して、ディレクトリを直接渡すこともできます — ティアはチェックポイントの `config.json` から自動検出されます: + +```bash +edge0 demo models/edge0-35b +edge0 serve models/edge0-8b +``` + +#### 4) 実行 + +```bash +# quick demo +edge0 demo edge0-35b + +# serve (OpenAI-compatible /v1/chat/completions) +edge0 serve edge0-35b +``` + +```bash +curl http://127.0.0.1:8000/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{"model":"edge0-35b","messages":[{"role":"user","content":"Hello!"}],"max_tokens":32}' + +# 5) One-shot chat (pass --max-new to cap length; add --show-thinking to +# print the model's reasoning block too) +edge0 chat edge0-35b --prompt "Explain streaming inference in one sentence." +``` + +`python -m edge0 ...` は `edge0 ...` と等価です。 + +#### Python API + +```python +from edge0 import AutoEngine +from edge0.server.chat import ChatMessage, ChatRequest, ChatSession + +engine = AutoEngine.from_pretrained("/path/to/model") # tier auto-detected +req = ChatRequest( + model=engine.name, + messages=[ChatMessage(role="user", content="Hello!")], + max_tokens=64, +) +tokens, meta = ChatSession(engine, req).run() +print(engine._tok.decode(tokens)) +engine.close() # release mmaps / expert cache +``` + +`python/examples/demo.py` も同じ最小限のウォークスルーです(`edge0 demo` はまさにこのパスを実行します)。 + +#### モデルとアダプタ + +- **チェックポイント**: 元のモデルディレクトリ(`config.json`、`model*.safetensors`、トークナイザー)。`edge0 serve ` / `AutoEngine.from_pretrained()` は `config.json` からティアを検出します。 +- **アダプタ**(LoRA + prerouter、safetensors)は、次のいずれかの場所から自動的に解決されます: + - モデルディレクトリ(推奨): ベースと同じ場所。例: `lora_edge0_35b.safetensors` + `prerouter_edge0_35b.safetensors` + - Python プロジェクトルートの `artifacts/`(gitignore 済み): モデルと同じ場所にないアダプタ safetensors のための、オプションのフォールバックキャッシュ。 +- 公開されているモデルリポジトリには、ベースチェックポイントと現在のデフォルトのアダプタリリースの両方が同梱されているため、`scripts/fetch_models.py` はすぐ実行できるモデルディレクトリを生成します。アダプタの来歴(訓練データ、owner-layer レイアウト)は各モデルのドキュメントページで確認してください。 +- prerouter + LoRA パイプラインには両方のアダプタが必要です。ファイルが不足している場合、`edge0` は明確なメッセージとともに失敗します(あるいは `--no-prerouter` / `--no-lora` を渡せば、素のベースモデルを実行できます)。 + +#### ドキュメント + +- [アーキテクチャ](docs/architecture.md) +- [アテンション](docs/attention.md) / [MoE](docs/moe.md) / [SSD ストリーミング](docs/streaming.md) / [prerouter](docs/prerouter.md) +- [モデルの追加](docs/adding-a-model.md) +- [edge0-35b](docs/models/edge0-35b.md) / [edge0-8b](docs/models/edge0-8b.md) +- 技術レポート: [The Other Half of the Memory Wall](https://arxiv.org/abs/2609.18063)([PDF](paper/main.pdf)) + +### macOS / iOS / Android / Windows + +4 つのプラットフォームエンジンはこのリポジトリでオープンソース化されています — 詳細は各ディレクトリの README を参照してください: + +- **macOS**: ローカル CLI / デーモン / デスクトップアプリ(Rust)— [`macos/README_ja.md`](macos/README_ja.md) を参照 +- **iOS**: オンデバイス iPhone アプリ(Swift + MLX Swift)— [`ios/README_ja.md`](ios/README_ja.md) を参照 +- **Android**: オンデバイスアプリ + ネイティブエンジン(Kotlin)— [`android/README_ja.md`](android/README_ja.md) を参照 +- **Windows**: デスクトップアプリ + ネイティブエンジン(C++ + Vulkan)— [`windows/README_ja.md`](windows/README_ja.md) を参照 + +**統合推論フレームワーク** — 1 つのアクセス層、iOS / macOS / Android / Windows / Python へ自動適応するランタイム — は **2026 年 Q4** に登場します。[ロードマップ](#ロードマップ)を参照。 + +## ロードマップ + +### 2026 年 Q4 + +**プラットフォーム & システム** + +- **edge0 統合推論フレームワーク** — 統合推論フレームワークをオープンソース化します: **1 つの統一アクセス層**(chat / serve / オンデバイス利用を横断する単一 API)を備え、**ランタイムがハードウェアプラットフォームへ自動適応**します — iOS、macOS、Android、Windows、Python。このリポジトリですでにオープンソース化されているプラットフォームエンジン(`ios/` · `macos/` · `android/` · `windows/`)の上に構築されます。 +- Python フレームワーク向け **CUDA バックエンド** — `python/src/edge0/backends/cuda/` に予約スロットがあり、コアコードの変更はゼロです。 + +**モデル & アルゴリズム** + +Q4 は 2 つの前線で進めます: 次世代アーキテクチャをフレームワークに取り込むこと、そして潜在推論を単なる算術上の削減ではなく実際のレイテンシ削減に変えることです。 + +- **次世代アーキテクチャのサポート(Qwen3.8-Flash クラス)** — ハイブリッド線形アテンション(GDN + QSA)、ゲート付きマルチブランチ残差、N-gram 埋め込みトポロジを edge0 で実行します。これらの設計は SSD ストリーミングオフロードと自然に相性が良いものです: O(1) 状態のアテンションにより長い思考が KV キャッシュの問題になるのを防ぎ、ルックアップのみの N-gram テーブルはオンデマンドでストリーミングされます。目標: そのティアが単一デバイスで動作し、ベンチマークが fp16 ベースに対して許容可能な差に収まること。 +- **潜在 thinking + バッチ化されたエキスパート事前予測** — 潜在推論を、単なる算術上の削減ではなく*レイテンシ*の削減にします。コアとなるエンジニアリング課題: エキスパートルーティングを位置ごとから**ブロックごとに 1 回**へ移行し、1 回の予測がブロック内のすべての位置とラウンドをカバーすることで、**エキスパートのロード量を推論ループの回数から切り離す**こと — さらに、現在のブロックの計算ウィンドウ内で次のブロックのエキスパートをロードするクロスブロックプリフェッチも加えます。進捗は**同一精度でのエンドツーエンドの thinking フェーズ時間**で測定します(決して tokens/s では測りません)。 +- 既存パイプラインにおける、さらなるモデルティアとアダプタのリリース。 + +## コントリビュート + +コントリビューションを歓迎します — issue、PR、ベンチマークレポート、モデルポート、すべてが対象です。 + +**Python フレームワーク**(現在利用可能): + +```bash +cd python +python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev,fetch]' + +pytest # unit tests (no real weights) +EDGE0_8B_MODEL=/path/to/edge0-8b pytest -m slow -q + # real-weight generation; missing tiers are skipped +.venv/bin/python scripts/e2e_smoke.py \ + --qwen-dir /path/to/edge0-35b --ling-dir /path/to/edge0-8b + # staged vs exact consistency + generation smoke +scripts/generate_example.py # full-pipeline API example +examples/demo.py # minimal API walkthrough +``` + +CI はすべての PR で、ユニットテスト(macOS + MLX)とリポジトリ衛生スイート(ハードコードされたパスの禁止、バックエンド境界とシークレットのチェック)を実行します。 + +**プラットフォームランタイム**(macOS / iOS / Android / Windows): 各プラットフォームのディレクトリには独自のビルドガイドとテストが含まれています——各ディレクトリの README を参照してください。 + +ワークフロー: fork → フィーチャーブランチ → `main` への PR。衛生スイートをグリーンのまま保ち、新しい動作にはテストを追加してください。 + +## 引用 + +edge0 が役に立った場合は、私たちの技術レポートを引用してください: + +```bibtex +@article{lin2026other, + title = {The Other Half of the Memory Wall: Serving 35B MoEs from SSD + with Trained Routing Prediction}, + author = {Lin, Yu and Wang, Yiming and Cai, Runyuan and Liu, Hanze and + Zeng, Xiaodong}, + journal = {arXiv preprint arXiv:2609.18063}, + year = {2026}, + url = {https://arxiv.org/abs/2609.18063} +} +``` + +## お問い合わせ + +コミュニティとサポートのチャネルは近日公開予定です — このセクションに、私たちへの公式の連絡方法を掲載します: + +- **メール**: samuel@edge0.ai + +バグと機能リクエストには、[GitHub Issues](https://github.com/Edge0-AI/Edge0/issues) をご利用ください。 + +## ライセンス + +Apache-2.0。ベンダー化されたサードパーティコードを含みます([NOTICE](NOTICE) を参照)。 diff --git a/README_zh.md b/README_zh.md index 09f0d5e..328fb5e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -6,6 +6,8 @@ **开源流式 MoE 推理框架 —— SSD 专家 offload + Recover-LoRA + prerouter 路由预判** +**Python** · **macOS** · **iOS** · **Android** —— 一套配方,全端落地。 + [![Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Edge0--35B--A3B--preview-yellow?style=for-the-badge)](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) [![Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Edge0--8B--A1B--preview-yellow?style=for-the-badge)](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) [![ModelScope](https://img.shields.io/badge/ModelScope-Edge0--35B--A3B--preview-624AFF?style=for-the-badge)](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) @@ -14,13 +16,57 @@ [![GitHub](https://img.shields.io/badge/GitHub-Edge0--AI%2FEdge0-black?style=for-the-badge&logo=github)](https://github.com/Edge0-AI/Edge0) [![License](https://img.shields.io/badge/License-Apache%202.0-blue?style=for-the-badge)](LICENSE) -[English](README.md) | 中文 +[English](README.md) | 中文 | [日本語](README_ja.md) | [Español](README_es.md) | [Français](README_fr.md) +## 新闻 + +- **【2026-09-30】** 我们发布了**四端推理引擎**,适配 iOS、macOS、 + Android、Windows 平台,让用户在不同架构、不同平台上都有最佳的推理 + 体验。四端源码已开源至本仓库([`ios/`](ios/) · [`macos/`](macos/) · + [`android/`](android/) · [`windows/`](windows/)),更多细节见各目录 + README;**统一推理框架**将于 **2026 Q4** 发布,详见[路线图](#路线图)。 +- **【2026-09-20】** `edge0 demo / chat / serve` 新增 `--prefill-ondemand` + ([#112](https://github.com/Edge0-AI/Edge0/pull/112))。 +- **【2026-09-16】** 技术报告上线 arXiv:[The Other Half of the Memory + Wall: Serving 35B MoEs from SSD with Trained Routing + Prediction](https://arxiv.org/abs/2609.18063)。 +- **【2026-09-08】** **edge0** 首次开源发布,两个模型档位 + [`Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) + 与 [`Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) + 同步登陆 Hugging Face 与 ModelScope。 + +## 关于 edge0 + **edge0** 是一个开源的流式 MoE 推理框架:把「SSD 专家 offload + -Recover-LoRA + prerouter 路由预判」抽象成可扩展的通用框架。后端隔离设计, -当前实现 MLX 后端(Apple Silicon),更多平台(CUDA 等)即将接入。 +Recover-LoRA + prerouter 路由预判」抽象成可扩展的通用框架,让大型稀疏 +MoE 模型跑在消费级硬件上——峰值内存由**激活**专家集而非参数量决定。 + +### 核心机制 + +- **SSD 专家 offload**:专家权重按需从存储流式加载,峰值内存由 + 激活集而非参数量决定; +- **prerouter**:训练头提前一步预测专家路由,专家装载与前向计算重叠 + 而非阻塞——解码吞吐**最高 +59%**,收益随存储延迟、模型规模与路由 + 宽度 *K* 增大; +- **Recover-LoRA**:冻结 int4 基模,用 FP teacher 蒸馏训练 LoRA, + 在 4bit 下恢复绝大部分量化损失(见[质量](#质量))。适配器不合并, + 一份只读基模服务多套适配器。 + +### 平台 + +一个仓库、一套配方、各平台独立 runtime: + +| 平台 | 目录 | 技术栈 | 状态 | +|---|---|---|---| +| **Python**(macOS · Apple Silicon) | [`python/`](python/README_zh.md) | Python + MLX | ✅ 现已可用 | +| **macOS** 桌面 App 与 CLI | [`macos/`](macos/README_zh.md) | Rust | ✅ 已开源(2026-09-30) | +| **iOS** App | [`ios/`](ios/README_zh.md) | Swift + MLX Swift | ✅ 已开源(2026-09-30) | +| **Android** App 与引擎 | [`android/`](android/README_zh.md) | Kotlin + 原生引擎 | ✅ 已开源(2026-09-30) | +| **Windows** App 与引擎 | [`windows/`](windows/README_zh.md) | C++ + Vulkan | ✅ 已开源(2026-09-30) | + +### 模型 框架随附两个模型档位。每个档位是一个端到端发布:发布的 checkpoint、 训练好的 LoRA 适配器与训练好的 prerouter 头作为整体协同工作。 @@ -35,60 +81,92 @@ Recover-LoRA + prerouter 路由预判」抽象成可扩展的通用框架。后 适配器文件与 checkpoint 同目录、自动加载,`edge0 serve ` 开箱即跑 训练好的完整管线。 -## 环境要求 - -- **系统 / 硬件**:MLX 后端目前仅支持 Apple Silicon 的 macOS - (M1/M2/M3/M4);CUDA 后端在路线图中,其余平台暂不支持。 -- **Python**:3.10+(推荐 3.12)。 -- **MLX**:`mlx==0.30.6` / `mlx-metal==0.30.6`(`mlx-lm==0.31.0`,见 - `pyproject.toml`)。Apple A18 / A18 Pro 上输出乱码 = mlx 版本旧: - `pip install 'mlx==0.30.6' 'mlx-metal==0.30.6'` - ([#8](https://github.com/Edge0-AI/Edge0/issues/8))。 -- **内存**:短上下文下 `edge0-35b` ≈2.9 GB、`edge0-8b` ≈1.0 GB - 峰值激活内存(见[性能实测](#性能实测));另为系统、tokenizer 与 - 长上下文 KV 增长预留余量。 -- **磁盘**:4bit checkpoint 约 23 GB(`edge0-35b`)/ 4.2 GB - (`edge0-8b`);专家权重 mmap 按需读取,不一次性载入内存。 - -## 设计 +### 设计 - **像 transformers 一样使用**:`AutoModel` / `AutoConfig` / `AutoEngine` 按模型名自动选类; -- **后端隔离**:全部 MLX 代码收在 `edge0/backends/mlx/`,核心逻辑 - (模型 spec / prerouter / 流式专家池 / server)只依赖后端门面 - (`edge0/backends/base.py` 的 `core` / `nn` 门面),新增后端实现同一门面 - 即可平级接入(`backends/cuda/` 预留插槽),核心代码零改动; +- **后端隔离(Python 框架)**:在 Python 框架内,全部 MLX 代码收在 + `python/src/edge0/backends/mlx/`,核心逻辑(模型 spec / prerouter / + 流式专家池 / server)只依赖后端门面(`backends/base.py` 的 `core` / + `nn` 门面),新增后端实现同一门面即可平级接入(`backends/cuda/` + 预留插槽),核心代码零改动。iOS / macOS / Android 引擎目前是各自的 + 平台原生技术栈——把所有平台收进统一接入层,正是统一推理框架 + (见[路线图](#路线图))要交付的内容; - **适配器统一为 safetensors**:LoRA 与 prerouter 权重均为带元数据 (来源、版本、owner 层)的 `.safetensors`,放模型目录或 `artifacts/` 均可自动解析; - **模型 + 适配器同目录布局**:一个模型目录同时放基模(`config.json` / - `model*.safetensors` / tokenizer)和该模型的适配器,升级适配器只换适配器文件 - 文件,基模不动、不 merge。 + `model*.safetensors` / tokenizer)和该模型的适配器,升级适配器只换 + 适配器文件,基模不动、不 merge。 -## 核心机制 +### 质量 -- **SSD 专家 offload**:专家权重按需从存储流式加载,峰值内存由 - 激活集而非参数量决定; -- **prerouter**:训练头提前一步预测专家路由,专家装载与前向计算重叠 - 而非阻塞——解码吞吐**最高 +59%**,收益随存储延迟、模型规模与路由 - 宽度 *K* 增大; -- **Recover-LoRA**:冻结 int4 基模,用 FP teacher 蒸馏训练 LoRA, - 在 4bit 下恢复绝大部分量化损失(见[质量](#质量))。适配器不合并, - 一份只读基模服务多套适配器。 +全部评测由我们使用 [OpenCompass](https://github.com/open-compass/opencompass)、 +在完全相同的设置与参数下对 edge0 模型(int4 + 训练适配器 + prerouter 路由) +与原 fp16 基座模型测得。edge0 管线的损失很小:**edge0-35b 平均仅落后 +3.9 分、edge0-8b 落后 2.8 分**(MMLU-Pro 甚至反超基座)。满分 100: + +| 评测集 | edge0-35b(int4) | Qwen3.6-35B-A3B(fp16) | edge0-8b(int4) | Ling 3.0 tiny(fp16) | +|---|---:|---:|---:|---:| +| AIME 2026 | 86.6 | 92.7 | 63.3 | 73.3 | +| HumanEval | 90.9 | 95.1 | 91.5 | 92.7 | +| GPQA-Diamond | 79.8 | 81.8 | 70.7 | 71.2 | +| MMLU-Pro | 81.0 | 84.6 | 70.1 | 65.8 | +| IFBench | 57.9 | 61.7 | 53.9 | 60.6 | +| **平均** | **79.2** | **83.2** | **69.9** | **72.7** | + +### 性能实测 + +`python/examples/bench.py` 实测(3.3k token prompt prefill → 10 步采样 +warmup → 200 token 计时段,每档 2 轮): + +| 档位 | 解码速度 | Prefill 吞吐(冷/热)* | 峰值 active 内存 | 测试机器 | +|---|---|---|---|---| +| `edge0-35b` | 14.9–17.7 tok/s | 113 / 140 tok/s | 2.9 GiB | Mac mini M4 Pro, 24 GB | +| `edge0-8b` | 23.9–25.3 tok/s | 500 / 1428 tok/s | 1.0 GiB | Mac mini M4 Pro, 24 GB | + +*冷 = 进程启动后首请求(专家权重从 SSD 逐页换入);热 = 后续请求(页缓存常驻)。Prefill 为 ≈3.3k token 长 prompt 的吞吐(`BENCH_LONG=1`)。* + +复现: + +```bash +cd python +python examples/bench.py edge0-35b # 经 $EDGE0_35B_MODEL +python examples/bench.py edge0-8b # 经 $EDGE0_8B_MODEL +``` ## 快速开始 -### 1) 安装 +### Python(macOS · Apple Silicon) + +#### 环境要求 + +- **系统 / 硬件**:MLX 后端目前仅支持 Apple Silicon 的 macOS + (M1/M2/M3/M4);CUDA 后端在路线图中,Python 框架暂不支持其他平台。 +- **Python**:3.10+(推荐 3.12)。 +- **MLX**:`mlx==0.30.6` / `mlx-metal==0.30.6`(`mlx-lm==0.31.0`,见 + `python/pyproject.toml`)。Apple A18 / A18 Pro 上输出乱码 = mlx 版本旧: + `pip install 'mlx==0.30.6' 'mlx-metal==0.30.6'` + ([#8](https://github.com/Edge0-AI/Edge0/issues/8))。 +- **内存**:短上下文下 `edge0-35b` ≈2.9 GB、`edge0-8b` ≈1.0 GB + 峰值激活内存(见[性能实测](#性能实测));另为系统、tokenizer 与 + 长上下文 KV 增长预留余量。 +- **磁盘**:4bit checkpoint 约 23 GB(`edge0-35b`)/ 4.2 GB + (`edge0-8b`);专家权重 mmap 按需读取,不一次性载入内存。 + +#### 1) 安装 ```bash +cd python # Python ≥3.10;MLX 后端需 macOS + Apple Silicon python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev,fetch]' ``` -### 2) 下载模型 +#### 2) 下载模型 -两个档位发布在 Hugging Face 与 ModelScope——每个仓库把基模 checkpoint 与训练好的 -LoRA + prerouter 适配器打包在**同一目录**,一次下载即为可运行的模型: +两个档位发布在 Hugging Face 与 ModelScope——每个仓库把基模 checkpoint +与训练好的 LoRA + prerouter 适配器打包在**同一目录**,一次下载即为 +可运行的模型: - [`Edge0/Edge0-35B-A3B-preview`](https://huggingface.co/Edge0/Edge0-35B-A3B-preview)(约 23 GB) · [ModelScope 镜像](https://www.modelscope.cn/models/Edge0/Edge0-35B-A3B-preview) - [`Edge0/Edge0-8B-A1B-preview`](https://huggingface.co/Edge0/Edge0-8B-A1B-preview)(约 4.2 GB) · [ModelScope 镜像](https://www.modelscope.cn/models/Edge0/Edge0-8B-A1B-preview) @@ -114,7 +192,7 @@ models/edge0-35b/ └── prerouter_edge0_35b.safetensors # 训练好的 prerouter 头 ``` -### 3) 指向模型目录 +#### 3) 指向模型目录 档位名经环境变量解析到本地目录(放哪由你决定): @@ -131,7 +209,7 @@ edge0 demo models/edge0-35b edge0 serve models/edge0-8b ``` -### 4) 运行 +#### 4) 运行 ```bash # 快速演示 @@ -144,7 +222,7 @@ edge0 serve edge0-35b ```bash curl http://127.0.0.1:8000/v1/chat/completions \ -H 'Content-Type: application/json' \ - -d '{"messages":[{"role":"user","content":"Hello!"}],"max_tokens":32}' + -d '{"model":"edge0-35b","messages":[{"role":"user","content":"Hello!"}],"max_tokens":32}' # 5) 单轮对话(--max-new 限制生成长度;加 --show-thinking 会一并打印思考块) edge0 chat edge0-35b --prompt "用一句话解释流式推理。" @@ -152,7 +230,7 @@ edge0 chat edge0-35b --prompt "用一句话解释流式推理。" `python -m edge0 ...` 等价于 `edge0 ...`。 -### Python API +#### Python API ```python from edge0 import AutoEngine @@ -169,9 +247,9 @@ print(engine._tok.decode(tokens)) engine.close() # 释放 mmap / 专家缓存 ``` -`examples/demo.py` 就是这条最小路径(`edge0 demo` 内部等价运行)。 +`python/examples/demo.py` 就是这条最小路径(`edge0 demo` 内部等价运行)。 -### 模型与适配器 +#### 模型与适配器 - **checkpoint**:原始模型目录(`config.json`、`model*.safetensors`、 tokenizer)。`edge0 serve ` / `AutoEngine.from_pretrained()` @@ -179,68 +257,122 @@ engine.close() # 释放 mmap / 专家缓存 - **适配器**(LoRA + prerouter,safetensors)放两处任一,自动解析: - **模型目录内**(推荐):与基模同目录,如 `lora_edge0_35b.safetensors` + `prerouter_edge0_35b.safetensors`; - - `artifacts/`(仓库根,gitignored):`edge0 convert-adapters` 从 - 训练侧 npz 一次性转换。 + - `artifacts/`(Python 项目根,gitignored):可选的回退缓存,放置未 + 与模型同目录的适配器 safetensors。 - 发布的模型仓库同时包含基模与当前默认适配器版本, `scripts/fetch_models.py` 下载后即为可运行的模型目录。适配器来源 (训练数据、owner 层分布)见各模型文档页。 - prerouter + LoRA 两条适配器都必需;缺文件时 `edge0` 会给出明确报错 (也可加 `--no-prerouter` / `--no-lora` 直接跑裸基模)。 -## 质量 +#### 文档 -全部评测由我们使用 [OpenCompass](https://github.com/open-compass/opencompass)、 -在完全相同的设置与参数下对 edge0 模型(int4 + 训练适配器 + prerouter 路由) -与原 fp16 基座模型测得。edge0 管线的损失很小:**edge0-35b 平均仅落后 -3.9 分、edge0-8b 落后 2.8 分**(MMLU-Pro 甚至反超基座)。满分 100: +- [架构总览](docs/architecture.md) +- [注意力抽象](docs/attention.md) / [MoE 抽象](docs/moe.md) / [SSD 流式](docs/streaming.md) / [prerouter](docs/prerouter.md) +- [如何接入新模型](docs/adding-a-model.md) +- [edge0-35b](docs/models/edge0-35b.md) / [edge0-8b](docs/models/edge0-8b.md) +- 技术报告:[The Other Half of the Memory Wall](https://arxiv.org/abs/2609.18063)([PDF](paper/main.pdf)) -| 评测集 | edge0-35b(int4) | Qwen3.6-35B-A3B(fp16) | edge0-8b(int4) | Ling 3.0 tiny(fp16) | -|---|---:|---:|---:|---:| -| AIME 2026 | 86.6 | 92.7 | 63.3 | 73.3 | -| HumanEval | 90.9 | 95.1 | 91.5 | 92.7 | -| GPQA-Diamond | 79.8 | 81.8 | 70.7 | 71.2 | -| MMLU-Pro | 81.0 | 84.6 | 70.1 | 65.8 | -| IFBench | 57.9 | 61.7 | 53.9 | 60.6 | -| **平均** | **79.2** | **83.2** | **69.9** | **72.7** | +### macOS / iOS / Android / Windows -## 性能实测 +四端推理引擎已开源在本仓库,更多细节见各目录 README: -`examples/bench.py` 实测(3.3k token prompt prefill → 10 步采样 warmup → -200 token 计时段,每档 2 轮): +- **macOS**:本地 CLI / daemon / 桌面 App(Rust)—— 详见 + [`macos/README_zh.md`](macos/README_zh.md) +- **iOS**:iPhone 端侧 App(Swift + MLX Swift)—— 详见 + [`ios/README_zh.md`](ios/README_zh.md) +- **Android**:端侧 App + 原生引擎(Kotlin)—— 详见 + [`android/README_zh.md`](android/README_zh.md) +- **Windows**:桌面 App + 原生引擎(C++ + Vulkan)—— 详见 + [`windows/README_zh.md`](windows/README_zh.md) -| 档位 | 解码速度 | Prefill 吞吐(冷/热)* | 峰值 active 内存 | 测试机器 | -|---|---|---|---|---| -| `edge0-35b` | 14.9–17.7 tok/s | 113 / 140 tok/s | 2.9 GiB | Mac mini M4 Pro, 24 GB | -| `edge0-8b` | 23.9–25.3 tok/s | 500 / 1428 tok/s | 1.0 GiB | Mac mini M4 Pro, 24 GB | +**统一推理框架**(接入层统一,runtime 自动适配 iOS / macOS / Android / +Windows / Python)将于 **2026 Q4** 发布,见[路线图](#路线图)。 -*冷 = 进程启动后首请求(专家权重从 SSD 逐页换入);热 = 后续请求(页缓存常驻)。Prefill 为 ≈3.3k token 长 prompt 的吞吐(`BENCH_LONG=1`)。 +## 路线图 -复现: +### 2026 Q4 -```bash -python examples/bench.py edge0-35b # 经 $EDGE0_35B_MODEL -python examples/bench.py edge0-8b # 经 $EDGE0_8B_MODEL -``` +**平台与系统** + +- **edge0 统一推理框架开源** —— 发布统一推理框架:**接入层统一**为一套 + API(chat / serve / 端侧共用),**runtime 自动适配不同硬件平台**—— + iOS、macOS、Android、Windows、Python;在本仓库已开源的平台引擎 + (`ios/` · `macos/` · `android/` · `windows/`)之上统一收编。 +- **CUDA 后端**(Python 框架)—— 插槽已预留在 + `python/src/edge0/backends/cuda/`,核心代码零改动。 + +**模型与算法** + +Q4 推进两条主线:把下一代架构引入框架,以及把潜在推理(latent +thinking)变成真正的延迟收益、而不只是算力节省。 + +- **下一代架构支持(Qwen3.8-Flash 级)** —— 在 edge0 上跑通混合线性 + 注意力(GDN + QSA)、门控多分支残差、N-gram embedding 拓扑。这些 + 设计天然契合 SSD 流式 offload:O(1) 状态的注意力层让长思考不再是 + KV cache 问题,纯查表的 N-gram 表按需流式加载。目标:该档位在单 + 设备跑通,且基准与 fp16 基座的差距在可接受范围内。 +- **潜在思考 + 批量专家预预测** —— 让潜在推理成为*延迟*收益,而不 + 只是算力收益。核心工程问题:把专家路由从 per-position 改为**每 block + 一次**,一次预测覆盖一个 block 内所有位置与所有轮次,使**专家加载量 + 与推理循环次数解耦**;配合跨 block 预取,在当前 block 的计算窗口内 + 加载下一个 block 的专家集。进展以**等准确率下的端到端思考阶段耗时** + 衡量(而非 tokens/s)。 +- 更多模型档位与适配器版本在现有管线上持续发布。 + +## 贡献 + +欢迎各种形式的贡献——issue、PR、性能实测报告、新模型接入都算。 -## 验证 +**Python 框架**(现已可用): ```bash +cd python +python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev,fetch]' + pytest # 单元测试(不含真实权重) EDGE0_8B_MODEL=/path/to/edge0-8b pytest -m slow -q - # 真实权重生成测试;缺少的档位会明确 skip + # 真实权重生成测试;缺少的档位会明确 skip .venv/bin/python scripts/e2e_smoke.py \ --qwen-dir /path/to/edge0-35b --ling-dir /path/to/edge0-8b - # staged vs exact 一致性 + 生成冒烟 + # staged vs exact 一致性 + 生成冒烟 scripts/generate_example.py # 完整 API 上手例子(prefill→生成→解码全链路) -examples/demo.py # 最小 API walkthrough(edge0 demo 的等价代码) +examples/demo.py # 最小 API walkthrough(edge0 demo 的等价代码) ``` -## 文档 +每个 PR 都会跑 CI:单元测试(macOS + MLX)与仓库卫生检查(无硬编码 +路径、后端边界、无密钥)。 -- [架构总览](docs/architecture.md) -- [注意力抽象](docs/attention.md) / [MoE 抽象](docs/moe.md) / [SSD 流式](docs/streaming.md) / [prerouter](docs/prerouter.md) -- [如何接入新模型](docs/adding-a-model.md) -- [edge0-35b](docs/models/edge0-35b.md) / [edge0-8b](docs/models/edge0-8b.md) +**平台 runtime**(macOS / iOS / Android / Windows):各平台目录自带 +构建指南与测试——见各目录 README。 + +流程:fork → 功能分支 → 向 `main` 发 PR。请保持卫生检查通过,并为新 +行为补测试。 + +## 引用 + +如果 edge0 对你有帮助,请引用我们的技术报告: + +```bibtex +@article{lin2026other, + title = {The Other Half of the Memory Wall: Serving 35B MoEs from SSD + with Trained Routing Prediction}, + author = {Lin, Yu and Wang, Yiming and Cai, Runyuan and Liu, Hanze and + Zeng, Xiaodong}, + journal = {arXiv preprint arXiv:2609.18063}, + year = {2026}, + url = {https://arxiv.org/abs/2609.18063} +} +``` + +## 联系我们 + +社区与支持渠道即将上线,本节将列出联系我们的官方方式: + +- **邮箱**:samuel@edge0.ai + +Bug 与功能建议请直接提 +[GitHub Issues](https://github.com/Edge0-AI/Edge0/issues)。 ## License diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..6249d89 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +*/build +*/.cxx +/captures +.externalNativeBuild +.cxx +local.properties +# engine build workbench (staging for libs; artifacts are never committed) +/build-dl +# model sources and converted gguf (self-service downloads) +/models diff --git a/android/NOTICE b/android/NOTICE new file mode 100644 index 0000000..e7f3546 --- /dev/null +++ b/android/NOTICE @@ -0,0 +1,20 @@ +NOTICE - edge0 / android +================================ + +Licensed under Apache-2.0 (see the LICENSE at the monorepo root). Besides our +own code, this project contains or references the following third-party material: + +1. llama.cpp - MIT (commit 7ab4ee7, release b11100, pinned) + Location: ../vendor/llama.cpp (pristine upstream checkout, never patched in + place). Built with ../patches/llama.cpp/{common,android} replayed (20 + hook-point bands) into the shipped shared libraries (libllama, libggml*). + ggml (Apache-2.0) is developed inside the same upstream tree. Not vendored + as files; built from source by the consumer (tools/llama/build_vendor_libs.sh). + +2. Gradle/Maven dependencies (Apache-2.0 / MIT / BSD) + Kotlin, Jetpack Compose, AndroidX (Activity, Room, DataStore, Lifecycle), + kotlinx-serialization, mikepenz multiplatform-markdown-renderer-m3 (MIT), + JUnit. Resolved at build time; each ships its own license text. + +3. Model assets - Edge0/Edge0-8B-A1B-preview and Edge0/Edge0-35B-A3B-preview on + Hugging Face; terms per the model cards. Not part of the source distribution. diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..ee9197c --- /dev/null +++ b/android/README.md @@ -0,0 +1,185 @@ +# edge0-android — Release Build Guide + +English | [中文](README_zh.md) | [日本語](README_ja.md) | [Español](README_es.md) | [Français](README_fr.md) + +This document is for **developers building or evaluating the Android app from source**. It covers: quick start (engine → app → models → test), measured performance on the reference device, and the technical choices behind the stack. + +edge0 is published as a **monorepo** — [`Edge0-AI/edge0`](https://github.com/Edge0-AI/edge0) — whose top level holds the shared engine supply (`vendor.llama.pin` + the scripts-materialized `vendor/llama.cpp`, `patches/llama.cpp/` band-sets) and the platform subprojects (`windows/` = the desktop companion, `android/` = this app). Inference runs on pinned upstream llama.cpp, patched as a replayable patch set, fully on-CPU with ARM-NEON kernels and a demand-paged expert pool. + +Third-party attribution: see `NOTICE` in this directory. Model cards: [Edge0/Edge0-8B-A1B-preview](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) · [Edge0/Edge0-35B-A3B-preview](https://huggingface.co/Edge0/Edge0-35B-A3B-preview). + +--- + +## 1. Quick Start + +### 1.1 Prerequisites + +| component | requirement | +|---|---| +| Device | arm64-v8a, Android 13+ (API 33); Snapdragon 8 Elite class recommended | +| RAM | 8 GB+ runs 8B; 12–16 GB runs 35B (expert paging, see §3.2) | +| Toolchain | JDK 17+, Android SDK 35, **NDK r28** (`28.2.13676358`), CMake ≥ 3.21 + Ninja | +| Python | 3.10+ with `numpy` (model converter, runs from the sibling `windows/tools`) | +| Disk | ≥ 30 GB free for model sources and converted GGUF builds | + +The NDK is **not** part of this repository — install it once (Android Studio: +*Android SDK → SDK Tools → NDK (Side by side)*, or via CLI): + +```bash +sdkmanager --install "ndk;28.2.13676358" +``` + +Then point the build at it with `NDK_DIR` (or an exported `ANDROID_NDK_HOME`): +`build_vendor_libs.sh` reads the compiler toolchain **and** the `libomp.so` +runtime it stages (§1.2) from inside that NDK installation; a missing NDK +fails fast with that hint rather than producing a broken library set. + +### 1.2 Build the engine + +The native libraries come from the pinned upstream tree with this platform's +patch bands replayed into an isolated worktree — the vendor tree is **never +patched in place**: + +```bash +git clone https://github.com/Edge0-AI/edge0 +cd edge0/android +bash tools/llama/build_vendor_libs.sh +``` + +The script replays `../patches/llama.cpp/{common,android}` (6 + 14 bands) onto +the pinned llama.cpp tree — the pin lives in `../vendor.llama.pin` (currently +`7ab4ee7`, tag b11100); the tree is **not** a submodule, so on first run it is +cloned from upstream into `../vendor/llama.cpp` (gitignored; set +`EDGE0_LLAMA_URL` to use a mirror) and detached at the pin. Replays happen in a +gitignored consumer worktree, the script +asserts the golden result-tree hash, and produces the four engine shared +libraries (plus the NDK `libomp.so` runtime that `libggml-cpu` needs) with +headers into `build-dl/llama-libs/` (the app's jniLibs staging point). +`--replay` re-applies the bands after patch changes; mismatched tree ⇒ RED, the +build refuses to start. This step is required once before the app build — +the Gradle plugin reads these libraries from `build-dl/llama-libs/`. + +### 1.3 Build & install the app + +```bash +./gradlew :app:assembleDebug +./gradlew :app:installDebug # or adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +### 1.4 Models + +GGUF builds are produced locally from the published checkpoints — everything +stays under this directory's `models/` (gitignored): + +```bash +huggingface-cli download Edge0/Edge0-8B-A1B-preview --local-dir models/edge0-8b +python ../windows/tools/convert_mlx_to_gguf.py --dir models/edge0-8b +# → models/edge0-8b-gguf/{edge0-8b.gguf, lora_edge0_8b-gguf.gguf, manifest.json} + +huggingface-cli download Edge0/Edge0-35B-A3B-preview --local-dir models/edge0-35b +python ../windows/tools/convert_mlx_to_gguf.py --dir models/edge0-35b + +bash tools/model/push_models.sh --all # md5-gated staging onto the device +``` + +The converter needs only python3 + numpy (no MLX/torch runtime — "MLX" denotes +the on-disk checkpoint layout). It runs the r3 repack with numeric parity gates +and emits a sha256 manifest; a correct conversion reproduces the baseline +checksums listed in `push_models.sh` byte-for-byte. Models can also be copied +into `files/models/` with the in-app picker. + +### 1.5 Run + +Launch **Edge0 Chat**. The title bar shows the active model (8B / 35B), the +top-right button switches. The composer is send-only; temperature, thinking and +the system prompt live in the drawer settings. Each reply carries an inline +metrics line: `tokens · TTFT · prefill t/s · decode t/s · RSS`. + +### 1.6 Test it + +Instrumented regression (needs both models staged — reinstalling the test APK +wipes app data, so restage right before the run): + +```bash +./gradlew :app:installDebugAndroidTest +bash tools/model/push_models.sh --all +adb shell am instrument -w -e class dev.edge0.runtime.app.LlamaRuntimeTest \ + dev.edge0.runtime.app.test/androidx.test.runner.AndroidJUnitRunner +# expected: OK (8 tests), ~7 min on the reference device +``` + +Coverage: 8B/35B smoke, 35B↔8B in-process switching, prefix-reuse fidelity, +thinking on/off × system-prompt quadrants (8B gate + 35B off leak probe), and +identity retention across multi-turn rendering. Host-side logic tests: +`./gradlew :app:testDebugUnitTest` (26 tests, no device needed). + +--- + +## 2. Performance + +Reference device: **Lenovo TB322FC (Snapdragon 8 Elite, 16 GB RAM)**, shipping +config, sustained windows (first segments = boost clocks, tail = thermal steady +state — both reported rather than cherry-picked peaks). + +| Model | TTFT (warm turn) | Decode | Prefill | Session RSS | +|---|---:|---:|---:|---:| +| **8B** (Q8-class GGUF + LoRA) | ≈ 1.4 s | 29–32 → ~10 t/s over a 480 s window | ~100 t/s | ≈ 250 MB | +| **35B** (mixed int8 MoE, demand-paged) | ≈ 1.1 s warm (≈ 10–15 s on the very first turn of a new topic — cold expert pool, flash-bound) | 6–9 t/s in-app (9.46 t/s sustained CLI) | ~1 s per incremental turn | pool budget 2–6 GB; resident ≪ file size | + +Notes: "first turn of a new topic" pays the cold-pool tax — e.g. a 55-token +question measured prefill 12.8 s with 25956 expert loads, 73 % of wall time in +flash I/O wait; that is demand paging working as designed, not a regression. +Turn two onwards is warm: KV prefix reuse + keepwarm refill bring TTFT to ~1 s. +Decode decay along a long window is DVFS/thermal behavior on this SoC. + +--- + +## 3. Technical Details + +### 3.1 Architecture + +```mermaid +graph TD + subgraph App ["Kotlin / Jetpack Compose"] + UI[ChatScreen · dark · send-only composer] --> VM[ChatViewModel] + VM --> RT[LlamaRuntime
coroutines + Flow events] + VM --> DB[(Room · threads & messages)] + ST[SettingsStore] --> VM + end + subgraph Native ["C JNI shell (llama_chat.c)"] + SHELL[generate loop · template-aware thinking control
segment-wise history render · UTF-8-safe streaming] + end + subgraph Engine ["patched llama.cpp @ b11100 · arm64 CPU-only"] + LLIB[libllama.so] + GCPU[libggml-cpu.so
NEON kernels + moe_pool] + end + RT -->|JNI| SHELL --> LLIB --> GCPU + GCPU -->|demand-paged expert IO| MODELS[GGUF on flash] +``` + +### 3.2 Why a 21.7 GB MoE fits on a phone — `moe_pool` + +The 35B model activates only a moving subset of its 256-per-layer experts per +token, so the shipped design pages experts **on demand** from flash instead of +resident memory (`ggml/src/ggml-cpu/moe_pool.c`, developed as the 14-patch +android band): copy-in private frames, an equal-slot state machine, background +IO staging queues tuned to measured UFS throughput, pin/blob/trim controls, a +turn-end keepwarm refill within a byte budget, and full cross-model reset +enabling 8B↔35B switching inside one process. With the pool disabled the engine +resolves expert rows exactly like upstream (NULL resolver ⇒ zero perturbation, +verified by symbol-set diffing). This is the same mechanism family the desktop +project implements over NVMe + Vulkan; here it is CPU/NEON by design — GPU +backends stay out of the shipping config for deterministic numerics and a +single memory model. + +### 3.3 Repo map + +``` +app/ Android app: Compose UI (src/main/java), JNI shell (src/main/cpp), + instrumented + unit tests (src/androidTest, src/test) +tools/llama/ build_vendor_libs.sh — engine rebuild from the pinned tree + bands +tools/model/ push_models.sh — md5-gated model staging to devices +../vendor/llama.cpp/ materialized by the build scripts from vendor.llama.pin (gitignored; never patched in place) +../patches/llama.cpp/ common(6) + android(14) hook-point bands + ledger README +../windows/ desktop companion — hosts the MLX→GGUF converter used in §1.4 +``` diff --git a/android/README_es.md b/android/README_es.md new file mode 100644 index 0000000..ef65486 --- /dev/null +++ b/android/README_es.md @@ -0,0 +1,191 @@ +# edge0-android — Guía de compilación de release + +[English](README.md) | [中文](README_zh.md) | [日本語](README_ja.md) | Español | [Français](README_fr.md) + +Este documento está dirigido a **desarrolladores que compilan o evalúan la aplicación de Android desde el código fuente**. Cubre: inicio rápido (motor → aplicación → modelos → pruebas), rendimiento medido en el dispositivo de referencia y las decisiones técnicas detrás del stack. + +edge0 se publica como **monorepo** — [`Edge0-AI/edge0`](https://github.com/Edge0-AI/edge0) — cuyo nivel superior contiene el suministro compartido del motor (`vendor.llama.pin` + el `vendor/llama.cpp` materializado por los scripts, los conjuntos de bandas de `patches/llama.cpp/`) y los subproyectos de plataforma (`windows/` = el complemento de escritorio, `android/` = esta aplicación). La inferencia se ejecuta sobre una versión fijada (pin) de llama.cpp upstream, parcheada como un conjunto de parches reejecutable, totalmente en CPU con kernels ARM-NEON y un pool de expertos paginado bajo demanda. + +Atribución de terceros: consulta `NOTICE` en este directorio. Fichas de modelo: [Edge0/Edge0-8B-A1B-preview](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) · [Edge0/Edge0-35B-A3B-preview](https://huggingface.co/Edge0/Edge0-35B-A3B-preview). + +--- + +## 1. Inicio rápido + +### 1.1 Requisitos previos + +| componente | requisito | +|---|---| +| Dispositivo | arm64-v8a, Android 13+ (API 33); se recomienda un Snapdragon 8 Elite o similar | +| RAM | 8 GB+ ejecuta 8B; 12–16 GB ejecuta 35B (paginación de expertos, ver §3.2) | +| Toolchain | JDK 17+, Android SDK 35, **NDK r28** (`28.2.13676358`), CMake ≥ 3.21 + Ninja | +| Python | 3.10+ con `numpy` (conversor de modelos, se ejecuta desde el `windows/tools` hermano) | +| Disco | ≥ 30 GB libres para las fuentes de los modelos y las compilaciones GGUF convertidas | + +El NDK **no** forma parte de este repositorio — instálalo una vez (Android Studio: +*Android SDK → SDK Tools → NDK (Side by side)*, o mediante CLI): + +```bash +sdkmanager --install "ndk;28.2.13676358" +``` + +Después apunta la compilación hacia él con `NDK_DIR` (o una variable `ANDROID_NDK_HOME` +exportada): `build_vendor_libs.sh` lee la toolchain del compilador **y** el runtime +`libomp.so` que prepara (§1.2) desde dentro de esa instalación del NDK; si falta el NDK, +falla rápidamente con esa indicación en lugar de producir un conjunto de bibliotecas +defectuoso. + +### 1.2 Compilar el motor + +Las bibliotecas nativas provienen del árbol upstream fijado con las bandas de parches de +esta plataforma reejecutadas en un worktree aislado — el árbol del vendor **nunca** se +parchea in situ: + +```bash +git clone https://github.com/Edge0-AI/edge0 +cd edge0/android +bash tools/llama/build_vendor_libs.sh +``` + +El script reejecuta `../patches/llama.cpp/{common,android}` (6 + 14 bandas) sobre el árbol +fijado de llama.cpp — el pin está en `../vendor.llama.pin` (actualmente `7ab4ee7`, tag +b11100); el árbol **no** es un submódulo, así que en la primera ejecución se clona desde +upstream en `../vendor/llama.cpp` (ignorado por git; establece `EDGE0_LLAMA_URL` para usar +un mirror) y se hace detach en el pin. Las reejecuciones ocurren en un worktree consumidor +ignorado por git, el script verifica el hash golden del árbol de resultado y produce las +cuatro bibliotecas compartidas del motor (más el runtime `libomp.so` del NDK que +`libggml-cpu` necesita) con las cabeceras en `build-dl/llama-libs/` (el punto de +preparación de jniLibs de la aplicación). `--replay` vuelve a aplicar las bandas tras +cambios en los parches; árbol discordante ⇒ ROJO, la compilación se niega a empezar. Este +paso es necesario una vez antes de compilar la aplicación — el plugin de Gradle lee estas +bibliotecas desde `build-dl/llama-libs/`. + +### 1.3 Compilar e instalar la aplicación + +```bash +./gradlew :app:assembleDebug +./gradlew :app:installDebug # or adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +### 1.4 Modelos + +Las compilaciones GGUF se producen localmente a partir de los checkpoints publicados — +todo permanece en el `models/` de este directorio (ignorado por git): + +```bash +huggingface-cli download Edge0/Edge0-8B-A1B-preview --local-dir models/edge0-8b +python ../windows/tools/convert_mlx_to_gguf.py --dir models/edge0-8b +# → models/edge0-8b-gguf/{edge0-8b.gguf, lora_edge0_8b-gguf.gguf, manifest.json} + +huggingface-cli download Edge0/Edge0-35B-A3B-preview --local-dir models/edge0-35b +python ../windows/tools/convert_mlx_to_gguf.py --dir models/edge0-35b + +bash tools/model/push_models.sh --all # md5-gated staging onto the device +``` + +El conversor solo necesita python3 + numpy (sin runtime MLX/torch — «MLX» denota la +disposición en disco del checkpoint). Ejecuta el reempaquetado r3 con puertas de paridad +numérica y emite un manifiesto sha256; una conversión correcta reproduce byte a byte las +sumas de verificación de referencia listadas en `push_models.sh`. Los modelos también se +pueden copiar a `files/models/` con el selector de la aplicación. + +### 1.5 Ejecución + +Inicia **Edge0 Chat**. La barra de título muestra el modelo activo (8B / 35B), el botón +superior derecho lo cambia. El compositor solo envía; la temperatura, el thinking y el +system prompt están en los ajustes del panel lateral. Cada respuesta lleva una línea de +métricas integrada: `tokens · TTFT · prefill t/s · decode t/s · RSS`. + +### 1.6 Probarlo + +Regresión instrumentada (requiere ambos modelos preparados — reinstalar el APK de prueba +borra los datos de la aplicación, así que vuelve a prepararlos justo antes de la +ejecución): + +```bash +./gradlew :app:installDebugAndroidTest +bash tools/model/push_models.sh --all +adb shell am instrument -w -e class dev.edge0.runtime.app.LlamaRuntimeTest \ + dev.edge0.runtime.app.test/androidx.test.runner.AndroidJUnitRunner +# expected: OK (8 tests), ~7 min on the reference device +``` + +Cobertura: smoke de 8B/35B, cambio en el proceso 35B↔8B, fidelidad de la reutilización de +prefijos, cuadrantes de thinking activado/desactivado × system prompt (puerta 8B + prueba +de fuga con 35B desactivado) y retención de identidad en el renderizado multiturno. +Pruebas de lógica del lado del host: `./gradlew :app:testDebugUnitTest` (26 pruebas, sin +necesidad de dispositivo). + +--- + +## 2. Rendimiento + +Dispositivo de referencia: **Lenovo TB322FC (Snapdragon 8 Elite, 16 GB de RAM)**, +configuración de serie, ventanas sostenidas (los primeros segmentos = relojes de boost, la +cola = estado estacionario térmico — se reportan ambos en lugar de picos seleccionados a +dedo). + +| Modelo | TTFT (turno caliente) | Decode | Prefill | RSS de sesión | +|---|---:|---:|---:|---:| +| **8B** (GGUF clase Q8 + LoRA) | ≈ 1.4 s | 29–32 → ~10 t/s en una ventana de 480 s | ~100 t/s | ≈ 250 MB | +| **35B** (MoE int8 mixto, paginado bajo demanda) | ≈ 1.1 s caliente (≈ 10–15 s en el primer turno de un tema nuevo — pool de expertos frío, limitado por flash) | 6–9 t/s en la aplicación (9.46 t/s sostenidos en CLI) | ~1 s por turno incremental | presupuesto del pool 2–6 GB; residente ≪ tamaño del archivo | + +Notas: el «primer turno de un tema nuevo» paga el costo del pool frío — p. ej., una +pregunta de 55 tokens midió un prefill de 12.8 s con 25956 cargas de expertos, el 73 % del +tiempo real en espera de E/S de flash; eso es la paginación bajo demanda funcionando según +lo diseñado, no una regresión. Desde el segundo turno todo está caliente: la reutilización +de prefijos KV + el refill keepwarm llevan el TTFT a ~1 s. El descenso del decode a lo +largo de una ventana larga es comportamiento DVFS/térmico en este SoC. + +--- + +## 3. Detalles técnicos + +### 3.1 Arquitectura + +```mermaid +graph TD + subgraph App ["Kotlin / Jetpack Compose"] + UI[ChatScreen · dark · send-only composer] --> VM[ChatViewModel] + VM --> RT[LlamaRuntime
coroutines + Flow events] + VM --> DB[(Room · threads & messages)] + ST[SettingsStore] --> VM + end + subgraph Native ["C JNI shell (llama_chat.c)"] + SHELL[generate loop · template-aware thinking control
segment-wise history render · UTF-8-safe streaming] + end + subgraph Engine ["patched llama.cpp @ b11100 · arm64 CPU-only"] + LLIB[libllama.so] + GCPU[libggml-cpu.so
NEON kernels + moe_pool] + end + RT -->|JNI| SHELL --> LLIB --> GCPU + GCPU -->|demand-paged expert IO| MODELS[GGUF on flash] +``` + +### 3.2 Por qué un MoE de 21.7 GB cabe en un teléfono — `moe_pool` + +El modelo de 35B solo activa un subconjunto móvil de sus 256 expertos por capa en cada +token, así que el diseño de serie pagina los expertos **bajo demanda** desde la flash en +lugar de la memoria residente (`ggml/src/ggml-cpu/moe_pool.c`, desarrollado como la banda +android de 14 parches): frames privados copy-in, una máquina de estados de slots iguales, +colas de preparación de E/S en segundo plano ajustadas al throughput UFS medido, controles +de pin/blob/trim, un refill keepwarm al final del turno dentro de un presupuesto de bytes y +un reinicio completo entre modelos que permite cambiar de 8B↔35B dentro de un mismo +proceso. Con el pool desactivado, el motor resuelve las filas de expertos exactamente igual +que upstream (resolver NULL ⇒ perturbación cero, verificado con diff del conjunto de +símbolos). Es la misma familia de mecanismos que el proyecto de escritorio implementa sobre +NVMe + Vulkan; aquí es CPU/NEON por diseño — los backends de GPU quedan fuera de la +configuración de serie para garantizar una numérica determinista y un único modelo de +memoria. + +### 3.3 Mapa del repositorio + +``` +app/ Android app: Compose UI (src/main/java), JNI shell (src/main/cpp), + instrumented + unit tests (src/androidTest, src/test) +tools/llama/ build_vendor_libs.sh — engine rebuild from the pinned tree + bands +tools/model/ push_models.sh — md5-gated model staging to devices +../vendor/llama.cpp/ materialized by the build scripts from vendor.llama.pin (gitignored; never patched in place) +../patches/llama.cpp/ common(6) + android(14) hook-point bands + ledger README +../windows/ desktop companion — hosts the MLX→GGUF converter used in §1.4 +``` diff --git a/android/README_fr.md b/android/README_fr.md new file mode 100644 index 0000000..5d0a988 --- /dev/null +++ b/android/README_fr.md @@ -0,0 +1,200 @@ +# edge0-android — Guide de compilation de la release + +[English](README.md) | [中文](README_zh.md) | [日本語](README_ja.md) | [Español](README_es.md) | Français + +Ce document s'adresse aux **développeurs qui compilent ou évaluent l'application Android depuis les sources**. Il couvre : le démarrage rapide (moteur → application → modèles → test), les performances mesurées sur l'appareil de référence et les choix techniques derrière la stack. + +edge0 est publié sous forme de **monorepo** — [`Edge0-AI/edge0`](https://github.com/Edge0-AI/edge0) — dont le niveau supérieur contient l'approvisionnement partagé du moteur (`vendor.llama.pin` + le `vendor/llama.cpp` matérialisé par les scripts, les jeux de bandes `patches/llama.cpp/`) et les sous-projets par plateforme (`windows/` = le compagnon de bureau, `android/` = cette application). L'inférence s'exécute sur une version épinglée de llama.cpp upstream, patchée sous forme d'un jeu de patchs rejouable, entièrement sur CPU avec des kernels ARM-NEON et un pool d'experts paginé à la demande. + +Attribution tierce : voir `NOTICE` dans ce répertoire. Cartes de modèles : [Edge0/Edge0-8B-A1B-preview](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) · [Edge0/Edge0-35B-A3B-preview](https://huggingface.co/Edge0/Edge0-35B-A3B-preview). + +--- + +## 1. Démarrage rapide + +### 1.1 Prérequis + +| composant | exigence | +|---|---| +| Appareil | arm64-v8a, Android 13+ (API 33) ; Snapdragon 8 Elite ou équivalent recommandé | +| RAM | 8 GB+ pour le 8B ; 12–16 GB pour le 35B (pagination des experts, voir §3.2) | +| Chaîne d'outils | JDK 17+, Android SDK 35, **NDK r28** (`28.2.13676358`), CMake ≥ 3.21 + Ninja | +| Python | 3.10+ avec `numpy` (convertisseur de modèles, exécuté depuis le `windows/tools` voisin) | +| Disque | ≥ 30 GB libres pour les sources des modèles et les compilations GGUF converties | + +Le NDK ne fait **pas** partie de ce dépôt — installez-le une fois (Android Studio : +*Android SDK → SDK Tools → NDK (Side by side)*, ou via la CLI) : + +```bash +sdkmanager --install "ndk;28.2.13676358" +``` + +Pointez ensuite la compilation dessus avec `NDK_DIR` (ou un `ANDROID_NDK_HOME` +exporté) : `build_vendor_libs.sh` lit la chaîne de compilation **et** le runtime +`libomp.so` qu'il met en place (§1.2) depuis cette installation NDK ; un NDK +manquant échoue immédiatement avec cette indication plutôt que de produire un +jeu de bibliothèques cassé. + +### 1.2 Compiler le moteur + +Les bibliothèques natives proviennent de l'arborescence upstream épinglée, avec +les bandes de patchs de cette plateforme rejouées dans un worktree isolé — +l'arborescence vendor n'est **jamais patchée en place** : + +```bash +git clone https://github.com/Edge0-AI/edge0 +cd edge0/android +bash tools/llama/build_vendor_libs.sh +``` + +Le script rejoue `../patches/llama.cpp/{common,android}` (6 + 14 bandes) sur +l'arborescence llama.cpp épinglée — l'épingle se trouve dans +`../vendor.llama.pin` (actuellement `7ab4ee7`, tag b11100) ; l'arborescence +n'est **pas** un sous-module, elle est donc clonée depuis upstream au premier +lancement dans `../vendor/llama.cpp` (ignoré par git ; définissez +`EDGE0_LLAMA_URL` pour utiliser un miroir) et détachée sur l'épingle. Les +rejeux se produisent dans un worktree consommateur ignoré par git, le script +vérifie le hash de l'arborescence résultat attendue (golden) et produit les +quatre bibliothèques partagées du moteur (plus le runtime NDK `libomp.so` dont +`libggml-cpu` a besoin) avec les en-têtes dans `build-dl/llama-libs/` (le point +de mise en place jniLibs de l'application). `--replay` réapplique les bandes +après modification des patchs ; arborescence différente ⇒ ROUGE, la +compilation refuse de démarrer. Cette étape est requise une fois avant la +compilation de l'application — le plugin Gradle lit ces bibliothèques depuis +`build-dl/llama-libs/`. + +### 1.3 Compiler et installer l'application + +```bash +./gradlew :app:assembleDebug +./gradlew :app:installDebug # or adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +### 1.4 Modèles + +Les compilations GGUF sont produites localement à partir des checkpoints +publiés — tout reste sous le `models/` de ce répertoire (ignoré par git) : + +```bash +huggingface-cli download Edge0/Edge0-8B-A1B-preview --local-dir models/edge0-8b +python ../windows/tools/convert_mlx_to_gguf.py --dir models/edge0-8b +# → models/edge0-8b-gguf/{edge0-8b.gguf, lora_edge0_8b-gguf.gguf, manifest.json} + +huggingface-cli download Edge0/Edge0-35B-A3B-preview --local-dir models/edge0-35b +python ../windows/tools/convert_mlx_to_gguf.py --dir models/edge0-35b + +bash tools/model/push_models.sh --all # md5-gated staging onto the device +``` + +Le convertisseur n'a besoin que de python3 + numpy (pas de runtime MLX/torch — +« MLX » désigne la disposition sur disque du checkpoint). Il exécute le repack +r3 avec des portes de parité numérique et émet un manifeste sha256 ; une +conversion correcte reproduit octet pour octet les checksums de référence +listées dans `push_models.sh`. Les modèles peuvent aussi être copiés dans +`files/models/` avec le sélecteur intégré à l'application. + +### 1.5 Exécution + +Lancez **Edge0 Chat**. La barre de titre affiche le modèle actif (8B / 35B), le +bouton en haut à droite permet d'en changer. La zone de saisie sert uniquement +à envoyer ; la température, la réflexion (thinking) et le prompt système se +trouvent dans les paramètres du tiroir. Chaque réponse comporte une ligne de +métriques inline : `tokens · TTFT · prefill t/s · decode t/s · RSS`. + +### 1.6 Tester + +Régression instrumentée (nécessite les deux modèles mis en place — +réinstaller l'APK de test efface les données de l'application, replacez donc +les modèles juste avant l'exécution) : + +```bash +./gradlew :app:installDebugAndroidTest +bash tools/model/push_models.sh --all +adb shell am instrument -w -e class dev.edge0.runtime.app.LlamaRuntimeTest \ + dev.edge0.runtime.app.test/androidx.test.runner.AndroidJUnitRunner +# expected: OK (8 tests), ~7 min on the reference device +``` + +Couverture : smoke 8B/35B, commutation 35B↔8B dans le même processus, +fidélité de la réutilisation de préfixe, quadrants thinking activé/désactivé × +prompt système (porte 8B + sonde de fuite 35B désactivée), et rétention +d'identité à travers le rendu multi-tours. Tests logiques côté hôte : +`./gradlew :app:testDebugUnitTest` (26 tests, aucun appareil requis). + +--- + +## 2. Performances + +Appareil de référence : **Lenovo TB322FC (Snapdragon 8 Elite, 16 GB RAM)**, +configuration de série, fenêtres soutenues (premiers segments = fréquences +boost, fin = régime thermique stable — les deux sont rapportés plutôt que des +pics triés sur le volet). + +| Modèle | TTFT (tour à chaud) | Decode | Prefill | RSS de session | +|---|---:|---:|---:|---:| +| **8B** (GGUF classe Q8 + LoRA) | ≈ 1.4 s | 29–32 → ~10 t/s sur une fenêtre de 480 s | ~100 t/s | ≈ 250 MB | +| **35B** (MoE int8 mixte, paginé à la demande) | ≈ 1.1 s à chaud (≈ 10–15 s au tout premier tour d'un nouveau sujet — pool d'experts froid, limité par le flash) | 6–9 t/s dans l'application (9.46 t/s soutenu en CLI) | ~1 s par tour incrémental | budget du pool 2–6 GB ; résident ≪ taille du fichier | + +Notes : le « premier tour d'un nouveau sujet » paie le coût du pool froid — +par exemple, une question de 55 tokens a mesuré un prefill de 12,8 s avec +25 956 chargements d'experts, 73 % du temps total passé en attente d'E/S +flash ; c'est la pagination à la demande qui fonctionne comme prévu, pas une +régression. À partir du deuxième tour, tout est à chaud : la réutilisation du +préfixe KV + le remplissage keepwarm ramènent le TTFT à ~1 s. La décroissance +du decode le long d'une longue fenêtre est le comportement DVFS/thermique sur +ce SoC. + +--- + +## 3. Détails techniques + +### 3.1 Architecture + +```mermaid +graph TD + subgraph App ["Kotlin / Jetpack Compose"] + UI[ChatScreen · dark · send-only composer] --> VM[ChatViewModel] + VM --> RT[LlamaRuntime
coroutines + Flow events] + VM --> DB[(Room · threads & messages)] + ST[SettingsStore] --> VM + end + subgraph Native ["C JNI shell (llama_chat.c)"] + SHELL[generate loop · template-aware thinking control
segment-wise history render · UTF-8-safe streaming] + end + subgraph Engine ["patched llama.cpp @ b11100 · arm64 CPU-only"] + LLIB[libllama.so] + GCPU[libggml-cpu.so
NEON kernels + moe_pool] + end + RT -->|JNI| SHELL --> LLIB --> GCPU + GCPU -->|demand-paged expert IO| MODELS[GGUF on flash] +``` + +### 3.2 Pourquoi un MoE de 21,7 GB tient sur un téléphone — `moe_pool` + +Le modèle 35B n'active qu'un sous-ensemble mobile de ses 256 experts par +couche et par token, de sorte que la conception livrée pagine les experts **à +la demande** depuis le flash au lieu de la mémoire résidente +(`ggml/src/ggml-cpu/moe_pool.c`, développé comme la bande android de +14 patchs) : cadres privés recopiés à l'entrée (copy-in), une machine à états +à créneaux égaux, des files d'attente d'E/S en arrière-plan calibrées sur le +débit UFS mesuré, des contrôles pin/blob/trim, un remplissage keepwarm de fin +de tour dans une limite d'octets, et une réinitialisation complète +inter-modèles permettant la commutation 8B↔35B à l'intérieur d'un même +processus. Pool désactivé, le moteur résout les lignes d'experts exactement +comme upstream (résolveur NULL ⇒ perturbation nulle, vérifiée par diff des +jeux de symboles). C'est la même famille de mécanismes que le projet de +bureau implémente sur NVMe + Vulkan ; ici, c'est CPU/NEON par conception — +les backends GPU restent hors de la configuration livrée pour garantir un +comportement numérique déterministe et un modèle de mémoire unique. + +### 3.3 Structure du dépôt + +``` +app/ Android app: Compose UI (src/main/java), JNI shell (src/main/cpp), + instrumented + unit tests (src/androidTest, src/test) +tools/llama/ build_vendor_libs.sh — engine rebuild from the pinned tree + bands +tools/model/ push_models.sh — md5-gated model staging to devices +../vendor/llama.cpp/ materialized by the build scripts from vendor.llama.pin (gitignored; never patched in place) +../patches/llama.cpp/ common(6) + android(14) hook-point bands + ledger README +../windows/ desktop companion — hosts the MLX→GGUF converter used in §1.4 +``` diff --git a/android/README_ja.md b/android/README_ja.md new file mode 100644 index 0000000..5f0c93a --- /dev/null +++ b/android/README_ja.md @@ -0,0 +1,140 @@ +# edge0-android — リリースビルドガイド + +[English](README.md) | [中文](README_zh.md) | 日本語 | [Español](README_es.md) | [Français](README_fr.md) + +このドキュメントは、**ソースから Android アプリをビルドまたは評価する開発者**向けのものです。内容は、クイックスタート(エンジン → アプリ → モデル → テスト)、リファレンスデバイスでの実測パフォーマンス、そしてスタックの背後にある技術的選択です。 + +edge0 は**モノレポ**として公開されています — [`Edge0-AI/edge0`](https://github.com/Edge0-AI/edge0) — 最上位階層には共有のエンジンサプライ(`vendor.llama.pin` + スクリプトが実体化する `vendor/llama.cpp`、`patches/llama.cpp/` のバンドセット)とプラットフォームのサブプロジェクト(`windows/` = デスクトップ版コンパニオン、`android/` = 本アプリ)が含まれます。推論はピン留めされた上流の llama.cpp 上で実行され、リプレイ可能なパッチセットとしてパッチが適用され、ARM-NEON カーネルとデマンドページ方式のエキスパートプールにより、完全に CPU 上で動作します。 + +サードパーティの帰属表示: このディレクトリの `NOTICE` を参照してください。モデルカード: [Edge0/Edge0-8B-A1B-preview](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) · [Edge0/Edge0-35B-A3B-preview](https://huggingface.co/Edge0/Edge0-35B-A3B-preview)。 + +--- + +## 1. クイックスタート + +### 1.1 前提条件 + +| コンポーネント | 要件 | +|---|---| +| デバイス | arm64-v8a、Android 13+(API 33);Snapdragon 8 Elite クラスを推奨 | +| RAM | 8 GB+ で 8B が動作;12–16 GB で 35B が動作(エキスパートページング、§3.2 を参照) | +| ツールチェーン | JDK 17+、Android SDK 35、**NDK r28**(`28.2.13676358`)、CMake ≥ 3.21 + Ninja | +| Python | 3.10+ と `numpy`(モデルコンバータ;隣接する `windows/tools` から実行される) | +| ディスク | モデルソースと変換済み GGUF ビルド用に 30 GB 以上の空き容量 | + +NDK はこのリポジトリの一部では**ありません** — 一度インストールしてください(Android Studio: *Android SDK → SDK Tools → NDK (Side by side)*、または CLI 経由): + +```bash +sdkmanager --install "ndk;28.2.13676358" +``` + +次に `NDK_DIR`(またはエクスポートされた `ANDROID_NDK_HOME`)でビルドにそれを指定します: +`build_vendor_libs.sh` は、その NDK インストール内からコンパイラツールチェーン**と**、ステージングする `libomp.so` ランタイム(§1.2)を読み取ります。NDK が見つからない場合は、壊れたライブラリセットを生成するのではなく、そのヒントを示して即座に失敗します。 + +### 1.2 エンジンのビルド + +ネイティブライブラリは、ピン留めされた上流ツリーにこのプラットフォームのパッチバンドを隔離されたワークツリーへリプレイして生成されます — ベンダーツリーが**その場でパッチされることは決してありません**: + +```bash +git clone https://github.com/Edge0-AI/edge0 +cd edge0/android +bash tools/llama/build_vendor_libs.sh +``` + +スクリプトは `../patches/llama.cpp/{common,android}`(6 + 14 バンド)をピン留めされた llama.cpp ツリーにリプレイします — ピンは `../vendor.llama.pin` にあります(現在 `7ab4ee7`、tag b11100)。このツリーはサブモジュールでは**ない**ため、初回実行時に上流から `../vendor/llama.cpp` にクローンされ(gitignore 済み;ミラーを使うには `EDGE0_LLAMA_URL` を設定)、ピンの位置で detach されます。リプレイは gitignore されたコンシューマーワークツリー内で行われ、スクリプトはゴールデンな結果ツリーハッシュをアサートし、4 つのエンジン共有ライブラリ(および `libggml-cpu` が必要とする NDK の `libomp.so` ランタイム)をヘッダとともに `build-dl/llama-libs/`(アプリの jniLibs ステージングポイント)に生成します。`--replay` はパッチ変更後にバンドを再適用します。ツリーが不一致の場合は RED となり、ビルドは開始を拒否します。このステップはアプリのビルドの前に一度必要です — Gradle プラグインはこれらのライブラリを `build-dl/llama-libs/` から読み取ります。 + +### 1.3 アプリのビルド & インストール + +```bash +./gradlew :app:assembleDebug +./gradlew :app:installDebug # or adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +### 1.4 モデル + +GGUF ビルドは公開チェックポイントからローカルで生成されます — すべてのものはこのディレクトリの `models/` 配下に置かれます(gitignore 済み): + +```bash +huggingface-cli download Edge0/Edge0-8B-A1B-preview --local-dir models/edge0-8b +python ../windows/tools/convert_mlx_to_gguf.py --dir models/edge0-8b +# → models/edge0-8b-gguf/{edge0-8b.gguf, lora_edge0_8b-gguf.gguf, manifest.json} + +huggingface-cli download Edge0/Edge0-35B-A3B-preview --local-dir models/edge0-35b +python ../windows/tools/convert_mlx_to_gguf.py --dir models/edge0-35b + +bash tools/model/push_models.sh --all # md5-gated staging onto the device +``` + +コンバータが必要とするのは python3 + numpy のみです(MLX/torch ランタイムは不要 — 「MLX」はディスク上のチェックポイントレイアウトを指します)。数値パリティゲートを備えた r3 リパックを実行し、sha256 マニフェストを出力します。正しい変換は、`push_models.sh` に記載されたベースラインチェックサムをバイト単位で再現します。モデルはアプリ内ピッカーで `files/models/` にコピーすることもできます。 + +### 1.5 実行 + +**Edge0 Chat** を起動します。タイトルバーはアクティブなモデル(8B / 35B)を表示し、右上のボタンで切り替えます。コンポーザーは送信専用です。temperature、thinking、システムプロンプトはドロワーの設定にあります。各返信にはインラインのメトリクス行が付きます: `tokens · TTFT · prefill t/s · decode t/s · RSS`。 + +### 1.6 テスト + +計装リグレッションテスト(両モデルのステージングが必要 — テスト APK の再インストールはアプリデータを消去するため、実行直前に再ステージングしてください): + +```bash +./gradlew :app:installDebugAndroidTest +bash tools/model/push_models.sh --all +adb shell am instrument -w -e class dev.edge0.runtime.app.LlamaRuntimeTest \ + dev.edge0.runtime.app.test/androidx.test.runner.AndroidJUnitRunner +# expected: OK (8 tests), ~7 min on the reference device +``` + +カバレッジ: 8B/35B スモーク、35B↔8B のプロセス内切り替え、プレフィックス再利用の忠実性、thinking オン/オフ × システムプロンプトの四象限(8B ゲート + 35B オフ時のリークプローブ)、マルチターンレンダリングを通じたアイデンティティの保持。ホスト側のロジックテスト: `./gradlew :app:testDebugUnitTest`(26 テスト、デバイス不要)。 + +--- + +## 2. パフォーマンス + +リファレンスデバイス: **Lenovo TB322FC(Snapdragon 8 Elite、16 GB RAM)**、出荷時構成、持続ウィンドウ(最初のセグメント = ブーストクロック、テール = 熱的定常状態 — チェリーピックしたピーク値ではなく両方を報告)。 + +| モデル | TTFT(ウォームターン) | デコード | プリフィル | セッション RSS | +|---|---:|---:|---:|---:| +| **8B**(Q8 クラス GGUF + LoRA) | ≈ 1.4 s | 480 秒のウィンドウで 29–32 → ~10 t/s | ~100 t/s | ≈ 250 MB | +| **35B**(混合 int8 MoE、デマンドページ方式) | ≈ 1.1 s ウォーム(≈ 10–15 s 新しいトピックの最初のターン — コールドエキスパートプール、フラッシュ律速) | アプリ内 6–9 t/s(CLI 持続値 9.46 t/s) | インクリメンタルターンあたり ~1 s | プールバジェット 2–6 GB;常駐 ≪ ファイルサイズ | + +注記: 「新しいトピックの最初のターン」はコールドプールのコストを支払います — 例えば 55 トークンの質問で、プリフィル 12.8 s、エキスパートロード 25956 回、実時間の 73 % がフラッシュ I/O 待ちと測定されました。これはデマンドページングが設計通りに機能しているものであり、リグレッションではありません。2 ターン目以降はウォームです: KV プレフィックスの再利用 + keepwarm リフィルにより、TTFT は約 1 s になります。長いウィンドウに沿ったデコードの減衰は、この SoC の DVFS/熱による挙動です。 + +--- + +## 3. 技術詳細 + +### 3.1 アーキテクチャ + +```mermaid +graph TD + subgraph App ["Kotlin / Jetpack Compose"] + UI[ChatScreen · dark · send-only composer] --> VM[ChatViewModel] + VM --> RT[LlamaRuntime
coroutines + Flow events] + VM --> DB[(Room · threads & messages)] + ST[SettingsStore] --> VM + end + subgraph Native ["C JNI shell (llama_chat.c)"] + SHELL[generate loop · template-aware thinking control
segment-wise history render · UTF-8-safe streaming] + end + subgraph Engine ["patched llama.cpp @ b11100 · arm64 CPU-only"] + LLIB[libllama.so] + GCPU[libggml-cpu.so
NEON kernels + moe_pool] + end + RT -->|JNI| SHELL --> LLIB --> GCPU + GCPU -->|demand-paged expert IO| MODELS[GGUF on flash] +``` + +### 3.2 21.7 GB の MoE はなぜスマートフォンに収まるのか — `moe_pool` + +35B モデルはトークンごとに、レイヤーあたり 256 のエキスパートのうち移動する部分集合のみをアクティブ化します。そのため出荷時の設計では、エキスパートを常駐メモリではなくフラッシュから**オンデマンドで**ページングします(`ggml/src/ggml-cpu/moe_pool.c`、14 パッチの android バンドとして開発): コピーインのプライベートフレーム、等スロットの状態機械、実測 UFS スループットにチューニングされたバックグラウンド IO ステージングキュー、pin/blob/trim 制御、バイトバジェット内でのターン終了時の keepwarm リフィル、そして 1 つのプロセス内での 8B↔35B 切り替えを可能にする完全なクロスモデルリセット。プールを無効にすると、エンジンは上流とまったく同じようにエキスパート行を解決します(NULL リゾルバー ⇒ ゼロ摂動、シンボルセットの diff により検証済み)。これは、デスクトッププロジェクトが NVMe + Vulkan 上で実装しているものと同じ機構ファミリーです。ここでは設計により CPU/NEON です — 決定的な数値計算と単一のメモリモデルのため、GPU バックエンドは出荷時構成から外されています。 + +### 3.3 リポジトリマップ + +``` +app/ Android app: Compose UI (src/main/java), JNI shell (src/main/cpp), + instrumented + unit tests (src/androidTest, src/test) +tools/llama/ build_vendor_libs.sh — engine rebuild from the pinned tree + bands +tools/model/ push_models.sh — md5-gated model staging to devices +../vendor/llama.cpp/ materialized by the build scripts from vendor.llama.pin (gitignored; never patched in place) +../patches/llama.cpp/ common(6) + android(14) hook-point bands + ledger README +../windows/ desktop companion — hosts the MLX→GGUF converter used in §1.4 +``` diff --git a/android/README_zh.md b/android/README_zh.md new file mode 100644 index 0000000..143b9c8 --- /dev/null +++ b/android/README_zh.md @@ -0,0 +1,177 @@ +# edge0-android — 发布构建指南 + +[English](README.md) | 中文 | [日本語](README_ja.md) | [Español](README_es.md) | [Français](README_fr.md) + +本文档面向**从源码构建或评估 Android App 的开发者**。内容包括:快速开始(引擎 → App → 模型 → 测试)、参考设备上的性能实测,以及技术栈背后的技术选型。 + +edge0 以 **monorepo** 形式发布 —— [`Edge0-AI/edge0`](https://github.com/Edge0-AI/edge0) —— 顶层目录存放共享的引擎供给(`vendor.llama.pin` + 由脚本物化的 `vendor/llama.cpp`、`patches/llama.cpp/` 补丁 band 集)与各平台子项目(`windows/` = 桌面伙伴项目,`android/` = 本 App)。推理运行在固定版本(pinned)的上游 llama.cpp 上,以可重放补丁集的方式打补丁,完全在 CPU 上运行,采用 ARM-NEON kernel 与按需分页的专家池。 + +第三方声明:见本目录下的 `NOTICE`。模型卡:[Edge0/Edge0-8B-A1B-preview](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) · [Edge0/Edge0-35B-A3B-preview](https://huggingface.co/Edge0/Edge0-35B-A3B-preview)。 + +--- + +## 1. 快速开始 + +### 1.1 环境要求 + +| 组件 | 要求 | +|---|---| +| 设备 | arm64-v8a,Android 13+(API 33);推荐骁龙 8 Elite 级别 | +| 内存 | 8 GB+ 可运行 8B;12–16 GB 可运行 35B(专家分页,见 §3.2) | +| 工具链 | JDK 17+,Android SDK 35,**NDK r28**(`28.2.13676358`),CMake ≥ 3.21 + Ninja | +| Python | 3.10+ 且带 `numpy`(模型转换器,从同级目录 `windows/tools` 运行) | +| 磁盘 | ≥ 30 GB 可用空间,用于模型源文件与转换后的 GGUF 构建产物 | + +NDK **不在**本仓库内 —— 只需安装一次(Android Studio: +*Android SDK → SDK Tools → NDK (Side by side)*,或通过 CLI): + +```bash +sdkmanager --install "ndk;28.2.13676358" +``` + +然后用 `NDK_DIR`(或导出的 `ANDROID_NDK_HOME`)让构建指向它: +`build_vendor_libs.sh` 会从该 NDK 安装中读取编译器工具链**以及**其暂存 +(§1.2)的 `libomp.so` runtime;若缺少 NDK,会按该提示快速失败,而不是 +产出损坏的库集合。 + +### 1.2 构建引擎 + +原生库来自固定版本的上游源码树,本平台的补丁 band 会被重放到一个隔离的 +worktree 中 —— vendor 树**绝不就地打补丁**: + +```bash +git clone https://github.com/Edge0-AI/edge0 +cd edge0/android +bash tools/llama/build_vendor_libs.sh +``` + +脚本把 `../patches/llama.cpp/{common,android}`(6 + 14 个 band)重放到 +固定版本的 llama.cpp 树上 —— pin 记录在 `../vendor.llama.pin`(当前为 +`7ab4ee7`,tag b11100);该树**不是** submodule,因此首次运行时会从上游 +克隆到 `../vendor/llama.cpp`(gitignored;设置 `EDGE0_LLAMA_URL` 可使用 +镜像)并 detach 到 pin 的提交。重放发生在一个 gitignored 的消费方 +worktree 中,脚本会校验黄金结果树哈希,并产出四个引擎共享库(外加 +`libggml-cpu` 所需的 NDK `libomp.so` runtime)与头文件到 +`build-dl/llama-libs/`(App 的 jniLibs 暂存点)。补丁变更后用 `--replay` +重新应用 band;树不匹配 ⇒ RED,构建拒绝启动。App 构建前需要执行一次 +本步骤 —— Gradle 插件会从 `build-dl/llama-libs/` 读取这些库。 + +### 1.3 构建并安装 App + +```bash +./gradlew :app:assembleDebug +./gradlew :app:installDebug # or adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +### 1.4 模型 + +GGUF 构建产物由已发布的 checkpoint 在本地生成 —— 全部内容都留在本目录的 +`models/` 下(gitignored): + +```bash +huggingface-cli download Edge0/Edge0-8B-A1B-preview --local-dir models/edge0-8b +python ../windows/tools/convert_mlx_to_gguf.py --dir models/edge0-8b +# → models/edge0-8b-gguf/{edge0-8b.gguf, lora_edge0_8b-gguf.gguf, manifest.json} + +huggingface-cli download Edge0/Edge0-35B-A3B-preview --local-dir models/edge0-35b +python ../windows/tools/convert_mlx_to_gguf.py --dir models/edge0-35b + +bash tools/model/push_models.sh --all # md5-gated staging onto the device +``` + +转换器只需 python3 + numpy(无需 MLX/torch runtime —— “MLX” 指的是磁盘上 +的 checkpoint 布局)。它执行带数值一致性校验门的 r3 重打包并输出 sha256 +清单;正确的转换会逐字节复现 `push_models.sh` 中列出的基线校验和。也可以 +通过 App 内的选择器把模型拷贝到 `files/models/`。 + +### 1.5 运行 + +启动 **Edge0 Chat**。标题栏显示当前模型(8B / 35B),右上角按钮用于切换。 +输入框只有发送功能;temperature、thinking 与系统提示词在抽屉设置中。每条 +回复附带一行内联指标:`tokens · TTFT · prefill t/s · decode t/s · RSS`。 + +### 1.6 测试 + +仪器化回归测试(需要两个模型都已就位 —— 重新安装测试 APK 会清空 App +数据,因此请在运行前重新推送模型): + +```bash +./gradlew :app:installDebugAndroidTest +bash tools/model/push_models.sh --all +adb shell am instrument -w -e class dev.edge0.runtime.app.LlamaRuntimeTest \ + dev.edge0.runtime.app.test/androidx.test.runner.AndroidJUnitRunner +# expected: OK (8 tests), ~7 min on the reference device +``` + +覆盖范围:8B/35B 冒烟、35B↔8B 进程内切换、前缀复用保真度、thinking +开/关 × 系统提示词四象限(8B 开启 + 35B 关闭泄漏探测),以及多轮渲染下的 +身份保持。宿主侧逻辑测试:`./gradlew :app:testDebugUnitTest`(26 个测试, +无需设备)。 + +--- + +## 2. 性能实测 + +参考设备:**Lenovo TB322FC(骁龙 8 Elite,16 GB RAM)**,出厂配置,持续 +测试窗口(前段 = 加速频率,尾段 = 热稳态 —— 两者都如实报告,而非只挑 +峰值)。 + +| 模型 | TTFT(热轮次) | 解码 | Prefill | 会话 RSS | +|---|---:|---:|---:|---:| +| **8B**(Q8 级 GGUF + LoRA) | ≈ 1.4 s | 480 s 窗口内 29–32 → 约 10 t/s | 约 100 t/s | ≈ 250 MB | +| **35B**(混合 int8 MoE,按需分页) | 热轮次 ≈ 1.1 s(新话题首轮 ≈ 10–15 s —— 专家池冷启动,受闪存 IO 限制) | App 内 6–9 t/s(CLI 持续 9.46 t/s) | 每个增量轮次约 1 s | 池预算 2–6 GB;常驻 ≪ 文件大小 | + +说明:“新话题首轮”要付出冷池代价 —— 例如一个 55 token 的问题实测 +prefill 12.8 s、25956 次专家加载,73% 的墙钟时间耗在闪存 I/O 等待上; +这是按需分页按设计工作,而非性能回退。第二轮起即为热轮次:KV 前缀复用 + +keepwarm 回填把 TTFT 降到约 1 s。长窗口内解码速度的衰减是该 SoC 上的 +DVFS/热行为。 + +--- + +## 3. 技术细节 + +### 3.1 架构 + +```mermaid +graph TD + subgraph App ["Kotlin / Jetpack Compose"] + UI[ChatScreen · dark · send-only composer] --> VM[ChatViewModel] + VM --> RT[LlamaRuntime
coroutines + Flow events] + VM --> DB[(Room · threads & messages)] + ST[SettingsStore] --> VM + end + subgraph Native ["C JNI shell (llama_chat.c)"] + SHELL[generate loop · template-aware thinking control
segment-wise history render · UTF-8-safe streaming] + end + subgraph Engine ["patched llama.cpp @ b11100 · arm64 CPU-only"] + LLIB[libllama.so] + GCPU[libggml-cpu.so
NEON kernels + moe_pool] + end + RT -->|JNI| SHELL --> LLIB --> GCPU + GCPU -->|demand-paged expert IO| MODELS[GGUF on flash] +``` + +### 3.2 为什么 21.7 GB 的 MoE 能装进手机 —— `moe_pool` + +35B 模型每个 token 只激活其每层 256 个专家中一个滚动的子集,因此发布版 +设计让专家从闪存**按需**分页,而非驻留内存 +(`ggml/src/ggml-cpu/moe_pool.c`,以 14 个补丁的 android band 开发): +copy-in 私有帧、等槽位状态机、按实测 UFS 吞吐调优的后台 IO 暂存队列、 +pin/blob/trim 控制、字节预算内的轮末 keepwarm 回填,以及支持单进程内 +8B↔35B 切换的完整跨模型重置。禁用专家池时,引擎解析专家行的行为与上游 +完全一致(NULL resolver ⇒ 零扰动,经符号集 diff 验证)。这与桌面项目在 +NVMe + Vulkan 上实现的是同一族机制;这里按设计走 CPU/NEON —— GPU 后端 +不进入发布配置,以保证确定性数值与单一内存模型。 + +### 3.3 仓库结构 + +``` +app/ Android app: Compose UI (src/main/java), JNI shell (src/main/cpp), + instrumented + unit tests (src/androidTest, src/test) +tools/llama/ build_vendor_libs.sh — engine rebuild from the pinned tree + bands +tools/model/ push_models.sh — md5-gated model staging to devices +../vendor/llama.cpp/ materialized by the build scripts from vendor.llama.pin (gitignored; never patched in place) +../patches/llama.cpp/ common(6) + android(14) hook-point bands + ledger README +../windows/ desktop companion — hosts the MLX→GGUF converter used in §1.4 +``` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..37876ca --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,95 @@ +// app/build.gradle.kts - the chat app module. +// Single APK: native llama shell (shared libs staged via jniLibs) + Compose UI. +// git SHA provenance: configuration cache forbids external processes at configure time, +// so the build takes E0_GIT_SHA from the environment: +// unset falls back to "unknown" (never blocks the build). +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.kotlinx.serialization) +} + +android { + namespace = "dev.edge0.runtime.app" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "dev.edge0.runtime.app" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.compileSdk.get().toInt() + versionCode = 1 + versionName = "0.1.0" + ndk { abiFilters += "arm64-v8a" } // arm64 only + externalNativeBuild { + cmake { + abiFilters += "arm64-v8a" + arguments += "-DLLAMA_LIBS=" + rootProject.file("build-dl/llama-libs/arm64-v8a").absolutePath + } + } + // instrumented suites follow install -> stage assets -> instrument; the first step is gradle. + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "GIT_SHA", + "\"${System.getenv("E0_GIT_SHA") ?: "unknown"}\"") + } + buildTypes { + debug { isMinifyEnabled = false } + release { + isMinifyEnabled = false // no minification at this stage + } + } + // llama native shell; .so assembly point is build-dl/llama-libs (gitignored) + externalNativeBuild { cmake { path = file("src/main/cpp/CMakeLists.txt"); version = "3.22.1" } } + sourceSets { + getByName("main") { + val ll = rootProject.file("build-dl/llama-libs/arm64-v8a") + if (ll.isDirectory) jniLibs.srcDirs(rootProject.file("build-dl/llama-libs")) + } + } + + buildFeatures { + compose = true + buildConfig = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } + packaging { + resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + } +} + +ksp { + // Room schema export into app/schemas/ for migration traceability + arg("room.schemaLocation", "$projectDir/schemas".toString()) + arg("room.generateKotlin", "true") +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) + debugImplementation(libs.compose.ui.tooling) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.process) // lifecycle hooks for trim mapping + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + implementation(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + implementation(libs.markdown.renderer.m3) // markdown rendering (tables/links/strikethrough) + + testImplementation("junit:junit:4.13.2") // JVM unit tests + androidTestImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test:runner:1.6.2") +} diff --git a/android/app/schemas/dev.edge0.runtime.app.data.AppDatabase/1.json b/android/app/schemas/dev.edge0.runtime.app.data.AppDatabase/1.json new file mode 100644 index 0000000..c7705c3 --- /dev/null +++ b/android/app/schemas/dev.edge0.runtime.app.data.AppDatabase/1.json @@ -0,0 +1,169 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "7e3b71bb48f755556c1020fe2717fb45", + "entities": [ + { + "tableName": "threads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `modelId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "modelId", + "columnName": "modelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_threads_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_threads_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + } + ] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `threadId` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `thinking` TEXT, `thinkingMs` INTEGER, `status` TEXT NOT NULL, `modelId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `promptTokens` INTEGER, `newTokens` INTEGER, `decodeTokS` REAL, PRIMARY KEY(`id`), FOREIGN KEY(`threadId`) REFERENCES `threads`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thinking", + "columnName": "thinking", + "affinity": "TEXT" + }, + { + "fieldPath": "thinkingMs", + "columnName": "thinkingMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelId", + "columnName": "modelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "promptTokens", + "columnName": "promptTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "newTokens", + "columnName": "newTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "decodeTokS", + "columnName": "decodeTokS", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_threadId_createdAt", + "unique": false, + "columnNames": [ + "threadId", + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_threadId_createdAt` ON `${TABLE_NAME}` (`threadId`, `createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "threads", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "threadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7e3b71bb48f755556c1020fe2717fb45')" + ] + } +} \ No newline at end of file diff --git a/android/app/schemas/dev.edge0.runtime.app.data.AppDatabase/2.json b/android/app/schemas/dev.edge0.runtime.app.data.AppDatabase/2.json new file mode 100644 index 0000000..e39ee62 --- /dev/null +++ b/android/app/schemas/dev.edge0.runtime.app.data.AppDatabase/2.json @@ -0,0 +1,174 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "95511891effc3c18a7d5a92ebfc02e22", + "entities": [ + { + "tableName": "threads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `modelId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "modelId", + "columnName": "modelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_threads_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_threads_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + } + ] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `threadId` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `thinking` TEXT, `thinkingMs` INTEGER, `status` TEXT NOT NULL, `modelId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `promptTokens` INTEGER, `newTokens` INTEGER, `decodeTokS` REAL, `genTokens` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`threadId`) REFERENCES `threads`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thinking", + "columnName": "thinking", + "affinity": "TEXT" + }, + { + "fieldPath": "thinkingMs", + "columnName": "thinkingMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelId", + "columnName": "modelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "promptTokens", + "columnName": "promptTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "newTokens", + "columnName": "newTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "decodeTokS", + "columnName": "decodeTokS", + "affinity": "REAL" + }, + { + "fieldPath": "genTokens", + "columnName": "genTokens", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_threadId_createdAt", + "unique": false, + "columnNames": [ + "threadId", + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_threadId_createdAt` ON `${TABLE_NAME}` (`threadId`, `createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "threads", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "threadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '95511891effc3c18a7d5a92ebfc02e22')" + ] + } +} \ No newline at end of file diff --git a/android/app/schemas/dev.edge0.runtime.app.data.AppDatabase/3.json b/android/app/schemas/dev.edge0.runtime.app.data.AppDatabase/3.json new file mode 100644 index 0000000..e795529 --- /dev/null +++ b/android/app/schemas/dev.edge0.runtime.app.data.AppDatabase/3.json @@ -0,0 +1,189 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "bedb08d6046568298f8264b8d94ffd0f", + "entities": [ + { + "tableName": "threads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `modelId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "modelId", + "columnName": "modelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_threads_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_threads_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + } + ] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `threadId` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `thinking` TEXT, `thinkingMs` INTEGER, `status` TEXT NOT NULL, `modelId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `promptTokens` INTEGER, `newTokens` INTEGER, `decodeTokS` REAL, `genTokens` BLOB, `ttftMs` INTEGER, `prefillTokS` REAL, `memBytes` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`threadId`) REFERENCES `threads`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thinking", + "columnName": "thinking", + "affinity": "TEXT" + }, + { + "fieldPath": "thinkingMs", + "columnName": "thinkingMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelId", + "columnName": "modelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "promptTokens", + "columnName": "promptTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "newTokens", + "columnName": "newTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "decodeTokS", + "columnName": "decodeTokS", + "affinity": "REAL" + }, + { + "fieldPath": "genTokens", + "columnName": "genTokens", + "affinity": "BLOB" + }, + { + "fieldPath": "ttftMs", + "columnName": "ttftMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "prefillTokS", + "columnName": "prefillTokS", + "affinity": "REAL" + }, + { + "fieldPath": "memBytes", + "columnName": "memBytes", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_threadId_createdAt", + "unique": false, + "columnNames": [ + "threadId", + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_threadId_createdAt` ON `${TABLE_NAME}` (`threadId`, `createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "threads", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "threadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bedb08d6046568298f8264b8d94ffd0f')" + ] + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/dev/edge0/runtime/app/LlamaRuntimeTest.kt b/android/app/src/androidTest/java/dev/edge0/runtime/app/LlamaRuntimeTest.kt new file mode 100644 index 0000000..4744267 --- /dev/null +++ b/android/app/src/androidTest/java/dev/edge0/runtime/app/LlamaRuntimeTest.kt @@ -0,0 +1,326 @@ +// LlamaRuntimeTest - app-domain smoke suite for the llama engine shell. +// Prerequisite: stage the models AFTER installing the test APK - +// adb shell run-as dev.edge0.runtime.app cp /data/local/tmp/lgguf/edge0-8b.gguf files/models/edge0-8b.gguf +// instrumented installs wipe app data, so staging must follow installation. +// Assertions keep a conservative floor (availability over speed; the CLI benchmark +// owns ceiling checks): >= 8 tokens produced, decode > 1 t/s, non-empty text. +package dev.edge0.runtime.app + +import dev.edge0.runtime.app.runtime.GenEvent +import dev.edge0.runtime.app.runtime.LlamaRuntime +import dev.edge0.runtime.engine.ChatMessage +import dev.edge0.runtime.engine.GenParams +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class LlamaRuntimeTest { + @Test + fun eightBChatRoundTrip() = runBlocking { + val ctx = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext + // files staged via run-as may carry an SELinux label the instrumentation cannot + // read; search candidate roots instead. Production loads through the normal path. + val want = androidx.test.platform.app.InstrumentationRegistry.getArguments() + .getString("model") ?: "edge0-35b.gguf" + val model = sequenceOf( + File(ctx.getDir("models", 0), want), + File(ctx.filesDir, "models/$want"), + File(ctx.cacheDir, "models/$want"), + File(ctx.getExternalFilesDir(null) ?: ctx.filesDir, "models/$want"), + ).firstOrNull { it.isFile } + ?: run { + val hit = generateSequence(ctx.dataDir) { f -> + f.listFiles()?.firstOrNull { it.isDirectory && !it.name.startsWith(".") } + }.flatMap { (it.walk().firstOrNull { f2 -> f2.name == want }?.let { x -> sequenceOf(x) } ?: emptySequence()) } + .firstOrNull() + hit ?: File(ctx.getDir("models", 0), want) + } + assertTrue("model not found anywhere: ${model.path}", model.isFile) + + val rt = LlamaRuntime() + rt.ensureLoaded(model.absolutePath) + assertTrue("load failed: ${rt.state.value}", rt.activeModelDir != null) + + val pieces = StringBuilder() + var tps = 0.0 + rt.submit("t1", listOf(ChatMessage("user", "Introduce yourself in one sentence")), + true, GenParams(temperature = 0f, maxNewTokens = 32)) + .toList().forEach { ev -> + when (ev) { + is GenEvent.TextPiece -> pieces.append(ev.s) + is GenEvent.ThinkPiece -> pieces.append(ev.s) + is GenEvent.Done -> tps = ev.metrics?.decodeTokS ?: 0.0 + is GenEvent.Failed -> throw AssertionError("gen failed: ${ev.diag}") + else -> Unit + } + } + android.util.Log.i("LlamaRT", "reply=${pieces.take(80)} tps=$tps") + // metrics wiring assertions (pool/rss must not be N/A) + val rm = rt.lastMetrics.value + assertTrue("metrics missing", rm != null) + assertTrue("pool metrics missing", rm?.pool != null) + if (want.contains("35b")) { + assertTrue("35B pool counters not wired", (rm!!.pool!!.hits + rm.pool!!.misses) > 0 && rm.pool!!.slots_total > 0) + } else { + // the 8B tier is fully resident: counters exist and read zero - that is the correct shape + assertTrue("8B should not occupy the pool", rm!!.pool!!.slots_total == 0L) + } + assertTrue("peak rss N/A", (rm?.peakRssBytes ?: 0L) > 100L * 1024 * 1024) + assertTrue("first-token timing anomaly: ${rm?.firstTokenMs}", (rm?.firstTokenMs ?: 0) > 0) + assertTrue("empty reply", pieces.isNotBlank()) + val floor = if (want.contains("35b")) 0.5 else 1.0 + assertTrue("tps too low: $tps", tps > floor) + + // second turn exercises KV prefix reuse (history diff sync) + val r2 = rt.submit("t2", listOf( + ChatMessage("user", "Introduce yourself in one sentence"), + ChatMessage("assistant", pieces.toString().trim().take(40)), + ChatMessage("user", "a bit shorter"), + ), true, GenParams(temperature = 0f, maxNewTokens = 24)).toList() + assertTrue("round2 no done", r2.any { it is GenEvent.Done }) + rt.unloadActive() + } + + // crash regression: use 8B, switch to 35B, send in a new session - stale LoRA/session +// state attaching to the new graph used to abort; bidirectional switching must survive. + @Test + fun switch8bThen35bSurvival() = runBlocking { + val ctx = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext + fun find(n: String) = sequenceOf(File(ctx.getDir("models", 0), n), File(ctx.filesDir, "models/$n")) + .firstOrNull { it.isFile } + val m8 = find("edge0-8b.gguf") ?: return@runBlocking + val rt = LlamaRuntime() + fun round(model: File, q: String) = runBlocking { + rt.ensureLoaded(model.absolutePath) + assertTrue("load fail ${model.name}: ${rt.state.value}", rt.activeModelDir != null) + var n = 0 + rt.submit("sw", listOf(ChatMessage("user", q)), true, + GenParams(temperature = 0f, maxNewTokens = 8)) + .toList().forEach { ev -> + when (ev) { + is GenEvent.TextPiece -> n += ev.s.length + is GenEvent.ThinkPiece -> n += ev.s.length + is GenEvent.Failed -> throw AssertionError("${model.name}: ${ev.diag}") + else -> Unit + } + } + assertTrue("no output ${model.name}", n > 2) + } + round(m8, "hello") + val m35 = find("edge0-35b.gguf") + if (m35 != null) { + round(m35, "hello") // the original crash point + round(m8, "hello") // switch back (pool/registry reset verified both ways) + } + rt.unloadActive() + } + + // real multi-turn on 35B (long answer, then thanks): log reused/prefill per round to locate forks + @Test + fun prefixReuseProbe() = runBlocking { + val ctx = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext + val want = androidx.test.platform.app.InstrumentationRegistry.getArguments() + .getString("model") ?: "edge0-35b.gguf" + val model = sequenceOf(File(ctx.getDir("models", 0), want), File(ctx.filesDir, "models/$want")) + .firstOrNull { it.isFile } ?: return@runBlocking + val syncMode = androidx.test.platform.app.InstrumentationRegistry.getArguments() + .getString("sync") ?: "" + if (syncMode.isNotEmpty()) android.system.Os.setenv("E0_POOL_SYNC", "1", true) + val rt = LlamaRuntime() + rt.ensureLoaded(model.absolutePath) + val probeThinking = (androidx.test.platform.app.InstrumentationRegistry.getArguments() + .getString("thinking") ?: "off") == "on" + val hist = mutableListOf() + val qs = listOf("Describe three core advantages of Mixture-of-Experts models in detail.", "thanks") + for ((i, q) in qs.withIndex()) { + hist.add(ChatMessage("user", q)) + val sb = StringBuilder() + rt.submit("probe$i", hist.toList(), probeThinking, GenParams(temperature = 0f, maxNewTokens = if (i == 0) 200 else 20)) + .toList().forEach { ev -> + when (ev) { + is GenEvent.TextPiece -> sb.append(ev.s) + is GenEvent.ThinkPiece -> sb.append("«T»").append(ev.s) + is GenEvent.Failed -> throw AssertionError(ev.diag) + else -> Unit + } + } + hist.add(ChatMessage("assistant", sb.toString())) + if (i == 0) Thread.sleep(25000) // simulate reading the reply: the keepwarm battleground + android.util.Log.i("ReuseProbe", "round$i head=[${sb.toString().take(70).replace("\n", " | ")}] len=${sb.length} metrics=${rt.lastMetrics.value?.let { "prompt=${it.promptTokens} reused=${it.prefix_reused_tokens} prefill_ms=${it.prefillMs} first_ms=${it.firstTokenMs} pool=${it.pool?.let { q -> "hits=${q.hits} miss=${q.misses} evict=${q.evictions} stall_ms=${q.stall_ns / 1000000} res_MB=${q.resident_bytes / 1048576} pf=${q.prefetch_used}/${q.prefetch_mispredict}" }}" }}") + } + rt.unloadActive() + } + + // thinking quality gate: same greedy prompt, 12 tokens, quadrants of {system,thinking}. + // Verdict via logcat ThinkGate: off cells must answer directly, on cells must think. + // Regression anchor: custom-system off leaking thinking (the ensure-position fix). + @Test + fun thinkSwitchQualityGate() = runBlocking { + val ctx = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext + val want = androidx.test.platform.app.InstrumentationRegistry.getArguments() + .getString("model") ?: "edge0-8b.gguf" + val model = sequenceOf(File(ctx.getDir("models", 0), want), File(ctx.filesDir, "models/$want")) + .firstOrNull { it.isFile } ?: return@runBlocking + val q = "Explain why the sky is blue in one short sentence." + val sp = "You're Edge0, an on-device AI assistant." + val heads = ArrayList() + for (sys in listOf(false, true)) for (th in listOf(false, true)) { + val rt = LlamaRuntime() + rt.ensureLoaded(model.absolutePath) + val base0 = if (sys) listOf(ChatMessage("system", sp)) else emptyList() + val turns = ArrayList() + val hist = base0.toMutableList() + for (t in 0..1) { // round two goes through the sidecar path (the empty-answer incident scene) + val sb = StringBuilder() + hist.add(ChatMessage("user", if (t == 0) q else "thanks, one more short sentence")) + rt.submit("g$sys$th$t", hist.toList(), th, + GenParams(temperature = 0f, maxNewTokens = 12)) + .toList().forEach { ev -> + when (ev) { + is GenEvent.TextPiece -> sb.append(ev.s) + is GenEvent.ThinkPiece -> sb.append("").append(ev.s) + is GenEvent.Failed -> throw AssertionError(ev.diag) + else -> Unit + } + } + turns.add("t" + t + "=" + sb.toString().take(44).replace("\n", " ")) + hist.add(ChatMessage("assistant", sb.toString())) + } + heads.add((if (sys) "sys" else "no") + "-th" + (if (th) "on" else "off") + ": " + turns.joinToString(" ~ ")) + // empty-answer anchor: no quadrant, no round may return empty + org.junit.Assert.assertTrue("gate: empty reply in " + heads.last(), + turns.all { it.length > 6 }) + rt.unloadActive() + } + android.util.Log.i("ThinkGate", heads.joinToString(" ||| ")) + org.junit.Assert.assertTrue("gate: 4 quadrants", heads.size == 4) + org.junit.Assert.assertTrue("gate: off-no-sys empty", heads[0].length > 12) + } + + // multi-turn identity probe: chat once (builds sidecar history), then ask who you are. + // Single-turn was green; the incident lived in the multi-turn path. + @Test + fun whoAreYouSysProbe() = runBlocking { + val ctx = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext + val model = File(ctx.filesDir, "models/edge0-8b.gguf") + if (!model.isFile) return@runBlocking + val rt = LlamaRuntime() + rt.ensureLoaded(model.absolutePath) + var heads_all = "" + val hist = mutableListOf(ChatMessage("system", "You're Edge0, an on-device AI assistant.")) + for ((i, q) in listOf("What is the capital of France? One word.", + "Who are you? What is your name? Answer in one sentence.").withIndex()) { + hist.add(ChatMessage("user", q)) + val sb = StringBuilder() + rt.submit("way$i", hist.toList(), false, GenParams(temperature = 0.2f, maxNewTokens = 60)) + .toList().forEach { ev -> + when (ev) { + is GenEvent.TextPiece -> sb.append(ev.s) + is GenEvent.ThinkPiece -> sb.append("") + is GenEvent.Failed -> throw AssertionError(ev.diag) + else -> Unit + } + } + heads_all += sb.toString(); android.util.Log.i("WayProbe", "turn" + i + " len=" + sb.length + " ans=" + sb.toString().take(90)) + hist.add(ChatMessage("assistant", sb.toString())) + } + rt.unloadActive() + org.junit.Assert.assertTrue("way: identity lost in multi-turn", + heads_all.contains("Edge0")) + } + + // fork dissecting table: mode=ui replays the stored UI text as the assistant turn; +// mode=native replays lastAssistantNative ground truth + @Test + fun reuseForkBisect() = runBlocking { + val mode = androidx.test.platform.app.InstrumentationRegistry.getArguments() + .getString("mode") ?: "ui" + val ctx = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext + val want = androidx.test.platform.app.InstrumentationRegistry.getArguments().getString("model") ?: "edge0-35b.gguf" + val model = sequenceOf( + File(ctx.getDir("models", 0), want), + File(ctx.filesDir, "models/$want"), + ).firstOrNull { it.isFile } ?: return@runBlocking + val rt = LlamaRuntime() + rt.ensureLoaded(model.absolutePath) + val hist = mutableListOf() + val qs = listOf("Introduce three advantages of Mixture-of-Experts in detail.", "thanks") + for ((i, q) in qs.withIndex()) { + hist.add(ChatMessage("user", q)) + val sb = StringBuilder() + rt.submit("bs$i", hist.toList(), true, GenParams(temperature = 0f, maxNewTokens = if (i == 0) 160 else 16)) + .toList().forEach { ev -> + when (ev) { + is GenEvent.TextPiece -> sb.append(ev.s) + is GenEvent.ThinkPiece -> sb.append(ev.s) + is GenEvent.Failed -> throw AssertionError(ev.diag) + else -> Unit + } + } + val content = if (mode == "native") (rt.lastAssistantNative() ?: sb.toString()) else sb.toString() + hist.add(ChatMessage("assistant", content)) + android.util.Log.i("ReuseProbe", "mode=$mode round$i uiLen=${sb.length} sentLen=${content.length} m=${rt.lastMetrics.value?.let { "prompt=${it.promptTokens} reused=${it.prefix_reused_tokens} prefill_ms=${it.prefillMs}" }}") + } + rt.unloadActive() + } + + // stack-smash regression: long ASCII pieces (>16 bytes) from byte-fallback must not crash + @Test + fun longAsciiPieceSurvival() = runBlocking { + val ctx = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext + val want = "edge0-8b.gguf" + val model = sequenceOf( + File(ctx.getDir("models", 0), want), + File(ctx.filesDir, "models/$want"), + ).firstOrNull { it.isFile } ?: return@runBlocking + val rt = LlamaRuntime() + rt.ensureLoaded(model.absolutePath) + var count = 0 + rt.submit("t-ascii", listOf( + ChatMessage("user", "Repeat exactly, no thinking: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB 123456789012345678901234567890 CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"), + ), true, GenParams(temperature = 0f, maxNewTokens = 192)).toList() + .forEach { ev -> when (ev) { + is GenEvent.TextPiece -> count += ev.s.length + is GenEvent.ThinkPiece -> count += ev.s.length + is GenEvent.Failed -> throw AssertionError(ev.diag) + else -> Unit + } } + android.util.Log.i("LlamaRT", "ascii survival chars=$count") + assertTrue("no output", count > 20) + rt.unloadActive() + } + + // 35B off-mode leak measurement: the empty-pair path is live (parity with the + // reference template); this case quantifies residual drift via thinkChars and + // driftFirst. Assertions guard gross shape only; drift is read from LeakProbe logs. + @Test + fun thinkOffLeak35b() = runBlocking { + val ctx = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext + val want = "edge0-35b.gguf" + val model = sequenceOf(File(ctx.getDir("models", 0), want), File(ctx.filesDir, "models/$want")) + .firstOrNull { it.isFile } ?: return@runBlocking + val rt = LlamaRuntime() + rt.ensureLoaded(model.absolutePath) + val qs = listOf("What is the capital of France? One word.", + "If you pick 3, 3, 8, 8 once each with +,-,*,/ to reach 24, give the expression directly.", + "Name one prime number larger than 90 and smaller than 100.") + for ((i, q) in qs.withIndex()) { + val sb = StringBuilder(); var thinkChars = 0; var textChars = 0; var firstKind = "?" + rt.submit("leak$i", listOf(ChatMessage("user", q)), false, + GenParams(temperature = 0f, maxNewTokens = 48)).toList().forEach { ev -> + when (ev) { + is GenEvent.TextPiece -> { if (firstKind == "?") firstKind = "T"; textChars += ev.s.length; sb.append(ev.s) } + is GenEvent.ThinkPiece -> { if (firstKind == "?") firstKind = "K"; thinkChars += ev.s.length; sb.append("").append(ev.s) } + is GenEvent.Failed -> throw AssertionError(ev.diag) + else -> Unit + } + } + android.util.Log.i("LeakProbe", "q$i first=$firstKind think=$thinkChars text=$textChars out=" + + sb.toString().take(70).replace("\n", " ")) + org.junit.Assert.assertTrue("leak probe empty q$i", textChars + thinkChars > 3) + } + rt.unloadActive() + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..803a6c4 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..9e66bb5 --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,21 @@ +# app/src/main/cpp/CMakeLists.txt - native llama engine shell for the app. +# Links the five shared libs staged at build-dl/llama-libs/arm64-v8a (built by +# tools/llama/build_vendor_libs.sh from the pinned supply tree: vendor@7ab4ee7 with +# the common+android patch bands replayed; see patches/llama.cpp/README). +cmake_minimum_required(VERSION 3.22.1) +project(edge0llama C) + +if(NOT DEFINED LLAMA_LIBS) + set(LLAMA_LIBS "${CMAKE_CURRENT_SOURCE_DIR}/../../../../build-dl/llama-libs/arm64-v8a") +endif() + +add_library(edge0llama SHARED llama_chat.c) +target_include_directories(edge0llama PRIVATE + "${LLAMA_LIBS}/../include" # llama.h/ggml.h headers (synced at build time) + "${LLAMA_LIBS}") +target_link_libraries(edge0llama + "${LLAMA_LIBS}/libllama.so" + "${LLAMA_LIBS}/libggml.so" + "${LLAMA_LIBS}/libggml-base.so" + "${LLAMA_LIBS}/libggml-cpu.so" + android log) diff --git a/android/app/src/main/cpp/llama_chat.c b/android/app/src/main/cpp/llama_chat.c new file mode 100644 index 0000000..a3ee9c1 --- /dev/null +++ b/android/app/src/main/cpp/llama_chat.c @@ -0,0 +1,650 @@ +// llama_chat.c - native llama engine shell for the Edge0 chat app. +// Session model: static history of turns; every request renders the chat template +// over the append-only transcript, tokenizes it, and lets KV prefix reuse turn the +// full render into an incremental prefill. Sampling = classic token_data_array +// chain (greedy when temperature <= 0). nativeInit carries expert-pool tier config +// (the 35B tier requires E0_MMAP_NOPREFETCH + E0_NO_REPACK), nativeReset drops KV, +// and each generate returns a structured metrics line for the Kotlin layer. +#include +#include +#include +#include +#include +#include +#include +#include "llama.h" + +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "E0LLAMA", __VA_ARGS__) + +static struct llama_model *g_model = NULL; +static struct llama_context *g_ctx = NULL; +static struct llama_adapter_lora *g_lora = NULL; + +#define MAX_MSG 128 +static llama_chat_message g_hist[MAX_MSG]; +static char *g_strs[MAX_MSG * 2]; +static int g_nmsg = 0; + +#define MAX_TOKS 8192 +static llama_token g_toks[MAX_TOKS]; +static llama_token *g_msg_ids[MAX_MSG]; // assistant turns: stepped token ids (sidecar) +static int g_msg_nids[MAX_MSG]; +static llama_token g_msg_eog[MAX_MSG]; // end-of-turn token for each history turn +static llama_token g_out_ids[MAX_TOKS]; // ids stepped this turn (EOS excluded) +static int g_out_n = 0; +static llama_token g_out_eog = -1; +static int g_nprocessed = 0; + +static volatile int g_cancel = 0; +// pool counters forward decl (defined at the file tail; the metrics window inside nativeGenerate needs them) +extern void moe_pool_stats(long long * hits, long long * misses, long long * loads, long long * evicts, + long long * bytes_loaded, size_t * cap_bytes, size_t * used_bytes); +extern void moe_pool_extra(long long * stall_ns, long long * slots_used, long long * slots_total, + long long * pf_used, long long * pf_mispredict); + +// Thinking control: the built-in BAILING2 renderer emits no think tags and ignores +// jinja variables - thinking is model-native. The real switch is the template's +// "detailed thinking on/off" instruction line in the system turn (training contract). +static int g_thinking = 0; +// The on direction rewrites in place with equal or shorter length, byte-matching the +// reference jinja render; returns the corrected render length. +// Both template families align with the reference (real-template) render: +// 8B Bailing V3: off = instruction line at the end of the system turn (the built-in +// renderer self-injects it when no system turn exists); assistant heads get the open +// tag for on, an open+close empty pair for off. +// 35B Qwen2/CHATML: off = empty pair after the generation head only - the instruction +// line is a foreign signal for this family (verified against the embedded template). + +static int32_t bailing_flip_on(char * r, int32_t len) { // V3: off -> on, shrink 1 byte left + const char * needle = "detailed thinking off"; + for (int32_t i = 0; i + 21 <= len; i++) { + if (memcmp(r + i, needle, 21) == 0) { + r[i + 19] = 'n'; + memmove(r + i + 20, r + i + 21, (size_t) (len - i - 20)); + return len - 1; + } + } + return len; +} + +// Reference-template evidence: with enable_thinking=false the jinja template emits NO +// instruction line - just the empty pair after the generation head. Carrying the +// "detailed thinking off" text into a fabricated system turn is a foreign signal for +// the qwen family (it invites the model to mimic reasoning prose in the body), so +// off-mode here is a pure erase (also catches residue from user-provided system text). +static int32_t qwen_pure_erase_directive(char * r, int32_t len) { + char needle[24]; + memcpy(needle, "detailed thin", 13); + memcpy(needle + 13, "king off", 9); + needle[22] = 0; + for (;;) { + char * f = strstr(r, needle); + if (!f) return (int32_t) strlen(r); + int32_t st = (int32_t) (f - r), en = st + 22; + if (st > 0 && r[st - 1] == '\n') st--; + else if (r[en] == '\n') en++; + memmove(r + st, r + en, (size_t) (len - en + 1)); + len -= (en - st); + } +} + +static int g_tpl_imstart = -1; + +// Bailing fallback: when a custom system turn exists the built-in renderer skips the +// instruction line entirely and the off signal is lost. The V3 contract inlines the +// line at the end of the SYSTEM turn content, before its role_end. Insert there; +// direction from g_thinking; only acts when no "detailed thinking" text exists. +static void bail_ensure_directive(char * r, int32_t * plen, int32_t cap) { + if (g_tpl_imstart == 1) return; + if (strstr(r, "detailed thinking")) return; // built-in already injected it, or the user wrote their own + char * sy = strstr(r, "SYSTEM"); + if (!sy) return; + char * re = strstr(sy + 19, "<|" "role_end" "|>"); + if (!re) return; + char frag[32]; + int fl = snprintf(frag, sizeof(frag), "\ndetailed thinking %s", g_thinking ? "on" : "off"); + if (fl <= 0 || *plen + fl + 1 > cap) return; + memmove(re + fl, re, (size_t) (*plen - (re - r) + 1)); + memcpy(re, frag, (size_t) fl); + *plen += fl; +} + +// Product decision for the 35B family: keep the template-conformant empty-pair form +// and do not seed the continuation; sampling-side forcing measured unreliable. + +/* Literal of the built-in BAILING2 default head for history without a system turn: + segment-wise rendering would inject it between segments - strip it to keep one SYSTEM. */ +static const char FAKE_SYS_HEAD[] = + "<" "role" ">" "SYSTEM" "<" "/" "role" ">" "detailed thinking off" "<|" "role_end" "|>"; +static int32_t bail_strip_fake_head(char * r, int32_t len) { + const int fl = (int) sizeof(FAKE_SYS_HEAD) - 1; + if (len >= fl && memcmp(r, FAKE_SYS_HEAD, (size_t) fl) == 0) { + memmove(r, r + fl, (size_t) (len - fl + 1)); + return len - fl; + } + return len; +} + +// Golden-path head form (instruction line alone is a half-fix against the reference +// render): after every bare assistant head emit newline + (on ? open : open+close pair). +static void bailing_think_heads(char * r, int32_t * plen, int32_t cap) { + const char * head = "ASSISTANT"; + const int hl = 22; + static const char tso[] = "\n" "<" "th" "ink" ">"; + static const char tsof[] = "\n" "<" "th" "ink" ">" ""; + const char * inj = g_thinking ? tso : tsof; + const int il = (int) strlen(inj); /* tso 8B / tsof 16B, self-adaptive */ + int32_t pos = 0; + for (;;) { + char * h = strstr(r + pos, head); + if (!h) break; + int32_t at = (int32_t) (h - r) + hl; + // already annotated (tag follows the newline) - skip to prevent double injection + if (memcmp(r + at, inj, (size_t) il) == 0) { pos = at + il; continue; } + if (*plen + il + 1 > cap) return; + memmove(r + at + il, r + at, (size_t) (*plen - at + 1)); + memcpy(r + at, inj, (size_t) il); + *plen += il; + pos = at + il; + } +} + +static void qwen_inject_empty_pair(char * r, char const * head, int32_t cap) { + size_t l = strlen(r), hl = strlen(head); + if (l == 0 || hl == 0 || l + 32 > (size_t) cap) return; + static const char pair[] = "\n\n\n\n"; + /* Training form = reference template: exactly one newline after the assistant + head, then the empty pair. The old append style added an extra newline (a + blank-paragraph continuation cue) and the model answered with mimic'd + "thinking process" prose - this overlay keeps it token-identical. */ + if (l >= hl && memcmp(r + l - hl, head, hl) == 0 && !strstr(r + l - hl, "")) { + memcpy(r + (l - 1 - hl), pair, sizeof(pair) - 1); /* keep head+newline; overlay empty pair right after it */ + r[(l - 1 - hl) + sizeof(pair) - 1] = 0; /* the overlay straddles the old terminator: rewrite it */ + } +} + + +static char g_reply_tail[8192]; // tail 8KB of the last reply (cross-check for history sync; longer = TOOLONG) +static size_t g_rlen = 0; +static llama_token_data *g_td = NULL; +static int g_reset_next = 0; + +// With byte-fallback vocabularies one token may be half a UTF-8 codepoint; feeding an +// illegal continuation byte to NewStringUTF aborts under CheckJNI. Emit only complete +// codepoints; carry the partial tail into the next piece. +static char g_pend[8]; +static int g_pend_len = 0; + +static void emit_valid_utf8(JNIEnv *env, jobject sink, jmethodID invoke, + const char *buf, int32_t len) { + char comb[16]; + int n = g_pend_len + len; + if (n > (int)sizeof(comb)) n = (int)sizeof(comb); + memcpy(comb, g_pend, g_pend_len); + memcpy(comb + g_pend_len, buf, n - g_pend_len); // clamp every memory op to the truncated n, never the raw len + int cut = n; + for (int k = 1; k <= 3 && k <= n; k++) { + unsigned char b = (unsigned char)comb[n - k]; + if ((b & 0xC0) != 0x80) { + int expect = (b < 0x80) ? 1 : (b < 0xE0) ? 2 : (b < 0xF0) ? 3 : 4; + if (k < expect) cut = n - k; + break; + } + } + g_pend_len = n - cut; + if (g_pend_len) memcpy(g_pend, comb + cut, g_pend_len); + if (cut > 0) { + char safe[16]; memcpy(safe, comb, cut); safe[cut] = 0; + jstring js = (*env)->NewStringUTF(env, safe); + (*env)->CallObjectMethod(env, sink, invoke, js); + (*env)->DeleteLocalRef(env, js); + } +} + +static void flush_pend(void) { g_pend_len = 0; } + +static long long now_ms(void) { + struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); + return (long long) ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +JNIEXPORT jstring JNICALL +Java_dev_edge0_runtime_app_runtime_LlamaNative_nativeInit( + JNIEnv *env, jclass cls, jstring jmodel, jstring jlora, jstring jheads, + jint n_ctx, jint pool_mb, jint blob_mb, jint pred, jint io_threads) { + (void) cls; + // idempotent self-clear: model/LoRA/pool registry/history from a previous session + // must never bleed into a new load (an 8B adapter on a 35B graph aborts on shape asserts) + extern void moe_pool_reset_all(void); + if (g_model || g_ctx || g_lora) { + if (g_ctx) { llama_free(g_ctx); g_ctx = NULL; } + if (g_lora) { llama_adapter_lora_free(g_lora); g_lora = NULL; } + if (g_model) { llama_model_free(g_model); g_model = NULL; } + for (int i = 0; i < g_nmsg * 2; i++) { free(g_strs[i]); g_strs[i] = NULL; } + for (int i = 0; i < g_nmsg; i++) { free(g_msg_ids[i]); g_msg_ids[i] = NULL; g_msg_nids[i] = 0; } + g_nmsg = 0; g_nprocessed = 0; + } + moe_pool_reset_all(); + const char *model = (*env)->GetStringUTFChars(env, jmodel, NULL); + const char *lora = (*env)->GetStringUTFChars(env, jlora, NULL); + const char *heads = (*env)->GetStringUTFChars(env, jheads, NULL); + char err[512]; + // pool env must be set before model load (moe_pool_init reads env at register time); + // 8B passes 0/0 = zero perturbation. + setenv("E0_MMAP_NOPREFETCH", "1", 1); // verified freeze-avoidance on 35B; harmless for 8B + setenv("E0_NO_REPACK", "1", 1); + { char b[32]; + snprintf(b, sizeof(b), "%d", pool_mb); setenv("E0_POOL_MB", b, 1); + snprintf(b, sizeof(b), "%d", blob_mb); setenv("E0_BLOB_MB", b, 1); + snprintf(b, sizeof(b), "%d", io_threads); setenv("E0_IO_THREADS", b, 1); + setenv("E0_MEM_FLOOR_MB", "2000", 1); } + if (heads && heads[0]) setenv("E0_HEADS", heads, 1); + if (blob_mb > 0) setenv("E0_KEEPWARM_MB", "512", 1); // 35B tier: turn-end keepwarm refill (moot without a pool) + if (pred) setenv("E0_PREROUTER", "1", 1); else unsetenv("E0_PREROUTER"); + llama_backend_init(); + struct llama_model_params mp = llama_model_default_params(); + mp.n_gpu_layers = 0; + g_model = llama_model_load_from_file(model, mp); + if (!g_model) { + snprintf(err, sizeof(err), "ERR load: %s", model); + goto ret; + } + if (lora && lora[0]) { + g_lora = llama_adapter_lora_init(g_model, lora); + if (!g_lora) { snprintf(err, sizeof(err), "ERR lora init: %s", lora); goto ret; } + } + { + struct llama_context_params cp = llama_context_default_params(); + cp.n_ctx = (uint32_t) n_ctx; + cp.n_batch = 512; + g_ctx = llama_init_from_model(g_model, cp); + if (!g_ctx) { snprintf(err, sizeof(err), "ERR ctx init"); goto ret; } + if (g_lora) { + float scale = 1.0f; // canonical form: file alpha 32 + explicit scale 1.0 + if (llama_set_adapters_lora(g_ctx, &g_lora, 1, &scale) != 0) { + snprintf(err, sizeof(err), "ERR lora set"); goto ret; + } + } + } + g_reset_next = 1; + snprintf(err, sizeof(err), "OK %s pool=%d blob=%d pred=%d", llama_version(), pool_mb, blob_mb, pred); +ret: + (*env)->ReleaseStringUTFChars(env, jmodel, model); + (*env)->ReleaseStringUTFChars(env, jlora, lora); + (*env)->ReleaseStringUTFChars(env, jheads, heads); + return (*env)->NewStringUTF(env, err); +} + +JNIEXPORT jstring JNICALL +Java_dev_edge0_runtime_app_runtime_LlamaNative_nativeReset( + JNIEnv *env, jclass cls) { + (void) cls; + // session reset: clear history and force full KV replay (thread switch / history fork) + for (int i = 0; i < g_nmsg * 2; i++) { free(g_strs[i]); g_strs[i] = NULL; } + for (int i = 0; i < g_nmsg; i++) { free(g_msg_ids[i]); g_msg_ids[i] = NULL; g_msg_nids[i] = 0; } + g_nmsg = 0; + g_reset_next = 1; + return (*env)->NewStringUTF(env, "OK"); +} + +JNIEXPORT jstring JNICALL +Java_dev_edge0_runtime_app_runtime_LlamaNative_nativeAddRole( + JNIEnv *env, jclass cls, jstring jrole, jstring jtext) { + (void) cls; + const char *role = (*env)->GetStringUTFChars(env, jrole, NULL); + const char *text = (*env)->GetStringUTFChars(env, jtext, NULL); + int full = g_nmsg >= MAX_MSG; + if (!full) { + char *r = strdup(role); char *t = strdup(text); + g_strs[g_nmsg * 2] = r; g_strs[g_nmsg * 2 + 1] = t; + g_hist[g_nmsg].role = r; g_hist[g_nmsg].content = t; + g_nmsg++; + } + (*env)->ReleaseStringUTFChars(env, jrole, role); + (*env)->ReleaseStringUTFChars(env, jtext, text); + return (*env)->NewStringUTF(env, full ? "FULL" : "OK"); +} + +// assistant turn enters history via sidecar: ids big-endian 4B per element; text render is skipped (stitched in nativeGenerate) +JNIEXPORT jstring JNICALL +Java_dev_edge0_runtime_app_runtime_LlamaNative_nativeAddRoleIds( + JNIEnv *env, jclass cls, jstring jrole, jbyteArray jids, jint eog) { + (void) cls; + const char *role = (*env)->GetStringUTFChars(env, jrole, NULL); + const int nb = jids ? (*env)->GetArrayLength(env, jids) : 0; + int full = (g_nmsg >= MAX_MSG) || (nb % 4) != 0; + if (!full) { + llama_token *ids = NULL; + if (nb > 0) { + jbyte *bd = (*env)->GetByteArrayElements(env, jids, NULL); + ids = (llama_token *) malloc(sizeof(llama_token) * (nb / 4)); + for (int i = 0; i < nb / 4; i++) { + unsigned char *q = (unsigned char *) (bd + i * 4); + ids[i] = (llama_token) ((q[0] << 24) | (q[1] << 16) | (q[2] << 8) | q[3]); + } + (*env)->ReleaseByteArrayElements(env, jids, bd, JNI_ABORT); + } + char *r = strdup(role); + g_strs[g_nmsg * 2] = r; g_strs[g_nmsg * 2 + 1] = NULL; + g_hist[g_nmsg].role = r; g_hist[g_nmsg].content = ""; + g_msg_ids[g_nmsg] = ids; g_msg_nids[g_nmsg] = nb / 4; g_msg_eog[g_nmsg] = (llama_token) eog; + g_nmsg++; + } + (*env)->ReleaseStringUTFChars(env, jrole, role); + return (*env)->NewStringUTF(env, full ? "FULL" : "OK"); +} + +// ids stepped this turn (big-endian 4B) plus the end-of-turn token +JNIEXPORT jbyteArray JNICALL +Java_dev_edge0_runtime_app_runtime_LlamaNative_nativeLastAssistantIds(JNIEnv *env, jclass cls) { + (void) cls; + jbyteArray out = (*env)->NewByteArray(env, g_out_n * 4); + if (out && g_out_n > 0) { + jbyte *bd = (*env)->GetByteArrayElements(env, out, NULL); + for (int i = 0; i < g_out_n; i++) { + unsigned char *q = (unsigned char *) (bd + i * 4); + q[0] = (unsigned char) (g_out_ids[i] >> 24); q[1] = (unsigned char) (g_out_ids[i] >> 16); + q[2] = (unsigned char) (g_out_ids[i] >> 8); q[3] = (unsigned char) g_out_ids[i]; + } + (*env)->ReleaseByteArrayElements(env, out, bd, 0); + } + return out; +} +JNIEXPORT jint JNICALL +Java_dev_edge0_runtime_app_runtime_LlamaNative_nativeLastEog(JNIEnv *env, jclass cls) { + (void) env; (void) cls; return (jint) g_out_eog; +} + +#include +static void * kw_thread(void * arg) { (void) arg; extern void moe_pool_keepwarm(int); moe_pool_keepwarm(0); return NULL; } + +JNIEXPORT jstring JNICALL +Java_dev_edge0_runtime_app_runtime_LlamaNative_nativeGenerate( + JNIEnv *env, jclass cls, jobject sink, jint max_tokens, jfloat temp, jboolean thinking_on) { + // Toggling thinking changes the rendered instruction line, which the UI-text + // history comparison cannot see - trusting positions would keep dirty KV context. + // Authoritative checkpoint: on a thinking change with live context, force a full rebuild. + if (g_ctx && g_thinking != (thinking_on ? 1 : 0) && g_nprocessed > 0) g_reset_next = 1; + g_thinking = thinking_on ? 1 : 0; + char out[512]; + out[0] = 0; + if (!g_ctx) { snprintf(out, sizeof(out), "ERR not init"); return (*env)->NewStringUTF(env, out); } + { + const char *t0 = llama_model_chat_template(g_model, NULL); + g_tpl_imstart = (t0 && strstr(t0, "<|im_start|>assistant")) ? 1 : 0; + } + const struct llama_vocab *vocab = llama_model_get_vocab(g_model); + const int32_t n_vocab = llama_vocab_n_tokens(vocab); + + static char render[65536]; + const char *tmpl = llama_model_chat_template(g_model, NULL); // BAILING2 -> built-in renderer + int has_ids = 0; + for (int i = 0; i < g_nmsg; i++) if (g_msg_nids[i] > 0) { has_ids = 1; break; } + int32_t n_tok = 0; + if (!has_ids) { + int32_t need = llama_chat_apply_template(tmpl, g_hist, (size_t) g_nmsg, true, render, sizeof(render)); + if (need <= 0 || (size_t) need >= sizeof(render)) { + snprintf(out, sizeof(out), "ERR template need=%d", need); + return (*env)->NewStringUTF(env, out); + } + if (!g_thinking) { + if (g_tpl_imstart == 1) { + // qwen off = pure empty pair (reference-template verified): no instruction line, no fabricated system turn + need = qwen_pure_erase_directive(render, need); + } + } else { + if (g_tpl_imstart != 1) need = bailing_flip_on(render, need); + } + bail_ensure_directive(render, &need, (int32_t) sizeof(render)); + if (g_tpl_imstart != 1) bailing_think_heads(render, &need, (int32_t) sizeof(render)); + qwen_inject_empty_pair(render, "<|im_start|>assistant\n", (int32_t) sizeof(render)); + n_tok = llama_tokenize(vocab, render, need, g_toks, MAX_TOKS, false, true); + if (n_tok <= 0) { snprintf(out, sizeof(out), "ERR tokenize %d", n_tok); return (*env)->NewStringUTF(env, out); } + } else { + // sidecar stitching: text segments render+tokenize, id segments paste verbatim + // (kills BPE re-encode boundary drift). Every text segment carries the generation + // prompt; id turns = stepped body + that turn's end-of-generation token. + int32_t w = 0; int ii = 0; int err = 0; + while (ii < g_nmsg) { + if (g_msg_nids[ii] > 0) { + if (w + g_msg_nids[ii] + 1 >= MAX_TOKS - 8) { err = 2; break; } + memcpy(&g_toks[w], g_msg_ids[ii], sizeof(llama_token) * (size_t) g_msg_nids[ii]); + w += g_msg_nids[ii]; + if (g_msg_eog[ii] >= 0) g_toks[w++] = g_msg_eog[ii]; + ii++; + continue; + } + int j = ii; while (j < g_nmsg && g_msg_nids[j] == 0) j++; + int32_t need = llama_chat_apply_template(tmpl, &g_hist[ii], (size_t) (j - ii), true, render, sizeof(render)); + if (need <= 0 || (size_t) need >= sizeof(render)) { err = 1; break; } + /* fake SYSTEM head stripping: the built-in renderer injects its default + system block into every non-first segment start; strip or the prompt + grows a second SYSTEM block and the model loses its identity */ + if (g_tpl_imstart != 1 && ii > 0) need = bail_strip_fake_head(render, need); + if (!g_thinking) { + if (g_tpl_imstart == 1) { + // qwen off = pure empty pair (reference-template verified): no instruction line, no fabricated system turn + need = qwen_pure_erase_directive(render, need); + } + } else { + if (g_tpl_imstart != 1) need = bailing_flip_on(render, need); + } + // run every segment unconditionally: gating this on the thinking branch or + // the last segment lost the instruction line from turn two onward (the + // empty-answer bug). Families without a SYSTEM in the segment self-check to a no-op. + if (g_tpl_imstart != 1) bail_ensure_directive(render, &need, (int32_t) sizeof(render)); + if (g_tpl_imstart != 1) bailing_think_heads(render, &need, (int32_t) sizeof(render)); + if (g_tpl_imstart == 1 && j >= g_nmsg) qwen_inject_empty_pair(render, "<|im_start>|assistant\n", (int32_t) sizeof(render)); + int32_t room = MAX_TOKS - 8 - w; + int32_t tn = llama_tokenize(vocab, render, need, g_toks + w, room, false, true); + if (tn <= 0) { err = 1; break; } + w += tn; ii = j; + } + if (err) { snprintf(out, sizeof(out), "ERR sidecar concat %d", err); return (*env)->NewStringUTF(env, out); } + n_tok = w; + } + if (n_tok >= MAX_TOKS - 8) { snprintf(out, sizeof(out), "ERR ctx too long"); return (*env)->NewStringUTF(env, out); } + + { // render forensics: single-line dump of the tail (newlines escaped) for empty-pair / migration checks + char dbg[512]; int di = 0; + int rl = (int) strlen(render); + int tl = rl > 110 ? 110 : rl; + const char * t = render + rl - tl; + for (int z = 0; z < tl && di < 380; z++) { + if (t[z] == '\n') { dbg[di++] = '\\'; dbg[di++] = 'n'; } + else dbg[di++] = t[z]; + } + dbg[di] = 0; + LOGI("REND tail=[%s]", dbg); + { /* head forensics: SYSTEM segment identity + instruction-line shape */ + char dh[260]; int dj = 0; + for (int z = 0; z < (int) strlen(render) && z < 130 && dj < 250; z++) { + char ch = render[z]; + if (ch == '\n') { dh[dj++] = '\\'; dh[dj++] = 'n'; } + else dh[dj++] = ch; + } + dh[dj] = 0; + LOGI("REND head=[%s]", dh); + } + } + int start = g_reset_next ? 0 : g_nprocessed; + if (start > n_tok) start = 0; + if (g_reset_next) { + llama_memory_seq_rm(llama_get_memory(g_ctx), 0, -1, -1); + g_reset_next = 0; + } + + long long ph0=0, pm0=0, pl0=0, pe0=0, pb0=0, pst0=0; + size_t pcap0=0, pused0=0; + moe_pool_stats(&ph0, &pm0, &pl0, &pe0, (long long*)&pb0, &pcap0, &pused0); + { long long su,st,pu,pm; moe_pool_extra(&pst0,&su,&st,&pu,&pm); } + jmethodID invoke = (*env)->GetMethodID(env, (*env)->GetObjectClass(env, sink), + "invoke", "(Ljava/lang/Object;)Ljava/lang/Object;"); + long long prefill_ms; + { + long long t0 = now_ms(); + // small incremental batches decode token-by-token: MoE gather cost does not + // amortize on tiny batches (measured 7x slower than decode); <=32 new tokens + // take the decode-like path, long batches keep the 512 window. + const int32_t inc = n_tok - start; + const int32_t chunk_max = (inc > 0 && inc <= 32) ? 1 : 512; + for (int i = start; i < n_tok; i += chunk_max) { + int chunk = (n_tok - i < chunk_max) ? n_tok - i : chunk_max; + struct llama_batch b = llama_batch_get_one(g_toks + i, chunk); + if (llama_decode(g_ctx, b) != 0) { snprintf(out, sizeof(out), "ERR prefill@%d", i); return (*env)->NewStringUTF(env, out); } + if (g_cancel) { g_cancel = 0; snprintf(out, sizeof(out), "CANCELLED"); return (*env)->NewStringUTF(env, out); } + } + prefill_ms = now_ms() - t0; + { long long h, m, l, e, b; long long su, stq, pu, pm; size_t cap, used; long long st1; + moe_pool_stats(&h, &m, &l, &e, &b, &cap, &used); + moe_pool_extra(&st1, &su, &stq, &pu, &pm); + char dbg[256]; + snprintf(dbg, sizeof(dbg), + "PRE fill hit=%lld miss=%lld evict=%lld stall_ms=%lld loads=%lld", + h - ph0, m - pm0, e - pe0, (st1 - pst0) / 1000000, l - pl0); + LOGI("%s", dbg); } + } + int reused = start; + g_nprocessed = n_tok; + + { // vocab width changes across models: the token buffer must realloc (8B-sized buffer fed a 35B vocab = OOB write) + static size_t g_td_cap = 0; + if (g_td_cap < (size_t) n_vocab) { + free(g_td); + g_td = (llama_token_data *) malloc(sizeof(llama_token_data) * (size_t) n_vocab); + g_td_cap = g_td ? (size_t) n_vocab : 0; + } + } + struct llama_sampler *smpl = llama_sampler_chain_init(llama_sampler_chain_default_params()); + if (temp <= 0.0f) llama_sampler_chain_add(smpl, llama_sampler_init_greedy()); + else { + llama_sampler_chain_add(smpl, llama_sampler_init_temp(temp)); + llama_sampler_chain_add(smpl, llama_sampler_init_top_k(40)); + llama_sampler_chain_add(smpl, llama_sampler_init_dist(0xC0FFEEu)); + } + char reply[32768]; size_t rlen = 0; reply[0] = 0; + g_rlen = 0; + flush_pend(); + char piece[256]; + long long t0 = now_ms(); + int gen = 0; + long long first_ms = 0; + g_out_n = 0; g_out_eog = -1; + double tps = 0.0; + llama_token next = -1; + while (gen < max_tokens && !g_cancel) { + const float *lg = llama_get_logits_ith(g_ctx, -1); + if (!lg) { snprintf(out, sizeof(out), "ERR logits@%d", gen); break; } + for (int32_t j = 0; j < n_vocab; j++) { g_td[j].id = j; g_td[j].logit = lg[j]; g_td[j].p = 1.0f; } + llama_token_data_array cur = { .data = g_td, .size = (size_t) n_vocab, .selected = -1, .sorted = false }; + llama_sampler_apply(smpl, &cur); + next = cur.data[cur.selected].id; + if (llama_vocab_is_eog(vocab, next)) { g_out_eog = next; break; } + int32_t plen = llama_token_to_piece(vocab, next, piece, sizeof(piece) - 1, 0, false); + if (plen > 0) { + piece[plen] = 0; + if (rlen + plen < sizeof(reply)) { memcpy(reply + rlen, piece, plen); rlen += plen; } + if (g_rlen + plen < sizeof(g_reply_tail)) { memcpy(g_reply_tail + g_rlen, piece, plen); g_rlen += plen; } + else g_rlen = sizeof(g_reply_tail) + 1; + emit_valid_utf8(env, sink, invoke, piece, plen); + } + if (g_out_n < MAX_TOKS) g_out_ids[g_out_n++] = next; + gen++; + if (gen == 1) first_ms = now_ms() - t0; + struct llama_batch b = llama_batch_get_one(&next, 1); + if (llama_decode(g_ctx, b) != 0) { snprintf(out, sizeof(out), "ERR decode@%d", gen); break; } + g_nprocessed++; + tps = gen * 1000.0 / (double) (now_ms() - t0 + 1); + } + flush_pend(); + while (rlen > 0 && (((unsigned char)reply[rlen - 1] & 0xC0) == 0x80)) rlen--; + if (rlen > 0) { unsigned char b = (unsigned char)reply[rlen - 1]; + int expect = (b < 0x80) ? 1 : (b < 0xE0) ? 2 : (b < 0xF0) ? 3 : 4; + if (expect > 1) rlen--; } + reply[rlen] = 0; + if (g_cancel) { g_cancel = 0; + snprintf(out, sizeof(out), "CANCELLED gen=%d tps=%.2f prefill_ms=%lld first_ms=%lld prompt=%d reused=%d", + gen, tps, prefill_ms, first_ms, n_tok, reused); } + else snprintf(out, sizeof(out), "OK gen=%d tps=%.2f prefill_ms=%lld first_ms=%lld prompt=%d reused=%d", + gen, tps, prefill_ms, first_ms, n_tok, reused); + LOGI("%s", out); + + if (gen > 0 && getenv("E0_KEEPWARM_MB")) { pthread_t kt; if (pthread_create(&kt, NULL, kw_thread, NULL) == 0) pthread_detach(kt); } + { // assistant turn recorded with stepped ids (sidecar); the text reply stays in logs only + char *ar = strdup("assistant"); + g_strs[g_nmsg * 2] = ar; g_strs[g_nmsg * 2 + 1] = NULL; + if (g_nmsg < MAX_MSG) { + llama_token *ids = NULL; + if (g_out_n > 0) { ids = (llama_token *) malloc(sizeof(llama_token) * (size_t) g_out_n); memcpy(ids, g_out_ids, sizeof(llama_token) * (size_t) g_out_n); } + g_hist[g_nmsg].role = ar; g_hist[g_nmsg].content = ""; + g_msg_ids[g_nmsg] = ids; g_msg_nids[g_nmsg] = ids ? g_out_n : 0; g_msg_eog[g_nmsg] = g_out_eog; + g_nmsg++; + } + } + (void) reply; + llama_sampler_free(smpl); + return (*env)->NewStringUTF(env, out); +} + +// pool counters + process RSS export (the metrics line under each reply: hit/stall/peak-rss). +// moe_pool_* symbols live in libggml-cpu.so (linked explicitly at build time). +extern void moe_pool_stats(long long * hits, long long * misses, long long * loads, long long * evicts, + long long * bytes_loaded, size_t * cap_bytes, size_t * used_bytes); +extern void moe_pool_extra(long long * stall_ns, long long * slots_used, long long * slots_total, + long long * pf_used, long long * pf_mispredict); + +JNIEXPORT jstring JNICALL +Java_dev_edge0_runtime_app_runtime_LlamaNative_nativeStats(JNIEnv *env, jclass cls) { + (void) cls; + long long h=0, m=0, l=0, e=0, bl=0, st=0, su=0, stot=0, pu=0, pm=0; + size_t cap=0, used=0; + moe_pool_stats(&h, &m, &l, &e, &bl, &cap, &used); + moe_pool_extra(&st, &su, &stot, &pu, &pm); + long vmhwm = 0, vmrss = 0; + FILE * f = fopen("/proc/self/status", "r"); + if (f) { + char ln[256]; + while (fgets(ln, sizeof(ln), f)) { + if (!strncmp(ln, "VmHWM:", 6)) vmhwm = strtol(ln + 6, NULL, 10); + else if (!strncmp(ln, "VmRSS:", 6)) vmrss = strtol(ln + 6, NULL, 10); + } + fclose(f); + } + char buf[512]; + snprintf(buf, sizeof(buf), + "hits=%lld misses=%lld loads=%lld evicts=%lld flash=%lld resident=%lld cap=%lld\n" + "stall_ns=%lld slots=%lld/%lld pf_used=%lld pf_miss=%lld vmhwm_kb=%ld vmrss_kb=%ld", + h, m, l, e, bl, (long long)used, (long long)cap, st, su, stot, pu, pm, vmhwm, vmrss); + return (*env)->NewStringUTF(env, buf); +} + +// tail 8KB of the last assistant reply in native form (when the UI shows equivalent text, replaying it keeps full prefix reuse; longer = TOOLONG) +JNIEXPORT jstring JNICALL +Java_dev_edge0_runtime_app_runtime_LlamaNative_nativeLastAssistant(JNIEnv *env, jclass cls) { + (void) cls; + if (g_rlen > sizeof(g_reply_tail)) return (*env)->NewStringUTF(env, "TOOLONG"); + char tmp[8200]; + size_t n = g_rlen; if (n > sizeof(g_reply_tail) - 1) n = sizeof(g_reply_tail) - 1; + memcpy(tmp, g_reply_tail, n); tmp[n] = 0; + return (*env)->NewStringUTF(env, tmp); +} + +JNIEXPORT void JNICALL +Java_dev_edge0_runtime_app_runtime_LlamaNative_nativeCancel(JNIEnv *env, jclass cls) { + (void) env; (void) cls; g_cancel = 1; +} + +// unload/free: real teardown. Order is law: sampler first, then adapter before model +// (freeing the model under an attached adapter crashed at 452s once) - plus all-static reset. +JNIEXPORT jstring JNICALL +Java_dev_edge0_runtime_app_runtime_LlamaNative_nativeFree(JNIEnv *env, jclass cls) { + (void) cls; + if (g_ctx) { llama_free(g_ctx); g_ctx = NULL; } + if (g_lora) { llama_adapter_lora_free(g_lora); g_lora = NULL; } + if (g_model) { llama_model_free(g_model); g_model = NULL; } + for (int i = 0; i < g_nmsg * 2; i++) { free(g_strs[i]); g_strs[i] = NULL; } + for (int i = 0; i < g_nmsg; i++) { free(g_msg_ids[i]); g_msg_ids[i] = NULL; g_msg_nids[i] = 0; } + g_nmsg = 0; g_nprocessed = 0; g_reset_next = 0; + { extern void moe_pool_reset_all(void); moe_pool_reset_all(); } + return (*env)->NewStringUTF(env, "OK"); +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/Edge0App.kt b/android/app/src/main/java/dev/edge0/runtime/app/Edge0App.kt new file mode 100644 index 0000000..7e57d95 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/Edge0App.kt @@ -0,0 +1,79 @@ +// Edge0App.kt - process-level container (manual DI, no framework) plus: +// cold-start cleanup of orphaned streaming rows, and onTrimMemory -> engine pressure relief +// (logcat probe tag=Edge0Trim). +package dev.edge0.runtime.app + +import android.app.Application +import android.content.ComponentCallbacks2 +import android.content.res.Configuration +import android.util.Log +import dev.edge0.runtime.app.data.AppDatabase +import dev.edge0.runtime.app.data.ChatRepository +import dev.edge0.runtime.app.data.RoomChatRepository +import dev.edge0.runtime.app.data.SettingsStore +import dev.edge0.runtime.app.runtime.LlamaRuntime +import dev.edge0.runtime.app.runtime.Runtime +import dev.edge0.runtime.app.runtime.ThermalSource +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class AppContainer(private val app: Application) { + private val llamaRuntime by lazy { LlamaRuntime() } + val runtime: Runtime by lazy { llamaRuntime } + val settings = SettingsStore(app) + val db by lazy { AppDatabase.create(app) } + val repository: ChatRepository by lazy { RoomChatRepository(db) } + val thermal by lazy { ThermalSource(app) } + /** Model import roots: internal files/models (primary - on some OEM builds + * shell-pushed files are invisible to the app uid, so device-internal copy is the + * reliable channel) plus external files/models; both roots are scanned. */ + val modelRoots: List + get() = listOf(File(app.filesDir, "models"), app.getExternalFilesDir("models")) +} + +class Edge0App : Application() { + lateinit var container: AppContainer + private set + + override fun onCreate() { + super.onCreate() + container = AppContainer(this) + // cold start: mark rows left in streaming by a killed process as interrupted (visible, not lost) + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + runCatching { container.repository.markStreamingInterrupted() } + } + // ComponentCallbacks2 registered directly on the Application - lifecycle observers + // (2.8) do not carry onTrimMemory; this is the official hook + registerComponentCallbacks(TrimBridge(container)) + } +} + +/** Pressure mapping: RUNNING_LOW/BACKGROUND levels -> trim(0.5); COMPLETE -> cancel then unload. */ +class TrimBridge(private val container: AppContainer) : ComponentCallbacks2 { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + override fun onConfigurationChanged(newConfig: Configuration) {} + @Deprecated("compat") override fun onLowMemory() {} + + override fun onTrimMemory(level: Int) { + // level bands: RUNNING_MODERATE=5/LOW=10/CRITICAL=15; UI_HIDDEN=16; + // BACKGROUND=20, MODERATE=40, COMPLETE=80 + when { + level == android.content.ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> { + Log.i(TAG, "onTrimMemory level=$level action=cancel+unload") + container.runtime.cancelActive() + scope.launch { runCatching { container.runtime.unloadActive() } } + } + level in 5..19 || level in 20..79 -> { + Log.i(TAG, "onTrimMemory level=$level action=trim(0.5)") + scope.launch { container.runtime.trim(0.5f) } + } + else -> Log.i(TAG, "onTrimMemory level=$level action=none") + } + } + + companion object { const val TAG = "Edge0Trim" } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/MainActivity.kt b/android/app/src/main/java/dev/edge0/runtime/app/MainActivity.kt new file mode 100644 index 0000000..4c0e6bd --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/MainActivity.kt @@ -0,0 +1,27 @@ +// MainActivity.kt - single-Activity host (edge-to-edge; Compose-drawn; theme follows settings). +package dev.edge0.runtime.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.edge0.runtime.app.data.AppSettings +import dev.edge0.runtime.app.ui.AppRoot +import dev.edge0.runtime.app.ui.theme.Edge0Theme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + val container = (application as Edge0App).container + setContent { + val settings by container.settings.flow + .collectAsStateWithLifecycle(initialValue = AppSettings()) + Edge0Theme(mode = settings.themeMode, fontScale = settings.fontScale) { + AppRoot() + } + } + } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/data/AppDatabase.kt b/android/app/src/main/java/dev/edge0/runtime/app/data/AppDatabase.kt new file mode 100644 index 0000000..3395588 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/data/AppDatabase.kt @@ -0,0 +1,38 @@ +// data/AppDatabase.kt +package dev.edge0.runtime.app.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +// v2: assistant token-id sidecar - messages.genTokens BLOB (additive ALTER). +private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE messages ADD COLUMN genTokens BLOB NULL") + } +} + +// v3: per-reply inline stats line - ttftMs/prefillTokS/memBytes columns. +private val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE messages ADD COLUMN ttftMs INTEGER NULL") + db.execSQL("ALTER TABLE messages ADD COLUMN prefillTokS REAL NULL") + db.execSQL("ALTER TABLE messages ADD COLUMN memBytes INTEGER NULL") + } +} + +@Database(entities = [ThreadEntity::class, MessageEntity::class], version = 3, + exportSchema = true) +abstract class AppDatabase : RoomDatabase() { + abstract fun threads(): ThreadDao + abstract fun messages(): MessageDao + + companion object { + fun create(context: Context): AppDatabase = + Room.databaseBuilder(context, AppDatabase::class.java, "edge0-chat.db") + .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build() + } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/data/ChatRepository.kt b/android/app/src/main/java/dev/edge0/runtime/app/data/ChatRepository.kt new file mode 100644 index 0000000..af1cf62 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/data/ChatRepository.kt @@ -0,0 +1,139 @@ +// data/ChatRepository.kt - seam between UI and persistence. A fake implementation +// can be injected so UI walkthroughs run without the engine or disk. +package dev.edge0.runtime.app.data + +import dev.edge0.runtime.app.runtime.StoredMessage +import dev.edge0.runtime.app.runtime.decodeIds +import java.util.UUID +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +/** Message as the UI sees it (includes persistence status). */ +data class MessageView( + val id: String, + val role: String, + val content: String, + val thinking: String?, + val thinkingMs: Long?, + val status: String, + val createdAt: Long, + val newTokens: Int?, + val decodeTokS: Double?, + val ttftMs: Long? = null, + val prefillTokS: Double? = null, + val memBytes: Long? = null, +) + +data class ThreadSummary(val id: String, val title: String?, val updatedAt: Long, + val modelId: String) + +interface ChatRepository { + fun observeThreads(): Flow> + fun observeMessages(threadId: String): Flow> + /** Creates the thread only with the first message (no empty entries). Returns threadId. */ + suspend fun startThreadWithUserMessage(modelId: String, userText: String): String + /** Appends a user message to an existing thread and bumps updatedAt. */ + suspend fun addUserMessage(threadId: String, userText: String) + /** Inserts a streaming placeholder assistant message, returns its id (finalize overwrites). */ + suspend fun beginAssistant(threadId: String, modelId: String): String + suspend fun finalizeAssistant(id: String, content: String, thinking: String?, + thinkingMs: Long?, status: String, + promptTokens: Int?, newTokens: Int?, decodeTokS: Double?, + genTokens: ByteArray? = null, + ttftMs: Long? = null, prefillTokS: Double? = null, + memBytes: Long? = null) + suspend fun renameThread(id: String, title: String) + suspend fun deleteThread(id: String) + /** Editing a user message truncates everything after it (incl. old answer). */ + suspend fun editUserMessage(id: String, newText: String) + suspend fun deleteMessage(id: String) + suspend fun markStreamingInterrupted() + /** Thread history (old to new, without the trailing user turn the caller appends). */ + suspend fun history(threadId: String): List + /** Full view with id/timestamps (used by regenerate to locate the last assistant row). */ + suspend fun historyWithIds(threadId: String): List + /** Deletes all messages at/after the given timestamp (regenerate truncation). */ + suspend fun deleteFrom(threadId: String, fromTs: Long) +} + +class RoomChatRepository(private val db: AppDatabase) : ChatRepository { + override fun observeThreads(): Flow> = + db.threads().observeAll().map { l -> l.map { ThreadSummary(it.id, it.title, it.updatedAt, it.modelId) } } + + override fun observeMessages(threadId: String): Flow> = + db.messages().observeFor(threadId).map { l -> + l.map { MessageView(it.id, it.role, it.content, it.thinking, it.thinkingMs, + it.status, it.createdAt, it.newTokens, it.decodeTokS, + it.ttftMs, it.prefillTokS, it.memBytes) } + } + + override suspend fun startThreadWithUserMessage(modelId: String, userText: String): String { + val now = System.currentTimeMillis() + val tid = UUID.randomUUID().toString() + db.threads().upsert(ThreadEntity(tid, userText.take(60), modelId, now, now)) + db.messages().insert(MessageEntity(UUID.randomUUID().toString(), tid, "user", + userText, null, null, MsgStatus.OK, modelId, now, + null, null, null)) + return tid + } + + override suspend fun beginAssistant(threadId: String, modelId: String): String { + val id = UUID.randomUUID().toString() + db.messages().insert(MessageEntity(id, threadId, "assistant", "", null, null, + MsgStatus.STREAMING, modelId, + System.currentTimeMillis(), null, null, null)) + return id + } + + override suspend fun addUserMessage(threadId: String, userText: String) { + val now = System.currentTimeMillis() + db.messages().insert(MessageEntity(UUID.randomUUID().toString(), threadId, "user", + userText, null, null, MsgStatus.OK, "", now, + null, null, null)) + db.threads().touch(threadId, null, now) + } + + override suspend fun finalizeAssistant(id: String, content: String, thinking: String?, + thinkingMs: Long?, status: String, + promptTokens: Int?, newTokens: Int?, + decodeTokS: Double?, genTokens: ByteArray?, + ttftMs: Long?, prefillTokS: Double?, memBytes: Long?) { + db.messages().finalizeAssistant(id, content, thinking, thinkingMs, status, + promptTokens, newTokens, decodeTokS, genTokens, + ttftMs, prefillTokS, memBytes) + } + + override suspend fun renameThread(id: String, title: String) = + db.threads().touch(id, title, System.currentTimeMillis()) + + override suspend fun deleteThread(id: String) = db.threads().delete(id) + + override suspend fun editUserMessage(id: String, newText: String) { + val msg = db.messages().byId(id) ?: return + db.messages().update(msg.copy(content = newText)) + db.messages().deleteFrom(msg.threadId, msg.createdAt + 1) // truncate everything after (incl. old answer) + // editing the first user message renames the thread (title = first user message) + if (db.messages().olderUserCount(msg.threadId, msg.createdAt) == 0) { + db.threads().touch(msg.threadId, newText.take(60), System.currentTimeMillis()) + } + } + + override suspend fun deleteMessage(id: String) = db.messages().deleteById(id) + + override suspend fun markStreamingInterrupted() = db.messages().markStreamingInterrupted() + + override suspend fun history(threadId: String): List = + db.messages().observeFor(threadId).first() + .map { StoredMessage(it.role, it.content, it.thinking, decodeIds(it.genTokens)) } + + override suspend fun historyWithIds(threadId: String): List = + db.messages().observeFor(threadId).first() + .map { MessageView(it.id, it.role, it.content, it.thinking, it.thinkingMs, + it.status, it.createdAt, it.newTokens, it.decodeTokS, + it.ttftMs, it.prefillTokS, it.memBytes) } + + override suspend fun deleteFrom(threadId: String, fromTs: Long) = + db.messages().deleteFrom(threadId, fromTs) +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/data/Daos.kt b/android/app/src/main/java/dev/edge0/runtime/app/data/Daos.kt new file mode 100644 index 0000000..5b2d9a5 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/data/Daos.kt @@ -0,0 +1,63 @@ +// data/Daos.kt - thread/message DAOs (flat list ordered by updatedAt desc). +package dev.edge0.runtime.app.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface ThreadDao { + @Query("SELECT * FROM threads ORDER BY updatedAt DESC") + fun observeAll(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(thread: ThreadEntity) + + @Query("UPDATE threads SET title = COALESCE(:title, title), updatedAt = :ts WHERE id = :id") + suspend fun touch(id: String, title: String?, ts: Long) + + @Query("DELETE FROM threads WHERE id = :id") + suspend fun delete(id: String) +} + +@Dao +interface MessageDao { + @Query("SELECT * FROM messages WHERE threadId = :threadId ORDER BY createdAt ASC") + fun observeFor(threadId: String): Flow> + + @Insert + suspend fun insert(msg: MessageEntity) + + @Update + suspend fun update(msg: MessageEntity) + + @Query("UPDATE messages SET content=:content, thinking=:thinking, thinkingMs=:thinkingMs, " + + "status=:status, promptTokens=:pt, newTokens=:nt, decodeTokS=:dts, " + + "genTokens=:genTokens, ttftMs=:ttft, prefillTokS=:pfs, memBytes=:mem WHERE id=:id") + suspend fun finalizeAssistant(id: String, content: String, thinking: String?, + thinkingMs: Long?, status: String, + pt: Int?, nt: Int?, dts: Double?, genTokens: ByteArray?, + ttft: Long?, pfs: Double?, mem: Long?) + + /** Cold-start sweep: rows left streaming by a killed process become interrupted. */ + @Query("UPDATE messages SET status='interrupted' WHERE status='streaming'") + suspend fun markStreamingInterrupted() + + /** Edit/regenerate truncation: delete every message at/after the given created-at. */ + @Query("DELETE FROM messages WHERE threadId=:threadId AND createdAt >= :fromTs") + suspend fun deleteFrom(threadId: String, fromTs: Long) + + @Query("SELECT * FROM messages WHERE id = :id") + suspend fun byId(id: String): MessageEntity? + + @Query("SELECT COUNT(*) FROM messages WHERE threadId=:threadId AND createdAt < :ts " + + "AND role='user'") + suspend fun olderUserCount(threadId: String, ts: Long): Int + + @Query("DELETE FROM messages WHERE id = :id") + suspend fun deleteById(id: String) +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/data/Entities.kt b/android/app/src/main/java/dev/edge0/runtime/app/data/Entities.kt new file mode 100644 index 0000000..d2da789 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/data/Entities.kt @@ -0,0 +1,52 @@ +// data/Entities.kt - minimal two-table Room schema. Tagged raw text is not stored: +// thinking/body are separate columns; history reconstruction goes through +// HistoryWindow (tag pairs are re-added at render time). +package dev.edge0.runtime.app.data + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity(tableName = "threads", indices = [Index("updatedAt")]) +data class ThreadEntity( + @PrimaryKey val id: String, + val title: String?, // truncated first user message (no AI summary) + val modelId: String, + val createdAt: Long, + val updatedAt: Long, +) + +/** status: ok | cancelled | error | interrupted (killed process leaves interrupted) */ +@Entity( + tableName = "messages", + foreignKeys = [ForeignKey(entity = ThreadEntity::class, parentColumns = ["id"], + childColumns = ["threadId"], onDelete = ForeignKey.CASCADE)], + indices = [Index("threadId", "createdAt")], +) +data class MessageEntity( + @PrimaryKey val id: String, + val threadId: String, + val role: String, // user | assistant + val content: String, // tag-free body text + val thinking: String?, // extracted reasoning (null = no thinking this turn) + val thinkingMs: Long?, + val status: String, // "streaming" while generating, overwritten at terminal state + val modelId: String, + val createdAt: Long, + val promptTokens: Int?, + val newTokens: Int?, + val decodeTokS: Double?, + val genTokens: ByteArray? = null, // sidecar: token ids stepped this turn (int32 LE BLOB) + val ttftMs: Long? = null, // v3: perceived time-to-first-token, wall clock ms + val prefillTokS: Double? = null, // v3: prefill tok/s + val memBytes: Long? = null, // v3: expert-pool resident bytes (excludes cacheable mmap) +) + +object MsgStatus { + const val STREAMING = "streaming" + const val OK = "ok" + const val CANCELLED = "cancelled" + const val ERROR = "error" + const val INTERRUPTED = "interrupted" +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/data/FakeChatRepository.kt b/android/app/src/main/java/dev/edge0/runtime/app/data/FakeChatRepository.kt new file mode 100644 index 0000000..ad05eae --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/data/FakeChatRepository.kt @@ -0,0 +1,99 @@ +// data/FakeChatRepository.kt - in-memory implementation for UI walkthroughs (no engine, no disk). +package dev.edge0.runtime.app.data + +import dev.edge0.runtime.app.runtime.StoredMessage +import dev.edge0.runtime.app.runtime.decodeIds +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +class FakeChatRepository : ChatRepository { + data class Msg(val id: String, val threadId: String, val role: String, var content: String, + var thinking: String?, var status: String, val createdAt: Long, + var genTokens: ByteArray? = null) + + private val threads = MutableStateFlow>(emptyList()) + private val msgs = MutableStateFlow>(emptyList()) + private var seq = 0 + private fun nid() = "fx${seq++}" + + override fun observeThreads(): Flow> = threads + override fun observeMessages(threadId: String): Flow> = + msgs.map { l -> l.filter { it.threadId == threadId }.sortedBy { it.createdAt } + .map { MessageView(it.id, it.role, it.content, it.thinking, null, it.status, + it.createdAt, null, null) } } + + override suspend fun startThreadWithUserMessage(modelId: String, userText: String): String { + val tid = nid() + val now = System.currentTimeMillis() + msgs.value += Msg(tid, tid, "user", userText, null, MsgStatus.OK, now) + threads.value = listOf(ThreadSummary(tid, userText.take(60), now, modelId)) + threads.value + return tid + } + + override suspend fun beginAssistant(threadId: String, modelId: String): String { + val id = nid() + msgs.value += Msg(id, threadId, "assistant", "", null, MsgStatus.STREAMING, + System.currentTimeMillis()) + return id + } + + override suspend fun addUserMessage(threadId: String, userText: String) { + val now = System.currentTimeMillis() + msgs.value += Msg(nid(), threadId, "user", userText, null, MsgStatus.OK, now) + threads.value = threads.value.map { + if (it.id == threadId) it.copy(updatedAt = now) else it + } + } + + override suspend fun finalizeAssistant(id: String, content: String, thinking: String?, + thinkingMs: Long?, status: String, + promptTokens: Int?, newTokens: Int?, + decodeTokS: Double?, genTokens: ByteArray?, + ttftMs: Long?, prefillTokS: Double?, memBytes: Long?) { + msgs.value = msgs.value.map { + if (it.id == id) it.copy(content = content, thinking = thinking, status = status, + genTokens = genTokens) + else it + } + } + + override suspend fun renameThread(id: String, title: String) { + threads.value = threads.value.map { if (it.id == id) it.copy(title = title) else it } + } + + override suspend fun deleteThread(id: String) { + threads.value = threads.value.filter { it.id != id } + msgs.value = msgs.value.filter { it.threadId != id } + } + + override suspend fun editUserMessage(id: String, newText: String) { + val target = msgs.value.firstOrNull { it.id == id } ?: return + msgs.value = msgs.value.filter { it.createdAt < target.createdAt } + + target.copy(content = newText) + } + + override suspend fun deleteMessage(id: String) { + msgs.value = msgs.value.filter { it.id != id } + } + + override suspend fun markStreamingInterrupted() { + msgs.value = msgs.value.map { + if (it.status == MsgStatus.STREAMING) it.copy(status = MsgStatus.INTERRUPTED) else it + } + } + + override suspend fun history(threadId: String): List = + msgs.value.filter { it.threadId == threadId && it.status != MsgStatus.STREAMING } + .sortedBy { it.createdAt } + .map { StoredMessage(it.role, it.content, it.thinking, decodeIds(it.genTokens)) } + + override suspend fun historyWithIds(threadId: String): List = + msgs.value.filter { it.threadId == threadId }.sortedBy { it.createdAt } + .map { MessageView(it.id, it.role, it.content, it.thinking, null, it.status, + it.createdAt, null, null) } + + override suspend fun deleteFrom(threadId: String, fromTs: Long) { + msgs.value = msgs.value.filter { !(it.threadId == threadId && it.createdAt >= fromTs) } + } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/data/SettingsStore.kt b/android/app/src/main/java/dev/edge0/runtime/app/data/SettingsStore.kt new file mode 100644 index 0000000..3fc1595 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/data/SettingsStore.kt @@ -0,0 +1,97 @@ +// data/SettingsStore.kt - DataStore preferences snapshot (appearance / assistant / sampling / toggles). +// Defaults follow the upstream sampling quartet; installs without a stored key +// fall back to the current defaults. +package dev.edge0.runtime.app.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dev.edge0.runtime.app.ui.theme.ThemeMode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore("edge0_settings") + +data class AppSettings( + val themeMode: ThemeMode = ThemeMode.DARK, // dark by default (does not follow system) + val fontScale: Float = 1.0f, // 0.85..1.3 + val systemPrompt: String = "You're Edge0, an on-device AI assistant.", + val useSystemPrompt: Boolean = true, // explicit toggle; empty text also means off + val temperature: Float = 0.2f, // precise default (control lives in Settings) + val topK: Int = 64, + val topP: Float = 0.95f, + val repetitionPenalty: Float = 1.1f, // aligned with upstream; 1.0 = off + val maxNewTokens: Int = 0, // 0 = app default 1024 (reasoning models need headroom) + val seed: Long = 0x20260915L, + val enableThinking: Boolean = false, // master thinking toggle: drives both the + // model-side template instruction and UI block visibility; + // storage key kept as show_thinking for backward compatibility + val metricsOverlay: Boolean = false, // off by default + val activeModelDir: String = "", +) + +object SettingsKeys { + val THEME = stringPreferencesKey("theme_mode") + val FONT_SCALE = floatPreferencesKey("font_scale") + val SYSTEM_PROMPT = stringPreferencesKey("system_prompt") + val USE_SYS_PROMPT = booleanPreferencesKey("use_system_prompt") + val TEMPERATURE = floatPreferencesKey("temperature") + val TOP_K = intPreferencesKey("top_k") + val TOP_P = floatPreferencesKey("top_p") + val REP_PENALTY = floatPreferencesKey("repetition_penalty") + val MAX_NEW = intPreferencesKey("max_new_tokens") + val SEED = longPreferencesKey("seed") + val SHOW_THINKING = booleanPreferencesKey("show_thinking") + val OVERLAY = booleanPreferencesKey("metrics_overlay") + val ACTIVE_MODEL = stringPreferencesKey("active_model_dir") +} + +class SettingsStore(private val context: Context) { + + val flow: Flow = context.dataStore.data.map { p -> + val d = AppSettings() + AppSettings( + themeMode = p[SettingsKeys.THEME]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() } ?: d.themeMode, + fontScale = p[SettingsKeys.FONT_SCALE] ?: d.fontScale, + systemPrompt = p[SettingsKeys.SYSTEM_PROMPT] ?: d.systemPrompt, + useSystemPrompt = p[SettingsKeys.USE_SYS_PROMPT] ?: d.useSystemPrompt, + temperature = p[SettingsKeys.TEMPERATURE] ?: d.temperature, + topK = p[SettingsKeys.TOP_K] ?: d.topK, + topP = p[SettingsKeys.TOP_P] ?: d.topP, + repetitionPenalty = p[SettingsKeys.REP_PENALTY] ?: d.repetitionPenalty, + maxNewTokens = p[SettingsKeys.MAX_NEW] ?: d.maxNewTokens, + seed = p[SettingsKeys.SEED] ?: d.seed, + enableThinking = p[SettingsKeys.SHOW_THINKING] ?: d.enableThinking, + metricsOverlay = p[SettingsKeys.OVERLAY] ?: d.metricsOverlay, + activeModelDir = p[SettingsKeys.ACTIVE_MODEL] ?: d.activeModelDir, + ) + } + + suspend fun update(block: (AppSettings) -> AppSettings) { + val current = runCatching { flow.first() }.getOrElse { AppSettings() } + val next = block(current) + context.dataStore.edit { p -> + p[SettingsKeys.THEME] = next.themeMode.name + p[SettingsKeys.FONT_SCALE] = next.fontScale + p[SettingsKeys.SYSTEM_PROMPT] = next.systemPrompt + p[SettingsKeys.USE_SYS_PROMPT] = next.useSystemPrompt + p[SettingsKeys.TEMPERATURE] = next.temperature + p[SettingsKeys.TOP_K] = next.topK + p[SettingsKeys.TOP_P] = next.topP + p[SettingsKeys.REP_PENALTY] = next.repetitionPenalty + p[SettingsKeys.MAX_NEW] = next.maxNewTokens + p[SettingsKeys.SEED] = next.seed + p[SettingsKeys.SHOW_THINKING] = next.enableThinking + p[SettingsKeys.OVERLAY] = next.metricsOverlay + p[SettingsKeys.ACTIVE_MODEL] = next.activeModelDir + } + } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/runtime/GenState.kt b/android/app/src/main/java/dev/edge0/runtime/app/runtime/GenState.kt new file mode 100644 index 0000000..bec25d0 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/runtime/GenState.kt @@ -0,0 +1,26 @@ +// runtime/GenState.kt - generation state machine and events (typed, single-slot). +package dev.edge0.runtime.app.runtime + +import dev.edge0.runtime.engine.Status + +sealed interface GenState { + data object Idle : GenState + data class Loading(val modelDir: String) : GenState + data class Streaming(val threadId: String) : GenState + data object Cancelling : GenState // settling gate: cancel sent, old job not yet home (new submits refused) + data class Error(val status: Status, val diag: String) : GenState +} + +sealed interface GenEvent { + data class ThinkPiece(val s: String) : GenEvent + data class TextPiece(val s: String) : GenEvent + // genIds: token ids actually stepped this turn (sidecar; absent on cancel/failure) + data class Done(val metrics: RequestMetrics?, val genIds: IntArray = IntArray(0)) : GenEvent + data class Cancelled(val metrics: RequestMetrics?) : GenEvent + data class Failed(val status: Status, val diag: String) : GenEvent +} + +fun Seg.toEvent(): GenEvent = when (this) { + is Seg.Text -> GenEvent.TextPiece(s) + is Seg.Think -> GenEvent.ThinkPiece(s) +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/runtime/HistoryWindow.kt b/android/app/src/main/java/dev/edge0/runtime/app/runtime/HistoryWindow.kt new file mode 100644 index 0000000..758b686 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/runtime/HistoryWindow.kt @@ -0,0 +1,69 @@ +// runtime/HistoryWindow.kt - multi-turn context assembly with a character budget. +// The budget is conservative (mixed CJK/Latin ~1.5 chars/token against a 4096-token +// context); the hard overflow gate is the engine itself (INVALID_ARG with a +// "prompt tokens=" diagnostic) and callers retry with a tighter budget. +// Assistant history re-enters the prompt wrapped in think tag pairs at render time. +package dev.edge0.runtime.app.runtime + +import dev.edge0.runtime.engine.ChatMessage + +/** Minimal persisted-message shape (Room entities map to this). + * genIds = assistant token-id sidecar: lives and dies with its row. */ +data class StoredMessage(val role: String, val content: String, val thinking: String? = null, + val genIds: IntArray? = null) + +/** Sidecar serialization: IntArray to int32-LE BLOB (Room column); empty becomes null. */ +fun encodeIds(ids: IntArray?): ByteArray? { + if (ids == null || ids.isEmpty()) return null + val bb = java.nio.ByteBuffer.allocate(ids.size * 4).order(java.nio.ByteOrder.LITTLE_ENDIAN) + ids.forEach { bb.putInt(it) } + return bb.array() +} + +/** Sidecar deserialization (length not a multiple of 4, or empty, falls back to text). */ +fun decodeIds(b: ByteArray?): IntArray? { + if (b == null || b.isEmpty() || b.size % 4 != 0) return null + val bb = java.nio.ByteBuffer.wrap(b).order(java.nio.ByteOrder.LITTLE_ENDIAN) + return IntArray(b.size / 4) { bb.int } +} + +object HistoryWindow { + const val DEFAULT_BUDGET_CHARS = 10000 + + /** turns old to new (user/assistant alternating, last is the user question); system first when non-blank. */ + fun build(systemPrompt: String?, turns: List, + budgetChars: Int = DEFAULT_BUDGET_CHARS): List { + val msgs = ArrayList(turns.size + 1) + var used = 0 + if (!systemPrompt.isNullOrBlank()) { + msgs.add(ChatMessage("system", systemPrompt)) + used += systemPrompt.length + } + // poison-turn filter: blank assistant turns without ids must not reach the + // model - replaying "I answered nothing" derails every later turn. Dropped from + // the model-facing context only; the UI record is untouched. + val clean = turns.filterNot { it.role == "assistant" && it.content.isBlank() && it.genIds?.isEmpty() != false } + val picked = ArrayList() + for (t in clean.asReversed()) { + val cost = t.content.length + (t.thinking?.length ?: 0) + 12 + // the current question is always kept, even over budget; engine overflow diagnostic triggers retry + if (picked.isNotEmpty() && used + cost > budgetChars) break + picked.add(t) + used += cost + } + picked.reverse() + for (t in picked) { + // Keep the engine transcript exact while the UI still stores and + // renders thinking separately. The native renderer handles the + // family-specific newline/empty-pair form. + val engineContent = if (t.role == "assistant" && !t.thinking.isNullOrEmpty()) { + "" + t.thinking + "" + t.content + } else t.content + msgs.add(ChatMessage(t.role, engineContent, t.genIds)) + } + return msgs + } + + /** Tightened budget for the overflow retry (caller catches the INVALID_ARG diagnostic). */ + fun tighterBudget(budgetChars: Int): Int = (budgetChars / 2).coerceAtLeast(400) +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/runtime/LlamaNative.kt b/android/app/src/main/java/dev/edge0/runtime/app/runtime/LlamaNative.kt new file mode 100644 index 0000000..098108c --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/runtime/LlamaNative.kt @@ -0,0 +1,25 @@ +// LlamaNative.kt - JNI declaration surface for llama_chat.c. +// external fun symbols bind hard to the package name +// (dev_edge0_runtime_app_runtime_LlamaNative_*): moving packages requires +// mirroring the rename on the C side. Top-level object members keep stable +// exported names (no @JvmName needed). +package dev.edge0.runtime.app.runtime + +object LlamaNative { + init { + // packaged via APK jniLibs (five shared libs from tools/llama/build_vendor_libs.sh); +// DT_NEEDED resolves libllama -> libggml* automatically + System.loadLibrary("edge0llama") + } + + external fun nativeInit(model: String, lora: String, heads: String, + nCtx: Int, poolMb: Int, blobMb: Int, pred: Int, ioThreads: Int): String + external fun nativeReset(): String + external fun nativeAddRole(role: String, text: String): String + external fun nativeGenerate(sink: (String) -> Unit, maxTokens: Int, temp: Float, + thinkingOn: Boolean): String + external fun nativeCancel() + external fun nativeStats(): String + external fun nativeLastAssistant(): String? + external fun nativeFree(): String +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/runtime/LlamaRuntime.kt b/android/app/src/main/java/dev/edge0/runtime/app/runtime/LlamaRuntime.kt new file mode 100644 index 0000000..780372a --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/runtime/LlamaRuntime.kt @@ -0,0 +1,264 @@ +// runtime/LlamaRuntime.kt - the Runtime implementation backed by llama.cpp + GGUF. +// Native side = LlamaNative / llama_chat.c; shared libs are staged into +// build-dl/llama-libs at build time (gitignored, produced by +// tools/llama/build_vendor_libs.sh from the pinned engine tree). +// Auto tiering (mirrors the validated CLI configuration): model >= 12GB takes the +// 35B tier - expert pool 6144MB + blob 3072MB + n_ctx 4096, demand-only +// (prefetch measured net-negative on-device, so E0_PREROUTER is never set); +// otherwise the 8B tier - fully resident, pool/blob disabled, n_ctx 8192, +// companion LoRA auto-discovered (lora_*.gguf). +// LoRA companion applies to the 8B tier only: the 35B build bakes its LoRA in, +// and attaching an adapter on top fails initialization (verified on-device). +// History contract: native is append-only; this class keeps a mirror and does a +// prefix diff - any fork triggers a full rebuild (thread switch / message edit). +package dev.edge0.runtime.app.runtime + +import dev.edge0.runtime.engine.ChatMessage +import dev.edge0.runtime.engine.GenParams +import dev.edge0.runtime.engine.Status +import dev.edge0.runtime.app.runtime.PoolMetrics +import java.io.File +import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class LlamaRuntime : Runtime { + override val state = MutableStateFlow(GenState.Idle) + override val lastMetrics = MutableStateFlow(null) + + @Volatile private var _dir: String? = null + override val activeModelDir: String? get() = _dir + + private val slotMutex = Mutex() + private val genExecutor = Executors.newSingleThreadExecutor { r -> + Thread(r, "llama-gen").apply { isDaemon = true } + } + private val genCtx: CoroutineDispatcher = genExecutor.asCoroutineDispatcher() + + private val mirror = ArrayList>() // (role, content) kept in sync with native + + companion object { + const val TAG = "LlamaRuntime" + const val BIG_MODEL_BYTES = 12L shl 30 // tier threshold (midpoint of 8B=5.0GB / 35B=21.7GB) + } + + private fun runCatchingNative(block: () -> String): String? = + try { block() } catch (t: Throwable) { + // never swallow native failures into a generic "so load failed" - + // the real cause must surface in the return value + "ERR native: ${t.javaClass.simpleName}: ${t.message?.take(160)}" + } + + override suspend fun ensureLoaded(path: String) { + slotMutex.withLock { + if (_dir == path && state.value !is GenState.Error) return + val model = resolveGguf(path) + ?: run { + state.value = GenState.Error(Status.FORMAT_VERSION, "model.gguf not found: $path") + return + } + state.value = GenState.Loading(path) + if (_dir != null) { // model swap: unload first (never two loads resident; nativeInit self-clears as backstop) + runCatching { LlamaNative.nativeFree() } + _dir = null + } + val big = model.length() >= BIG_MODEL_BYTES + val dir = model.parentFile + val lora = if (big) "" else dir?.listFiles { f -> + f.name.startsWith("lora_") && f.name.endsWith(".gguf") + }?.firstOrNull()?.absolutePath ?: "" + val heads = dir?.let { File(it, "e0_heads.bin") }?.takeIf { it.exists() }?.absolutePath ?: "" + val r = withContext(Dispatchers.IO) { + runCatchingNative { + LlamaNative.nativeInit(model.absolutePath, lora, + if (big) heads else "", // heads ship with the 35B tier (idle unless E0_PREROUTER is enabled) + if (big) 4096 else 8192, + if (big) 6144 else 0, + if (big) 3072 else 0, + 0, // prefetch: measured net-negative, permanently off + 4) + } + } + if (r == null || !r.startsWith("OK")) { + state.value = GenState.Error(Status.IO, (r ?: "libedge0llama.so failed to load")) + return + } + mirror.clear() + _dir = path + state.value = GenState.Idle + } + } + + /** path may be a .gguf file, or a directory containing model.gguf. */ + private fun resolveGguf(path: String): File? { + val f = File(path) + return when { + f.isFile && path.endsWith(".gguf") -> f + f.isDirectory -> File(f, "model.gguf").takeIf { it.exists() } + ?: f.listFiles { x -> x.name.endsWith(".gguf") && !x.name.startsWith("lora_") }?.firstOrNull() + else -> null + } + } + + override suspend fun unloadActive() { + slotMutex.withLock { + if (_dir == null) return + withContext(Dispatchers.IO) { runCatchingNative { LlamaNative.nativeFree() } } + mirror.clear() + _dir = null + if (state.value !is GenState.Error) state.value = GenState.Idle + } + } + + override suspend fun trim(ratio: Float): Boolean { + // TODO: expose moe_pool trim through native; the pool currently self-sheds via E0_MEM_FLOOR_MB + return true + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun submit(threadId: String, messages: List, + thinkingOn: Boolean, params: GenParams): Flow = + callbackFlow { + if (_dir == null) { + trySend(GenEvent.Failed(Status.INVALID_ARG, "no active llama session")); close(); return@callbackFlow + } + if (!state.compareAndSet(GenState.Idle, GenState.Streaming(threadId))) { + trySend(GenEvent.Failed(Status.INVALID_ARG, "generation slot busy")); close(); return@callbackFlow + } + launch(genCtx) { + val splitter = ThinkSplitter(initialThink = thinkingOn) + val tSend = System.nanoTime() + var tFirst = 0L + val uiText = StringBuilder() + val sink: (String) -> Unit = { piece -> + if (piece.isNotEmpty()) { + if (tFirst == 0L) tFirst = System.nanoTime() + uiText.append(piece) + splitter.feed(piece).forEach { trySend(it.toEvent()) } + } + } + syncHistory(messages) + val res = runCatchingNative { + LlamaNative.nativeGenerate(sink, params.maxNewTokens.coerceAtLeast(1), + params.temperature, thinkingOn) + } ?: "ERR native" + splitter.flush().forEach { trySend(it.toEvent()) } + // native already appended this turn with stepped ids (sidecar); mirror keeps index parity + if (res.startsWith("OK")) mirror.add("assistant" to uiText.toString()) + val term: GenEvent = when { + res.startsWith("OK") || res.startsWith("CANCELLED") -> { + val m = parseMetrics(res) + val mm = m?.copy( + // user-perceived TTFT measured here (native first_ms is post-prefill sampling time) + firstTokenMs = if (tFirst > 0) (tFirst - tSend) / 1_000_000 else m.firstTokenMs, + peakRssBytes = readPeakRss(), + pool = poolMetrics(), + gpu = GpuMetrics(probed = false, ok = false, active = false, + device = "cpu(llama-route)", why = "CPU-only (shipping configuration)"), + ) + if (mm != null) lastMetrics.value = mm + if (res.startsWith("OK")) GenEvent.Done(mm) else GenEvent.Cancelled(mm) + } + else -> GenEvent.Failed(Status.IO, res) + } + trySend(term) + if (state.value !is GenState.Error) state.value = GenState.Idle + close() + } + awaitClose { } + } + + private fun syncHistory(msgs: List) { + // Prefix match: user turns compare verbatim (UI text feeds native losslessly); + // assistant turns match by ROLE ONLY - the ground truth lives on the native side + // as stepped ids (sidecar), while UI text is a lossy reassembly with think tags + // stripped. Verbatim comparison there would always fake a fork and replay the + // whole history every turn (once cost 60s of dead waiting). Non-contiguous hits + // must not count toward the common prefix either. + // Known edge: editing/regenerating history falls back to full rebuild. + fun ok(i: Int) = i < mirror.size && mirror[i].first == msgs[i].role && + (msgs[i].role != "user" || mirror[i].second == msgs[i].content) + var common = 0 + while (common < msgs.size && ok(common)) common++ + if (common == mirror.size) { + msgs.drop(common).forEach { m -> + val content = m.content + LlamaNative.nativeAddRole(m.role, content) + mirror.add(m.role to content) + } + } else { + LlamaNative.nativeReset() + mirror.clear() + msgs.forEach { m -> + val content = m.content + LlamaNative.nativeAddRole(m.role, content) + mirror.add(m.role to content) + } + } + } + + internal fun parseMetrics(res: String): RequestMetrics? { + val kv = Regex("""(\w+)=(\S+)""").findAll(res).associate { it.groupValues[1] to it.groupValues[2] } + val gen = kv["gen"]?.toIntOrNull() ?: return null + val tps = kv["tps"]?.toDoubleOrNull() ?: 0.0 + val pf = kv["prefill_ms"]?.toLongOrNull() ?: 0L + val first = kv["first_ms"]?.toLongOrNull() ?: 0L + val prompt = kv["prompt"]?.toIntOrNull() ?: 0 + val reused = kv["reused"]?.toIntOrNull() ?: 0 + return RequestMetrics( + promptTokens = prompt, + prefix_reused = reused > 0, + prefix_reused_tokens = reused, + newTokens = gen, + prefillMs = pf, + firstTokenMs = first, + decodeMs = (if (gen > 0) (gen * 1000.0 / (tps + 1e-6)).toLong() else 0L) + first, + prefillTokS = if (pf > 0) (prompt - reused) * 1000.0 / pf else 0.0, + decodeTokS = tps, + peakRssBytes = 0, + chatTurns = 1, + ) + } + + private fun poolMetrics(): PoolMetrics? { + val raw = runCatching { LlamaNative.nativeStats() }.getOrNull() ?: return null + if (!raw.contains("hits=")) return null + val kv = raw.split(" ", "\n").mapNotNull { + val i = it.indexOf('='); if (i <= 0) null else it.substring(0, i) to it.substring(i + 1) + }.toMap() + fun L(k: String) = (kv[k]?.toLongOrNull() ?: 0L) + val slots = (kv["slots"] ?: "").split("/") + return PoolMetrics( + resident_bytes = L("resident"), + hits = L("hits"), misses = L("misses"), + prefetch_used = L("pf_used"), prefetch_mispredict = L("pf_miss"), + evictions = L("evicts"), stall_ns = L("stall_ns"), + flash_bytes = L("flash"), + slots_used = slots.firstOrNull()?.toLongOrNull() ?: 0L, + slots_total = slots.getOrNull(1)?.toLongOrNull() ?: 0L, + ) + } + + private fun readPeakRss(): Long { + val raw = runCatching { LlamaNative.nativeStats() }.getOrNull() ?: return 0 + return Regex("vmhwm_kb=(\\d+)").find(raw)?.groupValues?.get(1)?.toLongOrNull()?.times(1024) ?: 0 + } + + /** Raw native text of the last assistant turn (ground truth for history sync). */ + fun lastAssistantNative(): String? = runCatching { LlamaNative.nativeLastAssistant() }.getOrNull() + + override fun cancelActive() { + runCatching { LlamaNative.nativeCancel() } + } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/runtime/MetricsMapper.kt b/android/app/src/main/java/dev/edge0/runtime/app/runtime/MetricsMapper.kt new file mode 100644 index 0000000..4e04782 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/runtime/MetricsMapper.kt @@ -0,0 +1,99 @@ +// runtime/MetricsMapper.kt - typed mapping of engine metrics (append-only schema; +// unknown fields ignored, missing fields default to the N/A display semantics). +package dev.edge0.runtime.app.runtime + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class RouteMetrics( + val mode: String = "", // predicted|exact (audit echo, prevents track mixing) + val feature: String = "", // executed|teacher|n/a + @SerialName("dec_steps") val decSteps: Long = 0, + @SerialName("pred_used") val predUsed: Long = 0, + @SerialName("route_set_size") val routeSetSize: Int = 0, + val agree: Double? = null, +) + +@Serializable +data class GpuMetrics( + val probed: Boolean = false, + val ok: Boolean = false, + val active: Boolean = false, // whether this request used the GPU (prefill window) + val policy: Int = 0, + val device: String? = null, + val why: String = "", // probe conclusion echo (ok / dlopen:.. / policy-cpu ...) + @SerialName("cache_hit") val cacheHit: Boolean = false, +) + +@Serializable +data class ConfigEcho( + @SerialName("clip_value") val clipValue: Double = 0.0, // per-layer sanitize clamp (0 = off) + @SerialName("bundle_mode") val bundleMode: String = "", + @SerialName("production_top_k") val productionTopK: Int = 0, +) + +@Serializable +data class ParamsEcho( + @SerialName("repetition_penalty") val repPenalty: Double = 0.0, + @SerialName("first_token_greedy") val firstTokenGreedy: Int = 0, +) + +@Serializable +data class PoolMetrics( + val resident_bytes: Long = 0, + val hits: Long = 0, + val misses: Long = 0, + val prefetch_used: Long = 0, + val prefetch_mispredict: Long = 0, + val evictions: Long = 0, + val stall_ns: Long = 0, + val flash_bytes: Long = 0, + val slots_used: Long = 0, + val slots_total: Long = 0, +) + +@Serializable +data class RequestMetrics( + val schema: Int = 0, + @SerialName("prompt_tokens") val promptTokens: Int = 0, + val prefix_reused: Boolean = false, + val prefix_reused_tokens: Int = 0, + @SerialName("new_tokens") val newTokens: Int = 0, + @SerialName("prefill_ms") val prefillMs: Long = 0, + @SerialName("first_token_ms") val firstTokenMs: Long = 0, + @SerialName("decode_ms") val decodeMs: Long = 0, + @SerialName("prefill_tok_s") val prefillTokS: Double = 0.0, + @SerialName("decode_tok_s") val decodeTokS: Double = 0.0, + @SerialName("peak_rss_bytes") val peakRssBytes: Long = 0, + @SerialName("chat_turns") val chatTurns: Int = 0, + @SerialName("thinking_on") val thinkingOn: Boolean = true, + val cancelled: Boolean = false, + val error: String = "", + // additive echo section: missing fields on older engines fall back to N/A defaults + val config: ConfigEcho? = null, + val params: ParamsEcho? = null, + val route: RouteMetrics? = null, + val pool: PoolMetrics? = null, + val gpu: GpuMetrics? = null, +) { + val hitRate: Double? get() = pool?.let { + val t = it.hits + it.misses + if (t > 0) it.hits.toDouble() / t else null + } +} + +object MetricsMapper { + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + /** Parse failure / empty input returns null (UI shows N/A, never crashes). */ + fun parse(raw: String?): RequestMetrics? { + if (raw.isNullOrBlank()) return null + return try { + json.decodeFromString(RequestMetrics.serializer(), raw) + } catch (_: Exception) { + null + } + } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/runtime/ModelInventory.kt b/android/app/src/main/java/dev/edge0/runtime/app/runtime/ModelInventory.kt new file mode 100644 index 0000000..8773832 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/runtime/ModelInventory.kt @@ -0,0 +1,51 @@ +// runtime/ModelInventory.kt - model discovery for the import/models screen. +// Scans the app's model roots (internal files/models first; external files/models +// as a secondary root - on some OEM Android builds shell-pushed files under +// Android/data/ are invisible to the app uid, so in-app copy to the +// internal dir is the primary staging channel). Only flat *.gguf files are +// entries; lora_*.gguf companions are picked up by the runtime, not listed. +package dev.edge0.runtime.app.runtime + +import java.io.File + +data class ModelEntry( + val name: String, + val dir: File, // the .gguf file itself + val totalBytes: Long, // file size (requirement yardstick) + val dirBytes: Long, // same, kept for the UI's size column + val valid: Boolean, + val hint: String?, // reason shown when invalid + val mode: String = "llama-gguf", +) + +object ModelInventory { + + fun scanRoots(roots: List): List = + roots.filterNotNull().flatMap { scan(it) } + .distinctBy { it.name } + .sortedBy { it.name } + + fun scan(root: File): List = + (root.listFiles() ?: emptyArray()).filter { + it.isFile && it.name.endsWith(".gguf") && !it.name.startsWith("lora_") + }.map { f -> + val ok = f.length() > 0 + ModelEntry(f.name.removeSuffix(".gguf"), f, f.length(), f.length(), ok, + if (ok) null else "empty file") + } + + /** Free space on the volume backing the primary model root (UI warning gate). */ + fun volumeAvailBytes(root: File?): Long = + root?.let { runCatching { android.os.StatFs(it.absolutePath).availableBytes } + .getOrDefault(0L) } ?: 0L + + /** Import instructions shown on the models screen. */ + fun importTemplate(pkg: String): String = listOf( + "Place model files flat under files/models/ of this app.", + "Option 1 - in-app: use the picker to copy from device storage.", + "Option 2 - adb (debug builds):", + " adb push /data/local/tmp/", + " adb shell run-as $pkg cp /data/local/tmp/ files/models/", + "Expected names: edge0-8b.gguf (plus lora_edge0_8b-gguf.gguf), edge0-35b.gguf.", + ).joinToString("\n") +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/runtime/Runtime.kt b/android/app/src/main/java/dev/edge0/runtime/app/runtime/Runtime.kt new file mode 100644 index 0000000..c3c5a05 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/runtime/Runtime.kt @@ -0,0 +1,19 @@ +// runtime/Runtime.kt - the runtime facade contract consumed by ViewModel/screens. +package dev.edge0.runtime.app.runtime + +import dev.edge0.runtime.engine.ChatMessage +import dev.edge0.runtime.engine.GenParams +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +interface Runtime { + val state: MutableStateFlow + val lastMetrics: MutableStateFlow + val activeModelDir: String? + suspend fun ensureLoaded(path: String) + suspend fun unloadActive() + suspend fun trim(ratio: Float): Boolean + fun submit(threadId: String, messages: List, + thinkingOn: Boolean, params: GenParams): Flow + fun cancelActive() +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/runtime/ThermalSource.kt b/android/app/src/main/java/dev/edge0/runtime/app/runtime/ThermalSource.kt new file mode 100644 index 0000000..c4c4db9 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/runtime/ThermalSource.kt @@ -0,0 +1,66 @@ +// runtime/ThermalSource.kt - dual-source thermal telemetry: +// PowerManager.currentThermalStatus (API 29+ enum) plus /sys/class/thermal +// nodes (non-root readability verified per node; unreadable ones degrade to N/A). +package dev.edge0.runtime.app.runtime + +import android.content.Context +import android.os.Build +import android.os.PowerManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import java.io.File + +data class ThermalReading( + val statusLevel: Int?, // PowerManager level (null when unavailable) + val statusName: String?, + val topCpuGpuMilli: Int?, // max across compute-thermal zones (cpu/gpuss/ddr/qfprom) + val tsEpochMs: Long, +) + +class ThermalSource(context: Context) { + private val power = context.getSystemService(PowerManager::class.java) + + fun read(): ThermalReading { + var lvl: Int? = null + var name: String? = null + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + lvl = power.currentThermalStatus + name = when (lvl) { + PowerManager.THERMAL_STATUS_NONE -> "NONE" + PowerManager.THERMAL_STATUS_LIGHT -> "LIGHT" + PowerManager.THERMAL_STATUS_MODERATE -> "MODERATE" + PowerManager.THERMAL_STATUS_SEVERE -> "SEVERE" + PowerManager.THERMAL_STATUS_CRITICAL -> "CRITICAL" + PowerManager.THERMAL_STATUS_EMERGENCY -> "EMERGENCY" + PowerManager.THERMAL_STATUS_SHUTDOWN -> "SHUTDOWN" + else -> "S$lvl" + } + } + } catch (_: Exception) { /* degrade to N/A */ } + var max = -1 + try { + val zones = File("/sys/class/thermal").listFiles() ?: emptyArray() + for (z in zones) { + if (!z.name.startsWith("thermal_zone")) continue + val type = try { File(z, "type").readText().trim() } catch (_: Exception) { continue } + if (!(type.contains("cpu", true) || type.contains("gpuss", true) || + type.contains("ddr", true) || type.contains("qfprom", true))) continue + val milli = try { File(z, "temp").readText().trim().toIntOrNull() } catch (_: Exception) { null } + if (milli != null && milli > max) max = milli + } + } catch (_: Exception) { /* unreadable: null */ } + return ThermalReading(lvl, name, if (max >= 0) max else null, System.currentTimeMillis()) + } + + /** 1 Hz sampling flow (collected only while the overlay is visible; unsubscribes when hidden). */ + fun flow(): Flow = flow { + while (true) { + emit(read()) + delay(1000) + } + }.flowOn(Dispatchers.IO) +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/runtime/ThinkSplitter.kt b/android/app/src/main/java/dev/edge0/runtime/app/runtime/ThinkSplitter.kt new file mode 100644 index 0000000..0812705 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/runtime/ThinkSplitter.kt @@ -0,0 +1,86 @@ +// runtime/ThinkSplitter.kt - streaming piece splitter that demuxes engine output +// into body/thinking increments. The engine never strips think tags (template purity +// contract), so parsing happens here. Tag literals are assembled in two pieces to +// survive tooling that strips special-token lookalikes; verify bytes on disk after edits. +package dev.edge0.runtime.app.runtime + +sealed interface Seg { + data class Text(val s: String) : Seg + data class Think(val s: String) : Seg +} + +class ThinkSplitter(initialThink: Boolean = false) { + companion object { + const val OPEN = "" + const val CLOSE = "" + private const val MIN_OVERLAP_KEEP = 1 + } + + // when thinking_on the opening tag is injected at the prompt tail by the + // template, so the stream starts inside the thinking region - callers must pass + // initialThink=thinkingOn or thinking text leaks wholesale into the body. + private var inThink = initialThink + private var carry = "" // sole retained state: suffix of the watched tag that is a real prefix of it + + /** Whether we are currently inside a thinking segment (drives the UI timer). */ + val thinkingNow: Boolean get() = inThink + + fun feed(piece: String): List { + if (piece.isEmpty() && carry.isEmpty()) return emptyList() + val s = carry + piece + carry = "" + val out = ArrayList(4) + var i = 0 + while (i < s.length || i == 0) { + val tag = if (inThink) CLOSE else OPEN + val idx = s.indexOf(tag, i) + if (idx >= 0) { + if (idx > i) emit(out, s.substring(i, idx)) + inThink = !inThink + i = idx + tag.length + if (i >= s.length) return out + } else { + val keep = overlapSuffixLen(s, tag) + if (keep > 0 && i <= s.length - keep) { + if (s.length - i - keep > 0) emit(out, s.substring(i, s.length - keep)) + carry = s.substring(s.length - keep) + } else if (i < s.length) { + emit(out, s.substring(i)) + } + return out + } + } + return out + } + + /** End of stream: flush the carried prefix per current mode (unclosed thinking is tolerated). */ + fun flush(): List { + if (carry.isEmpty()) return emptyList() + val out = listOf(segOf(carry)) + carry = "" + return out + } + + // maximal overlap of the tail with a real prefix of tag (length <= tag.size-1); cross-chunk tags live only here + private fun overlapSuffixLen(s: String, tag: String): Int { + val max = minOf(tag.length - 1, s.length) + for (k in max downTo MIN_OVERLAP_KEEP) { + if (s.endsWith(tag.substring(0, k))) return k + } + return 0 + } + + private fun emit(out: MutableList, text: String) { + val seg = segOf(text) + // merge consecutive same-kind segments (keep event count minimal after flips within one feed) + val last = out.lastOrNull() + out.add(when { + last is Seg.Text && seg is Seg.Text -> { out.removeAt(out.size - 1); Seg.Text(last.s + seg.s) } + last is Seg.Think && seg is Seg.Think -> { out.removeAt(out.size - 1); Seg.Think(last.s + seg.s) } + else -> seg + }) + } + + private fun segOf(text: String): Seg = + if (inThink) Seg.Think(text) else Seg.Text(text) +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/ui/AppRoot.kt b/android/app/src/main/java/dev/edge0/runtime/app/ui/AppRoot.kt new file mode 100644 index 0000000..2a16ac5 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/ui/AppRoot.kt @@ -0,0 +1,206 @@ +// ui/AppRoot.kt - DrawerHost + NavHost wiring. +// Side sheet (288dp): New Chat / thread list (updatedAt desc, busy pulse dot, +// long-press delete menu) / footer with model management and settings. +package dev.edge0.runtime.app.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.outlined.Close +import androidx.compose.ui.draw.clip +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import dev.edge0.runtime.app.Edge0App +import dev.edge0.runtime.app.data.AppSettings +import dev.edge0.runtime.app.data.ThreadSummary +import dev.edge0.runtime.app.runtime.GenState +import dev.edge0.runtime.app.ui.chat.ChatScreen +import dev.edge0.runtime.app.ui.chat.ChatViewModel +import dev.edge0.runtime.app.ui.models.ModelsScreen +import dev.edge0.runtime.app.ui.settings.SettingsScreen +import kotlinx.coroutines.launch + +object Routes { + const val CHAT = "chat" + const val MODELS = "models" + const val SETTINGS = "settings" +} + +@Composable +fun AppRoot() { + val app = LocalContext.current.applicationContext as Edge0App + val container = app.container + val factory = remember { + object : ViewModelProvider.Factory { + override fun create( + modelClass: Class): T = + ChatViewModel(null, container.repository, container.runtime, + container.settings) as T + } + } + val vm: ChatViewModel = viewModel(factory = factory) + val drawerState = rememberDrawerState(DrawerValue.Closed) + val nav = rememberNavController() + val scope = rememberCoroutineScope() + val threads by container.repository.observeThreads() + .collectAsStateWithLifecycle(initialValue = emptyList()) + val genState by vm.genState.collectAsStateWithLifecycle() + val settings by container.settings.flow + .collectAsStateWithLifecycle(initialValue = AppSettings()) + + val closeDrawer: () -> Unit = { scope.launch { drawerState.close() } } + + ModalNavigationDrawer(drawerState = drawerState, drawerContent = { + ModalDrawerSheet(modifier = Modifier.width(288.dp), + drawerContainerColor = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxSize()) { + // title row: name left, X closes the drawer (thread/model entries live in the top bar) + Row(Modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp, + top = 28.dp, bottom = 14.dp), + verticalAlignment = Alignment.CenterVertically) { + Text("Edge0 Chat", style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f)) + IconButton(onClick = { closeDrawer() }) { + Icon(Icons.Outlined.Close, "Close drawer", + tint = MaterialTheme.colorScheme.onSurface) + } + } + Box(Modifier.fillMaxWidth().height(1.dp) + .background(MaterialTheme.colorScheme.outline)) + Text("History", style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp)) + LazyColumn(Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 10.dp)) { + items(threads, key = { it.id }) { t -> + ThreadItem( + t = t, + selected = vm.currentThreadId() == t.id, + busyHere = genState is GenState.Streaming && + vm.currentThreadId() == t.id, + onOpen = { vm.openThread(t.id); nav.navigate(Routes.CHAT) + closeDrawer() }, + onDelete = { vm.deleteThread(t.id) }, + ) + } + } + NavigationDrawerItem(label = { Text("Settings") }, + icon = { Icon(Icons.Outlined.Settings, null) }, selected = false, + onClick = { nav.navigate(Routes.SETTINGS); closeDrawer() }) + Spacer(Modifier.height(8.dp)) + } + } + }) { + Scaffold { insets -> + NavHost(navController = nav, startDestination = Routes.CHAT, + modifier = Modifier.padding(insets)) { + composable(Routes.CHAT) { + Box(Modifier.fillMaxSize()) { + ChatScreen( + vm = vm, + modelLabel = settings.activeModelDir.substringAfterLast('/'), + onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenModels = { nav.navigate(Routes.MODELS) }, + onNewChat = { vm.newChat(); nav.navigate(Routes.CHAT) }, + ) + } + } + composable(Routes.MODELS) { + ModelsScreen(onBack = { nav.popBackStack(Routes.CHAT, false) }) + } + composable(Routes.SETTINGS) { + SettingsScreen(onBack = { nav.popBackStack(Routes.CHAT, false) }) + } + } + } + } +} + +@Composable +private fun ThreadItem(t: ThreadSummary, selected: Boolean, busyHere: Boolean, + onOpen: () -> Unit, onDelete: () -> Unit) { + // row = rounded card (selected gets surfaceVariant backing); trailing X deletes + // immediately without confirmation; rename was retired by product decision. + Row(Modifier.fillMaxWidth().padding(vertical = 2.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + else androidx.compose.ui.graphics.Color.Transparent) + .clickable(onClick = onOpen) + .padding(start = 14.dp, end = 4.dp, top = 13.dp, bottom = 13.dp), + verticalAlignment = Alignment.CenterVertically) { + if (busyHere) { + val tr = rememberInfiniteTransition(label = "pulse") + val a by tr.animateFloat(0.25f, 1f, + infiniteRepeatable(tween(700), RepeatMode.Reverse), label = "p") + Box(Modifier.size(6.dp).background( + MaterialTheme.colorScheme.primary, CircleShape).alpha(a)) + Spacer(Modifier.width(8.dp)) + } + Text(t.title ?: "New chat", maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f)) + IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) { + Icon(Icons.Outlined.Close, "Delete chat", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(17.dp)) + } + } +} + +@Composable +private fun PlaceholderScreen(name: String) { + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + Text(name, style = MaterialTheme.typography.bodyLarge) + Text("S4-Scaffold-OK", style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error) + } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/ActionRow.kt b/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/ActionRow.kt new file mode 100644 index 0000000..8e82166 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/ActionRow.kt @@ -0,0 +1,81 @@ +// ui/chat/ActionRow.kt - per-message action row: copy / edit / delete +// (+ regenerate on the last assistant turn) and a tok/s badge; hidden while streaming. +package dev.edge0.runtime.app.ui.chat + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.edge0.runtime.app.ui.theme.LocalFontScale + +@Composable +fun ActionRow( + text: String, + canEdit: Boolean, + canRegenerate: Boolean, + onEdit: () -> Unit, + onDelete: () -> Unit, + onRegenerate: () -> Unit, + modifier: Modifier = Modifier, + endAligned: Boolean = false, +) { + val clip = LocalClipboardManager.current + Row(modifier.padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + if (endAligned) Spacer(Modifier.weight(1f)) + IconButton(onClick = { clip.setText(AnnotatedString(text)) }, + modifier = Modifier.width(36.dp)) { + Icon(Icons.Outlined.ContentCopy, "Copy", tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp)) + } + if (canEdit) IconButton(onClick = onEdit, modifier = Modifier.width(36.dp)) { + Icon(Icons.Outlined.Edit, "Edit", tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp)) + } + IconButton(onClick = onDelete, modifier = Modifier.width(36.dp)) { + Icon(Icons.Outlined.Delete, "Delete", tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp)) + } + if (canRegenerate) IconButton(onClick = onRegenerate, modifier = Modifier.width(36.dp)) { + Icon(Icons.Outlined.Refresh, "Regenerate", tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp)) + } + if (!endAligned) Spacer(Modifier.weight(1f)) + } +} + +/** Inline stats line under each reply (Atomic style). Memory segment reports + * resident pool bytes only (cacheable mmap views excluded). Missing fields elide. */ +@Composable +fun StatsLine(newTokens: Int?, ttftMs: Long?, prefillTokS: Double?, + decodeTokS: Double?, memBytes: Long?, + modifier: Modifier = Modifier) { + val parts = ArrayList(5) + if (newTokens != null && newTokens > 0) parts.add("$newTokens tokens") + if (ttftMs != null && ttftMs > 0) parts.add("TTFT %.1fs".format(ttftMs / 1000.0)) + if (prefillTokS != null && prefillTokS > 0.05) parts.add("prefill %.1f tok/s".format(prefillTokS)) + if (decodeTokS != null && decodeTokS > 0) parts.add("decode %.2f tok/s".format(decodeTokS)) + if (memBytes != null && memBytes > 0) parts.add("pool %.0f MB".format(memBytes / 1048576.0)) + if (parts.isEmpty()) return + Text(parts.joinToString(" · "), + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.5.sp * LocalFontScale.current, + color = MaterialTheme.colorScheme.onSurfaceVariant), + modifier = modifier.padding(top = 6.dp)) +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/ChatScreen.kt b/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..5f6e516 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/ChatScreen.kt @@ -0,0 +1,367 @@ +// ui/chat/ChatScreen.kt - the chat page: top bar (drawer button left, context usage +// right), centered message column (max 768dp) sticking to bottom, user bubbles on the +// right, full-width assistant markdown + reasoning, action row on the last message, +// Working... shimmer, destructive error card, rounded composer card. +package dev.edge0.runtime.app.ui.chat + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material.icons.outlined.SwapHoriz +import androidx.compose.foundation.background +import androidx.compose.ui.graphics.Color +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.edge0.runtime.app.data.MessageView +import dev.edge0.runtime.app.data.MsgStatus +import dev.edge0.runtime.app.ui.markdown.MarkdownText +import dev.edge0.runtime.app.ui.theme.LocalFontScale + +@Composable +fun ChatScreen( + vm: ChatViewModel, + modelLabel: String, + onOpenDrawer: () -> Unit, + onOpenModels: () -> Unit, + onNewChat: () -> Unit, +) { + val draft by vm.draft.collectAsStateWithLifecycle() + val streaming by vm.streaming.collectAsStateWithLifecycle() + val genState by vm.genState.collectAsStateWithLifecycle() + val banner by vm.banner.collectAsStateWithLifecycle() + val messages by vm.messages.collectAsStateWithLifecycle() + + val busy = genState is dev.edge0.runtime.app.runtime.GenState.Streaming || + genState is dev.edge0.runtime.app.runtime.GenState.Cancelling || streaming != null + val loading = genState is dev.edge0.runtime.app.runtime.GenState.Loading + + val listState = rememberLazyListState() + val scrollScope = rememberCoroutineScope() + val atBottom by remember { + derivedStateOf { + val info = listState.layoutInfo + val last = info.visibleItemsInfo.lastOrNull() + last == null || last.index >= info.totalItemsCount - 1 + } + } + // stick to bottom: streaming deltas and new messages follow; a user scroll up detaches without interrupting generation + LaunchedEffect(messages.size, streaming?.text, streaming?.thinking) { + if (atBottom) listState.scrollToItem(Int.MAX_VALUE / 2) + } + + Column(Modifier.fillMaxSize().imePadding()) { + // -- top bar -- + Row(Modifier.fillMaxWidth().height(52.dp).padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onOpenDrawer) { + Icon(Icons.Outlined.Menu, "Chats") + } + Spacer(Modifier.weight(1f)) + // centered title: perceived model name + On-device Inference subtitle + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val short = modelLabel.lowercase() + val title = when { + "35b" in short -> "Edge0 35B" + "8b" in short -> "Edge0 8B" + else -> "Edge0 Chat" + } + Text(title, style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, fontSize = 16.sp * LocalFontScale.current)) + Text("On-device Inference", style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp * LocalFontScale.current, + color = MaterialTheme.colorScheme.onSurfaceVariant)) + } + Spacer(Modifier.weight(1f)) + // new-thread button (lifted out of the drawer, beside the model switch) + Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) { + Surface(shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(40.dp).clickable(onClick = onNewChat)) {} + Icon(Icons.Outlined.Edit, "New chat", + modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurface) + } + Spacer(Modifier.width(12.dp)) + // top-right model switch (opens the models page) + Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) { + Surface(shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(40.dp).clickable(onClick = onOpenModels)) {} + Icon(Icons.Outlined.SwapHoriz, "Switch model ($modelLabel)", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurface) + val dot = when { + loading -> MaterialTheme.colorScheme.tertiary + busy -> MaterialTheme.colorScheme.primary + modelLabel.isNotBlank() -> Color(0xFF22C55E) + else -> MaterialTheme.colorScheme.outline + } + Box(Modifier.align(Alignment.TopEnd).padding(3.dp).size(8.dp) + .background(dot, CircleShape)) + } + Spacer(Modifier.width(8.dp)) + } + + // -- message column -- + Box(Modifier.weight(1f), contentAlignment = Alignment.TopCenter) { + LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + start = 16.dp, end = 16.dp, top = 4.dp, bottom = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + if (messages.isEmpty() && streaming == null) { + item { + WelcomePage(onPick = { pr -> vm.draft.value = pr; vm.sendMessage() }) + } + } + items(messages.size) { i -> + val m = messages[i] + MessageRow( + m = m, + isLastAssistant = m.role == "assistant" && + i == messages.indexOfLast { it.role == "assistant" }, + showActions = !busy && m.status != MsgStatus.STREAMING, + onEdit = { text -> vm.editUserMessage(m.id, text) }, + onDelete = { vm.deleteMessage(m.id) }, + onRegenerate = { vm.regenerate() }, + ) + } + streaming?.let { s -> + item { + AssistantBody( + body = s.text, thinking = s.thinking, + reasoning = ReasoningState( + active = s.thinkingActive, startedAt = s.thinkStartedMs, + endedAt = s.thinkEndedMs), + prefilling = s.prefilling, + status = null, isStreaming = true, + ) + } + } + item { Spacer(Modifier.height(2.dp)) } + } + if (!atBottom) { + FloatingActionButton( + onClick = { scrollScope.launch { listState.animateScrollToItem(Int.MAX_VALUE / 2) } }, + modifier = Modifier.align(Alignment.BottomEnd).padding(12.dp) + .size(36.dp), + shape = CircleShape, containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { Icon(Icons.Outlined.ArrowDownward, "Scroll to bottom", + modifier = Modifier.size(18.dp)) } + } + } + + // -- error / guidance banner -- + banner?.let { msg -> + Surface(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.4f)) + ) { + Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Warning, null, tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Text(msg, modifier = Modifier.weight(1f), maxLines = 3, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall) + IconButton(onClick = { vm.banner.value = null }) { + Icon(Icons.Outlined.Close, "Dismiss", modifier = Modifier.size(14.dp)) + } + } + } + } + + // -- composer (input card + send button; other controls moved to Settings/top bar) -- + Composer( + value = draft, onValueChange = { vm.draft.value = it }, + busy = busy, + enabledSend = draft.isNotBlank() && !loading && modelLabel.isNotBlank(), + onSend = { vm.sendMessage() }, onStop = { vm.cancel() }, + ) + } +} + +@Composable +private fun MessageRow(m: MessageView, isLastAssistant: Boolean, showActions: Boolean, + onEdit: (String) -> Unit, onDelete: () -> Unit, onRegenerate: () -> Unit) { + if (m.role == "user") { + var editing by remember(m.id) { mutableStateOf(false) } + var buf by remember(m.id) { mutableStateOf(m.content) } + Column(Modifier.fillMaxWidth().padding(top = 10.dp), + horizontalAlignment = Alignment.End) { + if (editing) { + Surface(Modifier.fillMaxWidth(0.92f), shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)) { + Column(Modifier.padding(8.dp)) { + BasicTextField(value = buf, onValueChange = { buf = it }, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface), + modifier = Modifier.fillMaxWidth().height(88.dp)) + Row(horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth()) { + Text("Cancel", color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(8.dp) + .clickableText { editing = false }) + Text("Save", color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(8.dp).clickableText { + if (buf.isNotBlank()) onEdit(buf.trim()) + editing = false + }) + } + } + } + } else { + Surface(shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.widthIn(max = 560.dp)) { + Text(m.content, Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = 14.sp * LocalFontScale.current)) + } + if (showActions) { + ActionRow(text = m.content, canEdit = true, canRegenerate = false, + onEdit = { editing = true }, onDelete = onDelete, + onRegenerate = {}, endAligned = true) + } + } + } + } else { + AssistantBody( + body = m.content, thinking = m.thinking ?: "", + // persisted row: startedAt at assistant-row creation, endedAt plus thinking duration + reasoning = ReasoningState(active = false, startedAt = m.createdAt, + endedAt = m.thinkingMs?.let { m.createdAt + it }), + prefilling = false, + status = m.status, isStreaming = false, + extra = { + if (showActions) Row { + Column(Modifier.weight(1f)) { + StatsLine(newTokens = m.newTokens, ttftMs = m.ttftMs, + prefillTokS = m.prefillTokS, decodeTokS = m.decodeTokS, + memBytes = m.memBytes) + ActionRow(text = m.content, canEdit = false, + canRegenerate = isLastAssistant, + onEdit = {}, onDelete = onDelete, + onRegenerate = onRegenerate) + } + } + }, + ) + } +} + +private data class ReasoningState(val active: Boolean, val startedAt: Long?, val endedAt: Long?) + +@Composable +private fun AssistantBody(body: String, thinking: String, reasoning: ReasoningState, + prefilling: Boolean, status: String?, isStreaming: Boolean, + extra: (@Composable () -> Unit)? = null) { + Column(Modifier.fillMaxWidth().widthIn(max = 768.dp).padding(top = 10.dp)) { + if (thinking.isNotEmpty()) { + ReasoningBlock(thinking = thinking, active = reasoning.active, + startedAtMs = reasoning.startedAt, endedAtMs = reasoning.endedAt, + nowMs = System.currentTimeMillis()) + } + if (prefilling && isStreaming) { + WorkingShimmer("Working...") + } + if (body.isNotEmpty()) { + MarkdownText(body, Modifier.fillMaxWidth()) + } + status?.let { st -> + val chip = when (st) { + MsgStatus.CANCELLED -> "Cancelled" + MsgStatus.INTERRUPTED -> "Interrupted" + MsgStatus.ERROR -> "Error" + else -> null + } + if (chip != null) { + Text(chip, style = MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.error), + modifier = Modifier.padding(top = 2.dp)) + } + } + extra?.invoke() + } +} + +// lightweight text button +private fun Modifier.clickableText(onClick: () -> Unit): Modifier = + this.clickable(onClick = onClick) + +// starter prompts (three fixed questions; display truncates with ellipsis, send uses full text) +private val PRESET_PROMPTS = listOf( + "Will the water level rise when ice floating in it melts? Explain in two sentences.", + "Write a sci-fi story with a twist in exactly six words.", + "Make 24 using 3, 3, 8, and 8 exactly once each, with only basic arithmetic and parentheses.", +) + +@Composable +private fun WelcomePage(onPick: (String) -> Unit) { + Column(Modifier.fillMaxWidth().padding(top = 110.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + Text("edge0", style = MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.Bold, fontSize = 34.sp * LocalFontScale.current)) + Spacer(Modifier.height(10.dp)) + Text("Private AI, running locally on your device", + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant)) + Spacer(Modifier.height(30.dp)) + PRESET_PROMPTS.forEach { pr -> + Surface(Modifier.padding(vertical = 5.dp).widthIn(max = 320.dp) + .clip(RoundedCornerShape(20.dp)) + .clickable { onPick(pr) }, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) { + Text(pr, Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 13.5.sp * LocalFontScale.current)) + } + } + } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/ChatViewModel.kt b/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..d8aaea8 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/ChatViewModel.kt @@ -0,0 +1,220 @@ +// ui/chat/ChatViewModel.kt - chat page orchestration: send -> load -> collect +// generation events -> persist terminal state. Engine cancellation goes exclusively +// through runtime.cancelActive (no fake interruption in the VM). The engine +// overflow diagnostic (INVALID_ARG "prompt tokens=") triggers one retry with a +// tighter budget. Both Done and Cancelled finalize to storage (a cancelled bubble +// keeps its partial content). +package dev.edge0.runtime.app.ui.chat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.edge0.runtime.app.data.AppSettings +import dev.edge0.runtime.app.data.ChatRepository +import dev.edge0.runtime.app.data.MessageView +import dev.edge0.runtime.app.data.MsgStatus +import dev.edge0.runtime.app.data.SettingsStore +import dev.edge0.runtime.app.runtime.Runtime +import dev.edge0.runtime.app.runtime.GenEvent +import dev.edge0.runtime.app.runtime.GenState +import dev.edge0.runtime.app.runtime.encodeIds +import dev.edge0.runtime.app.runtime.HistoryWindow +import dev.edge0.runtime.app.runtime.RequestMetrics +import dev.edge0.runtime.engine.GenParams +import dev.edge0.runtime.engine.Status +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class StreamingDraft( + val text: String = "", + val thinking: String = "", + val prefilling: Boolean = true, // before the first event: show the Working... shimmer + val thinkingActive: Boolean = false, + val thinkStartedMs: Long? = null, + val thinkEndedMs: Long? = null, +) + +class ChatViewModel( + initialThreadId: String?, + private val repo: ChatRepository, + private val runtime: Runtime, + private val settingsStore: SettingsStore, +) : ViewModel() { + + val draft = MutableStateFlow("") + val streaming = MutableStateFlow(null) + val banner = MutableStateFlow(null) + val genState: StateFlow = runtime.state + val lastMetrics: StateFlow = runtime.lastMetrics + + private val activeThreadId = MutableStateFlow(initialThreadId) + fun currentThreadId(): String? = activeThreadId.value + + /** Message stream of the current thread (re-follows on thread switch). */ + @OptIn(ExperimentalCoroutinesApi::class) + val messages: StateFlow> = activeThreadId + .flatMapLatest { tid -> if (tid == null) flowOf(emptyList()) else repo.observeMessages(tid) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun newChat() { + activeThreadId.value = null + draft.value = "" + banner.value = null + } + + fun openThread(id: String) { + if (runtime.state.value is GenState.Streaming) return // no thread switch mid-generation (single slot) + activeThreadId.value = id + banner.value = null + } + + fun sendMessage() { + val userText = draft.value.trim() + if (userText.isEmpty()) return + val st = runtime.state.value + if (st is GenState.Streaming || st is GenState.Cancelling || streaming.value != null) return + draft.value = "" + viewModelScope.launch { + val settings = runCatching { settingsStore.flow.first() }.getOrNull() ?: return@launch + val existing = activeThreadId.value + val tid = existing ?: repo.startThreadWithUserMessage( + runtime.activeModelDir ?: settings.activeModelDir, userText).also { + activeThreadId.value = it + } + if (existing != null) repo.addUserMessage(tid, userText) + + if (runtime.activeModelDir == null) { + if (settings.activeModelDir.isBlank()) { + banner.value = "No model loaded: import one into the app (adb push) via Models, then load it" + return@launch + } + try { + runtime.ensureLoaded(settings.activeModelDir) + } catch (e: Exception) { + banner.value = "Load failed: ${e.message}" + return@launch + } + } + runGenerate(tid, settings, budget = HistoryWindow.DEFAULT_BUDGET_CHARS, + allowRetry = true) + } + } + + /** One generation round (with the overflow-trim retry). The last history entry is the new user turn. */ + private suspend fun runGenerate(tid: String, settings: AppSettings, + budget: Int, allowRetry: Boolean) { + val history = repo.history(tid) + val msgs = HistoryWindow.build( + settings.systemPrompt.takeIf { settings.useSystemPrompt }?.ifBlank { null }, + history, budget) + val mid = repo.beginAssistant(tid, runtime.activeModelDir ?: "") + streaming.value = StreamingDraft() + val text = StringBuilder() + val think = StringBuilder() + var thinkStart = 0L + var thinkMs: Long? = null + var terminal: GenEvent? = null + val params = settings.toGenParams() + runtime.submit(tid, msgs, settings.enableThinking, params).collect { ev -> + when (ev) { + is GenEvent.ThinkPiece -> { + if (think.isEmpty()) thinkStart = System.currentTimeMillis() + think.append(ev.s) + streaming.value = (streaming.value ?: StreamingDraft()).copy( + prefilling = false, thinkingActive = true, + thinking = think.toString(), thinkStartedMs = thinkStart) + } + is GenEvent.TextPiece -> { + if (streaming.value?.thinkingActive == true && thinkMs == null) { + thinkMs = System.currentTimeMillis() - thinkStart + } + text.append(ev.s) + streaming.value = (streaming.value ?: StreamingDraft()).copy( + prefilling = false, thinkingActive = false, + text = text.toString(), thinking = think.toString(), + thinkEndedMs = thinkMs) + } + else -> terminal = ev + } + } + streaming.value = null + val metrics = (terminal as? GenEvent.Done)?.metrics + ?: (terminal as? GenEvent.Cancelled)?.metrics + // timing fallback: stopped/cancelled turns never flip THINK to TEXT - + // freeze the clock at the terminal moment. Unclosed thinking is kept whole in + // the thinking region (same semantics as the upstream split helper). + var thinkMsFinal = thinkMs + if (thinkMsFinal == null && think.isNotEmpty() && thinkStart > 0) { + thinkMsFinal = System.currentTimeMillis() - thinkStart + } + val status = when (terminal) { + is GenEvent.Done -> MsgStatus.OK + is GenEvent.Cancelled -> MsgStatus.CANCELLED + else -> MsgStatus.ERROR + } + // the sidecar persists only on successful turns (null on cancel/failure) + repo.finalizeAssistant(mid, text.toString(), think.toString().ifBlank { null }, + thinkMsFinal, status, metrics?.promptTokens, metrics?.newTokens, + metrics?.decodeTokS, + (terminal as? GenEvent.Done)?.genIds?.takeIf { it.isNotEmpty() } + ?.let { encodeIds(it) }, + ttftMs = metrics?.firstTokenMs?.takeIf { it > 0 }, + prefillTokS = metrics?.prefillTokS?.takeIf { it > 0.05 }, + // report resident pool usage only (cacheable mmap views excluded) + memBytes = metrics?.pool?.resident_bytes?.takeIf { it > 0 }) + val fail = terminal as? GenEvent.Failed + if (fail != null) { + if (allowRetry && fail.status == Status.INVALID_ARG && + fail.diag.contains("prompt tokens=")) { + runGenerate(tid, settings, + HistoryWindow.tighterBudget(budget), allowRetry = false) + } else { + banner.value = "Generation failed[${fail.status}]: ${fail.diag}" + } + } + } + + fun cancel() { runtime.cancelActive() } + + fun regenerate() { + val st = runtime.state.value + if (st !is GenState.Idle || streaming.value != null) return + viewModelScope.launch { + val tid = activeThreadId.value ?: return@launch + val rows = repo.historyWithIds(tid) + val lastAssistant = rows.lastOrNull { it.role == "assistant" } ?: return@launch + repo.deleteFrom(tid, lastAssistant.createdAt) + val settings = settingsStore.flow.first() + runGenerate(tid, settings, HistoryWindow.DEFAULT_BUDGET_CHARS, allowRetry = true) + } + } + + fun deleteMessage(id: String) = viewModelScope.launch { repo.deleteMessage(id) } + + fun editUserMessage(id: String, newText: String) = viewModelScope.launch { + repo.editUserMessage(id, newText) + } + + fun renameThread(id: String, title: String) = + viewModelScope.launch { repo.renameThread(id, title) } + + fun deleteThread(id: String) = viewModelScope.launch { + repo.deleteThread(id) + if (activeThreadId.value == id) newChat() + } +} + +private fun AppSettings.toGenParams() = + GenParams(temperature = temperature, topK = topK, topP = topP, seed = seed, + // reasoning models need more than the 256-token engine default (observed truncation on a "hello" turn) + maxNewTokens = if (maxNewTokens > 0) maxNewTokens else 1024, + // repetition penalty follows the settings panel; first-token greedy is fixed upstream behavior + repetitionPenalty = repetitionPenalty, + firstTokenGreedy = true) diff --git a/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/Composer.kt b/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/Composer.kt new file mode 100644 index 0000000..f6eebc1 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/Composer.kt @@ -0,0 +1,102 @@ +// ui/chat/Composer.kt - rounded input card plus send button, nothing else. +// Temperature/thinking live in Settings; model switching in the top bar. +// IME-safe: Enter always inserts a newline; sending is button-only. +package dev.edge0.runtime.app.ui.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.edge0.runtime.app.ui.theme.LocalFontScale + +@Composable +fun Composer( + value: String, + onValueChange: (String) -> Unit, + busy: Boolean, // streaming/cancelling shows the stop button + enabledSend: Boolean, + onSend: () -> Unit, + onStop: () -> Unit, + modifier: Modifier = Modifier, +) { + Row(modifier.fillMaxWidth().imePadding() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Surface(Modifier.weight(1f), + shape = RoundedCornerShape(26.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), + border = null) { + BasicTextField( + value = value, onValueChange = onValueChange, + textStyle = TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 15.sp * LocalFontScale.current, + lineHeight = 21.sp * LocalFontScale.current), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth() + .defaultMinSize(minHeight = 26.dp) + .heightIn(max = 168.dp) // ~8 lines + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + decorationBox = { inner -> + Box(contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + Text("Ask anything...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 15.sp * LocalFontScale.current) + } + inner() + } + }) + } + Spacer(Modifier.width(0.dp)) + val canSend = busy || enabledSend + val bg = when { + busy -> MaterialTheme.colorScheme.error + canSend -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.surfaceVariant + } + val fg = when { + busy -> Color.White + canSend -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Box(Modifier.size(48.dp).background(bg, CircleShape) + .clickable(enabled = canSend) { if (busy) onStop() else onSend() }, + contentAlignment = Alignment.Center) { + Icon(if (busy) Icons.Filled.Stop else Icons.Filled.ArrowUpward, + contentDescription = if (busy) "Stop" else "Send", + tint = fg, modifier = Modifier.size(22.dp)) + } + } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/ReasoningBlock.kt b/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/ReasoningBlock.kt new file mode 100644 index 0000000..ae6b445 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/ReasoningBlock.kt @@ -0,0 +1,67 @@ +// ui/chat/ReasoningBlock.kt - collapsible reasoning card: shows +// "Thinking for Ns..." while streaming, "Thought for Ns" when done; collapsed by default. +package dev.edge0.runtime.app.ui.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.edge0.runtime.app.ui.theme.LocalFontScale + +@Composable +fun ReasoningBlock(thinking: String, active: Boolean, startedAtMs: Long?, endedAtMs: Long?, + nowMs: Long, modifier: Modifier = Modifier) { + if (thinking.isEmpty() && !active) return + var expanded by remember { mutableStateOf(false) } + val header = if (active) { + "Thinking for %.0fs...".format((nowMs - (startedAtMs ?: nowMs)) / 1000.0) + } else if (startedAtMs == null && endedAtMs == null) { + "Thought" // legacy rows without a timing basis: qualitative label only + } else { + "Thought for %.0fs".format(((endedAtMs ?: nowMs) - (startedAtMs ?: endedAtMs!!)) / 1000.0) + } + Surface( + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ) { + Column { + Row(Modifier.fillMaxWidth().clickable { expanded = !expanded } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null, + modifier = Modifier.padding(end = 6.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text(header, style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp * LocalFontScale.current, + fontStyle = FontStyle.Italic)) + } + AnimatedVisibility(visible = expanded) { + Text(thinking, style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp * LocalFontScale.current), + modifier = Modifier.padding(start = 30.dp, end = 12.dp, bottom = 10.dp)) + } + } + } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/Shimmer.kt b/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/Shimmer.kt new file mode 100644 index 0000000..b14e5fd --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/ui/chat/Shimmer.kt @@ -0,0 +1,36 @@ +// ui/chat/Shimmer.kt - the Working... waiting state: gradient sweep text (opacity pulse variant). +package dev.edge0.runtime.app.ui.chat + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.sp +import androidx.compose.material3.MaterialTheme +import dev.edge0.runtime.app.ui.theme.LocalFontScale + +/** Waiting indicator before the first token (prefilling=true). */ +@Composable +fun WorkingShimmer(label: String = "Working...", modifier: Modifier = Modifier) { + val t = rememberInfiniteTransition(label = "shimmer") + val a by t.animateFloat(0.35f, 0.9f, + infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Reverse), + label = "a") + BasicText( + label, + modifier = modifier.alpha(a), + style = TextStyle( + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp * LocalFontScale.current, + fontStyle = FontStyle.Italic), + ) +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/ui/markdown/MarkdownText.kt b/android/app/src/main/java/dev/edge0/runtime/app/ui/markdown/MarkdownText.kt new file mode 100644 index 0000000..a1d0acc --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/ui/markdown/MarkdownText.kt @@ -0,0 +1,48 @@ +// ui/markdown/MarkdownText.kt - thin rendering adapter. +// Markdown is rendered by mikepenz multiplatform-markdown-renderer-m3 (tables, +// links, strikethrough included). Pinned to 0.27.0: newer releases require +// compileSdk >= 36 / Kotlin >= 2.1, while this project stays on AGP 8.7.3 (35) +// + Kotlin 2.0.21, so 0.27.0 is the last compatible line. Colors come from +// MaterialTheme, keeping the monochrome palette intact. +// +// LaTeX fallback: the renderer recognizes $$..$$ / $..$ math nodes but ships no +// math renderer, so model math output used to render as blank space. mathFallback +// degrades math to code style before parsing - raw LaTeX stays readable in the +// monochrome theme without pulling a typesetting engine into the app. +package dev.edge0.runtime.app.ui.markdown + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.mikepenz.markdown.m3.Markdown +import com.mikepenz.markdown.m3.markdownColor +import com.mikepenz.markdown.m3.markdownTypography + +@Composable +fun MarkdownText(src: String, modifier: Modifier = Modifier) { + Markdown(mathFallback(src), markdownColor(), markdownTypography(), modifier) +} + +/** + * Math degradation: the renderer recognizes math nodes but has no typesetter. + * Fenced code blocks break when the math sits inside a list item (the fence + * gets swallowed by the list paragraph), so every math form becomes inline + * code with its LaTeX backslashes escaped - readable anywhere in the tree. + */ +internal fun mathFallback(src: String): String { + if ('$' !in src && "\\(" !in src && "\\[" !in src) return src + var s = BLOCK_DOLLAR.replace(src) { inlineMath(it.groupValues[1]) } + s = BLOCK_BRACKET.replace(s) { inlineMath(it.groupValues[1]) } + s = INLINE_DOLLAR.replace(s) { inlineMath(it.groupValues[1]) } + s = INLINE_PAREN.replace(s) { inlineMath(it.groupValues[1]) } + return s +} + +private fun inlineMath(body: String): String { + val oneLine = body.trim().replace(Regex("\\s+"), " ").replace("\\", "\\\\") + return if (oneLine.length > 160) oneLine.take(160) else "`$oneLine`" +} + +private val BLOCK_DOLLAR = Regex("""\$\$\s*([\s\S]+?)\s*\$\$""") +private val BLOCK_BRACKET = Regex("""\\\[\s*([\s\S]+?)\s*\\\]""") +private val INLINE_DOLLAR = Regex("""\$([^\s$](?:[^$\n]{0,88}[^\s$])?)\$""") +private val INLINE_PAREN = Regex("""\\\(([^$\n]{1,90}?)\\\)""") diff --git a/android/app/src/main/java/dev/edge0/runtime/app/ui/models/ModelsScreen.kt b/android/app/src/main/java/dev/edge0/runtime/app/ui/models/ModelsScreen.kt new file mode 100644 index 0000000..e27f658 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/ui/models/ModelsScreen.kt @@ -0,0 +1,200 @@ +// ui/models/ModelsScreen.kt - model management: scans files/models, shows +// size/integrity/load state, load|unload buttons (disabled while generating), a disk +// headroom bar (warn under 1.2x requirement) and an import-instructions card. +package dev.edge0.runtime.app.ui.models + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.edge0.runtime.app.Edge0App +import dev.edge0.runtime.app.runtime.GenState +import dev.edge0.runtime.app.runtime.ModelEntry +import dev.edge0.runtime.app.runtime.ModelInventory +import dev.edge0.runtime.app.ui.theme.LocalFontScale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private fun fmtGiB(b: Long): String = "%.2f GiB".format(b / 1073741824.0) + +@Composable +fun ModelsScreen(onBack: () -> Unit) { + val app = LocalContext.current.applicationContext as Edge0App + val container = app.container + val scope = rememberCoroutineScope() + var entries by remember { mutableStateOf>(emptyList()) } + var avail by remember { mutableStateOf(-1L) } + var note by remember { mutableStateOf(null) } + val genState by container.runtime.state.collectAsStateWithLifecycle() + var refreshTick by remember { mutableStateOf(0) } + + LaunchedEffect(refreshTick) { + withContext(Dispatchers.IO) { + entries = ModelInventory.scanRoots(container.modelRoots) + avail = ModelInventory.volumeAvailBytes(app.filesDir) + } + } + + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, "Back") } + Text("Models", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.weight(1f)) + OutlinedButton(onClick = { refreshTick++ }, modifier = Modifier.padding(end = 12.dp)) { + Text("Refresh") + } + } + // disk headroom bar (available vs required x 1.2) + if (avail >= 0) { + val maxReq = entries.filter { it.valid }.maxOfOrNull { it.totalBytes } ?: 0L + val warn = maxReq > 0 && avail < maxReq * 1.2 + Surface(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) { + Column(Modifier.padding(12.dp)) { + Text("Storage: ${if (avail > 0) fmtGiB(avail) else "N/A"} free" + + if (maxReq > 0) " / largest model ${fmtGiB(maxReq)}, ${fmtGiB((maxReq * 1.2).toLong())} recommended" else "", + style = MaterialTheme.typography.bodySmall) + if (warn) { + Text("Free space below 1.2x requirement: loading may fail midway (engine reports out-of-storage)", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } + } + } + } + note?.let { + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + } + if (entries.isEmpty()) { + Text("No models found. Import via adb to:", + modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium) + Surface(Modifier.fillMaxWidth().padding(horizontal = 12.dp), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) { + Text(ModelInventory.importTemplate(app.packageName), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp * LocalFontScale.current), + modifier = Modifier.padding(12.dp)) + } + Text("Tap Refresh (top right) after importing. Note: uninstalling the app wipes its data " + + "(Android policy); re-import assets with the command above if they disappear.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp)) + } + for (e in entries) { + ModelRow(e, + active = container.runtime.activeModelDir == e.dir.absolutePath, + busy = genState is GenState.Streaming || genState is GenState.Cancelling, + loading = genState is GenState.Loading, + onLoad = { + scope.launch { + note = null + try { + container.runtime.ensureLoaded(e.dir.absolutePath) + container.settings.update { s -> s.copy(activeModelDir = e.dir.absolutePath) } + } catch (ex: Exception) { + android.util.Log.e("Edge0Load", "ensureLoaded failed", ex) + note = "Load failed: ${ex.message} (if the app was just updated, re-push assets to the import path)" + } + } + }, + onUnload = { + scope.launch { + note = null + try { container.runtime.unloadActive() } catch (ex: Exception) { + note = "Unload failed: ${ex.message}" + } + } + }) + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +private fun ModelRow(e: ModelEntry, active: Boolean, busy: Boolean, loading: Boolean, + onLoad: () -> Unit, onUnload: () -> Unit) { + Surface(Modifier.fillMaxWidth().padding(horizontal = 12.dp), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surface, + border = androidx.compose.foundation.BorderStroke( + 1.dp, if (active) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.outline)) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(e.name, maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.size(6.dp)) + Text("e0b", style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 4.dp)) + } + Text("${fmtGiB(if (e.dirBytes > 0) e.dirBytes else e.totalBytes)}" + + if (!e.valid) " · incomplete (${e.hint})" else "", + style = MaterialTheme.typography.bodySmall, + color = if (e.valid) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.error) + Text(if (active) "● Loaded" else if (loading) "◌ Loading..." else "○ Not loaded", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp * LocalFontScale.current), + color = if (active) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant) + } + if (!e.valid) { + Icon(Icons.Outlined.Warning, "Broken assets", tint = MaterialTheme.colorScheme.error) + return@Row + } + if (active) { + OutlinedButton(onClick = onUnload, enabled = !busy && !loading) { Text("Unload") } + } else { + Button(onClick = onLoad, enabled = !busy && !loading) { + if (loading) CircularProgressIndicator(Modifier.size(14.dp)) + else Text("Load") + } + } + } + } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/ui/settings/SettingsScreen.kt b/android/app/src/main/java/dev/edge0/runtime/app/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..490b513 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/ui/settings/SettingsScreen.kt @@ -0,0 +1,167 @@ +// ui/settings/SettingsScreen.kt - settings in a single-column card layout: +// appearance (theme / font scale), assistant (system prompt + thinking), sampling +// parameters, about. Writes go through SettingsStore.update. +package dev.edge0.runtime.app.ui.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.edge0.runtime.app.Edge0App +import dev.edge0.runtime.app.data.AppSettings +import dev.edge0.runtime.app.ui.theme.LocalFontScale +import dev.edge0.runtime.app.ui.theme.ThemeMode +import kotlinx.coroutines.launch + +@Composable +fun SettingsScreen(onBack: () -> Unit) { + val app = LocalContext.current.applicationContext as Edge0App + val store = app.container.settings + val s by store.flow.collectAsStateWithLifecycle(initialValue = AppSettings()) + val scope = rememberCoroutineScope() + fun patch(f: (AppSettings) -> AppSettings) = scope.launch { store.update(f) } + + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, "Back") } + Text("Settings", style = MaterialTheme.typography.titleMedium) + } + + Group("Appearance") { + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically) { + Text("Theme", modifier = Modifier.weight(1f)) + SingleChoiceSegmentedButtonRow { + val opts = listOf(ThemeMode.SYSTEM to "System", ThemeMode.LIGHT to "Light", + ThemeMode.DARK to "Dark") + opts.forEachIndexed { i, (mode, label) -> + SegmentedButton( + selected = s.themeMode == mode, + shape = SegmentedButtonDefaults.itemShape(i, opts.size), + onClick = { patch { cur -> cur.copy(themeMode = mode) } }, + ) { Text(label, fontSize = 12.sp) } + } + } + } + Text("Font size ${"%.2f".format(s.fontScale)}", style = MaterialTheme.typography.bodySmall) + Slider(value = s.fontScale, onValueChange = { v -> + scope.launch { store.update { cur -> cur.copy(fontScale = v) } } }, + valueRange = 0.85f..1.3f) + } + + Group("Assistant") { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically) { + Text("Use system prompt", modifier = Modifier.weight(1f)) + Switch(checked = s.useSystemPrompt, + onCheckedChange = { c -> patch { cur -> cur.copy(useSystemPrompt = c) } }) + } + if (s.useSystemPrompt) { + OutlinedTextField(value = s.systemPrompt, + onValueChange = { v -> patch { cur -> cur.copy(systemPrompt = v) } }, + label = { Text("System prompt") }, + minLines = 3, maxLines = 6, modifier = Modifier.fillMaxWidth()) + } + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically) { + Text("Enable thinking", modifier = Modifier.weight(1f)) + Switch(checked = s.enableThinking, + onCheckedChange = { c -> patch { cur -> cur.copy(enableThinking = c) } }) + } + } + + Group("Sampling") { + // defaults mirror the upstream sampling quartet + ParamSlider("repetition penalty (1.0=off)", s.repetitionPenalty, 1f, 1.5f) { v -> + patch { cur -> cur.copy(repetitionPenalty = v) } } + ParamSlider("temperature (0=greedy)", s.temperature, 0f, 2f) { v -> + patch { cur -> cur.copy(temperature = v) } } + ParamSliderInt("top-k (0=off)", s.topK, 0, 100) { v -> + patch { cur -> cur.copy(topK = v) } } + ParamSlider("top-p (>=1=off)", s.topP, 0.1f, 1f) { v -> + patch { cur -> cur.copy(topP = v) } } + ParamSliderInt("max new tokens (0=default 1024)", s.maxNewTokens, 0, 2048) { v -> + patch { cur -> cur.copy(maxNewTokens = v) } } + OutlinedTextField(value = s.seed.toString(), + onValueChange = { v -> v.toLongOrNull()?.let { seed -> + patch { cur -> cur.copy(seed = seed) } } }, + label = { Text("seed") }, singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) + } + + Group("About") { + Text("llama.cpp (pinned build) · " + + "app ${dev.edge0.runtime.app.BuildConfig.VERSION_NAME}" + + "+${dev.edge0.runtime.app.BuildConfig.GIT_SHA}", + style = MaterialTheme.typography.bodySmall) + Text("Runtime and UI ship in a single APK", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.height(24.dp)) + } +} + +@Composable +private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) { + Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) { + Text(title.uppercase(), style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, fontSize = 11.sp * LocalFontScale.current, + color = MaterialTheme.colorScheme.onSurfaceVariant), + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)) + Surface(shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)) { + Column(Modifier.padding(12.dp), content = content) + } + } +} + +@Composable +private fun ParamSlider(label: String, value: Float, min: Float, max: Float, + onValue: (Float) -> Unit) { + Text("$label = ${"%.2f".format(value)}", style = MaterialTheme.typography.bodySmall) + Slider(value = value.coerceIn(min, max), onValueChange = onValue, valueRange = min..max, + modifier = Modifier.fillMaxWidth()) +} + +@Composable +private fun ParamSliderInt(label: String, value: Int, min: Int, max: Int, + onValue: (Int) -> Unit) { + Text("$label = $value", style = MaterialTheme.typography.bodySmall) + Slider(value = value.toFloat().coerceIn(min.toFloat(), max.toFloat()), + onValueChange = { onValue(it.toInt()) }, valueRange = min.toFloat()..max.toFloat(), + modifier = Modifier.fillMaxWidth()) +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/ui/theme/Color.kt b/android/app/src/main/java/dev/edge0/runtime/app/ui/theme/Color.kt new file mode 100644 index 0000000..db7e6d3 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/ui/theme/Color.kt @@ -0,0 +1,41 @@ +// ui/theme/Color.kt - palette converted from the OKLCH design values to sRGB +// (OKLab -> linear sRGB -> gamma, per Björn Ottosson's transform). The original +// oklch() values are kept in paired comments for traceability. The palette is +// grayscale; blue appears only in active states such as download progress. +package dev.edge0.runtime.app.ui.theme + +import androidx.compose.ui.graphics.Color + +// ---- Light (index.css :root)---- +val LightBackground = Color(0xFFFFFFFF) // oklch(1 0 0) +val LightForeground = Color(0xFF0A0A0A) // oklch(0.145 0 0) +val LightPrimary = Color(0xFF171717) // oklch(0.205 0 0) send-button background +val LightOnPrimary = Color(0xFFFAFAFA) // primary foreground, shadcn default white +val LightSecondary = Color(0xFFF5F5F5) // oklch(0.97 0 0) user bubble background +val LightOnSecondary = Color(0xFF0A0A0A) // secondary-foreground +val LightMutedForeground = Color(0xFF737373) // oklch(0.556 0 0) secondary text / action row +val LightBorder = Color(0xFFE5E5E5) // oklch(0.922 0 0) +val LightSidebar = Color(0xFFF2F2F2) // oklch(0.96 0 0) +val LightDestructive = Color(0xFFE7000B) // oklch(0.577 0.245 27.325) + +// ---- Dark (index.css .dark)---- +val DarkBackground = Color(0xFF121212) // oklch(0.18 0 0) +val DarkForeground = Color(0xFFFAFAFA) // oklch(0.985 0 0) +val DarkPrimary = Color(0xFFE5E5E5) // oklch(0.922 0 0) dark primary = light gray, dark glyph on it +val DarkOnPrimary = Color(0xFF171717) +val DarkSecondary = Color(0xFF383838) // oklch(0.34 0 0) +val DarkOnSecondary = Color(0xFFFAFAFA) +val DarkMutedForeground = Color(0xFFB7B7B7) // oklch(0.78 0 0) +val DarkBorder = Color(0x24FFFFFF) // oklch(1 0 0 / 14%) +val DarkSidebar = Color(0xFF1B1B1B) // oklch(0.22 0 0) +val DarkDestructive = Color(0xFFFF6467) // oklch(0.704 0.191 22.216) +val DarkRing = Color(0xFFA4A4A4) // oklch(0.72 0 0) + +// layout constants (design spec to dp) +object Dimens { + const val RadiusCard = 10f // --radius: 0.625rem ≈ 10dp + const val RadiusComposer = 24f // rounded-3xl (composer card) + const val RadiusBubble = 6f // rounded-md (bubble) + const val DrawerWidth = 288f // Sheet 18rem (sidebar.tsx SIDEBAR_WIDTH_MOBILE) + const val ContentMaxWidth = 768f // max-w-3xl message column (auto-centers on tablets) +} diff --git a/android/app/src/main/java/dev/edge0/runtime/app/ui/theme/Theme.kt b/android/app/src/main/java/dev/edge0/runtime/app/ui/theme/Theme.kt new file mode 100644 index 0000000..d8b2b6b --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/app/ui/theme/Theme.kt @@ -0,0 +1,91 @@ +// ui/theme/Theme.kt - three theme modes (light/dark/system) plus a global font scale. +// LocalFontScale is the Compose equivalent of the design system's font-size-base +// multiplier; screens apply it explicitly (Material3 typography baseline is 14sp). +package dev.edge0.runtime.app.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +// whole-app text-sm baseline (system sans fallback; custom font files out of scope for v1) +private val Edge0Typography = Typography( + bodyLarge = TextStyle(fontFamily = FontFamily.Default, fontSize = 14.sp, lineHeight = 20.sp), + bodyMedium = TextStyle(fontFamily = FontFamily.Default, fontSize = 14.sp, lineHeight = 20.sp), + titleLarge = TextStyle(fontFamily = FontFamily.Default, fontSize = 18.sp, + fontWeight = FontWeight.Medium), +) + +private val LightScheme = lightColorScheme( + primary = LightPrimary, + onPrimary = LightOnPrimary, + secondary = LightSecondary, + onSecondary = LightOnSecondary, + background = LightBackground, + onBackground = LightForeground, + surface = LightBackground, + onSurface = LightForeground, + surfaceVariant = LightSecondary, + outline = LightBorder, + error = LightDestructive, +) + +private val DarkScheme = darkColorScheme( + primary = DarkPrimary, + onPrimary = DarkOnPrimary, + secondary = DarkSecondary, + onSecondary = DarkOnSecondary, + background = DarkBackground, + onBackground = DarkForeground, + surface = DarkBackground, + onSurface = DarkForeground, + surfaceVariant = DarkSecondary, + outline = DarkBorder, + error = DarkDestructive, +) + +/** Radius ladder: md = bubble / lg = card (10dp) / 2xl = composer (24dp). */ +private val Edge0Shapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(6.dp), // user bubble + medium = RoundedCornerShape(10.dp), // cards + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(24.dp), // composer +) + +/** Font scale (0.85..1.3, driven by the settings slider, persisted in DataStore). */ +val LocalFontScale = compositionLocalOf { 1.0f } + +/** Theme mode: SYSTEM follows the platform setting. */ +enum class ThemeMode { SYSTEM, LIGHT, DARK } + +@Composable +fun Edge0Theme( + mode: ThemeMode = ThemeMode.SYSTEM, + fontScale: Float = 1.0f, + content: @Composable () -> Unit, +) { + val dark = when (mode) { + ThemeMode.SYSTEM -> isSystemInDarkTheme() + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + } + androidx.compose.runtime.CompositionLocalProvider(LocalFontScale provides fontScale) { + MaterialTheme( + colorScheme = if (dark) DarkScheme else LightScheme, + typography = Edge0Typography, + shapes = Edge0Shapes, + content = content, + ) + } +} diff --git a/android/app/src/main/java/dev/edge0/runtime/engine/Types.kt b/android/app/src/main/java/dev/edge0/runtime/engine/Types.kt new file mode 100644 index 0000000..9c55074 --- /dev/null +++ b/android/app/src/main/java/dev/edge0/runtime/engine/Types.kt @@ -0,0 +1,36 @@ +// Shared data types of the runtime layer. +package dev.edge0.runtime.engine + +enum class Status(val code: Int) { + OK(0), + INSUFFICIENT_STORAGE(1), + FORMAT_VERSION(2), + ASSET_CORRUPT(3), + MEM_BUDGET(4), + IO(5), + CANCELLED(6), + SESSION_EXISTS(7), + INVALID_ARG(8), + NOT_IMPLEMENTED(9), + INTERNAL(100), + ; + companion object { fun of(v: Int): Status = entries.firstOrNull { it.code == v } ?: INTERNAL } +} + +class EngineException(val status: Status, message: String) : Exception("[$status] $message") + +data class GenParams( + val temperature: Float = 0f, // <= 0 selects greedy decoding (deterministic) + val topK: Int = 0, + val topP: Float = 1f, + val seed: Long = 0x20260915L, + val maxNewTokens: Int = 0, + val repetitionPenalty: Float = 0f, // 0 or 1.0 = off; > 0 and != 1 applies penalty + val firstTokenGreedy: Boolean = false, // true = argmax for the first token +) + +/** A chat turn. role is one of system/user/assistant; at most one system turn, first. + * genIds = assistant token-id sidecar: the exact token ids this assistant turn + * stepped through. When present the engine replays the turn from ids (the text + * is for the UI only), keeping the prompt byte-identical across turns. */ +data class ChatMessage(val role: String, val content: String, val genIds: IntArray? = null) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..56cb79d --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Edge0 Chat + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..461b543 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/app/src/test/java/dev/edge0/runtime/app/runtime/HistoryWindowTest.kt b/android/app/src/test/java/dev/edge0/runtime/app/runtime/HistoryWindowTest.kt new file mode 100644 index 0000000..d739be8 --- /dev/null +++ b/android/app/src/test/java/dev/edge0/runtime/app/runtime/HistoryWindowTest.kt @@ -0,0 +1,74 @@ +// HistoryWindowTest - budget trimming, ordering, assistant replay shape. +package dev.edge0.runtime.app.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class HistoryWindowTest { + private val O = ThinkSplitter.OPEN + private val C = ThinkSplitter.CLOSE + + @Test fun tailWithinBudgetKeptInOrder() { + val turns = listOf( + StoredMessage("user", "q1"), StoredMessage("assistant", "a1"), + StoredMessage("user", "q2"), StoredMessage("assistant", "a2"), + StoredMessage("user", "q3"), + ) + val msgs = HistoryWindow.build("sys", turns, budgetChars = 10000) + assertEquals(listOf("system", "user", "assistant", "user", "assistant", "user"), + msgs.map { it.role }) + assertEquals("sys", msgs[0].content) + assertEquals("q3", msgs.last().content) + } + + @Test fun budgetTrimsOldestNotLast() { + val big = "W".repeat(100) + val turns = listOf( + StoredMessage("user", big), StoredMessage("assistant", big), + StoredMessage("user", "lastQ"), + ) + val msgs = HistoryWindow.build(null, turns, budgetChars = 60) + // the last turn always survives; older turns beyond budget are trimmed + assertEquals(1, msgs.size) + assertEquals("lastQ", msgs[0].content) + } + + @Test fun assistantThinkingReplayedIntoEngineTranscript() { + // Revisited under the sidecar regime: thinking MUST enter the context - + // KV prefix reuse requires bit-identical replay of the live tokens. + val turns = listOf( + StoredMessage("user", "q1"), + StoredMessage("assistant", "body", thinking = "reason"), + StoredMessage("user", "q2"), + ) + val msgs = HistoryWindow.build(null, turns) + assertEquals(O + "reason" + C + "body", msgs[1].content) + } + + @Test fun sidecarIdsRideAlongReplay() { + val ids = intArrayOf(5, 42, 17) + val turns = listOf( + StoredMessage("user", "q"), + StoredMessage("assistant", "body", genIds = ids), + StoredMessage("user", "q2"), + ) + val msgs = HistoryWindow.build(null, turns) + assertTrue(msgs[1].genIds!!.contentEquals(ids)) + assertEquals("body", msgs[1].content) // no thinking column -> no tags (ids are authoritative) + } + + @Test fun assistantWithoutThinkingPlainBody() { + val turns = listOf( + StoredMessage("user", "q1"), + StoredMessage("assistant", "plainbody"), + ) + val msgs = HistoryWindow.build(null, turns) + assertEquals("plainbody", msgs[1].content) + } + + @Test fun tighterBudgetFloor() { + assertEquals(400, HistoryWindow.tighterBudget(700)) + assertTrue(HistoryWindow.tighterBudget(300) >= 400) + } +} diff --git a/android/app/src/test/java/dev/edge0/runtime/app/runtime/LlamaRuntimeLogicTest.kt b/android/app/src/test/java/dev/edge0/runtime/app/runtime/LlamaRuntimeLogicTest.kt new file mode 100644 index 0000000..2233415 --- /dev/null +++ b/android/app/src/test/java/dev/edge0/runtime/app/runtime/LlamaRuntimeLogicTest.kt @@ -0,0 +1,58 @@ +// LlamaRuntimeLogicTest - pure-logic unit tests (host JVM, no device). +// Device-facing behavior is covered by the instrumented LlamaRuntimeTest suite. +package dev.edge0.runtime.app.runtime + +import dev.edge0.runtime.engine.ChatMessage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class LlamaRuntimeLogicTest { + + @Test fun metricsParseRoundTrip() { + val rt = LlamaRuntime() + val m = rt.parseMetrics( + "OK gen=64 tps=18.19 prefill_ms=3087 first_ms=182 prompt=17 reused=12") + checkNotNull(m) + assertEquals(64, m.newTokens) + assertEquals(18.19, m.decodeTokS, 1e-6) + assertEquals(3087, m.prefillMs) + assertEquals(17, m.promptTokens) + assertEquals(12, m.prefix_reused_tokens) + assertTrue(m.prefix_reused) + // effective prefill rate = new input tokens (17-12=5) over prefill time + assertEquals(5 * 1000.0 / 3087, m.prefillTokS, 1e-6) + } + + @Test fun metricsParseCancelledAndGarbage() { + val rt = LlamaRuntime() + checkNotNull(rt.parseMetrics("CANCELLED gen=8 tps=2.00 prefill_ms=100 first_ms=10 prompt=5 reused=0")) + assertNull(rt.parseMetrics("ERR template need=-1")) + } + + @Test fun historyWindowSystemFirstAndLastTurnKept() { + val long = "x".repeat(400) + val turns = (1..30).map { i -> + if (i % 2 == 1) StoredMessage("user", "q$i $long") + else StoredMessage("assistant", "a$i $long") + } + StoredMessage("user", "final question " + long) + val out = HistoryWindow.build("You are Edge0.", turns, budgetChars = 1000) + assertEquals("system", out.first().role) // system always prepended + assertEquals("user", out.last().role) // current question survives + assertTrue(out.size < turns.size + 1) // budget clipped history + } + + @Test fun historyWindowDropsBlankAssistantPoisonTurns() { + // regression sentinel (empty-answer incident): blank assistant turns without + // genIds must never reach the model - the model would "see itself" answering empty + val turns = listOf( + StoredMessage("user", "hi"), + StoredMessage("assistant", ""), + StoredMessage("user", "who are you"), + ) + val out = HistoryWindow.build(null, turns) + assertTrue(out.none { it.role == "assistant" && it.content.isBlank() }) + assertEquals(2, out.size) + } +} diff --git a/android/app/src/test/java/dev/edge0/runtime/app/runtime/MetricsMapperTest.kt b/android/app/src/test/java/dev/edge0/runtime/app/runtime/MetricsMapperTest.kt new file mode 100644 index 0000000..1af086d --- /dev/null +++ b/android/app/src/test/java/dev/edge0/runtime/app/runtime/MetricsMapperTest.kt @@ -0,0 +1,63 @@ +// MetricsMapperTest - real-device schema1 sample capture plus tolerance checks. +package dev.edge0.runtime.app.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MetricsMapperTest { + + private fun resource(): String = + javaClass.classLoader!!.getResourceAsStream("metrics_real.json")!! + .readBytes().toString(Charsets.UTF_8) + + @Test fun realDeviceSampleMaps() { + val m = MetricsMapper.parse(resource()) + assertNotNull(m) + m!! + assertEquals(1, m.schema) + assertEquals(23, m.promptTokens) + assertEquals(false, m.prefix_reused) + assertEquals(0, m.prefix_reused_tokens) + assertEquals(8, m.newTokens) + assertEquals(1, m.chatTurns) + assertTrue(m.thinkingOn) + assertEquals(1_366_167_552L, m.peakRssBytes) + assertTrue(m.decodeTokS > 4.0 && m.decodeTokS < 4.4) + assertEquals("predicted", m.route?.mode) + assertEquals("executed", m.route?.feature) + assertNull(m.route?.agree) // JSON null → Kotlin null + assertEquals(635L, m.pool?.hits) + assertEquals(2095L, m.pool?.misses) + assertTrue(m.gpu?.active == true) + assertTrue(m.gpu?.device?.contains("Adreno") == true) + assertEquals("ok", m.gpu?.why) // backend-audit row data source + assertTrue(m.gpu?.cacheHit == true) // kernel cache hit (CLI-domain precedent) + val hr = m.hitRate!! + assertTrue(hr > 0.22 && hr < 0.24) // 635/2730 + } + + @Test fun missingFieldsDefaultNotThrow() { + val m = MetricsMapper.parse("""{"schema": 1}""") + assertNotNull(m) + assertEquals(0, m!!.newTokens) + assertEquals(false, m.prefix_reused) + assertEquals(0, m.prefix_reused_tokens) + assertNull(m.route) + assertNull(m.hitRate) + } + + @Test fun unknownFieldsIgnored() { + val m = MetricsMapper.parse("""{"schema":1,"brand_new_field":{"a":[1,2]},"decode_tok_s":3.0}""") + assertNotNull(m) + assertEquals(3.0, m!!.decodeTokS, 1e-9) + } + + @Test fun corruptInputReturnsNull() { + assertNull(MetricsMapper.parse("not json at all")) + assertNull(MetricsMapper.parse("")) + assertNull(MetricsMapper.parse(null)) + } +} diff --git a/android/app/src/test/java/dev/edge0/runtime/app/runtime/ThinkSplitterTest.kt b/android/app/src/test/java/dev/edge0/runtime/app/runtime/ThinkSplitterTest.kt new file mode 100644 index 0000000..e62b77e --- /dev/null +++ b/android/app/src/test/java/dev/edge0/runtime/app/runtime/ThinkSplitterTest.kt @@ -0,0 +1,110 @@ +// ThinkSplitterTest - incremental tag-pair splitting table (cross-chunk tag cases). +// Tag literals are always assembled from constants (tooling strips special-looking +// literals; the rule applies to tests too). +// Delivery semantics: same-kind segments merge within one feed; across feeds the +// UI aggregates. +package dev.edge0.runtime.app.runtime + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ThinkSplitterTest { + private val O = ThinkSplitter.OPEN + private val C = ThinkSplitter.CLOSE + + private fun run(pieces: List): List { + val sp = ThinkSplitter() + val out = ArrayList() + for (p in pieces) out.addAll(sp.feed(p)) + out.addAll(sp.flush()) + return foldAdjacent(out) + } + + /** Fold adjacent same-kind segments (normalize the cross-feed freedom). */ + private fun foldAdjacent(segs: List): List { + val out = ArrayList() + for (s in segs) { + val last = out.lastOrNull() + if (last != null && last::class == s::class) { + out[out.size - 1] = when (s) { + is Seg.Text -> Seg.Text((last as Seg.Text).s + s.s) + is Seg.Think -> Seg.Think((last as Seg.Think).s + s.s) + } + } else out.add(s) + } + return out + } + + @Test fun promptInjectedOpenStartsInThink() { + // real-device scenario: the OPEN tag lives in the prompt, the stream starts inside thinking + val sp = ThinkSplitter(initialThink = true) + val out = ArrayList() + out.addAll(sp.feed("plan text")) + out.addAll(sp.feed("$C" + "answer")) + out.addAll(sp.flush()) + assertEquals(listOf(Seg.Think("plan text"), Seg.Text("answer")), foldAdjacent(out)) + } + + @Test fun wholePairInOneFeed() { + assertEquals(listOf(Seg.Think("abc"), Seg.Text("def")), + run(listOf("$O" + "abc$C" + "def"))) + } + + @Test fun openTagSplitAcrossPieces() { + // open tag split across three chunks + close tag across two (folded result must be whole) + assertEquals(listOf(Seg.Text("hi "), Seg.Think("thinkingbody"), Seg.Text("tail")), + run(listOf("hi <", "th", "ink>thinking", "bodytail"))) + } + + @Test fun plainTextNoTags() { + assertEquals(listOf(Seg.Text("purebody.")), run(listOf("pure", "body."))) + } + + @Test fun fakePrefixNotTag() { + // body containing a fake " stays body + assertEquals(listOf(Seg.Text("a (Seg.Think(" again $O same again"), Seg.Text("post")), + run(listOf("$O again $O same again$C" + "post"))) + } + + @Test fun unclosedToEosIsAllThink() { + assertEquals(listOf(Seg.Think("never closed")), run(listOf("$O" + "never closed"))) + } + + @Test fun emptyPairEmitsNoThinkSegment() { + assertEquals(listOf(Seg.Text("x")), run(listOf("$O$C" + "x"))) + } + + @Test fun trailingFakeOpenFlushesAsText() { + // EOS with a half-tag carried: flush delivers under the current mode (TEXT) + assertEquals(listOf(Seg.Text("before(Seg.Think("a"), Seg.Text("b"), Seg.Think("c"), Seg.Text("d")), + run(listOf("$O" + "a$C" + "b$O" + "c$C" + "d"))) + } + + @Test fun charByCharFeedMatchesOneShot() { + val sp1 = ThinkSplitter() + val src = "$O" + "multi step" + "thinking$C" + "reply $O" + "again$C" + "end" + val oneShot = foldAdjacent(sp1.feed(src) + sp1.flush()) + val sp2 = ThinkSplitter() + val per = ArrayList() + for (ch in src) { per.addAll(sp2.feed(ch.toString())) } + per.addAll(sp2.flush()) + assertEquals(oneShot, foldAdjacent(per)) + } + + @Test fun thinkingNowTracksMode() { + val sp = ThinkSplitter() + sp.feed("$O" + "x") + assertEquals(true, sp.thinkingNow) + sp.feed("y$C" + "z") + assertEquals(false, sp.thinkingNow) + } +} diff --git a/android/app/src/test/java/dev/edge0/runtime/app/ui/markdown/MathFallbackTest.kt b/android/app/src/test/java/dev/edge0/runtime/app/ui/markdown/MathFallbackTest.kt new file mode 100644 index 0000000..735a61a --- /dev/null +++ b/android/app/src/test/java/dev/edge0/runtime/app/ui/markdown/MathFallbackTest.kt @@ -0,0 +1,49 @@ +// MathFallbackTest - LaTeX degradation preprocessing for the markdown adapter. +// Guards the blank-math-render incident: $$..$$ math nodes were accepted by the +// renderer but drawn as empty space; mathFallback makes them visible as inline +// code (fenced blocks break inside list items, which is where models put math). +package dev.edge0.runtime.app.ui.markdown + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MathFallbackTest { + private val D = "\$" // a dollar sign without triggering string interpolation + + @Test fun blockDollarBecomesInlineCode() { + val src = "1. Divide 8 by 3:\n\n" + D + D + "\\frac{8}{3}" + D + D + "\n\ndone" + val out = mathFallback(src) + assertTrue("math content visible", "frac" in out) + assertTrue("math delimiters gone", D !in out) + assertTrue("no fence noise", "```" !in out) + } + + @Test fun inlineDollarBecomesCode() { + val out = mathFallback("where " + D + "x^2 + y" + D + " holds") + assertTrue("backtick-wrapped", out.contains("`x^2 + y`")) + } + + @Test fun moneyTextUntouched() { + val src = "costs " + D + "5 and " + D + "6 total" + assertEquals(src, mathFallback(src)) + } + + @Test fun plainTextPassesThrough() { + assertEquals("no math here at all", mathFallback("no math here at all")) + } + + @Test fun parenAndBracketForms() { + assertTrue("`a+b`" in mathFallback("do \\(a+b\\) now")) + val br = mathFallback("\\[ \\frac{1}{2} \\]") + assertTrue("bracket body visible", "frac" in br) + assertTrue("delimiters gone", "\\[" !in br) + } + + @Test fun multilineBlockCollapsesToOneLine() { + val src = D + D + "\n\\sum_{i=1}^{n} i\n" + D + D + val out = mathFallback(src) + assertTrue("body present", "sum_{i=1}^{n} i" in out) + assertTrue("single line", !out.trim().contains("\n")) + } +} diff --git a/android/app/src/test/resources/metrics_real.json b/android/app/src/test/resources/metrics_real.json new file mode 100644 index 0000000..0e7ffdb --- /dev/null +++ b/android/app/src/test/resources/metrics_real.json @@ -0,0 +1 @@ +{"cancelled": false, "chat_turns": 1, "config": {"compute_threads": 1, "io_threads": 4, "max_context_tokens": 4096, "pool_budget_bytes": 100663296, "scrub_percent": 0}, "decode_ms": 1908, "decode_tok_s": 4.1928721174004195, "error": "", "first_token_ms": 2725, "generate_seq": 1, "gpu": {"active": true, "cache_hit": true, "cus": 12, "device": "QUALCOMM Adreno(TM) 830", "driver": "OpenCL 3.0 QUALCOMM build: 0800.72 Compiler E031.47.18.49", "mem_mib": 7576, "ms": 1415.627034187317, "n_submit": 7944, "n_sync": 207, "ok": true, "policy": 0, "probed": true, "why": "ok"}, "int8_effective": false, "layer_track": 0, "new_tokens": 8, "params": {"max_new_tokens": 8, "seed": 539363605, "temperature": 0.0, "top_k": 0, "top_p": 1.0}, "peak_rss_bytes": 1366167552, "pool": {"claim_ns": 0, "claim_ns_per_tok": 0.0, "evictions": 4029, "flash_bytes": 5429182464, "hits": 635, "io_failures": 0, "lookup_ns": 0, "lookup_ns_per_tok": 0.0, "misses": 2095, "prefetch_dropped": 0, "prefetch_issued": 4216, "prefetch_wasted": 2220, "resident_bytes": 100564992, "slots_total": 62, "slots_used": 62, "stall_ns": 637740209, "sync_rescues": 0}, "prefill_cold_s": 8.443465491923641, "prefill_ms": 2724, "prefill_tok_s": 8.443465491923641, "prefill_warm_s": 0.0, "prompt_tokens": 23, "route": {"agree": null, "dec_steps": 8, "feature": "executed", "mode": "predicted", "pred_used": 128}, "schema": 1, "thinking_on": true} \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..97a679c --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + // compose compiler plugin version must equal Kotlin (2.0.21); Room via KSP; metrics via kotlinx-serialization + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.kotlinx.serialization) apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..d703336 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +org.gradle.caching=true +org.gradle.configuration-cache=true +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..07b528d --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,47 @@ +[versions] +agp = "8.7.3" +kotlin = "2.0.21" +# aligned with the target platform constraints in the docs +minSdk = "33" +compileSdk = "35" +ndk = "27.2.12479018" +# pinned, verified combination; compose compiler plugin version must equal Kotlin +composeBom = "2024.12.01" +activityCompose = "1.9.3" +navigationCompose = "2.8.5" +lifecycle = "2.8.7" +room = "2.7.0" +ksp = "2.0.21-1.0.28" +datastore = "1.1.1" +coroutines = "1.9.0" +serializationJson = "1.7.3" +markdownRenderer = "0.27.0" # last release binary-compatible with Kotlin 2.0.21 + +[libraries] +# Compose (versions governed by the BOM) +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" } +markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" } + +[plugins] +android-library = { id = "com.android.library", version.ref = "agp" } +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..d95bf61 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100755 index 0000000..640d686 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..408eaee --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "edge0-runtime-android" +include(":app") // single APK: native engine + Compose UI diff --git a/android/tools/llama/build_vendor_libs.sh b/android/tools/llama/build_vendor_libs.sh new file mode 100644 index 0000000..49a9006 --- /dev/null +++ b/android/tools/llama/build_vendor_libs.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# build_vendor_libs.sh - rebuild the four shared libraries from the pinned supply tree. +# Supply topology (patches/llama.cpp/README in the depot repo): +# vendor@pin (vendor.llama.pin at the depot root; materialized by clone on first run - +# no submodule) -> wt/and worktree with common(6)+android(14) bands replayed in order +# -> golden tree hash. Set EDGE0_LLAMA_URL to clone from a mirror instead of GitHub. +# Usage: bash tools/llama/build_vendor_libs.sh [--replay] (--replay = re-apply bands + verify) +set -euo pipefail +REPO=$(cd "$(dirname "$0")/../.." && pwd) +DEPOT=${EDGE0_DEPOT:-$(cd "$REPO/.." && pwd -W)} # depot root (this repo lives at /android) +WT=$DEPOT/wt/and +[ -f "$DEPOT/vendor.llama.pin" ] || { echo "ABORT: vendor.llama.pin missing at depot root ($DEPOT)"; exit 1; } +PIN=$(cut -d' ' -f1 "$DEPOT/vendor.llama.pin") +VENDOR=$DEPOT/vendor/llama.cpp +GOLD_TREE=e974be50ba5c184bf0ba9a4a26adbbae4235c2ab +NDK=${NDK_DIR:-${ANDROID_NDK_HOME:-}} +[ -n "$NDK" ] || { echo "ABORT: set NDK_DIR (or ANDROID_NDK_HOME) to the Android NDK r28 path"; exit 1; } +LL=$REPO/build-dl/llama-libs + +# materialize the pinned vendor tree when absent (submodule-free supply) +if [ ! -d "$VENDOR/.git" ]; then + echo "[0/3] materializing vendor: clone llama.cpp at pinned $PIN -> $VENDOR" + mkdir -p "$DEPOT/vendor" + git clone --filter=blob:none "${EDGE0_LLAMA_URL:-https://github.com/ggml-org/llama.cpp}" "$VENDOR" +fi +[ "$(git -C "$VENDOR" rev-parse HEAD)" = "$PIN" ] || git -C "$VENDOR" checkout --detach "$PIN" +[ -z "$(git -C "$VENDOR" status --porcelain)" ] || { echo "ABORT: vendor tree dirty - the pristine supply is never patched in place; restore with: git -C $VENDOR checkout --detach $PIN"; exit 1; } + +# self-heal: a fresh clone has no consumer worktree yet - create it at the pin, +# and treat the bare tree as needing the band replay (otherwise the tree gate +# would compare an unpatched pin against the golden patched tree -> false RED) +FRESH_WT=0 +if [ ! -d "$WT" ]; then + git -C "$VENDOR" worktree add --detach "$WT" "$PIN" + FRESH_WT=1 +fi + +if [ "${1:-}" = "--replay" ] || [ "$FRESH_WT" = "1" ]; then + git -C "$WT" am --abort 2>/dev/null || true + git -C "$WT" reset --hard "$PIN" + for band in common android; do + for f in $(ls "$DEPOT/patches/llama.cpp/$band/"0*.patch | sort); do + git -C "$WT" am --3way "$f" + done + done +fi +T=$(git -C "$WT" rev-parse 'HEAD^{tree}') +[ "$T" = "$GOLD_TREE" ] || { echo "ABORT: tree mismatch got=$T want=$GOLD_TREE"; exit 1; } +echo "tree GREEN $T" + +cmake -S "$WT" -B "$WT/build-android-cpu" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-28 -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=ON -DLLAMA_BUILD_COMMON=ON -DGGML_OPENCL=OFF -DGGML_NATIVE=OFF +ninja -C "$WT/build-android-cpu" llama ggml ggml-base ggml-cpu + +mkdir -p "$LL/arm64-v8a" "$LL/include" +cp "$WT"/build-android-cpu/bin/{libllama.so,libggml.so,libggml-base.so,libggml-cpu.so} "$LL/arm64-v8a/" +# libggml-cpu DT_NEEDED libomp.so (NDK OpenMP runtime) - ship it alongside, +# otherwise the app fails dlopen at launch on a fresh build. +cp "$NDK"/toolchains/llvm/prebuilt/*/lib/clang/*/lib/linux/aarch64/libomp.so "$LL/arm64-v8a/" +cp "$WT"/include/*.h "$WT"/ggml/include/*.h "$LL/include/" +echo "OK: four libs from wt/and @$(git -C "$WT" rev-parse --short HEAD)" diff --git a/android/tools/model/push_models.sh b/android/tools/model/push_models.sh new file mode 100644 index 0000000..53258a2 --- /dev/null +++ b/android/tools/model/push_models.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# push_models.sh - stage the three GGUF artifacts onto the device files/models/. +# Usage: bash tools/model/push_models.sh --8b | --35b | --all +# Default sources are this repo's models/edge0-{8b,35b}-gguf/ (converter output). +# SRC_DIR= overrides; ADB_SERIAL selects the device (default: first online). +# Triple-hop discipline: host md5 -> adb push -> run-as copy -> on-device md5. +# Any 8-hex mismatch aborts. The 21.7GB artifact takes 2-6 min; reruns are idempotent. +set -euo pipefail +REPO=$(cd "$(dirname "$0")/../.." && pwd) +# Self-contained layout: sources and converted artifacts live under models/ (gitignored). +# The converter (../windows/tools/convert_mlx_to_gguf.py --dir models/edge0-{tier}) +# writes models/edge0-{tier}-gguf/; names pass through unchanged. +# SRC_DIR= overrides with a single flat directory. +src_for() { # $1 = artifact name -> host source path (convention dirs unless SRC_DIR set) + local dir + case "$1" in edge0-8b.gguf|lora_edge0_8b-gguf.gguf) dir=${SRC:-$REPO/models/edge0-8b-gguf} ;; + *) dir=${SRC:-$REPO/models/edge0-35b-gguf} ;; + esac + echo "$dir/$1" +} +DEV=${ADB_SERIAL:-} +PKG=dev.edge0.runtime.app +T=/data/local/tmp/lgguf + +# artifact -> official md5 prefix baseline (converter convention names, 2026-09) +md5_of() { case "$1" in + edge0-8b.gguf) echo b5f6021f ;; + lora_edge0_8b-gguf.gguf) echo e73c92e3 ;; + edge0-35b.gguf) echo 7d2c2e8c ;; + *) echo "?" ;; esac; } + +FILES_8B=(edge0-8b.gguf lora_edge0_8b-gguf.gguf) +FILES_35B=(edge0-35b.gguf) +case "${1:-}" in + --8b) FILES=("${FILES_8B[@]}") ;; + --35b) FILES=("${FILES_35B[@]}") ;; + --all) FILES=("${FILES_8B[@]}" "${FILES_35B[@]}") ;; + *) echo "usage: push_models.sh --8b|--35b|--all" >&2; exit 2 ;; +esac + +ADB=(adb ${DEV:+-s "$DEV"}) +"${ADB[@]}" get-state >/dev/null || { echo "ABORT: no device ($DEV)" >&2; exit 1; } +"${ADB[@]}" shell "mkdir -p $T" +for f in "${FILES[@]}"; do + want=$(md5_of "$f"); src="$(src_for "$f")" + [ -f "$src" ] || { echo "ABORT: missing $src (download + run converter, or place artifact)"; exit 1; } + got=$(md5sum "$src" | cut -c1-8) + [ "$got" = "$want" ] || { echo "ABORT: $f host md5=$got want=$want"; exit 1; } + echo "push $f ($(du -h "$src" | cut -f1)) ..." + MSYS_NO_PATHCONV=1 "${ADB[@]}" push "$(cygpath -w "$src" 2>/dev/null || echo "$src")" "$T/$f" >/dev/null + # copy into the app-private dir via run-as (non-root path) + on-device verification + "${ADB[@]}" shell "run-as $PKG mkdir -p files/models; run-as $PKG cp $T/$f files/models/$f" + dev=$(MSYS_NO_PATHCONV=1 "${ADB[@]}" shell "run-as $PKG md5sum files/models/$f" | tr -d '\r' | cut -c1-8) + [ "$dev" = "$want" ] || { echo "ABORT: $f device md5=$dev want=$want (truncated transfer? re-run)"; exit 1; } + echo "OK $f md5=$dev" +done +echo "staged -> $PKG/files/models" diff --git a/docs/adding-a-model.md b/docs/adding-a-model.md index 6af69b4..be304bb 100644 --- a/docs/adding-a-model.md +++ b/docs/adding-a-model.md @@ -42,7 +42,7 @@ The subclass only needs to implement the class method `_defaults(model_dir) -> C Key points: -- The repository root ships `scripts/convert_adapters_legacy.py`, which one-shot converts the training npz exports into safetensors artifacts under `artifacts/`; `ModelConfig.artifact(name)` (`models/base.py:28–30`) returns the absolute paths of those artifacts, and the adapter uses them to fill in the LoRA / prerouter weight paths. +- `ModelConfig.artifact(name)` (`models/base.py`) resolves an adapter's absolute path — from the model directory first, falling back to the gitignored `artifacts/` cache — and the adapter uses it to fill in the LoRA / prerouter weight paths. - LoRA overrides go through `resolve_lora` (`models/base.py:88–95`): a bare model name resolves to that tier's artifacts, `"model_dir"` means "keep the training weights in place", and an empty string disables it. ## Step 3: `build_model` / `build_engine` @@ -105,7 +105,7 @@ The lookup order of `_resolve_name` (registry.py:42–56): explicit `name` → t - `config.json` exists, and its `model_type` is registered (or covered by your `TYPE_ALIASES`); - the weights are in safetensors format, with key prefixes matching `moe_spec.key_template` (e.g. `language_model.model.layers.N.mlp.switch_mlp`); -- the LoRA / prerouter weights are `.safetensors` files carrying metadata (see the comment at the top of `models/base.py`: they are converted one-shot from the training npz by `convert_adapters_legacy.py`). +- the LoRA / prerouter weights are `.safetensors` files carrying metadata (see the comment at the top of `models/base.py`). The directory basename is the last-resort resolution fallback (`_model_type_from_dir`), so aligning the directory name with the registered name is recommended, but not required. diff --git a/docs/architecture.md b/docs/architecture.md index 3a07c42..c5adc22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,12 +19,10 @@ box: `edge0-35b` (Qwen3.6-35B-A3B, K=4) and `edge0-8b` (Ling 3.0 hybrid, K=8). future CUDA backend (`backends/cuda/`, selected via the `EDGE0_BACKEND` environment variable); 3. **Adapters unified on safetensors**: LoRA and prerouter weights are both - `.safetensors` files carrying metadata; legacy npz training exports are - converted by a one-off migration script and then deprecated; + `.safetensors` files carrying metadata; 4. **Unified terminology**: the pre-routing head is always called the - **prerouter** — zero legacy-term residue in code and docs. The legacy npz - exports are migrated by `scripts/convert_adapters_legacy.py`, which - normalizes their key namespace to `layers...weight`. + **prerouter** — zero legacy-term residue in code and docs. Prerouter + weight keys are normalized to `layers...weight`. ## Layered Structure diff --git a/docs/models/edge0-35b.md b/docs/models/edge0-35b.md index 99d611b..e6c12af 100644 --- a/docs/models/edge0-35b.md +++ b/docs/models/edge0-35b.md @@ -22,7 +22,7 @@ The performance profile is based on benchmarks of the current release adapter ve | Prefill chunk | 2048 | | Hot window | 4 | | Streaming prefetch history | on (`prefetch_history=True`) | -| Serving port | 8085 | +| Serving port | 8000 | | Measured throughput | 14.9–17.7 tok/s (M4 Pro) | | Measured peak activation memory | ≈ 2.9 GiB | @@ -42,7 +42,7 @@ This tier uses the `LayerOptions.staged_k4()` preset: ### Serving via the CLI ```bash -edge0 serve /path/to/checkpoint --host 127.0.0.1 --port 8085 +edge0 serve /path/to/checkpoint --host 127.0.0.1 --port 8000 ``` Optional arguments: @@ -99,7 +99,7 @@ cfg = AutoConfig.from_pretrained("/path/to/checkpoint", name="edge0-35b") ### Non-streaming chat ```bash -curl -s http://127.0.0.1:8085/v1/chat/completions \ +curl -s http://127.0.0.1:8000/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{ "model": "edge0-35b", @@ -131,7 +131,7 @@ Optional request fields: `model`, `messages` (with `role`/`content`; content sup ### Streaming chat (requires Flask) ```bash -curl -N http://127.0.0.1:8085/v1/chat/completions \ +curl -N http://127.0.0.1:8000/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{ "model": "edge0-35b", @@ -152,7 +152,7 @@ from edge0 import AutoEngine engine = AutoEngine.from_pretrained( "/path/to/checkpoint", name="edge0-35b", - port=9090, # override the default port 8085 + port=9090, # override the default port 8000 target_tok_s=14.0, # override the acceptance throughput target prerouter=None, # disable the prerouter lora="", # disable LoRA diff --git a/docs/models/edge0-8b.md b/docs/models/edge0-8b.md index 55c2db0..a71943f 100644 --- a/docs/models/edge0-8b.md +++ b/docs/models/edge0-8b.md @@ -22,7 +22,7 @@ The performance profile is based on benchmarks of the current release adapter ve | Prefill chunk | 2048 | | Hot window | 1 | | Streaming prefetch history | on (`prefetch_history=True`) | -| Serving port | 8083 | +| Serving port | 8000 | | Measured throughput | 23.9–25.3 tok/s (M4 Pro) | | Measured peak activation memory | ≈ 1.0 GiB | @@ -41,7 +41,7 @@ This tier uses the `LayerOptions.prod_k8()` preset (aligned with the reference d ### Serving via the CLI ```bash -edge0 serve /path/to/checkpoint --host 127.0.0.1 --port 8083 +edge0 serve /path/to/checkpoint --host 127.0.0.1 --port 8000 ``` Optional arguments: @@ -99,7 +99,7 @@ cfg = AutoConfig.from_pretrained("/path/to/checkpoint", name="edge0-8b") ### Non-streaming chat ```bash -curl -s http://127.0.0.1:8083/v1/chat/completions \ +curl -s http://127.0.0.1:8000/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{ "model": "edge0-8b", @@ -131,7 +131,7 @@ Optional request fields: `model`, `messages` (with `role`/`content`; content sup ### Streaming chat (requires Flask) ```bash -curl -N http://127.0.0.1:8083/v1/chat/completions \ +curl -N http://127.0.0.1:8000/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{ "model": "edge0-8b", @@ -152,7 +152,7 @@ from edge0 import AutoEngine engine = AutoEngine.from_pretrained( "/path/to/checkpoint", name="edge0-8b", - port=9083, # override the default port 8083 + port=9083, # override the default port 8000 target_tok_s=35.0, # override the acceptance throughput target prerouter=None, # disable the prerouter lora="", # disable LoRA diff --git a/docs/prerouter.md b/docs/prerouter.md index 460a1da..8744fa4 100644 --- a/docs/prerouter.md +++ b/docs/prerouter.md @@ -73,7 +73,7 @@ per `start_layer`/`owners`. Family differences are expressed through three hooks | weight file | `prerouter_edge0_35b.safetensors` | `prerouter_edge0_8b.safetensors` | Both weight files are resolved from the model directory by default, with -`artifacts/` (repo root, gitignored) as the fallback — see the README's +`artifacts/` (Python project root, gitignored) as the fallback — see the README's "Models and adapters" section. ## Tests and regression diff --git a/ios/Config/App.xcconfig b/ios/Config/App.xcconfig new file mode 100644 index 0000000..234f81e --- /dev/null +++ b/ios/Config/App.xcconfig @@ -0,0 +1 @@ +#include? "Local.xcconfig" diff --git a/ios/Config/Local.xcconfig.example b/ios/Config/Local.xcconfig.example new file mode 100644 index 0000000..80c6e5d --- /dev/null +++ b/ios/Config/Local.xcconfig.example @@ -0,0 +1,3 @@ +// Copy this file to Config/Local.xcconfig and set your Apple Developer Team ID. +// Config/Local.xcconfig is excluded by .gitignore. +DEVELOPMENT_TEAM = diff --git a/ios/Edge0PhoneProbe.xcodeproj/project.pbxproj b/ios/Edge0PhoneProbe.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4bdcaac --- /dev/null +++ b/ios/Edge0PhoneProbe.xcodeproj/project.pbxproj @@ -0,0 +1,259 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 71; + objects = { + +/* Begin PBXBuildFile section */ + A00000000000000000000001 /* Edge0PhoneProbeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000011 /* Edge0PhoneProbeApp.swift */; }; + A00000000000000000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000012 /* ContentView.swift */; }; + A00000000000000000000004 /* Edge0Core in Frameworks */ = {isa = PBXBuildFile; productRef = A00000000000000000000071 /* Edge0Core */; }; + A00000000000000000000005 /* Edge0MLX in Frameworks */ = {isa = PBXBuildFile; productRef = A00000000000000000000072 /* Edge0MLX */; }; + A00000000000000000000006 /* Models in Resources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000014 /* Models */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A00000000000000000000003 /* Edge0Phone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Edge0Phone.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A00000000000000000000011 /* Edge0PhoneProbeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Edge0PhoneProbeApp.swift; sourceTree = ""; }; + A00000000000000000000012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A00000000000000000000013 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A00000000000000000000014 /* Models */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Models; sourceTree = ""; }; + A00000000000000000000015 /* App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = App.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A00000000000000000000021 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A00000000000000000000004 /* Edge0Core in Frameworks */, + A00000000000000000000005 /* Edge0MLX in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A00000000000000000000022 = { + isa = PBXGroup; + children = ( + A00000000000000000000033 /* Config */, + A00000000000000000000014 /* Models */, + A00000000000000000000031 /* Edge0PhoneProbe */, + A00000000000000000000032 /* Products */, + ); + sourceTree = ""; + }; + A00000000000000000000031 /* Edge0PhoneProbe */ = { + isa = PBXGroup; + children = ( + A00000000000000000000011 /* Edge0PhoneProbeApp.swift */, + A00000000000000000000012 /* ContentView.swift */, + A00000000000000000000013 /* Info.plist */, + ); + path = Edge0PhoneProbe; + sourceTree = ""; + }; + A00000000000000000000032 /* Products */ = { + isa = PBXGroup; + children = ( + A00000000000000000000003 /* Edge0Phone.app */, + ); + name = Products; + sourceTree = ""; + }; + A00000000000000000000033 /* Config */ = { + isa = PBXGroup; + children = ( + A00000000000000000000015 /* App.xcconfig */, + ); + path = Config; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A00000000000000000000041 /* Edge0Phone */ = { + isa = PBXNativeTarget; + buildConfigurationList = A00000000000000000000051 /* Build configuration list for PBXNativeTarget "Edge0Phone" */; + buildPhases = ( + A00000000000000000000061 /* Sources */, + A00000000000000000000021 /* Frameworks */, + A00000000000000000000062 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Edge0Phone; + packageProductDependencies = ( + A00000000000000000000071 /* Edge0Core */, + A00000000000000000000072 /* Edge0MLX */, + ); + productName = Edge0Phone; + productReference = A00000000000000000000003 /* Edge0Phone.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A00000000000000000000042 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2610; + LastUpgradeCheck = 2610; + TargetAttributes = { + A00000000000000000000041 = { + CreatedOnToolsVersion = 26.6; + }; + }; + }; + buildConfigurationList = A00000000000000000000052 /* Build configuration list for PBXProject "Edge0PhoneProbe" */; + compatibilityVersion = "Xcode 16.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A00000000000000000000022; + packageReferences = ( + A00000000000000000000081 /* XCLocalSwiftPackageReference "." */, + ); + productRefGroup = A00000000000000000000032 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A00000000000000000000041 /* Edge0Phone */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A00000000000000000000062 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A00000000000000000000006 /* Models in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A00000000000000000000061 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A00000000000000000000001 /* Edge0PhoneProbeApp.swift in Sources */, + A00000000000000000000002 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A00000000000000000000091 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A00000000000000000000015 /* App.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Edge0PhoneProbe/Edge0PhoneProbe.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = X756QY2CTK; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Edge0PhoneProbe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.edge0phone; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + A00000000000000000000092 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A00000000000000000000015 /* App.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Edge0PhoneProbe/Edge0PhoneProbe.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = X756QY2CTK; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Edge0PhoneProbe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.edge0phone; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + A00000000000000000000093 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + }; + name = Debug; + }; + A00000000000000000000094 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A00000000000000000000051 /* Build configuration list for PBXNativeTarget "Edge0Phone" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A00000000000000000000091 /* Debug */, + A00000000000000000000092 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A00000000000000000000052 /* Build configuration list for PBXProject "Edge0PhoneProbe" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A00000000000000000000093 /* Debug */, + A00000000000000000000094 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + A00000000000000000000081 /* XCLocalSwiftPackageReference "." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = .; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A00000000000000000000071 /* Edge0Core */ = { + isa = XCSwiftPackageProductDependency; + package = A00000000000000000000081 /* XCLocalSwiftPackageReference "." */; + productName = Edge0Core; + }; + A00000000000000000000072 /* Edge0MLX */ = { + isa = XCSwiftPackageProductDependency; + package = A00000000000000000000081 /* XCLocalSwiftPackageReference "." */; + productName = Edge0MLX; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = A00000000000000000000042 /* Project object */; +} diff --git a/ios/Edge0PhoneProbe.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Edge0PhoneProbe.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Edge0PhoneProbe.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Edge0PhoneProbe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Edge0PhoneProbe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..e1dd047 --- /dev/null +++ b/ios/Edge0PhoneProbe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "bb8f886c2113b757a4f1caf03c3e9422df8f501d7a701d5dfdb178ee6043c4eb", + "pins" : [ + { + "identity" : "mlx-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift", + "state" : { + "revision" : "0bb916c67f4b9e5c682cbe02a42c701c93ab5021", + "version" : "0.31.6" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + } + ], + "version" : 3 +} diff --git a/ios/Edge0PhoneProbe.xcodeproj/xcshareddata/xcschemes/Edge0Phone.xcscheme b/ios/Edge0PhoneProbe.xcodeproj/xcshareddata/xcschemes/Edge0Phone.xcscheme new file mode 100644 index 0000000..0136b32 --- /dev/null +++ b/ios/Edge0PhoneProbe.xcodeproj/xcshareddata/xcschemes/Edge0Phone.xcscheme @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Edge0PhoneProbe/ContentView.swift b/ios/Edge0PhoneProbe/ContentView.swift new file mode 100644 index 0000000..4e33ad3 --- /dev/null +++ b/ios/Edge0PhoneProbe/ContentView.swift @@ -0,0 +1,552 @@ +import Edge0MLX +import Darwin +import Foundation +import SwiftUI + +private struct ChatMessage: Identifiable, Sendable { + enum Role: Sendable { case user, assistant } + let id: UUID + let role: Role + var text: String + var detail: String? + + init(id: UUID = UUID(), role: Role, text: String, detail: String?) { + self.id = id; self.role = role; self.text = text; self.detail = detail + } +} + +private enum LocalModel: String, CaseIterable, Identifiable, Sendable { + case edge8 + case edge35b + + var id: String { rawValue } + var title: String { + switch self { + case .edge8: "Edge0 8B" + case .edge35b: "Edge0 35B" + } + } + var folderName: String { + self == .edge8 ? "Edge0-8B-A1B-preview" : Edge0ChatEngine35B.modelFolderName + } +} + +private actor ChatRuntime { + private enum LoadedEngine { + case edge8(Edge0ChatEngine) + case edge35b(Edge0ChatEngine35B) + } + private var engine: LoadedEngine? + + func load(_ model: LocalModel, modelURL: URL) throws { + // Do not keep both models alive while switching; 35B is memory constrained. + engine = nil + switch model { + case .edge8: + engine = .edge8(try Edge0ChatEngine(modelURL: modelURL)) + case .edge35b: + engine = .edge35b(try Edge0ChatEngine35B(modelURL: modelURL)) + } + } + + func reply(to text: String, + maxTokens: Int? = nil, + thinking: Bool, + onText: @escaping @Sendable (String) -> Void) async throws -> Edge0GenerationResult { + let shouldContinue = { @Sendable in + !withUnsafeCurrentTask { $0?.isCancelled ?? false } + } + switch engine { + case .edge8(let engine): + if let maxTokens { + return try engine.reply( + to: text, maxTokens: maxTokens, thinking: thinking, + onText: onText, shouldContinue: shouldContinue) + } + return try engine.reply( + to: text, thinking: thinking, onText: onText, shouldContinue: shouldContinue) + case .edge35b(let engine): + if let maxTokens { + return try await engine.reply( + to: text, maxTokens: maxTokens, thinking: thinking, + onText: onText, shouldContinue: shouldContinue) + } + return try await engine.reply( + to: text, thinking: thinking, onText: onText, shouldContinue: shouldContinue) + case nil: + throw RuntimeFailure.modelNotLoaded + } + } + + func reset() { + switch engine { + case .edge8(let engine): engine.reset() + case .edge35b(let engine): engine.reset() + case nil: break + } + } + + func unload() { engine = nil } + + private enum RuntimeFailure: LocalizedError { + case modelNotLoaded + var errorDescription: String? { "Model is not loaded" } + } +} + +@MainActor +private final class ChatViewModel: ObservableObject { + enum Phase: Equatable { case choosing, ready, preparing, generating, failed(String) } + + @Published var input = "" + @Published private(set) var messages: [ChatMessage] = [] + @Published private(set) var phase: Phase = .choosing + @Published private(set) var selectedModel: LocalModel? + + private let runtime = ChatRuntime() + private var generationTask: Task? + + var isRunning: Bool { phase == .preparing || phase == .generating } + /// Off unless the process is launched with `--thinking`. `--no-thinking` forces it off. + private let thinkingEnabled: Bool = { + let args = CommandLine.arguments + if args.contains("--no-thinking") { return false } + return args.contains("--thinking") + }() + var statusText: String? { + switch phase { + case .choosing: nil + case .ready: nil + case .preparing: "Loading \(selectedModel?.title ?? "local model")…" + case .generating: "\(selectedModel?.title ?? "Model") is generating…" + case .failed(let message): message + } + } + + func isInstalled(_ model: LocalModel) -> Bool { + let url = resolveModelURL(model) + switch model { + case .edge8: + return FileManager.default.fileExists( + atPath: url.appendingPathComponent("model.safetensors").path) + case .edge35b: + return FileManager.default.fileExists( + atPath: url.appendingPathComponent("model.safetensors.index.json").path) + && FileManager.default.fileExists( + atPath: url.appendingPathComponent("tokenizer.bin").path) + && FileManager.default.fileExists( + atPath: url.appendingPathComponent("experts-L39.bin").path) + } + } + + func choose(_ model: LocalModel) { + guard !isRunning else { return } + selectedModel = model + messages.removeAll() + input = "" + phase = .preparing + let url = resolveModelURL(model) + Task { + do { + try await runtime.load(model, modelURL: url) + if phase == .preparing { phase = .ready } + } catch { + phase = .failed(Self.friendlyMessage(for: error)) + } + } + } + + func chooseAnotherModel() { + guard !isRunning else { return } + messages.removeAll() + input = "" + selectedModel = nil + phase = .choosing + Task { await runtime.unload() } + } + + func useSuggestion(_ text: String, maxTokens: Int? = nil) { + input = text + send(maxTokens: maxTokens) + } + + func send(maxTokens: Int? = nil) { + let text = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isRunning else { return } + input = "" + messages.append(ChatMessage(role: .user, text: text, detail: nil)) + let assistantID = UUID() + messages.append(ChatMessage(id: assistantID, role: .assistant, + text: "", detail: nil)) + phase = .generating + generationTask = Task { + do { + let result = try await runtime.reply( + to: text, + maxTokens: maxTokens, + thinking: thinkingEnabled, + onText: { [weak self] partial in + Task { @MainActor in + guard let self, + let index = self.messages.firstIndex(where: { $0.id == assistantID }) + else { return } + self.messages[index].text = partial + } + }) + let detail = String( + format: "%d tokens · TTFT %.1fs · prefill %.1f tok/s · decode %.2f tok/s · peak %.0f MB", + result.generatedTokenCount, result.timeToFirstTokenSeconds, + result.prefillTokensPerSecond, + result.decodeTokensPerSecond, + Double(result.peakMemoryBytes) / 1_048_576 + ) + if let index = messages.firstIndex(where: { $0.id == assistantID }) { + messages[index].text = result.text.isEmpty ? "(The model returned an empty response)" : result.text + messages[index].detail = detail + } + print("[generation] \(detail)") + if CommandLine.arguments.contains("--bench-second"), + CommandLine.arguments.contains("--bench-35b") { + print("[bench] second turn") + let second = try await runtime.reply( + to: "Summarize it in one sentence.", + maxTokens: 64, + thinking: thinkingEnabled, + onText: { _ in }) + let secondDetail = String( + format: "%d tokens · TTFT %.1fs · prefill %.1f tok/s · decode %.2f tok/s · peak %.0f MB", + second.generatedTokenCount, second.timeToFirstTokenSeconds, + second.prefillTokensPerSecond, + second.decodeTokensPerSecond, + Double(second.peakMemoryBytes) / 1_048_576 + ) + print("[generation turn2] \(secondDetail)") + } + if CommandLine.arguments.contains("--bench-8b") + || CommandLine.arguments.contains("--bench-35b") { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t( + MemoryLayout.stride / MemoryLayout.stride) + let status = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + print("[bench memory] endFootprintMiB=\(status == KERN_SUCCESS ? Double(info.phys_footprint) / 1_048_576 : -1) thermal=\(ProcessInfo.processInfo.thermalState.rawValue)") + print("[bench timing] ttftSeconds=\(result.timeToFirstTokenSeconds) prefillSeconds=\(result.prefillSeconds) prefillTokPerSec=\(result.prefillTokensPerSecond) decodeTokPerSec=\(result.decodeTokensPerSecond)") + } + phase = .ready + if CommandLine.arguments.contains("--bench-8b") + || CommandLine.arguments.contains("--bench-35b") + || CommandLine.arguments.contains("--smoke-35b") { + fflush(nil) + exit(0) + } + } catch is CancellationError { + phase = .ready + } catch { + phase = .failed(Self.friendlyMessage(for: error)) + if CommandLine.arguments.contains("--bench-8b") + || CommandLine.arguments.contains("--bench-35b") + || CommandLine.arguments.contains("--smoke-35b") { + fflush(stdout) + exit(1) + } + } + generationTask = nil + } + } + + func stop() { generationTask?.cancel() } + + func newConversation() { + guard !isRunning, selectedModel != nil else { return } + messages.removeAll() + input = "" + phase = .ready + Task { await runtime.reset() } + } + + private func resolveModelURL(_ model: LocalModel) -> URL { + let models = Bundle.main.resourceURL? + .appendingPathComponent("Models", isDirectory: true) + ?? Bundle.main.bundleURL.appendingPathComponent("Models", isDirectory: true) + return models.appendingPathComponent(model.folderName, isDirectory: true) + } + + private static func friendlyMessage(for error: Error) -> String { + let description = error.localizedDescription + if description.contains("doesn’t exist") || description.contains("No such file") + || description.contains("incomplete") { + return "Model not included in this build" + } + return "Run failed: \(description)" + } +} + +struct ContentView: View { + @StateObject private var chat = ChatViewModel() + @FocusState private var inputFocused: Bool + + private let suggestions = [ + "What is artificial intelligence?", + "How do LLMs understand context?", + "Tell me about renewable energy", + "Write a short poem about autumn", + ] + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + if chat.selectedModel == nil { + modelPicker + } else { + VStack(spacing: 0) { + header + conversation + } + } + } + .preferredColorScheme(.dark) + .safeAreaInset(edge: .bottom, spacing: 0) { + if chat.selectedModel != nil { composer } + } + .task { + // Keeps the normal launch picker intact while allowing a repeatable + // on-device smoke/benchmark run from `devicectl`. + let smoke = CommandLine.arguments.contains("--smoke-35b") + let bench8 = CommandLine.arguments.contains("--bench-8b") + let bench35 = CommandLine.arguments.contains("--bench-35b") + let benchmark = bench8 || bench35 + let target: LocalModel = + bench8 || CommandLine.arguments.contains("--model=8b") ? .edge8 : .edge35b + if chat.selectedModel == nil, + (CommandLine.arguments.contains("--model=35b") + || CommandLine.arguments.contains("--model=8b") + || smoke || benchmark), + chat.isInstalled(target) { + print("[bench] loading \(target.title)") + print("[bench] thermalAtLaunch=\(ProcessInfo.processInfo.thermalState.rawValue)") + chat.choose(target) + if smoke || benchmark { + while chat.phase == .preparing { + try? await Task.sleep(for: .milliseconds(100)) + } + if case .failed(let message) = chat.phase { + print("[bench] load failed: \(message)") + } else if chat.phase == .ready { + let long = CommandLine.arguments.contains("--bench-long") + let sentence = "Streaming inference overlaps disk bandwidth with compute. Each mixture-of-experts layer activates only a few experts." + let prompt = smoke + ? "Reply with this only: 35B is running" + : (long + ? Array(repeating: sentence, count: 80).joined() + : "What is artificial intelligence?") + chat.useSuggestion(prompt, maxTokens: long ? 8 : (benchmark ? 64 : nil)) + } + } + } + } + } + + private var modelPicker: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + Spacer(minLength: 54) + Text("Choose a local model") + .font(.system(size: 34, weight: .bold, design: .rounded)) + + ForEach(LocalModel.allCases) { model in + let installed = chat.isInstalled(model) + Button { chat.choose(model) } label: { + HStack(spacing: 16) { + Image(systemName: model == .edge8 ? "bolt.fill" : "brain.head.profile") + .font(.system(size: 23, weight: .semibold)) + .frame(width: 48, height: 48) + .background(.white.opacity(0.09), in: RoundedRectangle(cornerRadius: 14)) + VStack(alignment: .leading, spacing: 5) { + Text(model.title).font(.headline) + if !installed { + Text("Not included") + .font(.caption2) + .foregroundStyle(.orange) + } + } + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + .padding(18) + .background(.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 22)) + } + .buttonStyle(.plain) + .disabled(!installed) + .opacity(installed ? 1 : 0.62) + } + + Spacer(minLength: 40) + } + .padding(.horizontal, 22) + .frame(maxWidth: 620) + .frame(maxWidth: .infinity) + } + } + + private var header: some View { + HStack(spacing: 10) { + Text(chat.selectedModel?.title ?? "Edge0") + .font(.headline.weight(.semibold)) + Spacer() + Button(action: chat.chooseAnotherModel) { + Image(systemName: "arrow.left.arrow.right") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 38, height: 38) + .background(.white.opacity(0.07), in: Circle()) + } + .foregroundStyle(.white) + .disabled(chat.isRunning) + .accessibilityLabel("Switch model") + Button(action: chat.newConversation) { + Image(systemName: "square.and.pencil") + .font(.system(size: 17, weight: .semibold)) + .frame(width: 38, height: 38) + .background(.white.opacity(0.07), in: Circle()) + } + .foregroundStyle(.white) + .disabled(chat.isRunning) + .accessibilityLabel("New chat") + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 10) + } + + @ViewBuilder + private var conversation: some View { + if chat.messages.isEmpty { + welcome + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 18) { + ForEach(chat.messages) { message in + messageBubble(message).id(message.id) + } + if let status = chat.statusText { statusRow(status).id("status") } + } + .padding(.horizontal, 18) + .padding(.top, 16) + .padding(.bottom, 24) + } + .scrollDismissesKeyboard(.interactively) + .onChange(of: chat.messages.count) { + withAnimation { proxy.scrollTo(chat.messages.last?.id, anchor: .bottom) } + } + .onChange(of: chat.phase) { + withAnimation { proxy.scrollTo("status", anchor: .bottom) } + } + } + } + } + + private var welcome: some View { + ScrollView { + VStack(spacing: 18) { + Spacer(minLength: 90) + Text("edge0") + .font(.system(size: 58, weight: .medium, design: .rounded)) + .tracking(-3) + Text("Private AI, running locally on your iPhone") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + VStack(spacing: 10) { + ForEach(suggestions, id: \.self) { suggestion in + Button(suggestion) { chat.useSuggestion(suggestion) } + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white.opacity(0.88)) + .padding(.horizontal, 16) + .padding(.vertical, 11) + .background(.white.opacity(0.07), in: Capsule()) + } + } + .padding(.top, 8) + if let status = chat.statusText { statusRow(status) } + Spacer(minLength: 120) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 24) + } + } + + private func messageBubble(_ message: ChatMessage) -> some View { + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 6) { + Text(message.text) + .font(.body) + .foregroundStyle(.white.opacity(0.94)) + .textSelection(.enabled) + .padding(.horizontal, 15) + .padding(.vertical, 12) + .background( + message.role == .user ? Color.white.opacity(0.14) : Color.white.opacity(0.07), + in: RoundedRectangle(cornerRadius: 19, style: .continuous) + ) + if let detail = message.detail { + Text(detail) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + } + } + .frame(maxWidth: .infinity, alignment: message.role == .user ? .trailing : .leading) + } + + private func statusRow(_ text: String) -> some View { + HStack(spacing: 10) { + if chat.isRunning { ProgressView().controlSize(.small) } + Text(text) + .font(.footnote) + .foregroundStyle(chat.isRunning ? Color.secondary : Color.red.opacity(0.9)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + } + + private var composer: some View { + HStack(alignment: .bottom, spacing: 10) { + TextField("Ask anything…", text: $chat.input, axis: .vertical) + .lineLimit(1...5) + .focused($inputFocused) + .submitLabel(.send) + .onSubmit { chat.send() } + .padding(.horizontal, 16) + .padding(.vertical, 13) + .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + + Button { + if chat.isRunning, chat.phase == .generating { + chat.stop() + } else { + chat.send() + } + inputFocused = false + } label: { + Image(systemName: chat.phase == .generating ? "stop.fill" : "arrow.up") + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(.black) + .frame(width: 45, height: 45) + .background(.white, in: Circle()) + } + .disabled(chat.phase == .preparing || + chat.phase == .choosing || + (chat.phase != .generating && + chat.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) + .opacity(chat.phase == .preparing ? 0.5 : 1) + .accessibilityLabel(chat.phase == .generating ? "Stop" : "Send") + } + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 8) + .background(.ultraThinMaterial) + } +} diff --git a/ios/Edge0PhoneProbe/Edge0PhoneProbe.entitlements b/ios/Edge0PhoneProbe/Edge0PhoneProbe.entitlements new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/ios/Edge0PhoneProbe/Edge0PhoneProbe.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/ios/Edge0PhoneProbe/Edge0PhoneProbeApp.swift b/ios/Edge0PhoneProbe/Edge0PhoneProbeApp.swift new file mode 100644 index 0000000..0423c3d --- /dev/null +++ b/ios/Edge0PhoneProbe/Edge0PhoneProbeApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct Edge0PhoneProbeApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/ios/Edge0PhoneProbe/Info.plist b/ios/Edge0PhoneProbe/Info.plist new file mode 100644 index 0000000..91ac1d1 --- /dev/null +++ b/ios/Edge0PhoneProbe/Info.plist @@ -0,0 +1,35 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIFileSharingEnabled + + UILaunchScreen + + + diff --git a/ios/Models/.gitkeep b/ios/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ios/Package.resolved b/ios/Package.resolved new file mode 100644 index 0000000..66a2fa4 --- /dev/null +++ b/ios/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "14c443ca8e8c4734ac842efe15d1c40af0856e85f2a4579f1ff7b75f93b2ccc7", + "pins" : [ + { + "identity" : "mlx-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift", + "state" : { + "revision" : "0bb916c67f4b9e5c682cbe02a42c701c93ab5021", + "version" : "0.31.6" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + } + ], + "version" : 3 +} diff --git a/ios/Package.swift b/ios/Package.swift new file mode 100644 index 0000000..4672abb --- /dev/null +++ b/ios/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 6.1 +import PackageDescription + +let package = Package( + name: "Edge0Phone", + platforms: [ + .macOS(.v14), + .iOS(.v17), + ], + products: [ + .library(name: "Edge0Core", targets: ["Edge0Core"]), + .library(name: "Edge0MLX", targets: ["Edge0MLX"]), + ], + dependencies: [ + .package( + url: "https://github.com/ml-explore/mlx-swift", + .upToNextMinor(from: "0.31.4") + ), + ], + targets: [ + .target(name: "Edge0Core"), + .target( + name: "Edge0MLX", + dependencies: [ + "Edge0Core", + .product(name: "MLX", package: "mlx-swift"), + .product(name: "MLXFast", package: "mlx-swift"), + ] + ), + .testTarget(name: "Edge0MLXTests", dependencies: ["Edge0MLX", "Edge0Core"], resources: [.copy("Fixtures")]), + .testTarget(name: "Edge0CoreTests", dependencies: ["Edge0Core"]), + ] +) diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..1d3d24e --- /dev/null +++ b/ios/README.md @@ -0,0 +1,111 @@ +# Edge0Phone + +English | [中文](README_zh.md) | [日本語](README_ja.md) | [Español](README_es.md) | [Français](README_fr.md) + +On-device chat for iPhone. The app can run Edge0 8B, Edge0 35B, or both, and only the model you select is loaded. Model weights are not in this folder. + +## Quick Start + +You need an Apple Silicon Mac, Xcode, and an iPhone on iOS 17 or later with Developer Mode on. Python 3.10 or later is only needed to download weights and to convert 35B. If Xcode reports a missing Metal compiler, run `xcodebuild -downloadComponent MetalToolchain`. + +The `Models/` directory is copied into the app at build time. Put a model there before you build if you want that button to be selectable. An empty `Models/` directory still builds; the missing model shows **Not included**. + +### Download a model + +```sh +python3 -m venv .venv-tools +.venv-tools/bin/python -m pip install --upgrade pip huggingface_hub numpy +``` + +8B can be packed as downloaded. It is about 5 GB, so the installed app is about that size. + +```sh +.venv-tools/bin/hf download Edge0/Edge0-8B-A1B-preview \ + --local-dir Models/Edge0-8B-A1B-preview +``` + +35B is a layout change, not a change to the weights. The published checkpoint stores each expert as nine scattered reads across a 19 GB file. On an iPhone that is too slow, so the tools below rewrite each expert into one sequential read and write one file per layer. Keep the original download outside `Models/`. Only `Models/repacked/` is what the app should contain. A 35B build is about 20 GB. + +```sh +mkdir -p checkpoints +.venv-tools/bin/hf download Edge0/Edge0-35B-A3B-preview \ + --local-dir checkpoints/Edge0-35B-A3B-preview + +SOURCE=checkpoints/Edge0-35B-A3B-preview +OUTPUT=Models/repacked + +.venv-tools/bin/python tools/repack_experts.py pack "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/repack_experts.py resident "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/repack_experts.py verify "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/convert_tokenizer.py "$SOURCE" "$OUTPUT/tokenizer.bin" +.venv-tools/bin/python tools/convert_pregate.py \ + "$SOURCE/prerouter_edge0_35b.safetensors" \ + "$OUTPUT/prerouter-stacked.safetensors" +cp "$SOURCE/lora_edge0_35b.safetensors" "$OUTPUT/" +``` + +`Models/repacked/` must contain `resident.safetensors`, `tokenizer.bin`, and `experts-L00.bin` through `experts-L39.bin`. You can pack either model, or both. + +Checkpoints: + +- [Edge0/Edge0-8B-A1B-preview](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) +- [Edge0/Edge0-35B-A3B-preview](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) + +### Build and install + +`com.example.edge0phone` in the project is a placeholder bundle identifier. Before building or signing, replace it with a unique bundle identifier that you control. In Xcode, open `Edge0PhoneProbe.xcodeproj`, select the **Edge0Phone** target, then change **Signing & Capabilities → Bundle Identifier**. Choose your Apple Developer Team, connect the iPhone, and Run. + +To set the team from a file instead, copy `Config/Local.xcconfig.example` to `Config/Local.xcconfig` and fill in `DEVELOPMENT_TEAM`. + +The same build from the command line: + +```sh +DEVICE_ID= +TEAM_ID= +BUNDLE_ID=com.yourname.edge0phone # replace with your own unique bundle identifier + +xcodebuild -project Edge0PhoneProbe.xcodeproj \ + -scheme Edge0Phone \ + -destination "id=$DEVICE_ID" \ + -derivedDataPath .build-device \ + -allowProvisioningUpdates \ + -skipPackagePluginValidation \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" \ + build + +xcrun devicectl device install app \ + --device "$DEVICE_ID" \ + .build-device/Build/Products/Debug-iphoneos/Edge0Phone.app +``` + +The weights are inside that `.app`. There is no separate copy onto the phone. + +### Test + +`scripts/test.sh` builds the MLX Metal library with Xcode, then runs the unit tests. The first run resolves the MLX Swift package. + +```sh +scripts/test.sh +EDGE0_TEST_GPU=1 scripts/test.sh +``` + +### Use the app + +Launch the app and choose **Edge0 8B** or **Edge0 35B**. A model that was not in `Models/` when you built shows **Not included** and cannot be selected. Type a question, or tap one of the suggestions. The line under a reply shows the token count, time to first token, prefill, decode, and peak memory. Switching models unloads the one that is loaded. New chat clears the conversation. + +## Performance + +| Model | Device | OS | Prefill | TTFT | Decode | +| --- | --- | --- | --- | --- | --- | +| 8B | iPhone 16 Pro | iOS 26.6.2 | 7.2 tok/s | 3.6 s | 10.9 tok/s | +| 35B | iPhone 16 Pro | iOS 26.6.2 | 4.9 tok/s | 2.1 s | 6.4 tok/s | + +## Technical Details + +The app is SwiftUI. `Edge0MLX` runs the model. `Edge0Core` reads the checkpoint, tokenizer, and routing index. Math goes through MLX Swift on Metal. The deployment target is iOS 17. + +Edge0 8B is 24 layers, alternating Kimi Delta Attention and multi-head latent attention, Top-8, INT4. Prompt prefill runs a whole layer at a time, in chunks of up to 2048 tokens. The first token is greedy. Later tokens use temperature 0.7, top-k 64, top-p 0.95, and a repetition penalty of 1.1. + +Edge0 35B is 40 layers, 256 experts per layer, Top-4. Resident weights and the per-layer expert files are read separately. The prefix cache is stored in Application Support, because the app bundle is read-only. The two models are never loaded at the same time. diff --git a/ios/README_es.md b/ios/README_es.md new file mode 100644 index 0000000..3eb3ed8 --- /dev/null +++ b/ios/README_es.md @@ -0,0 +1,111 @@ +# Edge0Phone + +[English](README.md) | [中文](README_zh.md) | [日本語](README_ja.md) | Español | [Français](README_fr.md) + +Chat en el dispositivo para iPhone. La aplicación puede ejecutar Edge0 8B, Edge0 35B o ambos, y solo se carga el modelo que selecciones. Los pesos del modelo no están en esta carpeta. + +## Inicio rápido + +Necesitas un Mac con Apple Silicon, Xcode y un iPhone con iOS 17 o posterior y el modo de desarrollador activado. Python 3.10 o posterior solo se necesita para descargar los pesos y para convertir el 35B. Si Xcode indica que falta el compilador de Metal, ejecuta `xcodebuild -downloadComponent MetalToolchain`. + +El directorio `Models/` se copia dentro de la aplicación al compilar. Coloca ahí un modelo antes de compilar si quieres que ese botón sea seleccionable. Un directorio `Models/` vacío compila igualmente; el modelo ausente muestra **No incluido**. + +### Descargar un modelo + +```sh +python3 -m venv .venv-tools +.venv-tools/bin/python -m pip install --upgrade pip huggingface_hub numpy +``` + +El 8B se puede empaquetar tal como se descarga. Ocupa unos 5 GB, así que la aplicación instalada tiene aproximadamente ese tamaño. + +```sh +.venv-tools/bin/hf download Edge0/Edge0-8B-A1B-preview \ + --local-dir Models/Edge0-8B-A1B-preview +``` + +El 35B es un cambio de disposición (layout), no un cambio en los pesos. El checkpoint publicado almacena cada experto como nueve lecturas dispersas a lo largo de un archivo de 19 GB. En un iPhone eso es demasiado lento, así que las herramientas de abajo reescriben cada experto en una sola lectura secuencial y escriben un archivo por capa. Conserva la descarga original fuera de `Models/`. Solo `Models/repacked/` es lo que debe contener la aplicación. Una compilación del 35B ocupa unos 20 GB. + +```sh +mkdir -p checkpoints +.venv-tools/bin/hf download Edge0/Edge0-35B-A3B-preview \ + --local-dir checkpoints/Edge0-35B-A3B-preview + +SOURCE=checkpoints/Edge0-35B-A3B-preview +OUTPUT=Models/repacked + +.venv-tools/bin/python tools/repack_experts.py pack "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/repack_experts.py resident "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/repack_experts.py verify "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/convert_tokenizer.py "$SOURCE" "$OUTPUT/tokenizer.bin" +.venv-tools/bin/python tools/convert_pregate.py \ + "$SOURCE/prerouter_edge0_35b.safetensors" \ + "$OUTPUT/prerouter-stacked.safetensors" +cp "$SOURCE/lora_edge0_35b.safetensors" "$OUTPUT/" +``` + +`Models/repacked/` debe contener `resident.safetensors`, `tokenizer.bin` y desde `experts-L00.bin` hasta `experts-L39.bin`. Puedes empaquetar cualquiera de los dos modelos, o ambos. + +Checkpoints: + +- [Edge0/Edge0-8B-A1B-preview](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) +- [Edge0/Edge0-35B-A3B-preview](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) + +### Compilación e instalación + +`com.example.edge0phone` en el proyecto es un identificador de bundle de ejemplo. Antes de compilar o firmar, reemplázalo por un identificador de bundle único que controles. En Xcode, abre `Edge0PhoneProbe.xcodeproj`, selecciona el target **Edge0Phone** y cambia **Signing & Capabilities → Bundle Identifier**. Elige tu Apple Developer Team, conecta el iPhone y pulsa Run. + +Para configurar el equipo desde un archivo, copia `Config/Local.xcconfig.example` a `Config/Local.xcconfig` y completa `DEVELOPMENT_TEAM`. + +La misma compilación desde la línea de comandos: + +```sh +DEVICE_ID= +TEAM_ID= +BUNDLE_ID=com.yourname.edge0phone # replace with your own unique bundle identifier + +xcodebuild -project Edge0PhoneProbe.xcodeproj \ + -scheme Edge0Phone \ + -destination "id=$DEVICE_ID" \ + -derivedDataPath .build-device \ + -allowProvisioningUpdates \ + -skipPackagePluginValidation \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" \ + build + +xcrun devicectl device install app \ + --device "$DEVICE_ID" \ + .build-device/Build/Products/Debug-iphoneos/Edge0Phone.app +``` + +Los pesos van dentro de ese `.app`. No hay una copia independiente al teléfono. + +### Pruebas + +`scripts/test.sh` compila la biblioteca MLX Metal con Xcode y después ejecuta las pruebas unitarias. La primera ejecución resuelve el paquete MLX Swift. + +```sh +scripts/test.sh +EDGE0_TEST_GPU=1 scripts/test.sh +``` + +### Uso de la aplicación + +Abre la aplicación y elige **Edge0 8B** o **Edge0 35B**. Un modelo que no estaba en `Models/` al compilar muestra **No incluido** y no se puede seleccionar. Escribe una pregunta o toca una de las sugerencias. La línea bajo una respuesta muestra el recuento de tokens, el tiempo hasta el primer token, prefill, decode y memoria pico. Al cambiar de modelo se libera el que estaba cargado. Nuevo chat borra la conversación. + +## Rendimiento + +| Modelo | Dispositivo | SO | Prefill | TTFT | Decode | +| --- | --- | --- | --- | --- | --- | +| 8B | iPhone 16 Pro | iOS 26.6.2 | 7.2 tok/s | 3.6 s | 10.9 tok/s | +| 35B | iPhone 16 Pro | iOS 26.6.2 | 4.9 tok/s | 2.1 s | 6.4 tok/s | + +## Detalles técnicos + +La aplicación es SwiftUI. `Edge0MLX` ejecuta el modelo. `Edge0Core` lee el checkpoint, el tokenizer y el índice de enrutamiento. Las fórmulas matemáticas pasan por MLX Swift sobre Metal. El objetivo de despliegue es iOS 17. + +Edge0 8B tiene 24 capas, alternando Kimi Delta Attention y atención latente multicabeza, Top-8, INT4. El prefill del prompt ejecuta una capa completa cada vez, en fragmentos de hasta 2048 tokens. El primer token es greedy. Los tokens siguientes usan temperatura 0.7, top-k 64, top-p 0.95 y una penalización de repetición de 1.1. + +Edge0 35B tiene 40 capas, 256 expertos por capa, Top-4. Los pesos residentes y los archivos de expertos por capa se leen por separado. La caché de prefijos se guarda en Application Support, porque el bundle de la aplicación es de solo lectura. Los dos modelos nunca se cargan al mismo tiempo. diff --git a/ios/README_fr.md b/ios/README_fr.md new file mode 100644 index 0000000..e93c38c --- /dev/null +++ b/ios/README_fr.md @@ -0,0 +1,111 @@ +# Edge0Phone + +[English](README.md) | [中文](README_zh.md) | [日本語](README_ja.md) | [Español](README_es.md) | Français + +Chat embarqué pour iPhone. L'application peut exécuter Edge0 8B, Edge0 35B, ou les deux, et seul le modèle que vous sélectionnez est chargé. Les poids des modèles ne sont pas dans ce dossier. + +## Démarrage rapide + +Vous avez besoin d'un Mac Apple Silicon, de Xcode et d'un iPhone sous iOS 17 ou ultérieur avec le mode développeur activé. Python 3.10 ou ultérieur n'est nécessaire que pour télécharger les poids et pour convertir le 35B. Si Xcode signale un compilateur Metal manquant, exécutez `xcodebuild -downloadComponent MetalToolchain`. + +Le répertoire `Models/` est copié dans l'application au moment de la compilation. Placez-y un modèle avant de compiler si vous voulez que ce bouton soit sélectionnable. Un répertoire `Models/` vide compile quand même ; le modèle manquant affiche **Not included**. + +### Télécharger un modèle + +```sh +python3 -m venv .venv-tools +.venv-tools/bin/python -m pip install --upgrade pip huggingface_hub numpy +``` + +Le 8B peut être empaqueté tel que téléchargé. Il pèse environ 5 GB, l'application installée a donc à peu près cette taille. + +```sh +.venv-tools/bin/hf download Edge0/Edge0-8B-A1B-preview \ + --local-dir Models/Edge0-8B-A1B-preview +``` + +Le 35B est un changement de disposition, pas une modification des poids. Le checkpoint publié stocke chaque expert sous forme de neuf lectures dispersées dans un fichier de 19 GB. Sur un iPhone, c'est trop lent, donc les outils ci-dessous réécrivent chaque expert en une seule lecture séquentielle et écrivent un fichier par couche. Conservez le téléchargement d'origine hors de `Models/`. Seul `Models/repacked/` doit contenir ce que l'application doit embarquer. Une compilation 35B pèse environ 20 GB. + +```sh +mkdir -p checkpoints +.venv-tools/bin/hf download Edge0/Edge0-35B-A3B-preview \ + --local-dir checkpoints/Edge0-35B-A3B-preview + +SOURCE=checkpoints/Edge0-35B-A3B-preview +OUTPUT=Models/repacked + +.venv-tools/bin/python tools/repack_experts.py pack "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/repack_experts.py resident "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/repack_experts.py verify "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/convert_tokenizer.py "$SOURCE" "$OUTPUT/tokenizer.bin" +.venv-tools/bin/python tools/convert_pregate.py \ + "$SOURCE/prerouter_edge0_35b.safetensors" \ + "$OUTPUT/prerouter-stacked.safetensors" +cp "$SOURCE/lora_edge0_35b.safetensors" "$OUTPUT/" +``` + +`Models/repacked/` doit contenir `resident.safetensors`, `tokenizer.bin`, ainsi que `experts-L00.bin` à `experts-L39.bin`. Vous pouvez empaqueter l'un ou l'autre modèle, ou les deux. + +Checkpoints : + +- [Edge0/Edge0-8B-A1B-preview](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) +- [Edge0/Edge0-35B-A3B-preview](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) + +### Compilation et installation + +`com.example.edge0phone` dans le projet est un identifiant de bundle fictif. Avant de compiler ou de signer, remplacez-le par un identifiant de bundle unique que vous contrôlez. Dans Xcode, ouvrez `Edge0PhoneProbe.xcodeproj`, sélectionnez la cible **Edge0Phone**, puis modifiez **Signing & Capabilities → Bundle Identifier**. Choisissez votre équipe Apple Developer, connectez l'iPhone et lancez (Run). + +Pour définir l'équipe depuis un fichier, copiez `Config/Local.xcconfig.example` vers `Config/Local.xcconfig` et renseignez `DEVELOPMENT_TEAM`. + +La même compilation depuis la ligne de commande : + +```sh +DEVICE_ID= +TEAM_ID= +BUNDLE_ID=com.yourname.edge0phone # replace with your own unique bundle identifier + +xcodebuild -project Edge0PhoneProbe.xcodeproj \ + -scheme Edge0Phone \ + -destination "id=$DEVICE_ID" \ + -derivedDataPath .build-device \ + -allowProvisioningUpdates \ + -skipPackagePluginValidation \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" \ + build + +xcrun devicectl device install app \ + --device "$DEVICE_ID" \ + .build-device/Build/Products/Debug-iphoneos/Edge0Phone.app +``` + +Les poids se trouvent à l'intérieur de ce `.app`. Il n'y a pas de copie séparée vers le téléphone. + +### Tests + +`scripts/test.sh` compile la bibliothèque MLX Metal avec Xcode, puis exécute les tests unitaires. La première exécution résout le paquet MLX Swift. + +```sh +scripts/test.sh +EDGE0_TEST_GPU=1 scripts/test.sh +``` + +### Utiliser l'application + +Lancez l'application et choisissez **Edge0 8B** ou **Edge0 35B**. Un modèle qui n'était pas dans `Models/` au moment de la compilation affiche **Not included** et ne peut pas être sélectionné. Saisissez une question ou touchez l'une des suggestions. La ligne sous une réponse affiche le nombre de tokens, le temps avant le premier token, le prefill, le decode et la mémoire de pointe. Changer de modèle décharge celui qui est chargé. New chat efface la conversation. + +## Performances + +| Modèle | Appareil | OS | Prefill | TTFT | Decode | +| --- | --- | --- | --- | --- | --- | +| 8B | iPhone 16 Pro | iOS 26.6.2 | 7.2 tok/s | 3.6 s | 10.9 tok/s | +| 35B | iPhone 16 Pro | iOS 26.6.2 | 4.9 tok/s | 2.1 s | 6.4 tok/s | + +## Détails techniques + +L'application est en SwiftUI. `Edge0MLX` exécute le modèle. `Edge0Core` lit le checkpoint, le tokenizer et l'index de routage. Les calculs mathématiques passent par MLX Swift sur Metal. La cible de déploiement est iOS 17. + +Edge0 8B comporte 24 couches, alternant Kimi Delta Attention et attention latente multi-têtes, Top-8, INT4. Le prefill du prompt exécute une couche entière à la fois, par blocs de 2048 tokens au plus. Le premier token est glouton (greedy). Les tokens suivants utilisent une température de 0,7, top-k 64, top-p 0,95 et une pénalité de répétition de 1,1. + +Edge0 35B comporte 40 couches, 256 experts par couche, Top-4. Les poids résidents et les fichiers d'experts par couche sont lus séparément. Le cache de préfixes est stocké dans Application Support, car le bundle de l'application est en lecture seule. Les deux modèles ne sont jamais chargés en même temps. diff --git a/ios/README_ja.md b/ios/README_ja.md new file mode 100644 index 0000000..7f5f2a0 --- /dev/null +++ b/ios/README_ja.md @@ -0,0 +1,111 @@ +# Edge0Phone + +[English](README.md) | [中文](README_zh.md) | 日本語 | [Español](README_es.md) | [Français](README_fr.md) + +iPhone 向けオンデバイスチャット。アプリは Edge0 8B、Edge0 35B、またはその両方を実行でき、選択したモデルのみがロードされます。モデルの重みはこのフォルダには含まれていません。 + +## クイックスタート + +Apple Silicon Mac、Xcode、そして Developer Mode をオンにした iOS 17 以降の iPhone が必要です。Python 3.10 以降は、重みのダウンロードと 35B の変換にのみ必要です。Xcode が Metal コンパイラの不足を報告する場合は、`xcodebuild -downloadComponent MetalToolchain` を実行してください。 + +`Models/` ディレクトリはビルド時にアプリへコピーされます。ボタンを選択可能にしたい場合は、ビルド前にモデルをそこに置いてください。空の `Models/` ディレクトリでもビルドは成功します。存在しないモデルは **Not included** と表示されます。 + +### モデルのダウンロード + +```sh +python3 -m venv .venv-tools +.venv-tools/bin/python -m pip install --upgrade pip huggingface_hub numpy +``` + +8B はダウンロードしたままの状態でパックできます。約 5 GB なので、インストールされるアプリもほぼそのサイズになります。 + +```sh +.venv-tools/bin/hf download Edge0/Edge0-8B-A1B-preview \ + --local-dir Models/Edge0-8B-A1B-preview +``` + +35B は重みの変更ではなくレイアウトの変更です。公開されているチェックポイントは、各エキスパートを 19 GB のファイル全体に散らばった 9 回の読み取りとして格納しています。iPhone ではこれは遅すぎるため、以下のツールは各エキスパートを 1 回のシーケンシャル読み取りに書き換え、レイヤーごとに 1 ファイルを書き出します。元のダウンロードは `Models/` の外に保管してください。アプリに含めるべきものは `Models/repacked/` だけです。35B ビルドは約 20 GB になります。 + +```sh +mkdir -p checkpoints +.venv-tools/bin/hf download Edge0/Edge0-35B-A3B-preview \ + --local-dir checkpoints/Edge0-35B-A3B-preview + +SOURCE=checkpoints/Edge0-35B-A3B-preview +OUTPUT=Models/repacked + +.venv-tools/bin/python tools/repack_experts.py pack "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/repack_experts.py resident "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/repack_experts.py verify "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/convert_tokenizer.py "$SOURCE" "$OUTPUT/tokenizer.bin" +.venv-tools/bin/python tools/convert_pregate.py \ + "$SOURCE/prerouter_edge0_35b.safetensors" \ + "$OUTPUT/prerouter-stacked.safetensors" +cp "$SOURCE/lora_edge0_35b.safetensors" "$OUTPUT/" +``` + +`Models/repacked/` には `resident.safetensors`、`tokenizer.bin`、および `experts-L00.bin` から `experts-L39.bin` が含まれている必要があります。どちらか一方のモデル、または両方をパックできます。 + +チェックポイント: + +- [Edge0/Edge0-8B-A1B-preview](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) +- [Edge0/Edge0-35B-A3B-preview](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) + +### ビルドとインストール + +プロジェクト内の `com.example.edge0phone` はプレースホルダのバンドル識別子です。ビルドや署名の前に、自分が管理する一意のバンドル識別子に置き換えてください。Xcode で `Edge0PhoneProbe.xcodeproj` を開き、**Edge0Phone** ターゲットを選択して、**Signing & Capabilities → Bundle Identifier** を変更します。Apple Developer Team を選び、iPhone を接続して Run します。 + +代わりにファイルからチームを設定するには、`Config/Local.xcconfig.example` を `Config/Local.xcconfig` にコピーして `DEVELOPMENT_TEAM` を記入します。 + +コマンドラインからの同じビルド: + +```sh +DEVICE_ID= +TEAM_ID= +BUNDLE_ID=com.yourname.edge0phone # replace with your own unique bundle identifier + +xcodebuild -project Edge0PhoneProbe.xcodeproj \ + -scheme Edge0Phone \ + -destination "id=$DEVICE_ID" \ + -derivedDataPath .build-device \ + -allowProvisioningUpdates \ + -skipPackagePluginValidation \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" \ + build + +xcrun devicectl device install app \ + --device "$DEVICE_ID" \ + .build-device/Build/Products/Debug-iphoneos/Edge0Phone.app +``` + +重みはその `.app` の中に含まれています。スマートフォンへの個別のコピーはありません。 + +### テスト + +`scripts/test.sh` は Xcode で MLX Metal ライブラリをビルドし、その後ユニットテストを実行します。初回実行では MLX Swift パッケージを解決します。 + +```sh +scripts/test.sh +EDGE0_TEST_GPU=1 scripts/test.sh +``` + +### アプリの使い方 + +アプリを起動し、**Edge0 8B** または **Edge0 35B** を選択します。ビルド時に `Models/` になかったモデルは **Not included** と表示され、選択できません。質問を入力するか、提案の 1 つをタップします。返信の下の行には、トークン数、最初のトークンまでの時間、プリフィル、デコード、ピークメモリが表示されます。モデルを切り替えると、ロード済みのモデルがアンロードされます。New chat は会話をクリアします。 + +## パフォーマンス + +| モデル | デバイス | OS | プリフィル | TTFT | デコード | +| --- | --- | --- | --- | --- | --- | +| 8B | iPhone 16 Pro | iOS 26.6.2 | 7.2 tok/s | 3.6 s | 10.9 tok/s | +| 35B | iPhone 16 Pro | iOS 26.6.2 | 4.9 tok/s | 2.1 s | 6.4 tok/s | + +## 技術詳細 + +アプリは SwiftUI です。`Edge0MLX` がモデルを実行します。`Edge0Core` はチェックポイント、トークナイザー、ルーティングインデックスを読み取ります。計算は Metal 上の MLX Swift を通じて行われます。デプロイメントターゲットは iOS 17 です。 + +Edge0 8B は 24 層で、Kimi Delta Attention とマルチヘッド潜在アテンションが交互に配置され、Top-8、INT4 です。プロンプトのプリフィルは 1 度に 1 層全体を、最大 2048 トークンのチャンクで実行します。最初のトークンは greedy です。以降のトークンは temperature 0.7、top-k 64、top-p 0.95、repetition penalty 1.1 を使用します。 + +Edge0 35B は 40 層、レイヤーごとに 256 エキスパート、Top-4 です。常駐重みとレイヤーごとのエキスパートファイルは別々に読み取られます。プレフィックスキャッシュは Application Support に保存されます — アプリバンドルは読み取り専用だからです。2 つのモデルが同時にロードされることはありません。 diff --git a/ios/README_zh.md b/ios/README_zh.md new file mode 100644 index 0000000..4b20f87 --- /dev/null +++ b/ios/README_zh.md @@ -0,0 +1,111 @@ +# Edge0Phone + +[English](README.md) | 中文 | [日本語](README_ja.md) | [Español](README_es.md) | [Français](README_fr.md) + +面向 iPhone 的端侧对话 App。可运行 Edge0 8B、Edge0 35B 或两者兼备,且只加载你选中的模型。模型权重不在本目录内。 + +## 快速开始 + +你需要一台 Apple Silicon Mac、Xcode,以及一部开启开发者模式、运行 iOS 17 或更高版本的 iPhone。Python 3.10+ 仅用于下载权重与转换 35B。若 Xcode 提示缺少 Metal 编译器,运行 `xcodebuild -downloadComponent MetalToolchain`。 + +`Models/` 目录会在构建时拷贝进 App。若希望对应按钮可选,请在构建前把模型放入其中。`Models/` 为空也能构建;缺失的模型会显示 **Not included**。 + +### 下载模型 + +```sh +python3 -m venv .venv-tools +.venv-tools/bin/python -m pip install --upgrade pip huggingface_hub numpy +``` + +8B 下载后即可直接打包。它约 5 GB,因此安装后的 App 也约为该体积。 + +```sh +.venv-tools/bin/hf download Edge0/Edge0-8B-A1B-preview \ + --local-dir Models/Edge0-8B-A1B-preview +``` + +35B 只是布局变化,权重本身不变。发布的 checkpoint 把每个专家存为一个 19 GB 文件中的九次分散读取。这在 iPhone 上太慢,因此下面的工具会把每个专家重写为一次顺序读取,并按层各写一个文件。原始下载请放在 `Models/` 之外。App 只应包含 `Models/repacked/`。35B 构建约 20 GB。 + +```sh +mkdir -p checkpoints +.venv-tools/bin/hf download Edge0/Edge0-35B-A3B-preview \ + --local-dir checkpoints/Edge0-35B-A3B-preview + +SOURCE=checkpoints/Edge0-35B-A3B-preview +OUTPUT=Models/repacked + +.venv-tools/bin/python tools/repack_experts.py pack "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/repack_experts.py resident "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/repack_experts.py verify "$SOURCE" "$OUTPUT" +.venv-tools/bin/python tools/convert_tokenizer.py "$SOURCE" "$OUTPUT/tokenizer.bin" +.venv-tools/bin/python tools/convert_pregate.py \ + "$SOURCE/prerouter_edge0_35b.safetensors" \ + "$OUTPUT/prerouter-stacked.safetensors" +cp "$SOURCE/lora_edge0_35b.safetensors" "$OUTPUT/" +``` + +`Models/repacked/` 必须包含 `resident.safetensors`、`tokenizer.bin` 以及 `experts-L00.bin` 至 `experts-L39.bin`。可以只打包其中一个模型,也可以两个都打包。 + +Checkpoint: + +- [Edge0/Edge0-8B-A1B-preview](https://huggingface.co/Edge0/Edge0-8B-A1B-preview) +- [Edge0/Edge0-35B-A3B-preview](https://huggingface.co/Edge0/Edge0-35B-A3B-preview) + +### 构建与安装 + +项目中的 `com.example.edge0phone` 是占位 bundle identifier。构建或签名前,请替换为你自己掌控的唯一 bundle identifier。在 Xcode 中打开 `Edge0PhoneProbe.xcodeproj`,选择 **Edge0Phone** target,然后修改 **Signing & Capabilities → Bundle Identifier**。选择你的 Apple Developer Team,连接 iPhone 并运行。 + +也可以改用文件方式设置 team:把 `Config/Local.xcconfig.example` 复制为 `Config/Local.xcconfig`,填入 `DEVELOPMENT_TEAM`。 + +命令行执行同样的构建: + +```sh +DEVICE_ID= +TEAM_ID= +BUNDLE_ID=com.yourname.edge0phone # replace with your own unique bundle identifier + +xcodebuild -project Edge0PhoneProbe.xcodeproj \ + -scheme Edge0Phone \ + -destination "id=$DEVICE_ID" \ + -derivedDataPath .build-device \ + -allowProvisioningUpdates \ + -skipPackagePluginValidation \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" \ + build + +xcrun devicectl device install app \ + --device "$DEVICE_ID" \ + .build-device/Build/Products/Debug-iphoneos/Edge0Phone.app +``` + +权重就在该 `.app` 内部,不会另行拷贝到手机上。 + +### 测试 + +`scripts/test.sh` 会用 Xcode 构建 MLX Metal 库,然后运行单元测试。首次运行会解析 MLX Swift 包。 + +```sh +scripts/test.sh +EDGE0_TEST_GPU=1 scripts/test.sh +``` + +### 使用 App + +启动 App,选择 **Edge0 8B** 或 **Edge0 35B**。构建时不在 `Models/` 中的模型会显示 **Not included** 且不可选择。输入问题,或点按一条建议。回复下方一行显示 token 数、首 token 时间、prefill、decode 与峰值内存。切换模型会卸载当前已加载的模型。新对话会清空当前会话。 + +## 性能实测 + +| 模型 | 设备 | 系统 | Prefill | TTFT | 解码 | +| --- | --- | --- | --- | --- | --- | +| 8B | iPhone 16 Pro | iOS 26.6.2 | 7.2 tok/s | 3.6 s | 10.9 tok/s | +| 35B | iPhone 16 Pro | iOS 26.6.2 | 4.9 tok/s | 2.1 s | 6.4 tok/s | + +## 技术细节 + +App 采用 SwiftUI。`Edge0MLX` 负责运行模型。`Edge0Core` 读取 checkpoint、tokenizer 与路由索引。数学运算经由 Metal 上的 MLX Swift 完成。部署目标为 iOS 17。 + +Edge0 8B 为 24 层,Kimi Delta Attention 与多头潜在注意力(multi-head latent attention)交替,Top-8,INT4。Prompt prefill 以整层为单位运行,每块最多 2048 token。首 token 采用贪心解码,后续 token 使用 temperature 0.7、top-k 64、top-p 0.95 与 1.1 的重复惩罚。 + +Edge0 35B 为 40 层,每层 256 专家,Top-4。常驻权重与各层专家文件分开读取。前缀缓存存放在 Application Support 中,因为 App bundle 是只读的。两个模型绝不会同时加载。 diff --git a/ios/Sources/Edge0Core/Edge0Configuration8B.swift b/ios/Sources/Edge0Core/Edge0Configuration8B.swift new file mode 100644 index 0000000..2047695 --- /dev/null +++ b/ios/Sources/Edge0Core/Edge0Configuration8B.swift @@ -0,0 +1,99 @@ +import Foundation + +/// Minimal configuration surface needed for the Edge0 8B port. +/// Mirrors Edge0/Edge0-8B-A1B-preview config.json. +public struct Edge0Configuration8B: Codable, Sendable, Equatable { + public let hiddenSize: Int + public let hiddenLayers: Int + public let intermediateSize: Int + public let attentionHeads: Int + public let keyValueHeads: Int + public let headDim: Int + public let vocabularySize: Int + public let maxPositionEmbeddings: Int + public let rmsNormEps: Float + + public let layerGroupSize: Int + public let firstKDenseReplace: Int + public let shortConvKernelSize: Int + public let kdaSafeGate: Bool + public let kdaLowerBound: Float + + public let qLoraRank: Int + public let kvLoraRank: Int + public let qkNopeHeadDim: Int + public let qkRopeHeadDim: Int + public let vHeadDim: Int + public let ropeTheta: Float + public let ropeInterleave: Bool + + public let numExperts: Int + public let expertsPerToken: Int + public let numSharedExperts: Int + public let moeIntermediateSize: Int + public let sharedExpertIntermediateSize: Int + public let nGroup: Int + public let topkGroup: Int + public let normTopkProb: Bool + public let routedScalingFactor: Float + public let routerHasExpertBias: Bool + + public let quantization: Quantization + + public struct Quantization: Codable, Sendable, Equatable { + public let groupSize: Int + public let bits: Int + public let mode: String + + enum CodingKeys: String, CodingKey { + case groupSize = "group_size" + case bits + case mode + } + } + + enum CodingKeys: String, CodingKey { + case hiddenSize = "hidden_size" + case hiddenLayers = "num_hidden_layers" + case intermediateSize = "intermediate_size" + case attentionHeads = "num_attention_heads" + case keyValueHeads = "num_key_value_heads" + case headDim = "head_dim" + case vocabularySize = "vocab_size" + case maxPositionEmbeddings = "max_position_embeddings" + case rmsNormEps = "rms_norm_eps" + case layerGroupSize = "layer_group_size" + case firstKDenseReplace = "first_k_dense_replace" + case shortConvKernelSize = "short_conv_kernel_size" + case kdaSafeGate = "kda_safe_gate" + case kdaLowerBound = "kda_lower_bound" + case qLoraRank = "q_lora_rank" + case kvLoraRank = "kv_lora_rank" + case qkNopeHeadDim = "qk_nope_head_dim" + case qkRopeHeadDim = "qk_rope_head_dim" + case vHeadDim = "v_head_dim" + case ropeTheta = "rope_theta" + case ropeInterleave = "rope_interleave" + case numExperts = "num_experts" + case expertsPerToken = "num_experts_per_tok" + case numSharedExperts = "num_shared_experts" + case moeIntermediateSize = "moe_intermediate_size" + case sharedExpertIntermediateSize = "moe_shared_expert_intermediate_size" + case nGroup = "n_group" + case topkGroup = "topk_group" + case normTopkProb = "norm_topk_prob" + case routedScalingFactor = "routed_scaling_factor" + case routerHasExpertBias = "moe_router_enable_expert_bias" + case quantization + } + + public static func load(from url: URL) throws -> Self { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(Self.self, from: data) + } + + public func isMLALayer(_ index: Int) -> Bool { + let full = hiddenLayers / layerGroupSize * layerGroupSize + return (index + 1) % layerGroupSize == 0 || index >= full + } +} diff --git a/ios/Sources/Edge0Core/Edge0Tokenizer8B.swift b/ios/Sources/Edge0Core/Edge0Tokenizer8B.swift new file mode 100644 index 0000000..dd1a9eb --- /dev/null +++ b/ios/Sources/Edge0Core/Edge0Tokenizer8B.swift @@ -0,0 +1,186 @@ +import Foundation + +public enum Edge0TokenizerError8B: Error, LocalizedError { + case invalid(String) + + public var errorDescription: String? { + switch self { + case .invalid(let message): message + } + } +} + +/// Native Swift implementation of the checkpoint's NFC + regex + ByteLevel BPE pipeline. +/// It reads `tokenizer.json` from the model folder to use the checkpoint vocabulary and +/// merge table directly. +public final class Edge0Tokenizer8B: @unchecked Sendable { + private struct FileFormat: Decodable { + struct AddedToken: Decodable { + let id: Int + let content: String + } + + struct Model: Decodable { + let vocab: [String: Int] + let merges: [[String]] + } + + let added_tokens: [AddedToken] + let model: Model + } + + private struct Pair: Hashable { + let left: String + let right: String + } + + private static let splitPattern = #"'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+"# + + private let vocabulary: [String: Int] + private let tokensByID: [Int: String] + private let mergeRanks: [Pair: Int] + private let addedTokens: [(content: String, id: Int)] + private let byteEncoder: [UInt8: String] + private let byteDecoder: [Character: UInt8] + private let splitRegex: NSRegularExpression + + public let endOfTurnTokenID: Int + + public init(contentsOf url: URL) throws { + let payload = try JSONDecoder().decode(FileFormat.self, from: Data(contentsOf: url)) + vocabulary = payload.model.vocab + tokensByID = Dictionary(uniqueKeysWithValues: payload.model.vocab.map { ($0.value, $0.key) }) + mergeRanks = Dictionary(uniqueKeysWithValues: payload.model.merges.enumerated().compactMap { rank, merge in + guard merge.count == 2 else { return nil } + return (Pair(left: merge[0], right: merge[1]), rank) + }) + addedTokens = payload.added_tokens + .map { ($0.content, $0.id) } + .sorted { lhs, rhs in lhs.content.count > rhs.content.count } + guard let eos = payload.added_tokens.first(where: { $0.content == "<|role_end|>" })?.id else { + throw Edge0TokenizerError8B.invalid("tokenizer.json is missing <|role_end|>") + } + endOfTurnTokenID = eos + + let maps = Self.makeByteMaps() + byteEncoder = maps.encoder + byteDecoder = maps.decoder + splitRegex = try NSRegularExpression(pattern: Self.splitPattern) + } + + public func encode(_ text: String) throws -> [Int] { + let normalized = text.precomposedStringWithCanonicalMapping + var ids: [Int] = [] + var plain = "" + var cursor = normalized.startIndex + + func flushPlain() throws { + guard !plain.isEmpty else { return } + ids.append(contentsOf: try encodePlain(plain)) + plain.removeAll(keepingCapacity: true) + } + + while cursor < normalized.endIndex { + let suffix = normalized[cursor...] + if let token = addedTokens.first(where: { suffix.hasPrefix($0.content) }) { + try flushPlain() + ids.append(token.id) + cursor = normalized.index(cursor, offsetBy: token.content.count) + } else { + let next = normalized.index(after: cursor) + plain.append(contentsOf: normalized[cursor.. String { + var bytes: [UInt8] = [] + for id in ids { + guard let token = tokensByID[id] ?? addedTokens.first(where: { $0.id == id })?.content else { + throw Edge0TokenizerError8B.invalid("Unknown token ID \(id)") + } + if skipSpecialTokens, addedTokens.contains(where: { $0.id == id }) { continue } + for character in token { + if let byte = byteDecoder[character] { + bytes.append(byte) + } else { + bytes.append(contentsOf: String(character).utf8) + } + } + } + return String(decoding: bytes, as: UTF8.self) + } + + private func encodePlain(_ text: String) throws -> [Int] { + let range = NSRange(text.startIndex.. [String] { + var pieces = token.map(String.init) + while pieces.count > 1 { + var selected: Pair? + var selectedRank = Int.max + for index in 0..<(pieces.count - 1) { + let pair = Pair(left: pieces[index], right: pieces[index + 1]) + if let rank = mergeRanks[pair], rank < selectedRank { + selected = pair + selectedRank = rank + } + } + guard let selected else { break } + + var merged: [String] = [] + var index = 0 + while index < pieces.count { + if index + 1 < pieces.count, + pieces[index] == selected.left, + pieces[index + 1] == selected.right { + merged.append(selected.left + selected.right) + index += 2 + } else { + merged.append(pieces[index]) + index += 1 + } + } + pieces = merged + } + return pieces + } + + private static func makeByteMaps() -> (encoder: [UInt8: String], decoder: [Character: UInt8]) { + var bytes = Array(UInt8(33)...UInt8(126)) + bytes += Array(UInt8(161)...UInt8(172)) + bytes += Array(UInt8(174)...UInt8(255)) + var scalars = bytes.map(Int.init) + var extra = 0 + for value in 0...255 where !bytes.contains(UInt8(value)) { + bytes.append(UInt8(value)) + scalars.append(256 + extra) + extra += 1 + } + var encoder: [UInt8: String] = [:] + var decoder: [Character: UInt8] = [:] + for (byte, scalarValue) in zip(bytes, scalars) { + let character = Character(String(UnicodeScalar(scalarValue)!)) + encoder[byte] = String(character) + decoder[character] = byte + } + return (encoder, decoder) + } +} diff --git a/ios/Sources/Edge0Core/ExpertTensorStore.swift b/ios/Sources/Edge0Core/ExpertTensorStore.swift new file mode 100644 index 0000000..ae7412f --- /dev/null +++ b/ios/Sources/Edge0Core/ExpertTensorStore.swift @@ -0,0 +1,60 @@ +import Foundation + +public enum ExpertProjection: String, CaseIterable, Sendable { + case up = "up_proj" + case gate = "gate_proj" + case down = "down_proj" +} + +public enum QuantPart: String, CaseIterable, Sendable { + case weight + case scales + case biases +} + +public struct ExpertTensorKey: Hashable, Sendable { + public let layer: Int + public let expert: Int + public let projection: ExpertProjection + public let part: QuantPart +} + +public struct ExpertTensorSlice: @unchecked Sendable { + public let descriptor: TensorDescriptor + public let pointer: UnsafeMutableRawPointer +} + +/// Index + mmap layer for the Edge0 checkpoint. No MLX dependency here. +/// The MLX adapter can turn these slices into arrays only when an expert is selected. +public final class ExpertTensorStore: @unchecked Sendable { + public let index: SafetensorsIndex + public let mappedFile: MappedFile + public let expertCount: Int + + public init(modelURL: URL, expertCount: Int = 128) throws { + self.index = try SafetensorsIndex(fileURL: modelURL) + self.mappedFile = try MappedFile(url: modelURL) + self.expertCount = expertCount + } + + public func stackedTensorName(layer: Int, projection: ExpertProjection, part: QuantPart) -> String { + "model.layers.\(layer).mlp.experts.\(projection.rawValue).\(part.rawValue)" + } + + public func slice( + layer: Int, + expert: Int, + projection: ExpertProjection, + part: QuantPart + ) throws -> ExpertTensorSlice { + precondition((0.. Selection { + precondition(nGroup > 0) + precondition(logits.count / nGroup >= 2) + precondition(topK <= topkGroup * (logits.count / nGroup)) + precondition(logits.count == expertBias.count) + precondition(logits.count % nGroup == 0) + precondition(topK > 0 && topK <= logits.count) + precondition(topkGroup > 0 && topkGroup <= nGroup) + + let raw = logits.map { 1 / (1 + Foundation.exp(-$0)) } + var selection = zip(raw, expertBias).map(+) + let perGroup = logits.count / nGroup + + let groupScores: [(group: Int, score: Float)] = (0..).prefix(2) + return (group, bestTwo.reduce(0, +)) + } + let surviving = Set(groupScores.sorted { $0.score > $1.score }.prefix(topkGroup).map(\.group)) + + for group in 0.. selection[$1] + }.prefix(topK).map { $0 } + + var weights = indices.map { raw[$0] } + if normalize { + let denominator = weights.reduce(0, +) + 1e-20 + weights = weights.map { $0 / denominator } + } + weights = weights.map { $0 * routedScale } + return Selection(indices: indices, weights: weights) + } +} diff --git a/ios/Sources/Edge0Core/MappedFile.swift b/ios/Sources/Edge0Core/MappedFile.swift new file mode 100644 index 0000000..51d1853 --- /dev/null +++ b/ios/Sources/Edge0Core/MappedFile.swift @@ -0,0 +1,123 @@ +import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +public enum MappedFileError: Error, LocalizedError { + case openFailed(String) + case statFailed(String) + case mapFailed(String) + case outOfBounds(Range) + + public var errorDescription: String? { + switch self { + case .openFailed(let p): return "Could not open \(p)." + case .statFailed(let p): return "Could not stat \(p)." + case .mapFailed(let p): return "Could not mmap \(p)." + case .outOfBounds(let r): return "Mapped byte range out of bounds: \(r)." + } + } +} + +/// Read-only virtual-memory mapping of a model file. +/// mmap does not make the whole file resident; pages are faulted in on demand. +public final class MappedFile: @unchecked Sendable { + public let url: URL + public let count: Int + private let fd: Int32 + private let base: UnsafeMutableRawPointer + + public init(url: URL) throws { + self.url = url + let path = url.path + let descriptor = open(path, O_RDONLY) + guard descriptor >= 0 else { throw MappedFileError.openFailed(path) } + + var st = stat() + guard fstat(descriptor, &st) == 0 else { + close(descriptor) + throw MappedFileError.statFailed(path) + } + let size = Int(st.st_size) + guard size > 0 else { + close(descriptor) + throw MappedFileError.statFailed(path) + } + + let mapping = mmap(nil, size, PROT_READ, MAP_PRIVATE, descriptor, 0) + guard mapping != MAP_FAILED, let mapping else { + close(descriptor) + throw MappedFileError.mapFailed(path) + } + + self.fd = descriptor + self.count = size + self.base = mapping + } + + deinit { + munmap(base, count) + close(fd) + } + + public func pointer(to range: Range) throws -> UnsafeMutableRawPointer { + guard range.lowerBound >= 0, range.upperBound <= count else { + throw MappedFileError.outOfBounds(range) + } + return base.advanced(by: range.lowerBound) + } + + public func bytes(in range: Range) throws -> UnsafeRawBufferPointer { + let ptr = try pointer(to: range) + return UnsafeRawBufferPointer(start: UnsafeRawPointer(ptr), count: range.count) + } + + /// Advisory only; ignored if the OS declines it. + public func adviseSequential() { + #if canImport(Darwin) + _ = madvise(base, count, MADV_SEQUENTIAL) + #endif + } + + /// Official startup prewarm hint. Advisory only; iOS remains free to + /// limit page-cache residency under memory pressure. + public func adviseWillNeed() { + #if canImport(Darwin) + _ = madvise(base, count, MADV_WILLNEED) + #endif + } + + /// Advisory readahead for a checkpoint subrange. Streaming experts use + /// this before copying their tensor slices so page faults can be serviced + /// in bulk rather than serially on the decode critical path. + public func adviseWillNeed(range: Range) { + #if canImport(Darwin) + guard range.lowerBound >= 0, range.upperBound <= count, + !range.isEmpty else { return } + let pageSize = max(4096, Int(sysconf(_SC_PAGESIZE))) + let start = range.lowerBound / pageSize * pageSize + let end = min( + count, + ((range.upperBound + pageSize - 1) / pageSize) * pageSize) + _ = madvise(base.advanced(by: start), end - start, MADV_WILLNEED) + #endif + } + + /// Official `SafetensorsMmap.seq_read`: force a real sequential pass over + /// the checkpoint. `madvise` alone does not reliably fault every page in. + public func sequentialRead(chunkSize: Int = 1 << 24) { + let size = max(4096, chunkSize) + let buffer = UnsafeMutableRawPointer.allocate( + byteCount: size, alignment: MemoryLayout.alignment) + defer { buffer.deallocate() } + var offset = 0 + while offset < count { + let requested = min(size, count - offset) + let bytesRead = pread(fd, buffer, requested, off_t(offset)) + guard bytesRead > 0 else { break } + offset += bytesRead + } + } +} diff --git a/ios/Sources/Edge0Core/SafetensorsIndex.swift b/ios/Sources/Edge0Core/SafetensorsIndex.swift new file mode 100644 index 0000000..be08109 --- /dev/null +++ b/ios/Sources/Edge0Core/SafetensorsIndex.swift @@ -0,0 +1,152 @@ +import Foundation + +public enum SafetensorsError: Error, LocalizedError { + case fileTooSmall + case invalidHeaderLength(UInt64) + case invalidHeader + case malformedTensor(String) + case unsupportedDType(String) + case tensorNotFound(String) + case invalidExpertAxis(String) + + public var errorDescription: String? { + switch self { + case .fileTooSmall: return "Safetensors file is too small." + case .invalidHeaderLength(let n): return "Invalid safetensors header length: \(n)." + case .invalidHeader: return "Safetensors JSON header is invalid." + case .malformedTensor(let name): return "Malformed tensor entry: \(name)." + case .unsupportedDType(let dtype): return "Unsupported safetensors dtype: \(dtype)." + case .tensorNotFound(let name): return "Tensor not found: \(name)." + case .invalidExpertAxis(let name): return "Tensor has no valid expert axis: \(name)." + } + } +} + +public enum SafetensorsDType: String, Sendable, Codable { + case bool = "BOOL" + case u8 = "U8" + case i8 = "I8" + case i16 = "I16" + case u16 = "U16" + case i32 = "I32" + case u32 = "U32" + case i64 = "I64" + case u64 = "U64" + case f16 = "F16" + case bf16 = "BF16" + case f32 = "F32" + case f64 = "F64" + + public var byteWidth: Int { + switch self { + case .bool, .u8, .i8: 1 + case .i16, .u16, .f16, .bf16: 2 + case .i32, .u32, .f32: 4 + case .i64, .u64, .f64: 8 + } + } +} + +public struct TensorDescriptor: Sendable, Equatable { + public let name: String + public let dtype: SafetensorsDType + public let shape: [Int] + /// Absolute byte range within the safetensors file. + public let byteRange: Range + + public var byteCount: Int { byteRange.count } + + /// Returns a contiguous slice along axis 0. Edge0 stores routed experts stacked on axis 0. + public func axisZeroSlice(_ index: Int) throws -> TensorDescriptor { + guard let count = shape.first, count > 0, index >= 0, index < count else { + throw SafetensorsError.invalidExpertAxis(name) + } + guard byteCount % count == 0 else { + throw SafetensorsError.invalidExpertAxis(name) + } + let stride = byteCount / count + let start = byteRange.lowerBound + index * stride + return TensorDescriptor( + name: "\(name)[\(index)]", + dtype: dtype, + shape: Array(shape.dropFirst()), + byteRange: start..<(start + stride) + ) + } +} + +public struct SafetensorsIndex: Sendable { + public let fileURL: URL + public let headerLength: Int + public let dataOffset: Int + public let tensors: [String: TensorDescriptor] + + public init(fileURL: URL) throws { + self.fileURL = fileURL + let handle = try FileHandle(forReadingFrom: fileURL) + defer { try? handle.close() } + + let prefix = try handle.read(upToCount: 8) ?? Data() + guard prefix.count == 8 else { throw SafetensorsError.fileTooSmall } + let headerLength64 = prefix.withUnsafeBytes { raw -> UInt64 in + raw.loadUnaligned(as: UInt64.self).littleEndian + } + guard headerLength64 <= UInt64(Int.max) else { + throw SafetensorsError.invalidHeaderLength(headerLength64) + } + let headerLength = Int(headerLength64) + guard headerLength > 0 else { throw SafetensorsError.invalidHeaderLength(headerLength64) } + + let headerData = try handle.read(upToCount: headerLength) ?? Data() + guard headerData.count == headerLength else { throw SafetensorsError.fileTooSmall } + guard let json = try JSONSerialization.jsonObject(with: headerData) as? [String: Any] else { + throw SafetensorsError.invalidHeader + } + + let base = 8 + headerLength + var parsed: [String: TensorDescriptor] = [:] + parsed.reserveCapacity(json.count) + + for (name, value) in json where name != "__metadata__" { + guard + let entry = value as? [String: Any], + let dtypeString = entry["dtype"] as? String, + let dtype = SafetensorsDType(rawValue: dtypeString), + let shapeNumbers = entry["shape"] as? [NSNumber], + let offsets = entry["data_offsets"] as? [NSNumber], + offsets.count == 2 + else { + if let entry = value as? [String: Any], let dtypeString = entry["dtype"] as? String, + SafetensorsDType(rawValue: dtypeString) == nil { + throw SafetensorsError.unsupportedDType(dtypeString) + } + throw SafetensorsError.malformedTensor(name) + } + + let shape = shapeNumbers.map(\.intValue) + let relativeStart = offsets[0].intValue + let relativeEnd = offsets[1].intValue + guard relativeStart >= 0, relativeEnd >= relativeStart else { + throw SafetensorsError.malformedTensor(name) + } + + parsed[name] = TensorDescriptor( + name: name, + dtype: dtype, + shape: shape, + byteRange: (base + relativeStart)..<(base + relativeEnd) + ) + } + + self.headerLength = headerLength + self.dataOffset = base + self.tensors = parsed + } + + public subscript(_ name: String) -> TensorDescriptor? { tensors[name] } + + public func tensor(named name: String) throws -> TensorDescriptor { + guard let tensor = tensors[name] else { throw SafetensorsError.tensorNotFound(name) } + return tensor + } +} diff --git a/ios/Sources/Edge0MLX/Edge035B/ChatTemplate.swift b/ios/Sources/Edge0MLX/Edge035B/ChatTemplate.swift new file mode 100644 index 0000000..7cd6966 --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge035B/ChatTemplate.swift @@ -0,0 +1,122 @@ +import Foundation + +/// The text-only slice of this checkpoint's chat template. +/// +/// The shipped `chat_template.jinja` is 7,764 characters of Jinja with macros, image and +/// video handling, tool calls and vision counters. None of that is reachable from a +/// text-only app, and running a Jinja interpreter on device to reach a fixed string would +/// be a large amount of machinery for no behaviour. +/// +/// What the text path actually renders is regular: +/// +/// ``` +/// <|im_start|>{role}\n{content}<|im_end|>\n per message +/// <|im_start|>assistant\n\n generation prompt (thinking on) +/// <|im_start|>assistant\n generation prompt (thinking off) +/// ``` +/// +/// **Scope is the point, not a shortcut.** Vision and tool-call messages are out — and +/// they fail loudly rather than rendering something that looks plausible, because a +/// silently dropped image would produce a coherent answer about nothing. +/// +/// The trailing `\n` is the checkpoint's default: replies open inside a reasoning +/// block. Passing `thinking: false` omits it so the model writes the answer directly — +/// fewer tokens before anything useful appears, at the cost of losing that reasoning pass. +enum ChatTemplate { + + struct Message { + let role: String + let content: String + } + + enum Failure: LocalizedError { + case unsupportedRole(String) + + var errorDescription: String? { + switch self { + case .unsupportedRole(let role): + return "chat template supports system/user/assistant only, not '\(role)'" + } + } + } + + static let supportedRoles: Set = ["system", "user", "assistant"] + static let userOpening = "<|im_start|>user\n" + + /// The opening tag every assistant turn is generated under. + static func assistantOpening(thinking: Bool) -> String { + thinking + ? "<|im_start|>assistant\n\n" + : "<|im_start|>assistant\n" + } + + static func render( + _ messages: [Message], + addGenerationPrompt: Bool = true, + thinking: Bool = true + ) throws -> String { + var out = "" + for message in messages { + guard supportedRoles.contains(message.role) else { + throw Failure.unsupportedRole(message.role) + } + // **Past assistant turns are reopened with the same thinking mode.** + // Generation runs under `assistantOpening`, so a thinking-on reply begins + // inside a reasoning block and contains its ``. Rendering that text + // back without the opening tag put a dangling close tag in the history — + // well-formed-looking output describing a prompt the model never saw. + // + // It also makes replay exact, which is what lets a conversation's state be + // extended instead of rebuilt. + if message.role == "assistant" { + out += assistantOpening(thinking: thinking) + "\(message.content)<|im_end|>\n" + } else { + out += message.role == "user" + ? userOpening + "\(message.content)<|im_end|>\n" + : "<|im_start|>\(message.role)\n\(message.content)<|im_end|>\n" + } + } + if addGenerationPrompt { + out += assistantOpening(thinking: thinking) + } + return out + } + + /// What must be fed to continue a conversation whose state already ends inside the + /// previous assistant turn. + /// + /// This is the append path: it closes the open assistant turn, adds the new question, + /// and reopens for the answer. Nothing before it is re-rendered, so nothing before it + /// is recomputed. + static func continuation(user: String, thinking: Bool = true) -> String { + "<|im_end|>\n" + userOpening + "\(user)<|im_end|>\n" + assistantOpening(thinking: thinking) + } +} + +/// When to stop generating. +/// +/// Two conditions, and both are needed: `eos_token_id` is a **list** here — 248046 +/// (`<|im_end|>`) and 248044 (`<|endoftext|>`) — so checking only the first lets the +/// model run past the end of its turn. The length bound is the backstop for the case +/// where neither is ever emitted, which is what a mis-specified prompt produces. +struct StopCondition { + let endTokens: Set + let maximumTokens: Int + + init(endTokens: [Int32] = [248046, 248044], maximumTokens: Int = 256) { + self.endTokens = Set(endTokens) + self.maximumTokens = maximumTokens + } + + enum Outcome: Equatable { + case keepGoing + case hitEndToken(Int32) + case hitLimit + } + + func evaluate(token: Int32, produced: Int) -> Outcome { + if endTokens.contains(token) { return .hitEndToken(token) } + if produced >= maximumTokens { return .hitLimit } + return .keepGoing + } +} diff --git a/ios/Sources/Edge0MLX/Edge035B/CoreMLVocabularyHead.swift b/ios/Sources/Edge0MLX/Edge035B/CoreMLVocabularyHead.swift new file mode 100644 index 0000000..084e5c9 --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge035B/CoreMLVocabularyHead.swift @@ -0,0 +1,60 @@ +import CoreML +import Foundation +import MLX + +/// Optional Core ML vocabulary projection used by the 35B path. +/// +/// The model is discovered beside the streamed checkpoint rather than bundled with the +/// app: it is a derived 273 MiB asset and the existing MLX path remains the fallback +/// when it is absent or fails parity. Argmax stays in the graph, so only one Int32 token +/// crosses back from Core ML. +final class CoreMLVocabularyHead: @unchecked Sendable { + static let directoryName = "lm-head.mlmodelc" + static let width = 2_048 + + private let model: MLModel + + init(directory: URL) throws { + let url = directory.appendingPathComponent(Self.directoryName, isDirectory: true) + let configuration = MLModelConfiguration() + // Keep Core ML off the GPU so its projection does not compete with the MLX/Metal + // decoder. Unsupported operations can fall back to the CPU. + configuration.computeUnits = .cpuAndNeuralEngine + model = try MLModel(contentsOf: url, configuration: configuration) + } + + func predict(_ hidden: MLXArray) async throws -> Int32 { + let values = hidden.asType(.float16).asArray(Float16.self) + guard values.count == Self.width else { + throw Failure.badHiddenSize(values.count) + } + + let input = try MLMultiArray( + shape: [1, NSNumber(value: Self.width)], dataType: .float16) + values.withUnsafeBytes { source in + input.dataPointer.copyMemory(from: source.baseAddress!, byteCount: source.count) + } + let provider = try MLDictionaryFeatureProvider(dictionary: [ + "hidden_state": MLFeatureValue(multiArray: input) + ]) + let output = try await model.prediction(from: provider) + guard let token = output.featureValue(for: "token")?.multiArrayValue, + token.count == 1 + else { throw Failure.missingToken } + return token[0].int32Value + } + + private enum Failure: LocalizedError { + case badHiddenSize(Int) + case missingToken + + var errorDescription: String? { + switch self { + case .badHiddenSize(let count): + "Core ML lm_head expected \(CoreMLVocabularyHead.width) values, got \(count)" + case .missingToken: + "Core ML lm_head did not return token" + } + } + } +} diff --git a/ios/Sources/Edge0MLX/Edge035B/Edge0ChatEngine35B.swift b/ios/Sources/Edge0MLX/Edge035B/Edge0ChatEngine35B.swift new file mode 100644 index 0000000..518c58c --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge035B/Edge0ChatEngine35B.swift @@ -0,0 +1,284 @@ +import Darwin +import Foundation +import MLX + +/// Product-facing wrapper around the streaming Edge0 35B implementation. +/// +/// This engine deliberately lives beside `Edge0ChatEngine`: selecting 35B creates only +/// this model, while selecting 8B continues through the existing engine unchanged. +public final class Edge0ChatEngine35B: @unchecked Sendable { + public static let modelFolderName = "repacked" + + private let directory: URL + private let model: Edge0Model35B + private let tokenizer: Tokenizer + private let instructions: String + private let systemPrefix: [Int32] + private let state = Edge0Model35B.State() + + private var processed: [Int32] = [] + private var cachedPrefix: PrefixCache? + + public init( + modelURL: URL, + instructions: String = "You are a helpful assistant.", + progress: (String) -> Void = { _ in } + ) throws { + directory = modelURL + self.instructions = instructions + + // Configure MLX memory use for the selected 35B runtime. + let highMem = CommandLine.arguments.contains("--high-mem-35b") + Memory.cacheLimit = (highMem ? 1536 : 384) * 1_048_576 + print(String( + format: "[mem] cacheLimit=%dMiB highMem=%@ available=%dMiB", + Memory.cacheLimit / 1_048_576, + highMem ? "true" : "false", + os_proc_available_memory() / 1_048_576)) + progress("mapping Edge0 35B weights") + var configuration = Edge0Model35B.Configuration() + configuration.round6 = CommandLine.arguments.contains("--round6") + if configuration.round6 { + configuration.activeExperts = 2 + } + configuration.retainAllResidentWeights = highMem + model = try Edge0Model35B(directory: modelURL, configuration: configuration) + guard model.availableLayers == model.configuration.layers else { + throw Edge035BFailure.incompleteExperts( + found: model.availableLayers, expected: model.configuration.layers) + } + progress("loading Edge0 35B tokenizer") + tokenizer = try Tokenizer(url: modelURL.appendingPathComponent("tokenizer.bin")) + + if instructions.isEmpty { + systemPrefix = [] + } else { + let text = try ChatTemplate.render( + [.init(role: "system", content: instructions)], + addGenerationPrompt: false) + // The first user role marker is fixed too. Persisting its state saves those + // tokens on every new conversation; `PrefixCache.matches` still verifies the + // encoded prefix against the complete prompt before it is trusted. + systemPrefix = tokenizer.encode(text + ChatTemplate.userOpening) + } + cachedPrefix = PrefixCache.read(from: Self.prefixURL( + directory: modelURL, fingerprint: model.configuration.fingerprint)) + try model.prepareForFirstTurn() + progress("Edge0 35B ready") + } + + public func reset() { + state.reset() + processed.removeAll(keepingCapacity: true) + } + + public func reply( + to userText: String, + maxTokens: Int = 512, + thinking: Bool = true, + onText: @escaping @Sendable (String) -> Void = { _ in }, + shouldContinue: @escaping @Sendable () -> Bool = { true } + ) async throws -> Edge0GenerationResult { + let prompt = userText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !prompt.isEmpty else { throw Edge035BFailure.invalid("Message cannot be empty") } + guard maxTokens > 0 else { throw Edge035BFailure.invalid("maxTokens must be positive") } + + let continuing = !processed.isEmpty + let ids: [Int32] + if continuing { + ids = tokenizer.encode(ChatTemplate.continuation(user: prompt, thinking: thinking)) + } else { + var messages: [ChatTemplate.Message] = [] + if !instructions.isEmpty { + messages.append(.init(role: "system", content: instructions)) + } + messages.append(.init(role: "user", content: prompt)) + ids = tokenizer.encode(try ChatTemplate.render(messages, thinking: thinking)) + } + guard !ids.isEmpty else { throw Edge035BFailure.invalid("Tokenizer returned an empty prompt") } + print("[35b] thinking=\(thinking ? "on" : "off")") + + GPU.resetPeakMemory() + let started = Date() + let thermalAtStart = ProcessInfo.processInfo.thermalState + var start = 0 + if !continuing { + state.reset() + processed.removeAll(keepingCapacity: true) + if let cache = cachedPrefix, cache.matches(ids) { + restore(cache.state, into: state) + processed = cache.tokens + start = cache.tokens.count + } + } + + var next: Int32 = 0 + // Later turns already have attention weights resident. Keep that cache on + // for a continued prefill so those projections are not copied again. + // The first prefill still copies each of them once; retaining earlier + // only adds dirty pages with nothing to reuse inside that single pass. + if continuing { model.beginDecodeCaching() } + model.profile.reset() + // An older cache may contain only the system message. Restore it, compute just + // the newly-added user opening, then atomically replace it with the longer cache. + let capturePoint = !continuing && start < systemPrefix.count + && ids.starts(with: systemPrefix) ? systemPrefix.count : nil + var index = start + while index < ids.count { + guard shouldContinue() else { return try cancelled() } + var size = min(Edge0Model35B.maximumBatch, ids.count - index) + if let capturePoint, index < capturePoint { + size = min(size, capturePoint - index) + } + let slice = Array(ids[index ..< index + size]) + let isLast = index + size == ids.count + let logits = try model.step( + tokens: MLXArray(slice, [1, size]), state: state, needsLogits: isLast) + if isLast, let logits { next = try await model.greedy(logits) } + processed.append(contentsOf: slice) + index += size + + if let capturePoint, index == capturePoint { + let captured = PrefixCache(tokens: systemPrefix, state: state.snapshot()) + cachedPrefix = captured + captured.write(to: Self.prefixURL( + directory: directory, fingerprint: model.configuration.fingerprint)) + } + } + let prefillFinished = Date() + let prefillSeconds = prefillFinished.timeIntervalSince(started) + let prefillTokens = ids.count - start + print(String( + format: "[35b prefill] tokens=%d total=%.2fs rate=%.2f tok/s %@", + prefillTokens, + prefillSeconds, + Double(prefillTokens) / max(0.000_001, prefillSeconds), + model.profile.report)) + model.beginDecodeCaching() + model.profile.reset() + + let stop = StopCondition(maximumTokens: maxTokens) + var raw = "" + var shown = "" + var produced = 0 + var firstTokenAt: Date? + var decodeStartedAt: Date? + + while true { + switch stop.evaluate(token: next, produced: produced) { + case .hitEndToken, .hitLimit: + let finished = Date() + let decodeSeconds = decodeStartedAt.map { + finished.timeIntervalSince($0) + } ?? 0 + let footprint = Self.physicalFootprintBytes() + print(String( + format: "[35b decode] tokens=%d total=%.2fs rate=%.2f tok/s thermal=%@→%@ mlxPeak=%.0fMB footprint=%.0fMB available=%dMB %@", + max(0, produced - 1), + decodeSeconds, + Double(max(0, produced - 1)) / max(0.000_001, decodeSeconds), + Self.thermalName(thermalAtStart), + Self.thermalName(ProcessInfo.processInfo.thermalState), + Double(Memory.peakMemory) / 1_048_576, + Double(footprint) / 1_048_576, + os_proc_available_memory() / 1_048_576, + model.profile.report)) + shown = thinking + ? ThinkingBlock.settled(raw) + : ThinkingBlock.settledReply(raw) + if !shown.isEmpty { onText(shown) } + return Edge0GenerationResult( + text: shown, + generatedTokenCount: produced, + elapsedSeconds: finished.timeIntervalSince(started), + prefillSeconds: prefillSeconds, + prefillTokensPerSecond: Double(prefillTokens) / + max(0.000_001, prefillSeconds), + timeToFirstTokenSeconds: firstTokenAt?.timeIntervalSince(started) + ?? finished.timeIntervalSince(started), + decodeTokensPerSecond: decodeStartedAt.map { + Double(max(0, produced - 1)) / + max(0.000_001, finished.timeIntervalSince($0)) + } ?? 0, + peakMemoryBytes: max(Memory.peakMemory, footprint)) + case .keepGoing: + break + } + + guard shouldContinue() else { return try cancelled() } + if firstTokenAt == nil { + firstTokenAt = Date() + decodeStartedAt = firstTokenAt + } + raw += tokenizer.decode([next]) + let visible = thinking + ? ThinkingBlock.visible(in: raw) + : ThinkingBlock.visibleReply(in: raw) + if let visible, visible != shown { + shown = visible + onText(shown) + } + produced += 1 + + let logits = try model.step(tokens: MLXArray([next], [1, 1]), state: state) + processed.append(next) + next = try await model.greedy(logits) + } + } + + private func cancelled() throws -> T { + reset() + throw CancellationError() + } + + private func restore(_ source: Edge0Model35B.State, into target: Edge0Model35B.State) { + let snapshot = source.snapshot() + target.convolution = snapshot.convolution + target.recurrent = snapshot.recurrent + target.keys = snapshot.keys + target.values = snapshot.values + target.offset = snapshot.offset + } + + private static func prefixURL(directory: URL, fingerprint: String) -> URL { + PrefixCache.fileURL(in: directory, fingerprint: fingerprint) + } + + private static func thermalName(_ state: ProcessInfo.ThermalState) -> String { + switch state { + case .nominal: "nominal" + case .fair: "fair" + case .serious: "serious" + case .critical: "critical" + @unknown default: "unknown" + } + } + + /// Process physical footprint (jetsam-relevant), not just MLX allocator peak. + private static func physicalFootprintBytes() -> Int { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t( + MemoryLayout.stride / MemoryLayout.stride) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + guard result == KERN_SUCCESS else { return 0 } + return Int(info.phys_footprint) + } +} + +private enum Edge035BFailure: LocalizedError { + case incompleteExperts(found: Int, expected: Int) + case invalid(String) + + var errorDescription: String? { + switch self { + case .incompleteExperts(let found, let expected): + return "Edge0 35B expert files are incomplete (found \(found), expected \(expected))" + case .invalid(let message): + return message + } + } +} diff --git a/ios/Sources/Edge0MLX/Edge035B/Edge0Model35B.swift b/ios/Sources/Edge0MLX/Edge035B/Edge0Model35B.swift new file mode 100644 index 0000000..1b1669a --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge035B/Edge0Model35B.swift @@ -0,0 +1,1012 @@ +import Foundation +import MLX +import MLXFast + +/// Streaming Edge0 35B model implementation used by the iOS chat engine. +/// +/// This implementation is kept independent of the Python reference so parity checks can +/// compare separate code paths. It is not main-actor isolated because model steps perform +/// synchronous GPU and file I/O. The chat engine serializes access to one model instance. +final class Edge0Model35B: @unchecked Sendable { + + /// How many tokens may go through one forward pass. + /// + /// Sets the expert buffer ceiling: a batch of T tokens can in the worst case route to + /// T*K distinct experts. The loader grows staging storage only to the union actually + /// observed, so this ceiling no longer reserves the worst case up front. + /// + /// Storage is sized for the observed expert union rather than the worst-case T*K. + /// Top-4 routing makes the batch size a tradeoff between repeated base-weight reads + /// and the memory required for the active graph and expert union. + /// Tokens per prefill forward. Decode ignores this. + /// + /// A full-layer read is about 8 s of flash for 40 layers, independent of + /// how many tokens share it. 512 is the first size at which that fixed + /// cost can land near 60 tok/s before GPU time; the Gated DeltaNet scan + /// is what will jetsam first if 512 is too large. + static let maximumBatch = 512 + /// Below this, prefill keeps the decode-style expert union. A full-layer + /// read is 432 MiB × 40 and only pays for itself on a long chunk. + static let fullLayerPrefillMinimum = 64 + + struct Configuration { + var layers = 40 + var hidden = 2048 + var moeIntermediate = 512 + var expertsPerLayer = 256 + // The public Edge0-35B-A3B-preview adapter and prerouter were trained with K=4. + // This must stay aligned with the shipped artifacts: changing K changes both + // the MoE output and the routing history consumed by the next-step heads. + var activeExperts = 4 + /// Round-6 pregate + LoRA, trained at K=2. The two files are a pair. + var round6 = false + var groupSize = 64 + var bits = 4 + var fullAttentionInterval = 4 + var vocabulary = 248_320 + + /// When true, keep every non-expert resident tensor after first materialisation. + /// The default retains only selected tensors to limit active memory use. + var retainAllResidentWeights = false + + /// Source used to prefetch expert reads for the next decode step. + var routingGuess: RoutingGuess = .trainedHeads + + /// Number of recent routing steps used by `.recentRouting`. + var routingLookback = 1 + + enum RoutingGuess { + /// Do not prefetch expert reads. + case none + /// Prefetch the experts selected by each layer on the previous step. + case recentRouting + /// The trained pregate heads, when their file is present. Predicts the + /// *next* layer a *token* ahead, so it is the only source that can cover a + /// layer before that layer has run — and it replaces the router to do it. + case trainedHeads + } + + /// Identifies this configuration in caches whose contents depend on it. + var fingerprint: String { "full" } + + /// `(index + 1) % interval == 0` are full attention; the rest are linear. + func isLinear(_ layer: Int) -> Bool { + (layer + 1) % fullAttentionInterval != 0 + } + } + + enum Failure: LocalizedError { + case weightsMissing(URL) + case tensorMissing(String) + case layerFailed(Int) + + var errorDescription: String? { + switch self { + case .weightsMissing(let url): + return "no model at \(url.lastPathComponent)" + case .tensorMissing(let name): + return "missing tensor \(name)" + case .layerFailed(let layer): + return "layer \(layer) could not run" + } + } + } + + /// State carried between decode steps. + /// + /// Two kinds, and **they are not interchangeable**. Linear layers hold a fixed-size + /// `[1, heads, dim, dim]` float32 matrix updated in place: it does not grow with + /// context and cannot be pruned. Full-attention layers hold a KV cache that does + /// grow. Any context policy that drops old turns shortens the second and leaves the + /// first untouched — a model that appears to forget while still carrying everything. + final class State { + var convolution: [Int: MLXArray] = [:] + var recurrent: [Int: MLXArray] = [:] + var keys: [Int: MLXArray] = [:] + var values: [Int: MLXArray] = [:] + var offset = 0 + + /// Routing carried between decode steps. Per-conversation, not per-model: two + /// conversations share one set of heads but must never share the routing + /// history that feeds them. + let pregate = PregateHeads.State() + + /// Layer → the experts it ran, most recent step first, capped at the lookback. + /// Previous-step router output, used by `.recentRouting`. + var recentRouting: [Int: [[Int]]] = [:] + + /// Layer → what was asked for on its behalf. Exists so the hit rate is a + /// measurement of what was *issued* rather than a restatement of the guess: + /// `recentRouting` is overwritten as each layer runs, so comparing against it + /// would compare a layer's routing with its own. + var prefetched: [Int: [Int]] = [:] + + var byteCount: Int { + [convolution, recurrent, keys, values].reduce(0) { total, table in + total + table.values.reduce(0) { $0 + $1.size * $1.itemSize } + } + } + + func reset() { + convolution.removeAll(); recurrent.removeAll() + keys.removeAll(); values.removeAll() + offset = 0 + pregate.reset() + recentRouting.removeAll(); prefetched.removeAll() + } + } + + let configuration: Configuration + private let weights: SafetensorsBundle + private let experts: ExpertLoader + private let lora: SafetensorsShard? + private let directory: URL + private let coreMLHead: CoreMLVocabularyHead? + private var coreMLHeadEnabled: Bool + private var coreMLHeadValidated = false + + init(directory: URL, configuration: Configuration = Configuration()) throws { + let index = directory.appendingPathComponent("model.safetensors.index.json") + guard FileManager.default.fileExists(atPath: index.path) else { + throw Failure.weightsMissing(directory) + } + self.directory = directory + self.configuration = configuration + self.weights = try SafetensorsBundle(directory: directory) + self.experts = try ExpertLoader( + directory: directory, layers: configuration.layers, + slots: Edge0Model35B.maximumBatch * configuration.activeExperts, + hidden: configuration.hidden, + moeIntermediate: configuration.moeIntermediate, + groupSize: configuration.groupSize) + self.experts.profile = self.profile + let round6LoRA = directory.appendingPathComponent("lora-round6.safetensors") + let officialLoRA = directory.appendingPathComponent("lora_edge0_35b.safetensors") + let legacyLoRA = directory.appendingPathComponent("lora.safetensors") + let loraURL: URL + if configuration.round6 { + loraURL = round6LoRA + } else if FileManager.default.fileExists(atPath: officialLoRA.path) { + loraURL = officialLoRA + } else { + loraURL = legacyLoRA + } + self.lora = try? SafetensorsShard(url: loraURL) + if configuration.round6 { + print("[round6] K=\(configuration.activeExperts) lora=\(self.lora == nil ? "missing" : "loaded")") + } + let compiledHead = directory.appendingPathComponent( + CoreMLVocabularyHead.directoryName, isDirectory: true) + if CommandLine.arguments.contains("--ane-35b"), + FileManager.default.fileExists(atPath: compiledHead.path) { + do { + self.coreMLHead = try CoreMLVocabularyHead(directory: directory) + self.coreMLHeadEnabled = true + print("[coreml-head] loaded with computeUnits=cpuAndNeuralEngine") + } catch { + self.coreMLHead = nil + self.coreMLHeadEnabled = false + print("[coreml-head] unavailable: \(error)") + } + } else { + self.coreMLHead = nil + self.coreMLHeadEnabled = false + print("[coreml-head] disabled or absent") + } + let free = (try? directory.resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey]))? + .volumeAvailableCapacityForImportantUsage + print("[weights] free=\(free.map { String($0 / 1_048_576) } ?? "?")MiB") + + // Pregate weights are optional. Report an invalid present file so a fallback to + // the normal router is visible during diagnostics. + let pregateURL = PregateHeads.fileURL(in: directory, round6: configuration.round6) + if configuration.routingGuess == .trainedHeads, + FileManager.default.fileExists(atPath: pregateURL.path) { + do { + let heads = try PregateHeads( + url: pregateURL, hidden: configuration.hidden, + experts: RepackedExperts.expertsPerLayer) + self.pregate = heads + print("[pregate] heads loaded: owners \(heads.owners.first ?? -1)..\(heads.owners.last ?? -1) n=\(heads.owners.count)") + if let report = heads.selfCheck( + fixture: directory.appendingPathComponent("pregate-fixture.safetensors")) { + print(report) + } + } catch { + print("[pregate] present but unusable: \(error)") + self.pregate = nil + } + } else { + self.pregate = nil + } + } + + /// The trained routing heads, when the file is present. See `PregateHeads`. + private let pregate: PregateHeads? + + /// See `StepProfile` for why only the synchronous phases are timed. + let profile = StepProfile() + + var loraLoaded: Bool { lora != nil } + var availableLayers: Int { experts.availableLayers.count } + + // MARK: - Weights + + /// Selected tensors retained after first use to reduce repeated materialization. + private var retainedWeights: [String: MLXArray] = [:] + private var retainAttentionWeights = false + + /// First-turn prefill is latency-sensitive and sees every attention weight only + /// once, so retaining them there adds pressure without reuse. Decode immediately + /// reuses them; enable the cache at that boundary and keep it warm for later turns. + func beginDecodeCaching() { + retainAttentionWeights = true + } + + /// Prepare selected attention weights and issue a discarded token before generation + /// so initial Metal setup is outside the first reply's latency measurement. + func prepareForFirstTurn() throws { + beginDecodeCaching() + let started = Date() + var count = 0 + for name in weights.index.keys where + name.hasPrefix("language_model.lm_head.") + || name.contains(".linear_attn.") + || name.contains(".self_attn.") { + if weight(name) != nil { count += 1 } + } + print(String( + format: "[weights] first-turn cache tensors=%d %.2fs", + count, Date().timeIntervalSince(started))) + + let gpu = Date() + let scratch = State() + _ = try step(tokens: MLXArray([Int32(0)], [1, 1]), state: scratch) + profile.reset() + print(String(format: "[weights] gpu warm %.2fs", Date().timeIntervalSince(gpu))) + } + + /// Materialize a tensor from the mapped checkpoint. Retain selected tensors according + /// to the configured memory policy; other tensors are released after use. + private func weight(_ name: String) -> MLXArray? { + if let retained = retainedWeights[name] { return retained } + guard let entry = weights.entry(name), let pointer = weights.pointer(to: name) + else { return nil } + let raw = UnsafeRawBufferPointer(start: pointer, count: entry.byteCount) + profile.recordWeight(bytes: entry.byteCount) + let made = profile.measure(.weightCopy) { () -> MLXArray? in + switch entry.dtype { + case "U32", "I32": return MLXArray(raw, entry.shape, type: UInt32.self) + case "BF16": + return MLXArray(raw, entry.shape, type: UInt16.self).view(dtype: .bfloat16) + case "F32": return MLXArray(raw, entry.shape, type: Float.self) + default: return nil + } + } + let shouldRetain = configuration.retainAllResidentWeights + || (!coreMLHeadEnabled && name.hasPrefix("language_model.lm_head.")) + || (retainAttentionWeights + && (name.contains(".linear_attn.") || name.contains(".self_attn."))) + if shouldRetain, let made { + retainedWeights[name] = made + } + return made + } + + /// Apply the base quantized linear layer and its optional LoRA update. + private func linear(_ x: MLXArray, _ stem: String, bits: Int) -> MLXArray? { + guard let w = weight("\(stem).weight"), + let s = weight("\(stem).scales"), + let b = weight("\(stem).biases") + else { return nil } + let base = quantizedMatmul( + x.asType(.bfloat16), w, scales: s, biases: b, transpose: true, + groupSize: configuration.groupSize, bits: bits, mode: .affine) + + guard let prepared = preparedLoRA(stem) else { return base } + let delta = matmul(matmul(x.asType(.float16), prepared.a), prepared.b) + return base + delta.asType(base.dtype) + } + + /// LoRA matrices are converted, transposed, scaled, and cached on first use. + private struct PreparedLoRA { + let a: MLXArray + let b: MLXArray + } + private var loraCache: [String: PreparedLoRA] = [:] + + private func preparedLoRA(_ stem: String) -> PreparedLoRA? { + if let cached = loraCache[stem] { return cached } + guard let lora, + let aEntry = lora.entries["\(stem).lora_A"], + let bEntry = lora.entries["\(stem).lora_B"], + let aPointer = lora.pointer(to: "\(stem).lora_A"), + let bPointer = lora.pointer(to: "\(stem).lora_B") + else { return nil } + + func loadAdapter(_ entry: SafetensorsShard.Entry, + _ pointer: UnsafeRawPointer) -> MLXArray? { + let raw = UnsafeRawBufferPointer(start: pointer, count: entry.byteCount) + switch entry.dtype { + case "F16": return MLXArray(raw, entry.shape, type: Float16.self) + case "F32": return MLXArray(raw, entry.shape, type: Float.self) + default: return nil + } + } + guard let a = loadAdapter(aEntry, aPointer), + let bb = loadAdapter(bEntry, bPointer) + else { return nil } + // float16 throughout, as the Python implementation does. + let prepared = PreparedLoRA( + a: a.asType(.float16).transposed(), + b: (Float16(2.0) * bb.asType(.float16)).transposed()) + // Materialised here so the graph that produced them is not replayed per token. + eval(prepared.a, prepared.b) + loraCache[stem] = prepared + return prepared + } + + private func norm(_ x: MLXArray, _ name: String) -> MLXArray? { + guard let w = weight(name) else { return nil } + return MLXFast.rmsNorm(x.asType(.bfloat16), weight: w, eps: 1e-6) + } + + private func rmsNormalise(_ x: MLXArray) -> MLXArray { + let squares = mean(x.asType(.float32) * x.asType(.float32), axis: -1, keepDims: true) + return (x.asType(.float32) * rsqrt(squares + 1e-6)).asType(x.dtype) + } + + // MARK: - Layers + + /// Copy just the rows named, straight out of the mapping. + /// + /// `weight(_:)` builds an `MLXArray` over a whole tensor, and building one **copies** + /// — mlx-c routes it to `allocator::malloc` plus `std::copy`. For a table indexed one + /// row at a time that is the wrong shape by five orders of magnitude. + private func rows(_ name: String, _ indices: [Int32]) -> MLXArray? { + guard let entry = weights.entry(name), let base = weights.pointer(to: name), + entry.shape.count == 2, entry.shape[0] > 0 + else { return nil } + let columns = entry.shape[1] + let rowBytes = entry.byteCount / entry.shape[0] + var gathered = [UInt8](repeating: 0, count: rowBytes * indices.count) + gathered.withUnsafeMutableBytes { destination in + for (slot, row) in indices.enumerated() { + guard row >= 0, Int(row) < entry.shape[0] else { continue } + memcpy(destination.baseAddress!.advanced(by: slot * rowBytes), + base.advanced(by: Int(row) * rowBytes), rowBytes) + } + } + let shape = [indices.count, columns] + return gathered.withUnsafeBytes { raw -> MLXArray? in + switch entry.dtype { + case "U32", "I32": return MLXArray(raw, shape, type: UInt32.self) + case "BF16": return MLXArray(raw, shape, type: UInt16.self).view(dtype: .bfloat16) + case "F32": return MLXArray(raw, shape, type: Float.self) + default: return nil + } + } + } + + /// Gather only the embedding rows needed by the current token batch. + private func embed(_ tokens: MLXArray) -> MLXArray? { + let stem = "language_model.model.embed_tokens" + let ids = tokens.reshaped([-1]).asArray(Int32.self) + guard let w = rows("\(stem).weight", ids), + let s = rows("\(stem).scales", ids), + let b = rows("\(stem).biases", ids) + else { return nil } + // Dequantize only the gathered rows rather than the full embedding table. + let out = dequantized(w, scales: s, biases: b, + groupSize: configuration.groupSize, bits: configuration.bits, + mode: .affine) + return out.reshaped(tokens.shape + [out.dim(-1)]) + } + + /// Grouped-query attention with partial RoPE and an output gate. + /// + /// The gate is the second half of `q_proj`; RoPE covers 64 of each 256-dimensional + /// head and uses a base of 10,000,000. + private func fullAttention(_ x: MLXArray, layer: Int, state: State) -> MLXArray? { + let stem = "language_model.model.layers.\(layer).self_attn" + let heads = 16, kvHeads = 2, headDim = 256 + let (batch, length) = (x.dim(0), x.dim(1)) + + guard let qOut = linear(x, "\(stem).q_proj", bits: 4), + let kOut = linear(x, "\(stem).k_proj", bits: 4), + let vOut = linear(x, "\(stem).v_proj", bits: 4), + let qNorm = weight("\(stem).q_norm.weight"), + let kNorm = weight("\(stem).k_norm.weight") + else { return nil } + + let split = qOut.reshaped([batch, length, heads, 2 * headDim]) + var queries = split[.ellipsis, 0 ..< headDim] + let gate = split[.ellipsis, headDim ..< (2 * headDim)] + .reshaped([batch, length, heads * headDim]) + + var keys = kOut.reshaped([batch, length, kvHeads, headDim]) + var values = vOut.reshaped([batch, length, kvHeads, headDim]) + + queries = MLXFast.rmsNorm(queries, weight: qNorm, eps: 1e-6).transposed(0, 2, 1, 3) + keys = MLXFast.rmsNorm(keys, weight: kNorm, eps: 1e-6).transposed(0, 2, 1, 3) + values = values.transposed(0, 2, 1, 3) + + let offset = state.offset + func rope(_ a: MLXArray) -> MLXArray { + MLXFast.RoPE(a, dimensions: 64, traditional: false, + base: 10_000_000, scale: 1, offset: offset) + } + queries = rope(queries) + keys = rope(keys) + + if let pastKeys = state.keys[layer], let pastValues = state.values[layer] { + keys = concatenated([pastKeys, keys], axis: 2) + values = concatenated([pastValues, values], axis: 2) + } + state.keys[layer] = keys + state.values[layer] = values + + // A single query attending to everything before it needs no mask; passing a + // square one sized for the prompt is how a working prefill breaks decode. + let total = keys.dim(2) + var mask: MLXArray? = nil + if length > 1 { + var entries = [Float](repeating: 0, count: length * total) + for row in 0 ..< length { + for column in (offset + row + 1) ..< total { + entries[row * total + column] = -Float.greatestFiniteMagnitude + } + } + mask = MLXArray(entries, [length, total]).asType(queries.dtype) + } + + let attended = MLXFast.scaledDotProductAttention( + queries: queries, keys: keys, values: values, scale: 1.0 / 16.0, mask: mask) + let merged = attended.transposed(0, 2, 1, 3) + .reshaped([batch, length, heads * headDim]) + return linear(merged * sigmoid(gate), "\(stem).o_proj", bits: 4) + } + + /// Gated DeltaNet: depthwise causal convolution, then a gated delta-rule recurrence + /// over a float32 state. + /// + /// `q` and `k` carry 16 heads against `v`'s 32 and are matched by **consecutive** + /// duplication — h0, h0, h1, h1 — not by tiling. Both run; one is correct. + private func linearAttention(_ x: MLXArray, layer: Int, state: State) -> MLXArray? { + let stem = "language_model.model.layers.\(layer).linear_attn" + let kHeads = 16, vHeads = 32, headDim = 128 + let keyDim = kHeads * headDim, valueDim = vHeads * headDim + let convDim = keyDim * 2 + valueDim, kernel = 4 + let (batch, length) = (x.dim(0), x.dim(1)) + + guard let qkv = linear(x, "\(stem).in_proj_qkv", bits: 4), + let zRaw = linear(x, "\(stem).in_proj_z", bits: 4), + let aRaw = linear(x, "\(stem).in_proj_a", bits: 4), + let bRaw = linear(x, "\(stem).in_proj_b", bits: 4), + let convWeight = weight("\(stem).conv1d.weight"), + let aLog = weight("\(stem).A_log"), + let dtBias = weight("\(stem).dt_bias"), + let normWeight = weight("\(stem).norm.weight") + else { return nil } + + let history = state.convolution[layer]?.asType(qkv.dtype) + ?? MLXArray.zeros([batch, kernel - 1, convDim]).asType(qkv.dtype) + let tail = concatenated([history, qkv], axis: 1) + + // `conv1d` accumulates internally in float32. Preserve that precision for + // numerically consistent recurrent state updates. + let convolved = conv1d(tail, convWeight, groups: convDim) + let activated = convolved * sigmoid(convolved) + state.convolution[layer] = tail[0..., (tail.dim(1) - (kernel - 1))...] + + var q = activated[.ellipsis, 0 ..< keyDim].reshaped([batch, length, kHeads, headDim]) + var k = activated[.ellipsis, keyDim ..< (2 * keyDim)] + .reshaped([batch, length, kHeads, headDim]) + let v = activated[.ellipsis, (2 * keyDim) ..< convDim] + .reshaped([batch, length, vHeads, headDim]) + + // q is scaled by Dk⁻¹ and k by Dk^-0.5 — not the same factor. + let inverseScale = Float(pow(Double(headDim), -0.5)) + q = rmsNormalise(q) * (inverseScale * inverseScale) + k = rmsNormalise(k) * inverseScale + q = repeated(q, count: vHeads / kHeads, axis: -2) + k = repeated(k, count: vHeads / kHeads, axis: -2) + + let beta = sigmoid(bRaw.asType(.float32)) + let shifted = aRaw.asType(.float32) + dtBias.asType(.float32) + let g = MLX.exp(-MLX.exp(aLog.asType(.float32)) * MLX.log(MLX.exp(shifted) + 1)) + + var recurrent = state.recurrent[layer]?.asType(.float32) + ?? MLXArray.zeros([batch, vHeads, headDim, headDim], dtype: .float32) + var outputs: [MLXArray] = [] + // Use matrix contractions for the recurrent updates to avoid materializing large + // broadcast-multiply intermediates: + // + // (recurrent * kt[..., None, :]).sum(-1) 2 MiB temporary, reduced to 16 KB + // matmul(recurrent, kt[..., None]) 16 KB, straight out of a GEMV + // + // GEMV accumulation order can differ from elementwise reduction in the last bits. + for t in 0 ..< length { + let qt = q[0..., t].asType(.float32).expandedDimensions(axis: -1) + let kt = k[0..., t].asType(.float32).expandedDimensions(axis: -1) + let vt = v[0..., t].asType(.float32) + let decay = g[0..., t].expandedDimensions(axis: -1).expandedDimensions(axis: -1) + + recurrent = recurrent * decay + let memory = matmul(recurrent, kt).squeezed(axis: -1) + let delta = (vt - memory) * beta[0..., t].expandedDimensions(axis: -1) + // The outer product has to materialise 2 MiB whichever way it is written, so + // this one stays as it was. + recurrent = recurrent + + kt.swappedAxes(-1, -2) * delta.expandedDimensions(axis: -1) + outputs.append(matmul(recurrent, qt).squeezed(axis: -1)) + } + state.recurrent[layer] = recurrent + + let stacked = MLX.stacked(outputs, axis: 1).asType(x.dtype) + let z = zRaw.reshaped([batch, length, vHeads, headDim]) + // RMSNormGated, in float32 as trained. + let normalised = MLXFast.rmsNorm(stacked, weight: normWeight, eps: 1e-6) + .asType(.float32) + let gated = (sigmoid(z.asType(.float32)) * z.asType(.float32) * normalised) + .asType(x.dtype) + return linear(gated.reshaped([batch, length, valueDim]), "\(stem).out_proj", bits: 4) + } + + /// Router, K streamed experts, and the shared expert. + /// + /// Apply softmax over all expert logits before top-k selection, then renormalize the + /// selected scores. + /// Routed experts for **one or more** tokens. + /// + /// The single-token form this replaces treated the K experts as the batch axis, every + /// one of them seeing the same input. With T tokens there are T*K (token, expert) + /// pairs and each needs its own input row, so the batch axis becomes T*K and the + /// expert for each row is named by `rhsIndices`. + /// + /// For a batch, read the union of experts selected by its tokens. The union may be + /// smaller than T*K when tokens share expert selections. + private func moe(_ x: MLXArray, layer: Int, state: State) -> MLXArray? { + let prefix = "language_model.model.layers.\(layer).mlp" + let k = configuration.activeExperts + let tokens = x.dim(1) + + if tokens >= Self.fullLayerPrefillMinimum { + return moeFullLayer(x, layer: layer, state: state, prefix: prefix, k: k, tokens: tokens) + } + + let ids: [Int] + var scores: MLXArray + + // Predictions replace the normal gate during single-token decode. Prefill uses + // the normal gate because its multi-token routing shape differs. + if tokens == 1, let predicted = state.pregate.predicted[layer], + let predictedScores = state.pregate.scores[layer], predicted.count == k { + ids = predicted + // Rebuilt here, from k floats. The alternative — keeping the slice of the + // staging array — would hold that array and its graph across the step. + scores = MLXArray(predictedScores, [1, 1, k]) + } else { + guard let logits = linear(x, "\(prefix).gate", bits: 8) else { return nil } + let gates = softmax(logits.asType(.float32), axis: -1) + let chosen = argPartition(-gates, kth: k - 1, axis: -1)[.ellipsis, 0 ..< k] + scores = takeAlong(gates, chosen, axis: -1) + scores = scores / scores.sum(axis: -1, keepDims: true) + + // Expert IDs must reach the CPU before their file reads can be issued. + let kind: StepProfile.Phase = + configuration.isLinear(layer) ? .syncLinear : .syncFull + ids = profile.measure(kind) { () -> [Int] in + eval(chosen, scores) + return chosen.reshaped([-1]).asArray(Int32.self).map { Int($0) } + } + } + guard ids.count == tokens * k else { return nil } + + // Captured for this layer's own head, which runs at the step boundary and + // predicts for `layer + 1`. The last position is the right one for both: a + // prefill's final position is what the first decode step continues from. + // + // Store the routes actually used, including predictions, as inputs for the next + // pregate evaluation. Tensor reshaping is deferred until after graph evaluation. + if let pregate, pregate.owners.contains(layer) { + state.pregate.moeInput[layer] = x + state.pregate.executed[layer] = ids.suffix(k).map { Int32($0) } + } + + // Distinct experts in first-appearance order, and where each pair's expert landed. + var rowOf: [Int: Int] = [:] + var union: [Int] = [] + for id in ids where rowOf[id] == nil { + rowOf[id] = union.count + union.append(id) + } + profile.recordRouting(union: union.count, pairs: ids.count) + + // How much of what this layer needs was already asked for. **This is the number + // that bounds the whole scheme** — the read can only be hidden for blocks the + // guess anticipated — and it is free to collect, because both sets are already + // on the CPU. Recorded before the routing is overwritten below. + if configuration.routingGuess != .none { + let guessed = state.prefetched[layer] ?? [] + profile.recordPrefetch( + hits: union.count { guessed.contains($0) }, total: union.count) + } + // What executed, for the next step to guess from. The union rather than the + // last position's pair: during prefill that is strictly more informative, and + // during decode they are the same thing. + var history = state.recentRouting[layer] ?? [] + history.insert(union, at: 0) + state.recentRouting[layer] = Array(history.prefix(configuration.routingLookback)) + + guard let pool = try? experts.load(layer: layer, experts: union), + let rows = try? ids.map({ id -> Int32 in + guard let row = rowOf[id] else { throw Failure.layerFailed(layer) } + return Int32(row) + }) + else { return nil } + + // **The single-token form is kept separate, and it is not premature.** Generalising + // this for batching added a `reshaped` on top of a broadcast — a stride-0 view that + // a reshape has to materialise — and decode runs this on every token of every + // layer. Batching is used only by prefill; making decode pay for it was a + // regression, not a simplification. + let expanded = x.reshaped([tokens, 1, configuration.hidden]).asType(.bfloat16) + let inputs = tokens == 1 + ? broadcast(expanded, to: [k, 1, configuration.hidden]) + : broadcast(expanded, to: [tokens, k, configuration.hidden]) + .reshaped([tokens * k, 1, configuration.hidden]) + let indices = MLXArray(rows) + + func project(_ input: MLXArray, _ offset: Int) -> MLXArray { + gatherQuantizedMM( + input, pool[offset], scales: pool[offset + 1], biases: pool[offset + 2], + rhsIndices: indices, transpose: true, + groupSize: configuration.groupSize, bits: configuration.bits, + // **An assertion about the input, not a request**, so it is true only + // when it is true. With one token the union is built in first-appearance + // order from k distinct experts, making the rows exactly `0 ..< k` and + // ascending; with more, rows repeat and are unordered, and claiming + // otherwise would silently gather the wrong experts. + mode: .affine, sortedIndices: tokens == 1) + } + let gate = project(inputs, ExpertLoader.gate) + let up = project(inputs, ExpertLoader.up) + let routed = project(gate * sigmoid(gate) * up, ExpertLoader.down) + let weighted = routed + * scores.reshaped([tokens * k, 1, 1]).asType(routed.dtype) + let combined = weighted + .reshaped([tokens, k, configuration.hidden]) + .sum(axis: 1) + + // Compute the shared expert after routed expert loading to avoid competing for + // memory bandwidth during the reads. + guard let sharedGate = linear(x, "\(prefix).shared_expert_gate", bits: 8), + let sg = linear(x, "\(prefix).shared_expert.gate_proj", bits: 4), + let su = linear(x, "\(prefix).shared_expert.up_proj", bits: 4), + let shared = linear(sg * sigmoid(sg) * su, + "\(prefix).shared_expert.down_proj", bits: 4) + else { return nil } + return combined.reshaped(shared.shape) + sigmoid(sharedGate) * shared + } + + /// Prefill for a long chunk: the layer file is read once, and expert ids + /// index that stack directly. The ids still come back to the CPU afterwards + /// so the pregate feature sees what actually ran; the read no longer waits + /// on that sync. + private func moeFullLayer(_ x: MLXArray, layer: Int, state: State, + prefix: String, k: Int, tokens: Int) -> MLXArray? { + guard let pool = try? experts.loadFullLayer(layer: layer), + let logits = linear(x, "\(prefix).gate", bits: 8) + else { return nil } + let gates = softmax(logits.asType(.float32), axis: -1) + let chosen = argPartition(-gates, kth: k - 1, axis: -1)[.ellipsis, 0 ..< k] + var scores = takeAlong(gates, chosen, axis: -1) + scores = scores / scores.sum(axis: -1, keepDims: true) + + let kind: StepProfile.Phase = + configuration.isLinear(layer) ? .syncLinear : .syncFull + let ids: [Int] = profile.measure(kind) { + eval(chosen, scores) + return chosen.reshaped([-1]).asArray(Int32.self).map { Int($0) } + } + guard ids.count == tokens * k else { return nil } + if let pregate, pregate.owners.contains(layer) { + state.pregate.moeInput[layer] = x + state.pregate.executed[layer] = ids.suffix(k).map { Int32($0) } + } + var rowOf: [Int: Int] = [:] + var union: [Int] = [] + for id in ids where rowOf[id] == nil { + rowOf[id] = union.count + union.append(id) + } + profile.recordRouting(union: union.count, pairs: ids.count) + var history = state.recentRouting[layer] ?? [] + history.insert(union, at: 0) + state.recentRouting[layer] = Array(history.prefix(configuration.routingLookback)) + + let expanded = x.reshaped([tokens, 1, configuration.hidden]).asType(.bfloat16) + let inputs = broadcast(expanded, to: [tokens, k, configuration.hidden]) + .reshaped([tokens * k, 1, configuration.hidden]) + // Row `e` of the full stack is expert `e`, so the router index is the gather index. + let indices = chosen.reshaped([tokens * k]) + func project(_ input: MLXArray, _ offset: Int) -> MLXArray { + gatherQuantizedMM( + input, pool[offset], scales: pool[offset + 1], biases: pool[offset + 2], + rhsIndices: indices, transpose: true, + groupSize: configuration.groupSize, bits: configuration.bits, + mode: .affine, sortedIndices: false) + } + let gate = project(inputs, ExpertLoader.gate) + let up = project(inputs, ExpertLoader.up) + let routed = project(gate * sigmoid(gate) * up, ExpertLoader.down) + let weighted = routed * scores.reshaped([tokens * k, 1, 1]).asType(routed.dtype) + let combined = weighted.reshaped([tokens, k, configuration.hidden]).sum(axis: 1) + guard let sharedGate = linear(x, "\(prefix).shared_expert_gate", bits: 8), + let sg = linear(x, "\(prefix).shared_expert.gate_proj", bits: 4), + let su = linear(x, "\(prefix).shared_expert.up_proj", bits: 4), + let shared = linear(sg * sigmoid(sg) * su, + "\(prefix).shared_expert.down_proj", bits: 4) + else { return nil } + return combined.reshaped(shared.shape) + sigmoid(sharedGate) * shared + } + + /// Run all routing heads as one batch and return the expert IDs for the next step. + /// + /// Returns the expert set per consuming layer, which is what a prefetch needs. + @discardableResult + private func stagePregate(state: State) -> [Int: [Int]] { + guard let pregate else { return [:] } + let k = configuration.activeExperts + + var inputs: [MLXArray] = [] + var executed: [[Int32]] = [], previous: [[Int32]] = [] + inputs.reserveCapacity(pregate.owners.count) + for owner in pregate.owners { + // A pruned or otherwise absent owner would desynchronise the stack from + // `owners`, so the whole staging is skipped rather than silently shifted. + // + // Skipping has to *clear* the predictions, not just decline to write new + // ones. `advance()` deliberately keeps `predicted` alive across the step + // boundary — that is how a prediction made at step N reaches step N+1 — so + // leaving it untouched here would hand step N+1 the routing computed for + // step N, one token stale, with nothing in any log to say so. + guard let input = state.pregate.moeInput[owner], + let ran = state.pregate.executed[owner] + else { + state.pregate.predicted = [:] + state.pregate.scores = [:] + state.pregate.advance() + return [:] + } + // Sliced here rather than at capture time: by now `eval(h)` has run, so this + // reshapes a materialised array instead of adding a node to a live graph. + // The last position is the right one for a prefill chunk too — it is what + // the first decode step continues from. + let vector = input.ndim == 1 + ? input + : (input.dim(1) == 1 ? input.reshaped([-1]) : input[0, -1]) + inputs.append(vector) + executed.append(ran) + // Use a zero row when no previous-token routing is available. + previous.append(state.pregate.previous[owner] ?? []) + } + + let logits = pregate.logits( + inputs: stacked(inputs, axis: 0), + executed: pregate.oneHotRows(executed), + previous: pregate.oneHotRows(previous)) + + // Apply the same softmax, top-k selection, and score normalization as the router. + let gates = softmax(logits, axis: -1) + let chosen = argPartition(-gates, kth: k - 1, axis: -1)[.ellipsis, 0 ..< k] + var weights = takeAlong(gates, chosen, axis: -1) + weights = weights / weights.sum(axis: -1, keepDims: true) + + // The one barrier, and it brings back both halves. Pulling the weights across + // too costs 66 floats and means nothing MLX-shaped is carried into the next + // step — a view into `weights` would have kept the array, and its graph, alive + // for every one of the thirty-three layers that held one. + let (flat, weighting) = profile.measure(.pregateStage) { () -> ([Int32], [Float]) in + eval(chosen, weights) + return (chosen.reshaped([-1]).asArray(Int32.self), + weights.reshaped([-1]).asArray(Float.self)) + } + + var sets: [Int: [Int]] = [:] + for (index, consumer) in pregate.consumers.enumerated() { + let range = (index * k) ..< ((index + 1) * k) + let slice = flat[range].map { Int($0) } + state.pregate.predicted[consumer] = slice + state.pregate.scores[consumer] = Array(weighting[range]) + sets[consumer] = Array(Set(slice)).sorted() + } + + state.pregate.advance() + return sets + } + + // MARK: - Forward + + /// Run one model step and evaluate the resulting graph once at the end. + func step(tokens: MLXArray, state: State) throws -> MLXArray { + try step(tokens: tokens, state: state, needsLogits: true)! + } + + /// One step, with the option of **not** projecting to the vocabulary. + /// + /// During prefill, intermediate positions do not need vocabulary logits. This option + /// skips that projection when the result will not be consumed. + func step(tokens: MLXArray, state: State, needsLogits: Bool) throws -> MLXArray? { + guard var h = embed(tokens) else { throw Failure.tensorMissing("embed_tokens") } + // Checkpoint weights use bfloat16; keep activations in the same format. + h = h.asType(.bfloat16) + + let batchedPrefill = tokens.dim(1) >= Self.fullLayerPrefillMinimum + for layer in 0 ..< configuration.layers { + if batchedPrefill && layer % 10 == 0 { + print("[35b layer] \(layer) T=\(tokens.dim(1))") + } + let stem = "language_model.model.layers.\(layer)" + guard let normed = norm(h, "\(stem).input_layernorm.weight") else { + throw Failure.layerFailed(layer) + } + let attended = configuration.isLinear(layer) + ? linearAttention(normed, layer: layer, state: state) + : fullAttention(normed, layer: layer, state: state) + guard let attended else { throw Failure.layerFailed(layer) } + h = h + attended + + guard let post = norm(h, "\(stem).post_attention_layernorm.weight"), + let mixed = moe(post, layer: layer, state: state) + else { throw Failure.layerFailed(layer) } + h = h + mixed + // A full-layer expert stack is 432 MiB. Evaluating here drops it + // before the next layer's read; holding all forty would be 17 GB. + if batchedPrefill { + eval(h) + if let held = state.pregate.moeInput[layer] { + let last = held[0, -1] + eval(last) + state.pregate.moeInput[layer] = last + } + } + } + eval(h) + + // Run routing predictions after graph evaluation; they consume hidden states + // produced by this step and provide expert IDs for the next step. + switch configuration.routingGuess { + case .none: + break + case .trainedHeads: + if tokens.dim(1) > 1 && !needsLogits { + // Batched prefill never consumes the one-token-ahead head predictions. + // Reuse the real routing as a read hint and avoid loading/running 132 MiB + // of heads until the final prefill batch, whose prediction is consumed + // by the first decode step. + state.prefetched = state.recentRouting.mapValues { Array(Set($0.joined())) } + for (layer, chosen) in state.prefetched { + experts.prefetch(layer: layer, experts: chosen) + } + break + } + state.prefetched = [:] + for (layer, chosen) in stagePregate(state: state) { + state.prefetched[layer] = chosen + experts.prefetch(layer: layer, experts: chosen) + } + // The official heads cover consumers 7...39. Preserve their exact routing, + // but prefetch the uncovered first layers from their last real routing. + // This is advice only: misses still run the router and read the right files. + for layer in 0 ..< configuration.layers where state.prefetched[layer] == nil { + guard let chosen = state.recentRouting[layer]?.first else { continue } + state.prefetched[layer] = chosen + experts.prefetch(layer: layer, experts: chosen) + } + case .recentRouting: + // Snapshotted, not read live: `recentRouting` is rewritten layer by layer as + // the next step runs, so scoring the hit rate against it would compare a + // layer's routing with its own and report a meaningless 100%. + state.prefetched = state.recentRouting.mapValues { Array(Set($0.joined())) } + for (layer, chosen) in state.prefetched { + experts.prefetch(layer: layer, experts: chosen) + } + } + + state.offset += tokens.dim(1) + profile.countStep() + guard needsLogits else { return nil } + guard let final = norm(h, "language_model.model.norm.weight") else { + throw Failure.tensorMissing("model.norm") + } + if coreMLHeadEnabled { + // Core ML consumes only the final position. This is 4 KiB instead of the + // 248,320-element logits vector, and avoids retaining MLX's 286 MiB head. + let last = final[0, -1].reshaped([1, configuration.hidden]) + eval(last) + return last + } + guard let logits = linear(final, "language_model.lm_head", bits: 4) else { + throw Failure.tensorMissing("lm_head") + } + return logits + } + + /// Argmax over the last position's logits. + func greedy(_ output: MLXArray) async throws -> Int32 { + if coreMLHeadEnabled, let coreMLHead, + output.dim(output.ndim - 1) == configuration.hidden { + let coreMLToken = try await profile.measure(.vocabularyHead) { + try await coreMLHead.predict(output) + } + if !coreMLHeadValidated { + guard let logits = linear(output, "language_model.lm_head", bits: 4) else { + throw Failure.tensorMissing("lm_head") + } + let mlxToken = Self.mlxGreedy(logits) + if mlxToken != coreMLToken { + coreMLHeadEnabled = false + print("[coreml-head] parity FAIL coreml=\(coreMLToken) mlx=\(mlxToken); falling back") + return mlxToken + } + coreMLHeadValidated = true + retainedWeights = retainedWeights.filter { + !$0.key.hasPrefix("language_model.lm_head.") + } + print("[coreml-head] parity PASS token=\(coreMLToken)") + } + return coreMLToken + } + return Self.mlxGreedy(output) + } + + private static func mlxGreedy(_ logits: MLXArray) -> Int32 { + // `-1`, not `0`: with a batched forward the interesting logits are the last + // position's. Identical for a single token, which is why this was never wrong. + let final = logits.ndim == 2 ? logits[0] : logits[0, -1] + let choice = argMax(final.asType(.float32), axis: -1) + eval(choice) + return choice.item(Int32.self) + } + + /// Greedy decode. Prompt and generation take the same path — one token at a time — + /// which costs a little on prefill and buys a single code path. + func generate(promptIds: [Int32], stop: StopCondition, + state: State? = nil, + onToken: @escaping (Int32) -> Bool) throws -> [Int32] + { + let state = state ?? State() + var produced: [Int32] = [] + var next: Int32 = 0 + + for (index, token) in promptIds.enumerated() { + let logits = try step(tokens: MLXArray([token], [1, 1]), state: state) + if index == promptIds.count - 1 { + let choice = argMax(logits[0, 0].asType(.float32), axis: -1) + eval(choice) + next = choice.item(Int32.self) + } + } + + while true { + switch stop.evaluate(token: next, produced: produced.count) { + case .hitEndToken, .hitLimit: + return produced + case .keepGoing: + break + } + produced.append(next) + // The callback decides whether to continue, so cancellation is the caller's + // to express rather than something this loop has to be told about. + guard onToken(next) else { return produced } + + let logits = try step(tokens: MLXArray([next], [1, 1]), state: state) + let choice = argMax(logits[0, 0].asType(.float32), axis: -1) + eval(choice) + next = choice.item(Int32.self) + } + } +} diff --git a/ios/Sources/Edge0MLX/Edge035B/ExpertLoader.swift b/ios/Sources/Edge0MLX/Edge035B/ExpertLoader.swift new file mode 100644 index 0000000..1687f20 --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge035B/ExpertLoader.swift @@ -0,0 +1,241 @@ +import Foundation +import MLX + +/// Loads routed experts from the per-layer files and builds the stacked tensors used by +/// `gatherQuantizedMM`. Reads for the selected experts are issued concurrently. +final class ExpertLoader: @unchecked Sendable { + + enum Failure: LocalizedError { + case noLayers(URL) + case unknownLayer(Int, available: Int) + case expertOutOfRange(Int) + case wrongSlotCount(asked: Int, configured: Int) + case readFailed(layer: Int, expert: Int) + + var errorDescription: String? { + switch self { + case .noLayers(let url): + return "no intact layer files in \(url.lastPathComponent)" + case .unknownLayer(let layer, let available): + return "layer \(layer) is not loaded (\(available) layers available)" + case .expertOutOfRange(let expert): + return "expert \(expert) outside 0..<\(RepackedExperts.expertsPerLayer)" + case .wrongSlotCount(let asked, let configured): + return "asked for \(asked) experts, loader is built for \(configured)" + case .readFailed(let layer, let expert): + return "read failed for layer \(layer) expert \(expert)" + } + } + } + + /// Where each tensor sits in the stacked pool handed to `gatherQuantizedMM`: + /// weight, scales, biases for gate, then up, then down. + static let gate = 0, up = 3, down = 6 + + private let experts: RepackedExperts + private let slots: Int + private var buffers: [UnsafeMutableRawPointer] = [] + + + private let shapes: [(weight: [Int], scale: [Int])] + + /// Set by the model so reads and array construction land in the same accounting as + /// the router barrier. Optional so the loader stays usable without it. + var profile: StepProfile? + + var availableLayers: [Int] { experts.availableLayers } + var rejectedFiles: [(layer: Int, bytes: Int)] { experts.rejected } + + init(directory: URL, layers: Int, slots: Int, hidden: Int, + moeIntermediate: Int, groupSize: Int) throws + { + self.experts = RepackedExperts(directory: directory, expectedLayers: layers) + guard !experts.availableLayers.isEmpty else { throw Failure.noLayers(directory) } + + self.slots = slots + + self.shapes = [ + (weight: [moeIntermediate, hidden / 8], scale: [moeIntermediate, hidden / groupSize]), + (weight: [moeIntermediate, hidden / 8], scale: [moeIntermediate, hidden / groupSize]), + (weight: [hidden, moeIntermediate / 8], scale: [hidden, moeIntermediate / groupSize]), + ] + } + + /// Nine tensors: gate weight/scales/biases, then up, then down. + /// + /// Throws rather than returning something plausible. A silently wrong expert + /// produces fluent nonsense several layers later, which is far more expensive to + /// find than a thrown error here. + deinit { + buffers.forEach { $0.deallocate() } + } + + /// Ask the kernel to prefetch the selected expert blocks into the file cache. + func prefetch(layer: Int, experts chosen: [Int]) { + for expert in chosen where expert >= 0 && expert < RepackedExperts.expertsPerLayer { + experts.prefetch(layer: layer, expert: expert) + } + } + + /// Load **1 to `slots`** distinct experts. + /// + /// Was fixed at exactly `slots`, which is what a single token needs at K=2. A batch + /// of T tokens routes to the *union* of their choices — anywhere from K (all tokens + /// agree) to T*K (none do) — so `slots` became the ceiling rather than the count. + func load(layer: Int, experts chosen: [Int]) throws -> [MLXArray] { + guard !chosen.isEmpty, chosen.count <= slots else { + throw Failure.wrongSlotCount(asked: chosen.count, configured: slots) + } + let count = chosen.count + guard experts.availableLayers.contains(layer) else { + throw Failure.unknownLayer(layer, available: experts.availableLayers.count) + } + for expert in chosen where expert < 0 || expert >= RepackedExperts.expertsPerLayer { + throw Failure.expertOutOfRange(expert) + } + + // A larger prefill batch needs a larger *possible* expert union, but reserving + // T*K blocks eagerly makes the worst case permanent. Grow to the union we have + // actually observed and reuse that high-water mark on later layers/turns. + while buffers.count < count { + buffers.append(UnsafeMutableRawPointer.allocate( + byteCount: RepackedExperts.blockBytes, + alignment: RepackedExperts.pageSize)) + } + + // Read each expert block into reusable aligned buffers, then stack its tensors. + let sources = Array(buffers.prefix(count)) + let missing = Array(0 ..< count) + + var failures = [Bool](repeating: false, count: missing.count) + let buffers = self.buffers + let experts = self.experts + let read = { + failures.withUnsafeMutableBufferPointer { flags in + let flagged = flags + let slots = missing + DispatchQueue.concurrentPerform(iterations: slots.count) { index in + let slot = slots[index] + flagged[index] = !experts.read( + layer: layer, expert: chosen[slot], into: buffers[slot]) + } + } + } + if let profile { profile.measure(.expertRead, read) } else { read() } + profile?.recordRead(bytes: missing.count * RepackedExperts.blockBytes, + issued: missing.count) + if let index = failures.firstIndex(of: true) { + throw Failure.readFailed(layer: layer, expert: chosen[missing[index]]) + } + + // `stack` is where `MLXArray(raw:)` runs, and that is `malloc` + `std::copy` + // in mlx-c — eager, so timing it needs no forced evaluation. + var pool: [MLXArray] = [] + pool.reserveCapacity(9) + let shapes = self.shapes + let build: () -> Void = { + for (projection, parts) in [ + (0, (RepackedExperts.Part.gateWeight, RepackedExperts.Part.gateScales, + RepackedExperts.Part.gateBiases)), + (1, (.upWeight, .upScales, .upBiases)), + (2, (.downWeight, .downScales, .downBiases)), + ] { + let shape = shapes[projection] + pool.append(self.stack(parts.0, shape.weight, bf16: false, from: sources)) + pool.append(self.stack(parts.1, shape.scale, bf16: true, from: sources)) + pool.append(self.stack(parts.2, shape.scale, bf16: true, from: sources)) + } + } + if let profile { profile.measure(.expertStack, build) } else { build() } + return pool + } + + /// Every expert in the layer, stacked as `[256, …]` in expert-id order. + /// + /// The file is one sequential 432 MiB read. Parts are then packed into the + /// nine tensors `gatherQuantizedMM` already understands, so a prefill chunk + /// can gather by expert id without a per-expert `pread` or a CPU copy of + /// the index list. The file buffer is released before return; MLX has + /// copied the nine tensors. Callers must `eval` and drop those tensors + /// before the next layer, or forty of them become 17 GB. + func loadFullLayer(layer: Int) throws -> [MLXArray] { + guard experts.availableLayers.contains(layer) else { + throw Failure.unknownLayer(layer, available: experts.availableLayers.count) + } + let file = UnsafeMutableRawPointer.allocate( + byteCount: RepackedExperts.layerBytes, alignment: RepackedExperts.pageSize) + defer { file.deallocate() } + + let read: () -> Bool = { self.experts.readLayer(layer, into: file) } + let ok = profile?.measure(.expertRead, read) ?? read() + guard ok else { throw Failure.readFailed(layer: layer, expert: -1) } + profile?.recordRead(bytes: RepackedExperts.layerBytes, issued: 1) + + let shapes = self.shapes + let parts: [(RepackedExperts.Part, RepackedExperts.Part, RepackedExperts.Part)] = [ + (.gateWeight, .gateScales, .gateBiases), + (.upWeight, .upScales, .upBiases), + (.downWeight, .downScales, .downBiases), + ] + var pool: [MLXArray] = [] + pool.reserveCapacity(9) + let build: () -> Void = { + for (projection, triple) in parts.enumerated() { + let shape = shapes[projection] + for (part, bf16, partShape) in [ + (triple.0, false, shape.weight), + (triple.1, true, shape.scale), + (triple.2, true, shape.scale), + ] { + pool.append(self.stackedPart( + part, partShape, bf16: bf16, file: file)) + } + } + } + if let profile { profile.measure(.expertStack, build) } else { build() } + return pool + } + + /// Expert id `e` lives at byte `e * block` in the layer file. Gather one + /// part from all 256 blocks into a contiguous `[256, …]` tensor. + private func stackedPart(_ part: RepackedExperts.Part, _ shape: [Int], + bf16: Bool, file: UnsafeMutableRawPointer) -> MLXArray { + let count = RepackedExperts.expertsPerLayer + let bytes = part.byteCount + let packed = UnsafeMutableRawPointer.allocate( + byteCount: count * bytes, alignment: 64) + defer { packed.deallocate() } + for expert in 0 ..< count { + packed.advanced(by: expert * bytes).copyMemory( + from: file.advanced(by: expert * RepackedExperts.blockBytes + part.offset), + byteCount: bytes) + } + let raw = UnsafeRawBufferPointer(start: packed, count: count * bytes) + let stackedShape = [count] + shape + return bf16 + ? MLXArray(raw, stackedShape, type: UInt16.self).view(dtype: .bfloat16) + : MLXArray(raw, stackedShape, type: UInt32.self) + } + + /// One part across all K slots, stacked along a new leading axis. + /// + /// bf16 is reached by reinterpreting the bits, never by converting values. An early + /// version widened each half in a Swift loop — roughly 200,000 iterations per layer + /// — and that single artifact produced the spike's original 0.5 tok/s figure. + private func stack(_ part: RepackedExperts.Part, _ shape: [Int], bf16: Bool, + from sources: [UnsafeMutableRawPointer]) -> MLXArray + { + // Sources are the staging buffers `load` has just filled. `MLXArray(raw:)` + // copies eagerly, so they are free to be overwritten the moment this returns. + let slices = sources.map { buffer -> MLXArray in + let raw = UnsafeRawBufferPointer( + start: buffer.advanced(by: part.offset), count: part.byteCount) + return bf16 + ? MLXArray(raw, shape, type: UInt16.self).view(dtype: .bfloat16) + : MLXArray(raw, shape, type: UInt32.self) + } + return slices.count == 1 + ? slices[0].expandedDimensions(axis: 0) + : MLX.stacked(slices, axis: 0) + } +} diff --git a/ios/Sources/Edge0MLX/Edge035B/PrefixCache.swift b/ios/Sources/Edge0MLX/Edge035B/PrefixCache.swift new file mode 100644 index 0000000..25b980e --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge035B/PrefixCache.swift @@ -0,0 +1,192 @@ +import Foundation +import MLX + +/// Decode state captured after a fixed run of prompt tokens, so those tokens are never +/// processed twice. +/// +/// **This is the single largest win available on time-to-first-character.** A measured +/// prefill runs at 258 ms/token, and of a 96-token prompt the great majority is the +/// system instructions — byte-identical on every request, in every conversation, for the +/// life of the install. Recomputing it is the bulk of a ~25 second wait. +/// +/// ## Why it is unusually cheap here +/// +/// Measured on device: **63 MiB**, against a 2.31 GB peak and a ~3.5 GB jetsam line. +/// +/// Sixty of those MiB are the thirty DeltaNet layers' recurrent state, whose size **does +/// not depend on how many tokens produced it** — caching a 96-token prefix and a +/// 10,000-token prefix cost the same. Only the ten full-attention layers contribute a +/// per-token term, at 2 KiB each. +/// +/// The property that makes those thirty layers awkward elsewhere — the state is a fixed +/// matrix updated in place, so dropping history does not shrink it and `replaceHistory` +/// cannot prune cheaply — is exactly the property that makes them nearly free to cache. +/// A plain forty-layer transformer of these dimensions would need 80 KiB per token. +/// +/// ## Why the prefix is verified rather than assumed +/// +/// The obvious implementation trusts that encoding the system message alone yields the +/// same tokens as encoding it as the head of the full prompt. Byte-level BPE gives no +/// such guarantee in general: merges are free to cross a boundary the renderer considers +/// structural. So the cached token run is stored alongside the state and checked against +/// the real prompt on every use. A mismatch costs an array comparison and falls back to +/// prefilling normally — it can never produce a wrong answer. +/// +/// This also makes invalidation automatic. Editing the system instructions changes the +/// tokens, the stored run stops matching, and the stale file is simply ignored. +struct PrefixCache { + + /// The exact tokens this state was produced by. + let tokens: [Int32] + let state: Edge0Model35B.State + + /// Whether `ids` begins with this cache's tokens, and so can skip them. + func matches(_ ids: [Int32]) -> Bool { + // An empty run is rejected explicitly. `prefix(0)` equals `[]` for every input, + // so without this a cache holding nothing would claim to match everything — + // currently unreachable, since neither capture nor `read` can produce one, but + // it is the kind of vacuous truth that stops being unreachable quietly. + guard !tokens.isEmpty else { return false } + + // A prompt *equal* to the prefix is rejected: replaying the last token is what + // produces the logits generation starts from, so there has to be one left. + return ids.count > tokens.count && Array(ids.prefix(tokens.count)) == tokens + } + + // MARK: - Persistence + + /// Where the snapshot lives between launches. + /// + /// **Persisting matters more than caching in memory.** An in-process cache only helps + /// the second message onward; the first one after launch — the one that decides + /// whether the app feels usable at all — still pays in full. + /// + /// **Deliberately not beside the weights.** `Documents/repacked` is created by + /// `devicectl` when the 19 GB is pushed, and the app cannot write into it: + /// + /// free=6063MiB dirExists=true + /// smallWrite=Code=513 "You don't have permission to save the file" + /// + /// Six gigabytes free and a directory that plainly exists, so this is ownership, not + /// space. The same cause explains a puzzle from the transfer work — partial files that + /// **neither** `devicectl` **nor** the app could delete. Anything the app needs to + /// write belongs somewhere the app made. + /// + /// Application Support rather than Caches: this is derived and rebuildable, but + /// rebuilding costs twenty-five seconds of the user's attention, which is not what + /// Caches is for. + /// - Parameter fingerprint: identifies the model configuration. **Required, because + /// `matches` compares tokens and nothing else.** A snapshot taken by the full + /// forty-layer model is meaningless to a pruned one — same prompt, different + /// function — and restoring it would produce confident nonsense with no error + /// anywhere. Separate files mean a configuration change simply misses the cache. + static func fileURL(in directory: URL, fingerprint: String) -> URL { + let manager = FileManager.default + guard let support = manager.urls( + for: .applicationSupportDirectory, in: .userDomainMask).first + else { + return directory.appendingPathComponent( + "prefix-cache-\(fingerprint).safetensors") + } + try? manager.createDirectory(at: support, withIntermediateDirectories: true) + return support.appendingPathComponent("prefix-cache-\(fingerprint).safetensors") + } + + /// Flattened for safetensors, which stores a flat name→array map. + /// + /// The token run is written as an array too, so the file is self-describing: nothing + /// outside it needs to stay in sync for the verification above to work. + private func flattened() -> [String: MLXArray] { + // **Everything is forced contiguous before it is written.** safetensors stores a + // plain row-major buffer and cannot represent a strided view, so saving one + // fails. The convolution state is exactly that — `state.convolution[layer]` is a + // tail slice of a larger array — and the failure was silent: the write was + // wrapped in `try?`, so the only symptom was a cache that worked within a session + // and never survived a launch. + var out: [String: MLXArray] = ["tokens": MLXArray(tokens)] + for (layer, value) in state.convolution { out["conv.\(layer)"] = contiguous(value) } + for (layer, value) in state.recurrent { out["rec.\(layer)"] = contiguous(value) } + for (layer, value) in state.keys { out["k.\(layer)"] = contiguous(value) } + for (layer, value) in state.values { out["v.\(layer)"] = contiguous(value) } + return out + } + + /// Write the snapshot. Cache persistence is best-effort; a missing cache affects + /// startup speed but not generation correctness. + func write(to url: URL) { + do { + let arrays = flattened() + eval(Array(arrays.values)) + try save(arrays: arrays, url: url) + } catch { + let directory = url.deletingLastPathComponent() + let free = (try? directory.resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey]))? + .volumeAvailableCapacityForImportantUsage + let probe = directory.appendingPathComponent(".write-probe") + var probeResult = "ok" + do { + try Data([0]).write(to: probe) + try? FileManager.default.removeItem(at: probe) + } catch { + probeResult = "\(error)" + } + print(""" + [prefix-cache] write failed: \(error) + [prefix-cache] free=\(free.map { "\($0 / 1_048_576)MiB" } ?? "unknown") \ + dirExists=\(FileManager.default.fileExists(atPath: directory.path)) \ + smallWrite=\(probeResult) + """) + } + } + + /// Read a snapshot back, or `nil` if there is nothing usable there. + /// + /// Every failure mode — absent file, truncated write, a format from an older build — + /// resolves to `nil` and a normal prefill. A cache is an optimisation; it is never + /// worth failing a request over. + static func read(from url: URL) -> PrefixCache? { + guard FileManager.default.fileExists(atPath: url.path), + let arrays = try? loadArrays(url: url), + let tokenArray = arrays["tokens"] + else { return nil } + + let state = Edge0Model35B.State() + for (name, value) in arrays where name != "tokens" { + let parts = name.split(separator: ".") + guard parts.count == 2, let layer = Int(parts[1]) else { continue } + switch parts[0] { + case "conv": state.convolution[layer] = value + case "rec": state.recurrent[layer] = value + case "k": state.keys[layer] = value + case "v": state.values[layer] = value + default: break + } + } + let tokens = tokenArray.asArray(Int32.self) + guard !tokens.isEmpty else { return nil } + state.offset = tokens.count + return PrefixCache(tokens: tokens, state: state) + } +} + +extension Edge0Model35B.State { + + /// A copy that generation can advance without disturbing the original. + /// + /// The four tables are Swift dictionaries — value types — so copying them is a real + /// snapshot, and the `MLXArray`s they hold are safe to share because **every write in + /// the model reassigns rather than mutating** (`state.recurrent[layer] = ...`, all + /// four verified at their call sites). If that ever changes to an in-place update, + /// this becomes silent corruption rather than a compile error, which is why it is + /// written down here. + func snapshot() -> Edge0Model35B.State { + let copy = Edge0Model35B.State() + copy.convolution = convolution + copy.recurrent = recurrent + copy.keys = keys + copy.values = values + copy.offset = offset + return copy + } +} diff --git a/ios/Sources/Edge0MLX/Edge035B/PregateHeads.swift b/ios/Sources/Edge0MLX/Edge035B/PregateHeads.swift new file mode 100644 index 0000000..388847c --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge035B/PregateHeads.swift @@ -0,0 +1,220 @@ +import Foundation +import MLX + +/// Trained routing heads that predict expert selections for upcoming decode steps. +/// +/// During decode, these heads replace the router for their configured consumer layers; +/// the model's real gate is not evaluated for those layers. The heads and their LoRA +/// adapter are a trained pair and must be used together. Layers without a prediction use +/// the normal router. +/// +/// A head on layer N predicts routing for layer N+1 on the next token. This layer and +/// token offset allows expert reads to be prefetched before the corresponding forward +/// pass. Features contain the current hidden state and one-hot encodings of the current +/// and previous executed routes. +final class PregateHeads: @unchecked Sendable { + + enum Failure: LocalizedError { + case tensorMissing(String) + case wrongShape(String, expected: [Int], found: [Int]) + + var errorDescription: String? { + switch self { + case .tensorMissing(let name): return "pregate file has no \(name)" + case .wrongShape(let name, let expected, let found): + return "pregate \(name) is \(found), expected \(expected)" + } + } + } + + /// Layers whose head runs. Each predicts for `owner + 1`. + let owners: [Int] + /// Which layers consume a prediction, in the order their heads appear. + let consumers: [Int] + let hidden: Int + let experts: Int + + /// Keep the file mapped and materialize arrays only during a pregate forward pass. + private let shard: SafetensorsShard + private let middle: Int + + /// Where the file lives, beside the weights. Optional by construction: a build + /// without it, the normal router is used. + static func fileURL(in directory: URL, round6: Bool = false) -> URL { + if round6 { + return directory.appendingPathComponent("pregate-round6.safetensors") + } + let official = directory.appendingPathComponent("prerouter-stacked.safetensors") + if FileManager.default.fileExists(atPath: official.path) { return official } + return directory.appendingPathComponent("pregate-round6.safetensors") + } + + init(url: URL, hidden: Int, experts: Int) throws { + let shard = try SafetensorsShard(url: url) + self.shard = shard + self.hidden = hidden + self.experts = experts + let inputWidth = hidden + 2 * experts + + // `nil` in the expected shape means "whatever the file says" — the hidden width + // of the heads is the file's to decide, and everything else is pinned by the + // model it has to plug into. + func validate(_ name: String, expecting: [Int?]) throws -> SafetensorsShard.Entry { + guard let entry = shard.entries[name] else { + throw Failure.tensorMissing(name) + } + let matches = entry.shape.count == expecting.count + && zip(entry.shape, expecting).allSatisfy { $1 == nil || $0 == $1 } + guard matches else { + throw Failure.wrongShape( + name, expected: expecting.map { $0 ?? -1 }, found: entry.shape) + } + guard entry.dtype == "F16" else { + throw Failure.wrongShape(name, expected: expecting.map { $0 ?? -1 }, + found: entry.shape) + } + return entry + } + + guard let ownersEntry = shard.entries["pregate.owners"], + let ownersBase = shard.pointer(to: "pregate.owners") + else { throw Failure.tensorMissing("pregate.owners") } + let count = ownersEntry.shape.first ?? 0 + owners = (0 ..< count).map { + Int(ownersBase.load(fromByteOffset: $0 * 4, as: Int32.self)) + } + consumers = owners.map { $0 + 1 } + + let fc1 = try validate("pregate.fc1", expecting: [count, inputWidth, nil]) + middle = fc1.shape[2] + _ = try validate("pregate.fc2", expecting: [count, middle, experts]) + _ = try validate("pregate.linear_init", expecting: [count, inputWidth, experts]) + } + + private func array(_ name: String) -> MLXArray { + guard let entry = shard.entries[name], let base = shard.pointer(to: name) else { + preconditionFailure("validated pregate tensor disappeared: \(name)") + } + return MLXArray( + UnsafeRawBufferPointer(start: base, count: entry.byteCount), + entry.shape, type: Float16.self) + } + + /// One-hot rows for every owner layer at once, built on the CPU. + /// + /// **Deliberately not `identity[indices].sum(axis: 0)` per layer.** That is the + /// obvious phrasing and it was measured: thirty-three lazy MLX nodes, each pinning + /// its inputs until the step boundary evaluated them, contributed to a footprint + /// that rose from ~600 MiB to 1535 MiB. On this device that is not merely untidy — + /// the expert blocks live in *clean file-backed* pages, so anything the process + /// holds dirty is taken out of the page cache those reads depend on. `read` doubled + /// to 134 ms and the weight copy rate halved, and together they ate a 70 ms saving. + /// + /// The routing is already on the CPU — `moe` has just called `asArray` on it — so + /// the whole `[n, experts]` block is a memset and `n * k` stores, then one array. + /// No graph, nothing retained, nothing to evaluate. + func oneHotRows(_ perLayer: [[Int32]]) -> MLXArray { + var flat = [Float16](repeating: 0, count: perLayer.count * experts) + for (row, chosen) in perLayer.enumerated() { + for expert in chosen where expert >= 0 && Int(expert) < experts { + // Accumulate so duplicate IDs retain their multiplicity. + flat[row * experts + Int(expert)] += 1 + } + } + return MLXArray(flat, [perLayer.count, experts]) + } + + /// Run every head as one batch. + /// + /// - Parameters: + /// - inputs: `[n, hidden]`, the owner layers' MoE inputs this token, in owner order. + /// - executed: `[n, experts]`, the one-hot rows those layers actually ran, from + /// `oneHotRows`. + /// - previous: `[n, experts]`, the previous token's routing or a zero row. + /// - Returns: `[n, experts]` float32 logits for the consuming layers. + func logits(inputs: MLXArray, executed: MLXArray, previous: MLXArray) -> MLXArray { + let fc1 = array("pregate.fc1") + let fc2 = array("pregate.fc2") + let linearInit = array("pregate.linear_init") + let features = concatenated( + [inputs.asType(.float16), executed, previous], axis: -1) + .expandedDimensions(axis: 1) // [n, 1, in] + let inner = matmul(features, fc1) // [n, 1, mid] + // Exact erf GELU, as trained (torch's default). `geluApproximate` would be a + // different function evaluated on 33 × 512 values a step, and there is no + // reference to check it against; the tanh form is not what these weights saw. + let activated = 0.5 * inner * (1 + erf(inner / Float(2).squareRoot())) + let out = matmul(features, linearInit) + matmul(activated, fc2) + return out.squeezed(axis: 1).asType(.float32) // [n, experts] + } + + /// What has to survive from one decode step to the next. + /// + /// Per-conversation routing history used by the trained heads. + final class State { + /// Owner layer to its MoE input for the current token. Stored as an MLX array so + /// conversion to CPU does not introduce an additional synchronization point. + var moeInput: [Int: MLXArray] = [:] + /// Owner layer → the experts it actually ran this token. + var executed: [Int: [Int32]] = [:] + /// The previous token's executed experts, or absent on the first token. + var previous: [Int: [Int32]] = [:] + /// Consuming layer → the experts it will use next step, and their weights. + /// + /// Both plain Swift. They cross a step boundary, and an `MLXArray` that does + /// that carries whatever graph produced it into the next step. + var predicted: [Int: [Int]] = [:] + var scores: [Int: [Float]] = [:] + + /// Advance routing history at the end of a decode step. + func advance() { + previous = executed + executed = [:] + moeInput = [:] + } + + func reset() { + moeInput = [:]; executed = [:]; previous = [:] + predicted = [:]; scores = [:] + } + } + + // MARK: - Optional numerical self-check + + /// Compare the heads' output against an optional fixture containing inputs, expected + /// logits, and top-2 expert IDs. Returns `nil` when the fixture is unavailable. + func selfCheck(fixture url: URL) -> String? { + guard let shard = try? SafetensorsShard(url: url), + let feats = shard.entries["feats"], let featsBase = shard.pointer(to: "feats"), + let ref = shard.entries["logits"], let refBase = shard.pointer(to: "logits"), + let top = shard.entries["top2"], let topBase = shard.pointer(to: "top2") + else { return nil } + + let features = MLXArray( + UnsafeRawBufferPointer(start: featsBase, count: feats.byteCount), + feats.shape, type: Float16.self) + let expected = MLXArray( + UnsafeRawBufferPointer(start: refBase, count: ref.byteCount), + ref.shape, type: Float32.self) + let expectedTop = MLXArray( + UnsafeRawBufferPointer(start: topBase, count: top.byteCount), + top.shape, type: Int32.self) + + // The fixture holds the concatenated 2560-wide features; split them back so the + // public entry point is what gets exercised, not a shortcut around it. + let n = feats.shape[0] + let inputs = features[0..., 0 ..< hidden] + let executed = features[0..., hidden ..< (hidden + experts)] + let previous = features[0..., (hidden + experts)...] + let got = logits(inputs: inputs, executed: executed, previous: previous) + + let difference = abs(got - expected) + let gotTop = argSort(-got, axis: -1)[0..., 0 ..< 2] + let agree = (gotTop .== expectedTop).all(axis: -1).sum() + eval(difference, agree) + return String( + format: "[pregate] self-check: max|Δ|=%.4f mean|Δ|=%.5f top-2 agree %d/%d", + difference.max().item(Float.self), difference.mean().item(Float.self), + agree.item(Int32.self), Int32(n)) + } +} diff --git a/ios/Sources/Edge0MLX/Edge035B/RepackedExperts.swift b/ios/Sources/Edge0MLX/Edge035B/RepackedExperts.swift new file mode 100644 index 0000000..a42de63 --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge035B/RepackedExperts.swift @@ -0,0 +1,162 @@ +import Foundation + +/// The repacked layout: one file per layer, one expert per contiguous block. +/// +/// Each block holds an expert's nine tensors back to back, 1,769,472 bytes — exactly +/// 108 pages of 16 KB — so with data starting at offset 0 of its layer's file, **every +/// block is page-aligned**. Reading one expert is one sequential 1.69 MB read instead +/// of the nine scattered reads the original safetensors layout forces. +/// +/// Each file is checked against the expected length before it is opened so truncated +/// or incomplete files cannot be used as model weights. +final class RepackedExperts { + + /// Byte layout inside one expert's block, in the order the packer wrote them. + enum Part: Int, CaseIterable { + case gateWeight, gateScales, gateBiases + case upWeight, upScales, upBiases + case downWeight, downScales, downBiases + + var byteCount: Int { + switch self { + case .gateWeight, .upWeight, .downWeight: return 524_288 + default: return 32_768 + } + } + + var offset: Int { + Part.allCases.prefix(rawValue).reduce(0) { $0 + $1.byteCount } + } + } + + static let blockBytes = 1_769_472 + static let expertsPerLayer = 256 + static let layerBytes = blockBytes * expertsPerLayer // 452,984,832 + static let pageSize = 16384 // blockBytes is 108 of these + + private struct Opened { + let size: Int + let descriptor: Int32 + } + + // Keep descriptors, not 40 whole-file mappings. Mapping every 432 MiB layer + // consumes ~17 GiB of virtual address space and fails after 11 layers when a + // development profile lacks Extended Virtual Addressing. The hot path already + // uses pread, so those mappings were only an optional prefetch mechanism. + private var layers: [Int: Opened] = [:] + + /// Layers whose file was present but the wrong length, with the length found. + private(set) var rejected: [(layer: Int, bytes: Int)] = [] + private(set) var missing: [Int] = [] + + var availableLayers: [Int] { layers.keys.sorted() } + + init(directory: URL, expectedLayers: Int) { + for layer in 0 ..< expectedLayers { + let url = directory.appendingPathComponent(String(format: "experts-L%02d.bin", layer)) + + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = (attributes[.size] as? NSNumber)?.intValue + else { + missing.append(layer) + continue + } + guard size == Self.layerBytes else { + rejected.append((layer, size)) + continue + } + + let descriptor = open(url.path, O_RDONLY) + guard descriptor >= 0 else { + missing.append(layer) + continue + } + layers[layer] = Opened(size: size, descriptor: descriptor) + } + } + + deinit { + for opened in layers.values { + close(opened.descriptor) + } + } + + /// Ask the kernel for an expert's whole block in one go. + func prefetch(layer: Int, expert: Int) { + guard let opened = layers[layer], expert >= 0, expert < Self.expertsPerLayer + else { return } + + // `F_RDADVISE` is the descriptor equivalent of the old mmap + MADV_WILLNEED + // path: it starts asynchronous readahead without reserving 17 GiB of virtual + // address space. The eventual `pread` remains the source of truth, so ignored + // or failed advice can affect only latency, never model output. + var advice = radvisory( + ra_offset: off_t(expert * Self.blockBytes), + ra_count: Int32(Self.blockBytes)) + _ = withUnsafeMutablePointer(to: &advice) { + fcntl(opened.descriptor, F_RDADVISE, UnsafeMutableRawPointer($0)) + } + } + + /// Remove files that are present but have the wrong length, returning any failures. + static func removeTruncated(in directory: URL, expectedLayers: Int) + -> (removed: [(Int, Int)], failed: [(Int, String)]) + { + var removed: [(Int, Int)] = [] + var failed: [(Int, String)] = [] + for layer in 0 ..< expectedLayers { + let url = directory.appendingPathComponent(String(format: "experts-L%02d.bin", layer)) + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = (attributes[.size] as? NSNumber)?.intValue, + size != layerBytes + else { continue } + do { + try FileManager.default.removeItem(at: url) + removed.append((layer, size)) + } catch { + failed.append((layer, error.localizedDescription)) + } + } + return (removed, failed) + } + + /// Read one expert's whole block with a single `pread`. + /// + /// The mmap path reaches the same bytes through page faults, which the kernel + /// services in its own units; this asks for all 1,769,472 bytes in one call and + /// lets it issue one large sequential read. Worth measuring against the fault path + /// now that a block is contiguous — 768 MB/s is still only about a third of what + /// the device can do. + /// One sequential read of the whole layer file (432 MiB, 256 expert blocks). + func readLayer(_ layer: Int, into buffer: UnsafeMutableRawPointer) -> Bool { + guard let opened = layers[layer] else { return false } + var moved = 0 + while moved < Self.layerBytes { + let got = pread( + opened.descriptor, + buffer.advanced(by: moved), + Self.layerBytes - moved, + off_t(moved)) + if got <= 0 { return false } + moved += got + } + return true + } + + func read(layer: Int, expert: Int, into buffer: UnsafeMutableRawPointer) -> Bool { + guard let opened = layers[layer], expert >= 0, expert < Self.expertsPerLayer + else { return false } + + var moved = 0 + while moved < Self.blockBytes { + let got = pread( + opened.descriptor, + buffer.advanced(by: moved), + Self.blockBytes - moved, + off_t(expert * Self.blockBytes + moved)) + if got <= 0 { return false } + moved += got + } + return true + } +} diff --git a/ios/Sources/Edge0MLX/Edge035B/Safetensors.swift b/ios/Sources/Edge0MLX/Edge035B/Safetensors.swift new file mode 100644 index 0000000..c48b554 --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge035B/Safetensors.swift @@ -0,0 +1,264 @@ +import Foundation + +/// Read-only mmap over one safetensors shard, plus the header that says where each +/// tensor's bytes begin. +/// +/// A shard is: 8 bytes of little-endian header length, that many bytes of JSON, then +/// the payload. Every tensor's `data_offsets` are relative to the start of the +/// payload, so a tensor — or, for a stacked expert tensor, one expert's slice of it +/// — is a byte range and nothing more. That is the premise the whole streaming +/// design rests on: **no parsing, no dequantisation, no copy on the read path.** +/// +/// Mapped `MAP_SHARED` + read-only so the pages stay clean and file-backed. Dirtying +/// them would turn the 19 GB into the app's own memory and defeat the exercise. +final class SafetensorsShard { + + struct Entry { + let dtype: String + let shape: [Int] + /// Absolute offset in the file, payload base already added. + let offset: Int + let byteCount: Int + } + + let url: URL + private let descriptor: Int32 + private let base: UnsafeRawPointer + private let mappedSize: Int + private(set) var entries: [String: Entry] = [:] + + enum Failure: LocalizedError { + case cannotOpen(String) + case cannotMap(String) + case badHeader(String) + case truncated(String, needs: Int, has: Int) + + var errorDescription: String? { + switch self { + case .cannotOpen(let p): return "cannot open \(p)" + case .cannotMap(let p): return "cannot mmap \(p)" + case .badHeader(let p): return "bad safetensors header in \(p)" + case .truncated(let p, let needs, let has): + return "\(p) is truncated: header describes \(needs) bytes, file has \(has)" + } + } + } + + init(url: URL) throws { + self.url = url + + descriptor = open(url.path, O_RDONLY) + guard descriptor >= 0 else { throw Failure.cannotOpen(url.lastPathComponent) } + + var status = stat() + guard fstat(descriptor, &status) == 0, status.st_size > 16 else { + close(descriptor) + throw Failure.cannotOpen(url.lastPathComponent) + } + mappedSize = Int(status.st_size) + + guard let mapped = mmap(nil, mappedSize, PROT_READ, MAP_SHARED, descriptor, 0), + mapped != MAP_FAILED + else { + close(descriptor) + throw Failure.cannotMap(url.lastPathComponent) + } + base = UnsafeRawPointer(mapped) + + // Read advice is applied at the call site because access patterns vary by tensor. + + let headerLength = Int(base.loadUnaligned(as: UInt64.self)) + guard headerLength > 0, headerLength + 8 <= mappedSize else { + munmap(mapped, mappedSize) + close(descriptor) + throw Failure.badHeader(url.lastPathComponent) + } + + let payloadBase = 8 + headerLength + let headerData = Data(bytes: base.advanced(by: 8), count: headerLength) + + guard let json = try JSONSerialization.jsonObject(with: headerData) as? [String: Any] + else { + munmap(mapped, mappedSize) + close(descriptor) + throw Failure.badHeader(url.lastPathComponent) + } + + for (name, value) in json { + guard name != "__metadata__", + let fields = value as? [String: Any], + let dtype = fields["dtype"] as? String, + let shape = fields["shape"] as? [Int], + let offsets = fields["data_offsets"] as? [Int], + offsets.count == 2 + else { continue } + + entries[name] = Entry( + dtype: dtype, + shape: shape, + offset: payloadBase + offsets[0], + byteCount: offsets[1] - offsets[0] + ) + } + + // Does the header describe more bytes than the file actually holds? + // + // The device-side size check in the transfer script compares against what + // `devicectl` reports, which is three significant figures — enough to catch a + // file that arrived at 133 MB of 432, useless for catching one that is short by + // a few megabytes. This catches that case, cheaply, at the only point where it + // can still be reported as a load failure rather than as quiet nonsense several + // layers into inference. + if let end = entries.values.map({ $0.offset + $0.byteCount }).max(), end > mappedSize { + munmap(mapped, mappedSize) + close(descriptor) + throw Failure.truncated(url.lastPathComponent, needs: end, has: mappedSize) + } + } + + deinit { + munmap(UnsafeMutableRawPointer(mutating: base), mappedSize) + close(descriptor) + } + + /// Raw pointer to a whole tensor. Does not touch the pages. + func pointer(to name: String) -> UnsafeRawPointer? { + guard let entry = entries[name] else { return nil } + return base.advanced(by: entry.offset) + } + + /// Raw pointer to one slice along the leading axis of a stacked tensor. + /// + /// Expert weights are stored as `[num_experts, out, in]`, so expert *e* is one + /// contiguous run — `rowBytes` apart from its neighbour. This single line is why + /// streaming is possible at all: pulling one expert is an offset, not a load. + func pointer(to name: String, slice index: Int, of leadingDimension: Int) -> UnsafeRawPointer? { + guard let entry = entries[name], leadingDimension > 0 else { return nil } + let rowBytes = entry.byteCount / leadingDimension + guard index >= 0, index < leadingDimension else { return nil } + return base.advanced(by: entry.offset + index * rowBytes) + } + + func sliceByteCount(of name: String, leadingDimension: Int) -> Int? { + guard let entry = entries[name], leadingDimension > 0 else { return nil } + return entry.byteCount / leadingDimension + } + + /// Reset the kernel's read-ahead policy for the whole mapping. + func advise(_ advice: Int32) { + madvise(UnsafeMutableRawPointer(mutating: base), mappedSize, advice) + } + + /// Ask the kernel to start pulling one expert's slice in. + /// + /// `MADV_WILLNEED` is advice, not a read: it returns once the I/O is queued rather + /// than once the pages are there. Issued immediately before the copy it can only + /// overlap within the slice; issued a step ahead — which is what pregate makes + /// possible — it has a whole layer of compute to land in. + func prefetch(_ name: String, slice index: Int, of leadingDimension: Int) { + guard let entry = entries[name], leadingDimension > 0, + index >= 0, index < leadingDimension + else { return } + let rowBytes = entry.byteCount / leadingDimension + madvise( + UnsafeMutableRawPointer(mutating: base).advanced(by: entry.offset + index * rowBytes), + rowBytes, MADV_WILLNEED) + } + + /// Ask the kernel to bring one whole tensor in. + /// + /// Request that the kernel warm the tensor's clean, file-backed pages. + /// Returns the number of bytes requested. + @discardableResult + func warm(_ name: String) -> Int { + guard let entry = entries[name] else { return 0 } + madvise(UnsafeMutableRawPointer(mutating: base).advanced(by: entry.offset), + entry.byteCount, MADV_WILLNEED) + return entry.byteCount + } +} + +/// The four shards plus the index that says which shard holds which tensor. +final class SafetensorsBundle { + + private var shards: [String: SafetensorsShard] = [:] + private var shardForTensor: [String: String] = [:] + + let directory: URL + + init(directory: URL) throws { + self.directory = directory + + let indexURL = directory.appendingPathComponent("model.safetensors.index.json") + let indexData = try Data(contentsOf: indexURL) + guard let root = try JSONSerialization.jsonObject(with: indexData) as? [String: Any], + let map = root["weight_map"] as? [String: String] + else { + throw SafetensorsShard.Failure.badHeader("model.safetensors.index.json") + } + shardForTensor = map + + for file in Set(map.values) { + shards[file] = try SafetensorsShard(url: directory.appendingPathComponent(file)) + } + } + + var mappedShardCount: Int { shards.count } + + /// Every tensor name in the index, and which shard holds it. + var index: [String: String] { shardForTensor } + + func entry(_ name: String) -> SafetensorsShard.Entry? { + guard let file = shardForTensor[name] else { return nil } + return shards[file]?.entries[name] + } + + func pointer(to name: String) -> UnsafeRawPointer? { + guard let file = shardForTensor[name] else { return nil } + return shards[file]?.pointer(to: name) + } + + func pointer(to name: String, slice index: Int, of leadingDimension: Int) -> UnsafeRawPointer? { + guard let file = shardForTensor[name] else { return nil } + return shards[file]?.pointer(to: name, slice: index, of: leadingDimension) + } + + func sliceByteCount(of name: String, leadingDimension: Int) -> Int? { + guard let file = shardForTensor[name] else { return nil } + return shards[file]?.sliceByteCount(of: name, leadingDimension: leadingDimension) + } + + func advise(_ advice: Int32) { + for shard in shards.values { shard.advise(advice) } + } + + /// Bring in every tensor the decode path reads on **every** step. + /// + /// Deliberately not "the whole mapping": these shards hold the 256 routed experts per + /// layer as well, nineteen gigabytes of data that only two-of-256 of is touched per + /// layer per token. Asking for that would evict everything worth having. + /// + /// Two exclusions, both by measurement rather than taste: + /// + /// - `switch_mlp` — the routed experts, streamed on demand and prefetched from their + /// own repacked files by their actual routing. + /// - `embed_tokens` — 273 MiB of table from which `rows(_:_:)` copies one row per + /// token. Warming it would be 273 MiB of page cache for 1,152 bytes of need. + /// + /// What remains is what `weight(_:)` copies every step: 20.4 MiB per layer of + /// attention and shared-expert projections, plus `lm_head`. + @discardableResult + func warmResidentWeights() -> Int { + var total = 0 + for (name, file) in shardForTensor + where !name.contains("switch_mlp") && !name.contains("embed_tokens") { + total += shards[file]?.warm(name) ?? 0 + } + return total + } + + func prefetch(_ name: String, slice index: Int, of leadingDimension: Int) { + guard let file = shardForTensor[name] else { return } + shards[file]?.prefetch(name, slice: index, of: leadingDimension) + } +} diff --git a/ios/Sources/Edge0MLX/Edge035B/StepProfile.swift b/ios/Sources/Edge0MLX/Edge035B/StepProfile.swift new file mode 100644 index 0000000..19e8fa6 --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge035B/StepProfile.swift @@ -0,0 +1,148 @@ +import Foundation + +/// Tracks synchronous portions of model steps without forcing additional MLX evaluation. +/// GPU operations remain lazy, so timing them separately would change execution order. +final class StepProfile { + + private(set) var syncLinear: Double = 0 + private(set) var syncFull: Double = 0 + var routerSync: Double { syncLinear + syncFull } + private(set) var expertRead: Double = 0 + private(set) var expertStack: Double = 0 + + /// Time spent constructing MLX arrays from mapped weight bytes. + private(set) var weightCopy: Double = 0 + private(set) var weightBytes: Int = 0 + private(set) var expertBytes: Int = 0 + + /// Number of expert read batches and individual reads issued. + private(set) var readBatches = 0 + private(set) var readsIssued = 0 + + /// Number of unique experts requested compared with the naive T*K upper bound. + private(set) var unionTotal = 0 + private(set) var pairsTotal = 0 + + /// How often the "same experts as last token" guess was right. + /// + /// The whole prefetch scheme rests on this. It costs nothing when wrong, so any + /// positive rate is upside — but it bounds the gain: the read can only be hidden for + /// the fraction of blocks the guess anticipated. + private(set) var prefetchHits = 0 + private(set) var prefetchTotal = 0 + + /// Cumulative cost of the batched head forward plus its single barrier. + private(set) var pregateStage = 0.0 + /// Core ML vocabulary projection plus its 4 KiB MLX-to-Core ML handoff. + private(set) var vocabularyHead = 0.0 + + private var steps = 0 + + func reset() { + syncLinear = 0; syncFull = 0; expertRead = 0; expertStack = 0; expertBytes = 0 + weightCopy = 0; weightBytes = 0; pregateStage = 0; vocabularyHead = 0 + prefetchHits = 0; prefetchTotal = 0 + readBatches = 0; readsIssued = 0; steps = 0 + unionTotal = 0; pairsTotal = 0 + } + + func countStep() { steps += 1 } + + /// Timing categories for synchronous portions of a model step. + enum Phase { + case syncLinear, syncFull, expertRead, expertStack, weightCopy + /// Synchronous work performed by the trained routing heads. + case pregateStage, vocabularyHead + } + + /// Time a synchronous block. Returns the block's value so call sites stay expressions. + @inline(__always) + func measure(_ phase: Phase, _ body: () throws -> T) rethrows -> T { + let start = DispatchTime.now().uptimeNanoseconds + let value = try body() + let elapsed = Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000 + switch phase { + case .syncLinear: syncLinear += elapsed + case .syncFull: syncFull += elapsed + case .expertRead: expertRead += elapsed + case .expertStack: expertStack += elapsed + case .weightCopy: weightCopy += elapsed + case .pregateStage: pregateStage += elapsed + case .vocabularyHead: vocabularyHead += elapsed + } + return value + } + + @inline(__always) + func measure(_ phase: Phase, _ body: () async throws -> T) async rethrows -> T { + let start = DispatchTime.now().uptimeNanoseconds + let value = try await body() + let elapsed = Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000 + switch phase { + case .syncLinear: syncLinear += elapsed + case .syncFull: syncFull += elapsed + case .expertRead: expertRead += elapsed + case .expertStack: expertStack += elapsed + case .weightCopy: weightCopy += elapsed + case .pregateStage: pregateStage += elapsed + case .vocabularyHead: vocabularyHead += elapsed + } + return value + } + + func recordRouting(union: Int, pairs: Int) { + unionTotal += union + pairsTotal += pairs + } + + func recordPrefetch(hits: Int, total: Int) { + prefetchHits += hits + prefetchTotal += total + } + + func recordWeight(bytes: Int) { weightBytes += bytes } + + func recordRead(bytes: Int, issued: Int) { + expertBytes += bytes + readBatches += 1 + readsIssued += issued + } + + /// Per-step medians are not available without keeping every sample; per-step means + /// are, and the question here is where a quarter-second goes, not its distribution. + var report: String { + guard steps > 0 else { return "no steps" } + let n = Double(steps) + func ms(_ total: Double) -> String { String(format: "%.0f", total / n) } + let throughput = expertRead > 0 + ? String(format: "%.0f", Double(expertBytes) / (expertRead / 1000) / 1_000_000) + : "-" + let depth = readBatches > 0 + ? String(format: "%.2f", Double(readsIssued) / Double(readBatches)) + : "-" + let copyRate = weightCopy > 0 + ? String(format: "%.0f", Double(weightBytes) / (weightCopy / 1000) / 1_000_000) + : "-" + let hitRate = prefetchTotal > 0 + ? String(format: "%.0f%%", + Double(prefetchHits) / Double(prefetchTotal) * 100) + : "-" + let sharing = pairsTotal > 0 + ? String(format: "%.2f", Double(unionTotal) / Double(pairsTotal)) + : "-" + return "syncLinear=\(ms(syncLinear))ms syncFull=\(ms(syncFull))ms " + + "read=\(ms(expertRead))ms " + + "stack=\(ms(expertStack))ms readMBps=\(throughput) depth=\(depth) " + + "union/pairs=\(sharing) " + + "weightCopy=\(ms(weightCopy))ms wMBps=\(copyRate)" + // Only when the heads are installed, so a baseline build's line is + // byte-identical to the one every earlier measurement was read from. + + (pregateStage > 0 ? " pregate=\(ms(pregateStage))ms" : "") + + (vocabularyHead > 0 ? " coremlHead=\(ms(vocabularyHead))ms" : "") + // `hitRate` was computed and dropped on the floor by an earlier version of + // this line. It is the number that bounds the whole prefetch scheme — the + // read can only be hidden for the fraction of blocks the guess anticipated + // — so leaving it unprinted made the one decision it informs unanswerable. + + (prefetchTotal > 0 ? " prefetch=\(hitRate)" : "") + } +} diff --git a/ios/Sources/Edge0MLX/Edge035B/ThinkingBlock.swift b/ios/Sources/Edge0MLX/Edge035B/ThinkingBlock.swift new file mode 100644 index 0000000..5d1f377 --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge035B/ThinkingBlock.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Separates the model's reasoning from its reply. +/// +/// This checkpoint is opened with `<|im_start|>assistant\n\n`, so **every answer +/// begins inside a reasoning block** and the first thing it writes is working-out, not a +/// reply. The closing `` is generated; the opening tag never is, because it came +/// from the prompt. +/// +/// That shape leaks straight through a stream that treats decoded text as the answer — +/// the user sees the model's notes to itself, followed by a bare ``, followed by +/// the reply. +/// +/// **The cost of hiding it is that nothing appears until the model stops reasoning.** At +/// a few tokens per second that is a real wait with an empty bubble at the end of it; the +/// generating indicator is what carries that interval. Showing the reasoning instead was +/// the alternative, and it is worse for the ordinary case of wanting an answer. +enum ThinkingBlock { + + static let terminator = "" + + /// The part of `raw` the user should see, or `nil` while the model is still reasoning. + /// + /// Leading whitespace after the terminator is dropped: the model writes a blank line + /// or two before the reply proper, which would otherwise open every answer with a gap. + static func visible(in raw: String) -> String? { + guard let end = raw.range(of: terminator) else { return nil } + return String(raw[end.upperBound...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// The non-thinking prompt normally starts directly in the answer, but some + /// generations still emit the checkpoint's literal thinking wrapper. Hide that + /// block while it is incomplete, then expose only the text after its terminator. + static func visibleReply(in raw: String) -> String? { + guard raw.contains("") || raw.contains(terminator) else { return raw } + return visible(in: raw) + } + + /// What to show once generation has finished. + /// + /// **Falls back to the whole text when the block never closed.** A reply truncated by + /// the token limit mid-reasoning has no terminator, and showing nothing at all would + /// present a completed generation as an empty answer. Raw reasoning is poor output; + /// an empty bubble is a bug report. + static func settled(_ raw: String) -> String { + visible(in: raw) ?? raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Final text for a prompt that requested a direct answer. + static func settledReply(_ raw: String) -> String { + guard raw.contains("") || raw.contains(terminator) else { + return raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + return visible(in: raw) ?? "" + } +} diff --git a/ios/Sources/Edge0MLX/Edge035B/Tokenizer.swift b/ios/Sources/Edge0MLX/Edge035B/Tokenizer.swift new file mode 100644 index 0000000..8020dee --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge035B/Tokenizer.swift @@ -0,0 +1,267 @@ +import Foundation + +/// Byte-level BPE, the GPT-4 variant this checkpoint uses. +/// +/// The pipeline, in order, because getting the order wrong produces a tokenizer that +/// works on English and mis-splits everything else: +/// +/// 1. **NFC** normalisation +/// 2. **Split** on the pre-tokenizer regex, `Isolated` — each match becomes its own piece +/// 3. **ByteLevel** — every UTF-8 byte maps to one printable character, so BPE never sees +/// a raw byte and no `unk` token is needed +/// 4. **BPE** — repeatedly merge the adjacent pair with the lowest rank +/// +/// Loaded from the compact binary produced by `tools/convert_tokenizer.py`: 5.6 MiB of +/// flat arrays rather than 19 MiB of JSON, with merges stored as **token id pairs** so +/// the merge loop never looks anything up by string. +final class Tokenizer { + + enum Failure: LocalizedError { + case cannotOpen(String) + case badHeader(String) + case truncated(String, needs: Int, has: Int) + + var errorDescription: String? { + switch self { + case .cannotOpen(let p): return "cannot open \(p)" + case .badHeader(let p): return "bad tokenizer header in \(p)" + case .truncated(let p, let needs, let has): + return "\(p) is truncated: needs \(needs) bytes, has \(has)" + } + } + } + + private let tokens: [String] // id -> token text (byte-level encoded) + private let ids: [String: Int32] // token text -> id + private let ranks: [UInt64: Int32] // packed (left, right) -> merge rank + private let specials: [String: Int32] + private let specialTexts: [Int32: String] + private let splitter: NSRegularExpression + + /// Byte ↔ printable-character tables, the GPT-2 mapping. + /// + /// Bytes that are already printable map to themselves; the rest are shifted into an + /// unused Unicode range. This is what lets a BPE vocabulary of *characters* represent + /// arbitrary bytes without an unknown token. + private static let byteToUnicode: [Character] = { + var mapping: [Int] = [] + var table = [Character](repeating: " ", count: 256) + for value in UInt8(ascii: "!") ... UInt8(ascii: "~") { mapping.append(Int(value)) } + for value in 0xA1 ... 0xAC { mapping.append(value) } + for value in 0xAE ... 0xFF { mapping.append(value) } + + var extra = 0 + for byte in 0 ..< 256 { + if mapping.contains(byte) { + table[byte] = Character(UnicodeScalar(byte)!) + } else { + table[byte] = Character(UnicodeScalar(256 + extra)!) + extra += 1 + } + } + return table + }() + + private static let unicodeToByte: [Character: UInt8] = { + var inverse: [Character: UInt8] = [:] + for byte in 0 ..< 256 { inverse[byteToUnicode[byte]] = UInt8(byte) } + return inverse + }() + + init(url: URL) throws { + guard let data = try? Data(contentsOf: url, options: .mappedIfSafe), + data.count > 8 + else { throw Failure.cannotOpen(url.lastPathComponent) } + + let headerLength = Int(data.withUnsafeBytes { $0.loadUnaligned(as: UInt64.self) }) + guard headerLength > 0, 8 + headerLength <= data.count, + let header = try? JSONSerialization.jsonObject( + with: data[8 ..< (8 + headerLength)]) as? [String: Any], + let vocabSize = header["vocab_size"] as? Int, + let mergeCount = header["merge_count"] as? Int, + let specialCount = header["special_count"] as? Int, + let pattern = header["regex"] as? String + else { throw Failure.badHeader(url.lastPathComponent) } + + // Sections are fixed-width and laid out in a known order, so each is a slice at a + // running offset. Checked against the file length before any of it is read — a + // truncated tokenizer that silently loses its tail would encode most text + // correctly and fail only on rare tokens. + var cursor = 8 + headerLength + func take(_ count: Int) throws -> Data { + guard cursor + count <= data.count else { + throw Failure.truncated(url.lastPathComponent, + needs: cursor + count, has: data.count) + } + defer { cursor += count } + return data[cursor ..< (cursor + count)] + } + + let offsets = try take((vocabSize + 1) * 4).toUInt32Array() + let blob = try take(Int(offsets[vocabSize])) + let mergeFlat = try take(mergeCount * 8).toUInt32Array() + let specialIds = try take(specialCount * 4).toUInt32Array() + let specialOffsets = try take((specialCount + 1) * 4).toUInt32Array() + let specialBlob = try take(Int(specialOffsets[specialCount])) + + var tokens = [String](repeating: "", count: vocabSize) + var ids: [String: Int32] = [:] + ids.reserveCapacity(vocabSize) + blob.withUnsafeBytes { raw in + let base = raw.bindMemory(to: UInt8.self).baseAddress! + for index in 0 ..< vocabSize { + let start = Int(offsets[index]), end = Int(offsets[index + 1]) + let text = String(decoding: UnsafeBufferPointer( + start: base + start, count: end - start), as: UTF8.self) + tokens[index] = text + ids[text] = Int32(index) + } + } + self.tokens = tokens + self.ids = ids + + var ranks: [UInt64: Int32] = [:] + ranks.reserveCapacity(mergeCount) + for rank in 0 ..< mergeCount { + let left = UInt64(mergeFlat[rank * 2]) + let right = UInt64(mergeFlat[rank * 2 + 1]) + ranks[(left << 32) | right] = Int32(rank) + } + self.ranks = ranks + + var specials: [String: Int32] = [:] + var specialTexts: [Int32: String] = [:] + specialBlob.withUnsafeBytes { raw in + let base = raw.bindMemory(to: UInt8.self).baseAddress! + for index in 0 ..< specialCount { + let start = Int(specialOffsets[index]), end = Int(specialOffsets[index + 1]) + let text = String(decoding: UnsafeBufferPointer( + start: base + start, count: end - start), as: UTF8.self) + specials[text] = Int32(specialIds[index]) + specialTexts[Int32(specialIds[index])] = text + } + } + self.specials = specials + self.specialTexts = specialTexts + + // Carried across from the source file rather than rewritten, because this pattern + // is where CJK and emoji splitting is decided. + self.splitter = try NSRegularExpression(pattern: pattern, options: []) + } + + var vocabularySize: Int { tokens.count } + var specialTokenCount: Int { specials.count } + + func id(of special: String) -> Int32? { specials[special] } + + // MARK: - Encode + + func encode(_ text: String) -> [Int32] { + // Special tokens are matched before anything else and never split — a chat + // template's `<|im_start|>` reaching the BPE loop would come back as a handful of + // ordinary pieces that happen to spell it. + var output: [Int32] = [] + for (chunk, special) in splitOnSpecials(text) { + if let special { + output.append(special) + } else { + output.append(contentsOf: encodeOrdinary(chunk)) + } + } + return output + } + + private func splitOnSpecials(_ text: String) -> [(String, Int32?)] { + guard !specials.isEmpty else { return [(text, nil)] } + var pieces: [(String, Int32?)] = [] + var remainder = Substring(text) + + while !remainder.isEmpty { + var earliest: (Range, Int32)? = nil + for (token, id) in specials { + if let range = remainder.range(of: token), + earliest == nil || range.lowerBound < earliest!.0.lowerBound { + earliest = (range, id) + } + } + guard let (range, id) = earliest else { break } + if range.lowerBound > remainder.startIndex { + pieces.append((String(remainder[remainder.startIndex ..< range.lowerBound]), nil)) + } + pieces.append(("", id)) + remainder = remainder[range.upperBound...] + } + if !remainder.isEmpty { pieces.append((String(remainder), nil)) } + return pieces + } + + private func encodeOrdinary(_ text: String) -> [Int32] { + let normalised = text.precomposedStringWithCanonicalMapping // NFC + var output: [Int32] = [] + + let full = NSRange(normalised.startIndex ..< normalised.endIndex, in: normalised) + splitter.enumerateMatches(in: normalised, options: [], range: full) { match, _, _ in + guard let match, let range = Range(match.range, in: normalised) else { return } + let piece = String(normalised[range]) + + // Byte level: every UTF-8 byte becomes one printable character. + let mapped = String(piece.utf8.map { Self.byteToUnicode[Int($0)] }) + output.append(contentsOf: bpe(mapped)) + } + return output + } + + /// Merge the lowest-ranked adjacent pair until none remains. + private func bpe(_ text: String) -> [Int32] { + if let whole = ids[text] { return [whole] } + + var parts = text.map { String($0) }.compactMap { ids[$0] } + guard parts.count > 1 else { return parts } + + while parts.count > 1 { + var bestRank = Int32.max + var bestIndex = -1 + for index in 0 ..< (parts.count - 1) { + let key = (UInt64(UInt32(bitPattern: parts[index])) << 32) + | UInt64(UInt32(bitPattern: parts[index + 1])) + if let rank = ranks[key], rank < bestRank { + bestRank = rank + bestIndex = index + } + } + guard bestIndex >= 0 else { break } + + let merged = tokens[Int(parts[bestIndex])] + tokens[Int(parts[bestIndex + 1])] + guard let id = ids[merged] else { break } + parts.replaceSubrange(bestIndex ... (bestIndex + 1), with: [id]) + } + return parts + } + + // MARK: - Decode + + func decode(_ sequence: [Int32]) -> String { + var bytes: [UInt8] = [] + for id in sequence { + if let special = specialTexts[id] { + bytes.append(contentsOf: Array(special.utf8)) + continue + } + guard id >= 0, Int(id) < tokens.count else { continue } + for character in tokens[Int(id)] { + if let byte = Self.unicodeToByte[character] { bytes.append(byte) } + } + } + return String(decoding: bytes, as: UTF8.self) + } +} + +private extension Data { + func toUInt32Array() -> [UInt32] { + withUnsafeBytes { raw in + Array(UnsafeBufferPointer( + start: raw.bindMemory(to: UInt32.self).baseAddress!, + count: count / 4)) + } + } +} diff --git a/ios/Sources/Edge0MLX/Edge0ChatEngine.swift b/ios/Sources/Edge0MLX/Edge0ChatEngine.swift new file mode 100644 index 0000000..c5515da --- /dev/null +++ b/ios/Sources/Edge0MLX/Edge0ChatEngine.swift @@ -0,0 +1,249 @@ +import Edge0Core +import Foundation +import MLX + +public struct Edge0GenerationResult: Sendable { + public let text: String + public let generatedTokenCount: Int + public let elapsedSeconds: TimeInterval + public let prefillSeconds: TimeInterval + public let prefillTokensPerSecond: Double + public let timeToFirstTokenSeconds: TimeInterval + public let decodeTokensPerSecond: Double + public let peakMemoryBytes: Int +} + +private enum Edge0ChatTemplate8B { + static func firstTurn(_ text: String, thinking: Bool) -> String { + "SYSTEMdetailed thinking \(thinking ? "on" : "off")<|role_end|>" + + "HUMAN\(text)<|role_end|>" + assistantPrefix(thinking: thinking) + } + + static func nextTurn(_ text: String, thinking: Bool) -> String { + "<|role_end|>HUMAN\(text)<|role_end|>" + + assistantPrefix(thinking: thinking) + } + + private static func assistantPrefix(thinking: Bool) -> String { + "ASSISTANT\n" + (thinking ? "" : "") + } +} + +/// Product-facing, stateful chat entry point. The model math and cache lifetime +/// remain owned by StreamingEdge0Model8B; this type only mirrors the official +/// chat template and official sampling loop. +public final class Edge0ChatEngine: @unchecked Sendable { + private let tokenizer: Edge0Tokenizer8B + private let model: StreamingEdge0Model8B + private var hasConversationContext = false + private var tokenCount = 0 + + /// Short prompts do not amortize loading all 128 experts per layer. + private static let fullLayerPrefillMinimum = 128 + + private static var runtimeDevice: Device { + #if targetEnvironment(simulator) + .cpu + #else + .gpu + #endif + } + + public init(modelURL: URL, progress: (String) -> Void = { _ in }) throws { + let built = try Device.withDefaultDevice(Self.runtimeDevice) { + let configuration = try Edge0Configuration8B.load(from: modelURL.appendingPathComponent("config.json")) + let tokenizer = try Edge0Tokenizer8B(contentsOf: modelURL.appendingPathComponent("tokenizer.json")) + let store = try ExpertTensorStore( + modelURL: modelURL.appendingPathComponent("model.safetensors"), + expertCount: configuration.numExperts + ) + let model = try StreamingEdge0Model8B( + configuration: configuration, store: store, + loraWeightsURL: modelURL.appendingPathComponent( + "lora_edge0_8b.safetensors"), + prerouterWeightsURL: modelURL.appendingPathComponent( + "prerouter_edge0_8b.safetensors"), + progress: progress) + progress("prewarming official decode path") + try model.prewarm() + return (tokenizer, model) + } + tokenizer = built.0 + model = built.1 + } + + public func reset() { + model.reset() + hasConversationContext = false + tokenCount = 0 + } + + public func reply( + to userText: String, + maxTokens: Int = 2048, + thinking: Bool = false, + seed: UInt64? = nil, + onText: @escaping @Sendable (String) -> Void = { _ in }, + shouldContinue: @escaping @Sendable () -> Bool = { true } + ) throws -> Edge0GenerationResult { + try Device.withDefaultDevice(Self.runtimeDevice) { + try generateReply(to: userText, maxTokens: maxTokens, + thinking: thinking, seed: seed, + onText: onText, shouldContinue: shouldContinue) + } + } + + private func generateReply( + to userText: String, maxTokens: Int, thinking: Bool, seed: UInt64?, + onText: @escaping @Sendable (String) -> Void, + shouldContinue: @escaping @Sendable () -> Bool + ) throws -> Edge0GenerationResult { + let trimmed = userText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw M1Error.invalid("Message cannot be empty") } + guard maxTokens > 0 else { throw M1Error.invalid("maxTokens must be positive") } + + let prompt = hasConversationContext + ? Edge0ChatTemplate8B.nextTurn(trimmed, thinking: thinking) + : Edge0ChatTemplate8B.firstTurn(trimmed, thinking: thinking) + let promptIDs = try tokenizer.encode(prompt) + guard !promptIDs.isEmpty else { throw M1Error.invalid("Tokenizer returned an empty prompt") } + + let started = Date() + guard shouldContinue() else { + model.reset(); hasConversationContext = false; tokenCount = 0 + throw CancellationError() + } + var output: StreamingModelOutput? + // Full-layer prefill loads every expert in a layer. That cost is almost + // independent of length, so a short chat prompt pays for the whole 4 GB + // expert set. Below this cutoff, walk the same top-8 decode path. + let streamingPrefill = promptIDs.count < Self.fullLayerPrefillMinimum + if streamingPrefill { + for id in promptIDs { + guard shouldContinue() else { + model.reset(); hasConversationContext = false; tokenCount = 0 + throw CancellationError() + } + output = try model(tokenID: id) + } + } else { + let prefillChunk = 2048 + for start in stride(from: 0, to: promptIDs.count, by: prefillChunk) { + guard shouldContinue() else { + model.reset(); hasConversationContext = false; tokenCount = 0 + throw CancellationError() + } + let end = min(start + prefillChunk, promptIDs.count) + let chunk = Array(promptIDs[start.. 1 { + output = try model.prefill(tokenIDs: chunk) + } else if let id = chunk.first { + output = try model(tokenID: id) + } + } + } + let prefillFinished = Date() + tokenCount += promptIDs.count + + var generated: [Int] = [] + var samplingHistory = promptIDs + var peakMemory = output?.peakMemory ?? 0 + var firstTokenAt: Date? + var decodeStartedAt: Date? + while generated.count < maxTokens, let current = output { + if !shouldContinue() { + if generated.isEmpty { + model.reset(); hasConversationContext = false; tokenCount = 0 + throw CancellationError() + } + break + } + let next = try sample(current.logits, history: samplingHistory, + firstToken: generated.isEmpty, seed: seed.map { + $0 &+ UInt64(generated.count) + }) + if next == tokenizer.endOfTurnTokenID { break } + generated.append(next) + samplingHistory.append(next) + onText(try tokenizer.decode(generated)) + if firstTokenAt == nil { + firstTokenAt = Date() + decodeStartedAt = firstTokenAt + } + if generated.count == maxTokens { break } + output = try model(tokenID: next) + peakMemory = max(peakMemory, output?.peakMemory ?? 0) + tokenCount += 1 + } + hasConversationContext = true + + let finished = Date() + let firstTokenSeconds = firstTokenAt?.timeIntervalSince(started) + ?? finished.timeIntervalSince(started) + let decodeTokenCount = max(0, generated.count - 1) + let decodeSeconds = decodeStartedAt.map { finished.timeIntervalSince($0) } ?? 0 + let prefillSeconds = prefillFinished.timeIntervalSince(started) + let decodeTokensPerSecond = decodeSeconds > 0 + ? Double(decodeTokenCount) / decodeSeconds : 0 + print(String( + format: "[8b prefill] path=%@ tokens=%d total=%.2fs rate=%.2f tok/s", + streamingPrefill ? "streaming-top8" : "full-layer", + promptIDs.count, + prefillSeconds, + Double(promptIDs.count) / max(0.000_001, prefillSeconds))) + print(String( + format: "[8b decode] tokens=%d total=%.2fs rate=%.2f tok/s peak=%.0f MB", + decodeTokenCount, + decodeSeconds, + decodeTokensPerSecond, + Double(peakMemory) / 1_048_576)) + + return Edge0GenerationResult( + text: try tokenizer.decode(generated), + generatedTokenCount: generated.count, + elapsedSeconds: finished.timeIntervalSince(started), + prefillSeconds: prefillSeconds, + prefillTokensPerSecond: Double(promptIDs.count) / + max(0.000_001, prefillSeconds), + timeToFirstTokenSeconds: firstTokenSeconds, + decodeTokensPerSecond: decodeTokensPerSecond, + peakMemoryBytes: peakMemory + ) + } + + /// Official Edge0 policy: first token greedy, then temperature 0.7, + /// top-k 64, top-p 0.95 and HF-style repetition penalty 1.1. + private func sample(_ input: MLXArray, history: [Int], + firstToken: Bool, seed: UInt64?) throws -> Int { + let logits = input.flattened().asType(.float32) + if firstToken { return argMax(logits).item(Int32.self).intValue } + + if !history.isEmpty { + let unique = Array(Set(history)).sorted() + let ids = MLXArray(unique.map(Int32.init)) + let values = logits[ids] + logits[ids] = which(values .> 0, values / 1.1, values * 1.1) + } + let scaled = logits / 0.7 + let k = min(64, scaled.dim(0)) + let candidateIDs = argPartition(-scaled, kth: k - 1)[.. (indices: MLXArray, weights: MLXArray) { + precondition(nGroup > 0) + precondition(logits.shape == expertBias.shape || expertBias.shape == [logits.dim(-1)]) + precondition(logits.dim(-1) / nGroup >= 2) + precondition(topK <= topkGroup * (logits.dim(-1) / nGroup)) + let raw = sigmoid(logits.asType(.float32)) + var selection = raw + expertBias + let experts = selection.dim(-1) + precondition(experts % nGroup == 0) + precondition(topK > 0 && topK <= experts) + precondition(topkGroup > 0 && topkGroup <= nGroup) + + let groupsToDrop = nGroup - topkGroup + if groupsToDrop > 0 { + let grouped = unflatten(selection, axis: -1, shape: [nGroup, -1]) + let groupScores = top(grouped, k: 2, axis: -1).sum(axis: -1, keepDims: true) + let dropped = argPartition( + groupScores, + kth: groupsToDrop - 1, + axis: -2 + )[.ellipsis, .. MLXArray { + try stacked(ownerIDs.map { + try store.mlxArrayCopying(named: "layers.\($0).\(suffix)") + }, axis: 0) + } + fc1 = try weights("fc1.weight") + fc2 = try weights("fc2.weight") + linear = try weights("linear_init.weight") + eval(fc1, fc2, linear) + } + + func predict(features: [Int: LingPrerouterFeature]) throws + -> [Int: PredictedMoERoute] { + var activeOwners: [Int] = [] + var inputs: [MLXArray] = [] + var currentIndices: [Int: MLXArray] = [:] + + for owner in owners { + guard let feature = features[owner] else { continue } + let rows = feature.routeIndices.dim(0) + guard rows > 0 else { continue } + let current = row(feature.routeIndices, at: rows - 1) + let previous = previousIndices[owner] + ?? (rows > 1 ? row(feature.routeIndices, at: rows - 2) : nil) + currentIndices[owner] = current + + let tokenInput = lastToken(feature.input) + let combined = concatenated([ + tokenInput, + oneHot(current), + oneHot(previous), + ], axis: -1) + activeOwners.append(owner) + inputs.append(combined) + } + guard activeOwners == owners else { return [:] } + + // Official `head_batch`: 16 owners share three batched matmuls rather + // than issuing 48 tiny per-head GPU operations. + let x = stacked(inputs, axis: 0).asType(fc1.dtype) + let hidden = matmul(x, fc1.transposed(0, 2, 1)) + let gelu = 0.5 * hidden * (1 + erf(hidden / Float(2).squareRoot())) + let logits = matmul(gelu, fc2.transposed(0, 2, 1)) + + matmul(x, linear.transposed(0, 2, 1)) + let route = Edge0GroupedRouter8B.select( + logits: logits, expertBias: MLXArray.zeros([c.numExperts]), + topK: c.expertsPerToken, nGroup: c.nGroup, + topkGroup: c.topkGroup, normalize: c.normTopkProb, + routedScale: c.routedScalingFactor) + eval(route.indices, route.weights) + let allIDs = route.indices.asArray(Int32.self).map(Int.init) + let allWeights = route.weights.asArray(Float.self) + + var predictions: [Int: PredictedMoERoute] = [:] + for (position, owner) in owners.enumerated() { + let start = position * c.expertsPerToken + let end = start + c.expertsPerToken + predictions[owner + 1] = PredictedMoERoute( + expertIndices: Array(allIDs[start.. MLXArray { + var values = MLXArray.zeros([c.numExperts]) + guard let ids else { return values.reshaped([1, c.numExperts]) } + values[ids.flattened()] = MLXArray.ones([c.expertsPerToken]) + return values.reshaped([1, c.numExperts]) + } + + private func row(_ values: MLXArray, at index: Int) -> MLXArray { + let parts = split(values, indices: [index, index + 1], axis: 0) + return parts[1] + } + + private func lastToken(_ input: MLXArray) -> MLXArray { + guard input.dim(0) > 1 else { return input } + return split(input, indices: [input.dim(0) - 1], axis: 0)[1] + } + + func reset() { previousIndices.removeAll(keepingCapacity: true) } +} diff --git a/ios/Sources/Edge0MLX/MLXExpertTensorStore.swift b/ios/Sources/Edge0MLX/MLXExpertTensorStore.swift new file mode 100644 index 0000000..3e7962a --- /dev/null +++ b/ios/Sources/Edge0MLX/MLXExpertTensorStore.swift @@ -0,0 +1,56 @@ +import Edge0Core +import Foundation +import MLX + +public enum Edge0MLXError: Error { + case unsupportedDType(SafetensorsDType) +} + +public extension ExpertTensorStore { + /// Mirrors the upstream streaming loader's targeted `MADV_WILLNEED` + /// pass. It retains no MLX arrays and is safe to issue speculatively. + func adviseExpertsWillNeed(layer: Int, experts: [Int]) { + for expert in Set(experts) where (0.. MLXArray { + let slice = try slice(layer: layer, expert: expert, projection: projection, part: part) + return try copyTensor(descriptor: slice.descriptor, pointer: slice.pointer) + } + + /// Copies one row from an arbitrary axis-0-contiguous checkpoint tensor. + /// Used by the quantized embedding so a token lookup never materializes + /// the full 157k x 1536 embedding table. + func mlxAxisZeroSliceCopying(named name: String, index row: Int) throws -> MLXArray { + let tensor = try index.tensor(named: name) + guard tensor.shape.first.map({ row >= 0 && row < $0 }) == true else { + throw M1Error.invalid("Axis-zero index out of range for \(name)") + } + let slice = try tensor.axisZeroSlice(row) + let pointer = try mappedFile.pointer(to: slice.byteRange) + return try copyTensor(descriptor: slice, pointer: pointer) + } +} diff --git a/ios/Sources/Edge0MLX/OfficialGatedDelta.swift b/ios/Sources/Edge0MLX/OfficialGatedDelta.swift new file mode 100644 index 0000000..acaa296 --- /dev/null +++ b/ios/Sources/Edge0MLX/OfficialGatedDelta.swift @@ -0,0 +1,114 @@ +import MLX + +/// Language-level Swift port of mlx-lm's vector-gated delta Metal kernel used +/// by the official Edge0 8B KDA prefill path. The Metal body, grid and +/// template parameters intentionally retain the upstream structure. +public enum OfficialGatedDelta { + private static let vectorKernel = MLXFast.metalKernel( + name: "edge0_gated_delta_step_vec", + inputNames: ["q", "k", "v", "g", "beta", "state_in", "T"], + outputNames: ["y", "state_out"], + source: """ + auto n = thread_position_in_grid.z; + auto b_idx = n / Hv; + auto hv_idx = n % Hv; + auto hk_idx = hv_idx / (Hv / Hk); + constexpr int n_per_t = Dk / 32; + + // q, k: [B, T, Hk, Dk] + auto q_ = q + b_idx * T * Hk * Dk + hk_idx * Dk; + auto k_ = k + b_idx * T * Hk * Dk + hk_idx * Dk; + + // v, y: [B, T, Hv, Dv] + auto v_ = v + b_idx * T * Hv * Dv + hv_idx * Dv; + y += b_idx * T * Hv * Dv + hv_idx * Dv; + + auto dk_idx = thread_position_in_threadgroup.x; + auto dv_idx = thread_position_in_grid.y; + + // state_in, state_out: [B, Hv, Dv, Dk] + auto i_state = state_in + (n * Dv + dv_idx) * Dk; + auto o_state = state_out + (n * Dv + dv_idx) * Dk; + + float state[n_per_t]; + for (int i = 0; i < n_per_t; ++i) { + auto s_idx = n_per_t * dk_idx + i; + state[i] = static_cast(i_state[s_idx]); + } + + // g: [B, T, Hv, Dk] + auto g_ = g + (b_idx * T * Hv + hv_idx) * Dk; + auto beta_ = beta + b_idx * T * Hv; + + for (int t = 0; t < T; ++t) { + float kv_mem = 0.0f; + for (int i = 0; i < n_per_t; ++i) { + auto s_idx = n_per_t * dk_idx + i; + state[i] = state[i] * g_[s_idx]; + kv_mem += state[i] * k_[s_idx]; + } + kv_mem = simd_sum(kv_mem); + + auto delta = (v_[dv_idx] - kv_mem) * beta_[hv_idx]; + + float out = 0.0f; + for (int i = 0; i < n_per_t; ++i) { + auto s_idx = n_per_t * dk_idx + i; + state[i] = state[i] + k_[s_idx] * delta; + out += state[i] * q_[s_idx]; + } + out = simd_sum(out); + if (thread_index_in_simdgroup == 0) { + y[dv_idx] = static_cast(out); + } + + q_ += Hk * Dk; + k_ += Hk * Dk; + v_ += Hv * Dv; + y += Hv * Dv; + g_ += Hv * Dk; + beta_ += Hv; + } + for (int i = 0; i < n_per_t; ++i) { + auto s_idx = n_per_t * dk_idx + i; + o_state[s_idx] = static_cast(state[i]); + } + """) + + public static func callAsFunction(q: MLXArray, k: MLXArray, v: MLXArray, + decay: MLXArray, beta: MLXArray, + state: MLXArray) throws + -> (output: MLXArray, state: MLXArray) { + guard q.ndim == 4, k.shape == q.shape, v.ndim == 4, + decay.shape == q.shape, + beta.shape == [q.dim(0), q.dim(1), v.dim(2)], + q.dim(2) > 0, v.dim(2) % q.dim(2) == 0, + q.dim(3) > 0, q.dim(3) % 32 == 0, + state.shape == [q.dim(0), v.dim(2), v.dim(3), q.dim(3)], + q.dtype == k.dtype, q.dtype == v.dtype, + q.dtype == decay.dtype, q.dtype == state.dtype else { + throw M1Error.invalid("Invalid official gated-delta tensor shapes") + } + let batch = q.dim(0) + let tokens = q.dim(1) + let keyHeads = q.dim(2) + let keyDimension = q.dim(3) + let valueHeads = v.dim(2) + let valueDimension = v.dim(3) + let outputs = vectorKernel( + [q, k, v, decay, beta, state, tokens], + template: [ + ("InT", q.dtype), ("Dk", keyDimension), + ("Dv", valueDimension), ("Hk", keyHeads), + ("Hv", valueHeads), + ], + grid: (32, valueDimension, batch * valueHeads), + threadGroup: (32, 4, 1), + outputShapes: [ + [batch, tokens, valueHeads, valueDimension], + state.shape, + ], + outputDTypes: [q.dtype, q.dtype]) + return (outputs[0], outputs[1]) + } +} diff --git a/ios/Sources/Edge0MLX/ParallelLoRA.swift b/ios/Sources/Edge0MLX/ParallelLoRA.swift new file mode 100644 index 0000000..ab9c34d --- /dev/null +++ b/ios/Sources/Edge0MLX/ParallelLoRA.swift @@ -0,0 +1,62 @@ +import Edge0Core +import MLX + +/// Parallel, unmerged LoRA used by the official Edge0 deployment. +/// Base INT4 weights stay untouched; fp16 rank-16 adapters are added at run time. +struct ParallelLinear { + let base: QuantizedExpertLinear + private let loraA: MLXArray? + private let loraB: MLXArray? + private let scale: Float + + init(base: QuantizedExpertLinear, loraStore: ExpertTensorStore? = nil, + prefix: String? = nil, rank: Int = 16, alpha: Float = 32) throws { + self.base = base + scale = alpha / Float(rank) + if let loraStore, let prefix { + let a = try loraStore.mlxArrayCopying(named: prefix + ".lora_A") + let b = try loraStore.mlxArrayCopying(named: prefix + ".lora_B") + guard a.shape == [rank, base.inputSize], + b.shape == [base.outputSize, rank] else { + throw M1Error.invalid("LoRA dimensions disagree for \(prefix)") + } + loraA = a + loraB = b + eval(a, b) + } else { + loraA = nil + loraB = nil + } + } + + var inputSize: Int { base.inputSize } + var outputSize: Int { base.outputSize } + + func callAsFunction(_ input: MLXArray) throws -> MLXArray { + let output = try base(input) + guard let loraA, let loraB else { return output } + let x = input.asType(loraA.dtype) + let delta = matmul(matmul(x, loraA.T), loraB.T) * scale + return output + delta.asType(output.dtype) + } +} + +struct ParallelExpert { + let up: ParallelLinear + let gate: ParallelLinear + let down: ParallelLinear + + init(base: QuantizedExpert, loraStore: ExpertTensorStore?, prefix: String) throws { + up = try ParallelLinear(base: base.up, loraStore: loraStore, + prefix: prefix + ".up_proj") + gate = try ParallelLinear(base: base.gate, loraStore: loraStore, + prefix: prefix + ".gate_proj") + down = try ParallelLinear(base: base.down, loraStore: loraStore, + prefix: prefix + ".down_proj") + } + + func callAsFunction(_ input: MLXArray) throws -> MLXArray { + let g = try gate(input) + return try down((g * sigmoid(g)) * up(input)) + } +} diff --git a/ios/Sources/Edge0MLX/QuantizedExpert.swift b/ios/Sources/Edge0MLX/QuantizedExpert.swift new file mode 100644 index 0000000..84c6b60 --- /dev/null +++ b/ios/Sources/Edge0MLX/QuantizedExpert.swift @@ -0,0 +1,227 @@ +import Edge0Core +import Foundation +import MLX + +public enum M1Error: Error, LocalizedError { + case invalid(String) + public var errorDescription: String? { + switch self { case .invalid(let message): return message } + } +} + +/// Affine INT4: W[o,i] = nibble[o,i] * scale[o,i/64] + bias[o,i/64]. +/// Production affine INT4 linear. Coefficients retain their checkpoint dtype +/// and the output follows the activation dtype, matching upstream MLX. +public struct QuantizedExpertLinear { + public let weight: MLXArray + public let scales: MLXArray + public let biases: MLXArray + public let inputSize: Int + public let outputSize: Int + + public init(weight: MLXArray, scales: MLXArray, biases: MLXArray) throws { + guard weight.ndim == 2, weight.dtype == .uint32, + weight.dim(0) > 0, weight.dim(1) > 0, weight.dim(1) % 8 == 0, + scales.shape == [weight.dim(0), weight.dim(1) / 8], + biases.shape == scales.shape, + [.float32, .float16, .bfloat16].contains(scales.dtype), + [.float32, .float16, .bfloat16].contains(biases.dtype) else { + throw M1Error.invalid("Expected affine INT4 [out,in/8] U32 and floating [out,in/64] coefficients") + } + self.weight = weight + self.scales = scales + self.biases = biases + inputSize = weight.dim(1) * 8 + outputSize = weight.dim(0) + } + + public func callAsFunction(_ input: MLXArray) throws -> MLXArray { + guard input.ndim == 2, input.dim(0) > 0, input.dim(1) == inputSize, + [.float32, .float16, .bfloat16].contains(input.dtype) else { + throw M1Error.invalid("Linear input must be floating [tokens,\(inputSize)]") + } + return quantizedMM(input, weight, scales: scales, + biases: biases, transpose: true, groupSize: 64, bits: 4, mode: .affine) + } +} + +public struct QuantizedExpert { + public let up: QuantizedExpertLinear + public let gate: QuantizedExpertLinear + public let down: QuantizedExpertLinear + + public init(up: QuantizedExpertLinear, gate: QuantizedExpertLinear, + down: QuantizedExpertLinear) throws { + guard up.inputSize == gate.inputSize, up.outputSize == gate.outputSize, + down.inputSize == up.outputSize, down.outputSize == up.inputSize else { + throw M1Error.invalid("Inconsistent SwiGLU projection dimensions") + } + self.up = up; self.gate = gate; self.down = down + } + + public func callAsFunction(_ input: MLXArray) throws -> MLXArray { + let g = try gate(input) + return try down((g * sigmoid(g)) * up(input)) + } +} + +/// Official Edge0 whole-layer prefill representation: every routed expert for +/// one projection remains stacked on axis zero and is selected by +/// `gatherQuantizedMM`. Unlike the one-expert correctness path, checkpoint +/// BF16 scales/biases stay BF16, matching `StreamingSwitchGLU.load_full_layer`. +public struct QuantizedExpertStackedLinear { + public let weight: MLXArray + public let scales: MLXArray + public let biases: MLXArray + public let expertCount: Int + public let inputSize: Int + public let outputSize: Int + + public init(weight: MLXArray, scales: MLXArray, biases: MLXArray) throws { + guard weight.ndim == 3, weight.dtype == .uint32, + weight.dim(0) > 0, weight.dim(1) > 0, + weight.dim(2) > 0, weight.dim(2) % 8 == 0, + scales.shape == [weight.dim(0), weight.dim(1), weight.dim(2) / 8], + biases.shape == scales.shape, + [.float32, .float16, .bfloat16].contains(scales.dtype), + [.float32, .float16, .bfloat16].contains(biases.dtype) else { + throw M1Error.invalid("Expected stacked affine INT4 expert tensors") + } + self.weight = weight + self.scales = scales + self.biases = biases + expertCount = weight.dim(0) + inputSize = weight.dim(2) * 8 + outputSize = weight.dim(1) + } + + public func callAsFunction(_ input: MLXArray, expertIndices: MLXArray, + sortedIndices: Bool = true) throws -> MLXArray { + guard input.ndim >= 2, input.dim(-1) == inputSize, + [.uint8, .uint16, .uint32, .uint64, + .int8, .int16, .int32, .int64].contains(expertIndices.dtype), + [.float32, .float16, .bfloat16].contains(input.dtype) else { + throw M1Error.invalid("Invalid stacked expert input or indices") + } + return gatherQuantizedMM( + input, weight, scales: scales, biases: biases, + rhsIndices: expertIndices, transpose: true, + groupSize: 64, bits: 4, mode: .affine, + sortedIndices: sortedIndices) + } +} + +public struct QuantizedExpertStack { + public let up: QuantizedExpertStackedLinear + public let gate: QuantizedExpertStackedLinear + public let down: QuantizedExpertStackedLinear + + public init(up: QuantizedExpertStackedLinear, gate: QuantizedExpertStackedLinear, + down: QuantizedExpertStackedLinear) throws { + guard up.expertCount == gate.expertCount, + up.expertCount == down.expertCount, + up.inputSize == gate.inputSize, + up.outputSize == gate.outputSize, + down.inputSize == up.outputSize, + down.outputSize == up.inputSize else { + throw M1Error.invalid("Inconsistent stacked SwiGLU dimensions") + } + self.up = up + self.gate = gate + self.down = down + } + + /// Official staged-slot assembly: selected expert bundles are stacked in + /// prediction order, then consumed by gathered quantized matmuls. + public init(experts: [QuantizedExpert]) throws { + guard !experts.isEmpty else { + throw M1Error.invalid("Cannot stack an empty expert selection") + } + func projection(_ keyPath: KeyPath) throws + -> QuantizedExpertStackedLinear { + let values = experts.map { $0[keyPath: keyPath] } + return try QuantizedExpertStackedLinear( + weight: stacked(values.map(\.weight), axis: 0), + scales: stacked(values.map(\.scales), axis: 0), + biases: stacked(values.map(\.biases), axis: 0)) + } + try self.init(up: projection(\.up), gate: projection(\.gate), + down: projection(\.down)) + } +} + +public extension ExpertTensorStore { + /// Copies one named tensor, used for the small router and shared expert. + func mlxArrayCopying(named name: String) throws -> MLXArray { + let descriptor = try index.tensor(named: name) + let pointer = try mappedFile.pointer(to: descriptor.byteRange) + return try copyTensor(descriptor: descriptor, pointer: pointer) + } + + func loadExpert(layer: Int, expert: Int) throws -> QuantizedExpert { + guard (0.. QuantizedExpertLinear { + try QuantizedExpertLinear( + weight: mlxArrayCopying(layer: layer, expert: expert, projection: p, part: .weight), + scales: mlxArrayCopying(layer: layer, expert: expert, projection: p, part: .scales), + biases: mlxArrayCopying(layer: layer, expert: expert, projection: p, part: .biases)) + } + return try QuantizedExpert(up: projection(.up), gate: projection(.gate), down: projection(.down)) + } + + func loadSharedExpert(layer: Int) throws -> QuantizedExpert { + func projection(_ p: ExpertProjection) throws -> QuantizedExpertLinear { + let prefix = "model.layers.\(layer).mlp.shared_experts.\(p.rawValue)" + return try QuantizedExpertLinear(weight: mlxArrayCopying(named: prefix + ".weight"), + scales: mlxArrayCopying(named: prefix + ".scales"), + biases: mlxArrayCopying(named: prefix + ".biases")) + } + return try QuantizedExpert(up: projection(.up), gate: projection(.gate), down: projection(.down)) + } + + func loadDenseMLP(layer: Int) throws -> QuantizedExpert { + func projection(_ p: ExpertProjection) throws -> QuantizedExpertLinear { + let prefix = "model.layers.\(layer).mlp.\(p.rawValue)" + return try QuantizedExpertLinear(weight: mlxArrayCopying(named: prefix + ".weight"), + scales: mlxArrayCopying(named: prefix + ".scales"), + biases: mlxArrayCopying(named: prefix + ".biases")) + } + return try QuantizedExpert(up: projection(.up), gate: projection(.gate), down: projection(.down)) + } + + /// Direct translation of official `StreamingSwitchGLU.load_full_layer` for + /// Edge0-8B's separate gate/up/down layout: nine whole stacked tensors. + func loadFullExpertLayer(layer: Int) throws -> QuantizedExpertStack { + func projection(_ p: ExpertProjection) throws -> QuantizedExpertStackedLinear { + let prefix = "model.layers.\(layer).mlp.experts.\(p.rawValue)." + return try QuantizedExpertStackedLinear( + weight: mlxArrayCopying(named: prefix + "weight"), + scales: mlxArrayCopying(named: prefix + "scales"), + biases: mlxArrayCopying(named: prefix + "biases")) + } + return try QuantizedExpertStack(up: projection(.up), gate: projection(.gate), + down: projection(.down)) + } +} + +func copyTensor(descriptor: TensorDescriptor, pointer: UnsafeMutableRawPointer) throws -> MLXArray { + let dtype: DType + switch descriptor.dtype { + case .u32: dtype = .uint32 + case .bf16: dtype = .bfloat16 + case .f16: dtype = .float16 + case .f32: dtype = .float32 + default: throw Edge0MLXError.unsupportedDType(descriptor.dtype) + } + var elements = 1 + for dimension in descriptor.shape { + let product = elements.multipliedReportingOverflow(by: dimension) + guard dimension > 0, !product.overflow else { throw M1Error.invalid("Invalid tensor shape") } + elements = product.partialValue + } + let size = elements.multipliedReportingOverflow(by: descriptor.dtype.byteWidth) + guard !size.overflow, size.partialValue == descriptor.byteCount else { + throw M1Error.invalid("Tensor shape/byte count mismatch: \(descriptor.name)") + } + return MLXArray(Data(bytes: pointer, count: descriptor.byteCount), descriptor.shape, dtype: dtype) +} diff --git a/ios/Sources/Edge0MLX/StreamingEdge0Model8B.swift b/ios/Sources/Edge0MLX/StreamingEdge0Model8B.swift new file mode 100644 index 0000000..06a6bfd --- /dev/null +++ b/ios/Sources/Edge0MLX/StreamingEdge0Model8B.swift @@ -0,0 +1,295 @@ +import Edge0Core +import Foundation +import MLX + +public struct StreamingModelOutput { + public let logits: MLXArray + public let activeMemory: Int + public let peakMemory: Int + public let cacheMemory: Int +} + +private enum StreamingLayer { + case kda(StreamingKDADecoderBlock, KDAStateCache) + case mla(StreamingMLADecoderBlock, MLAKVCache) + + func callAsFunction(_ hidden: MLXArray, + predictedRoute: PredictedMoERoute? = nil) throws -> MLXArray { + switch self { + case .kda(let block, let cache): + return try block(hidden, cache: cache, predictedRoute: predictedRoute).hidden + case .mla(let block, let cache): + return try block(hidden, position: cache.count, cache: cache, + predictedRoute: predictedRoute).hidden + } + } + + var prerouterFeature: LingPrerouterFeature? { + switch self { + case .kda(let block, _): + guard let input = block.prerouterInput, + let indices = block.prerouterIndices else { return nil } + return LingPrerouterFeature(input: input, routeIndices: indices) + case .mla(let block, _): + guard let input = block.prerouterInput, + let indices = block.prerouterIndices else { return nil } + return LingPrerouterFeature(input: input, routeIndices: indices) + } + } + + func stage(experts: [Int]) throws { + switch self { + case .kda(let block, _): try block.stage(experts: experts) + case .mla(let block, _): try block.stage(experts: experts) + } + } + + func reset() { + switch self { + case .kda(let block, let cache): + cache.reset(); block.resetStaging() + case .mla(let block, let cache): + cache.reset(); block.resetStaging() + } + } + + func prefill(_ hidden: MLXArray) throws -> MLXArray { + switch self { + case .kda(let block, let cache): + return try block.prefill(hidden, cache: cache).hidden + case .mla(let block, let cache): + return try block.prefill( + hidden, position: cache.count, cache: cache).hidden + } + } + + func loadFullExpertLayer() throws { + switch self { + case .kda(let block, _): try block.loadFullExpertLayer() + case .mla(let block, _): try block.loadFullExpertLayer() + } + } + + func clearFullExpertLayer() { + switch self { + case .kda(let block, _): block.clearFullExpertLayer() + case .mla(let block, _): block.clearFullExpertLayer() + } + } + + var moeStats: StreamingMoEStats? { + switch self { + case .kda(let block, _): block.moeStats + case .mla(let block, _): block.moeStats + } + } +} + +public struct StreamingRuntimeStats: Sendable { + public let layers: [StreamingMoEStats] + public let cacheHits: Int + public let cacheMisses: Int + public let cachedExperts: Int +} + +/// One-token exact-streaming Edge0 model. +/// +/// Resident tensors are limited to the non-routed backbone, routers, and shared +/// experts. Routed experts remain in the checkpoint mmap and are copied only for +/// the selected top-8, matching the official repository's memory contract. MLX's +/// reusable free-buffer cache is capped at 256 MiB, also matching the official +/// engine default. +public final class StreamingEdge0Model8B { + private let configuration: Edge0Configuration8B + private let store: ExpertTensorStore + private let layers: [StreamingLayer] + private let finalNorm: MLXArray + private let lmHead: QuantizedExpertLinear + private let prerouter: LingPrerouter? + private let bundleCache: ExpertBundleCache + private var predictedRoutes: [Int: PredictedMoERoute] = [:] + + public init(configuration c: Edge0Configuration8B, store: ExpertTensorStore, + cacheLimitBytes: Int = 256 * 1024 * 1024, + loraWeightsURL: URL? = nil, + prerouterWeightsURL: URL? = nil, + progress: (String) -> Void = { _ in }) throws { + guard c.quantization.bits == 4, c.quantization.groupSize == 64, + c.quantization.mode == "affine" else { + throw M1Error.invalid("Full model requires affine INT4/group-64 weights") + } + Memory.cacheLimit = cacheLimitBytes + configuration = c + self.store = store + finalNorm = try store.mlxArrayCopying(named: "model.norm.weight") + lmHead = try QuantizedExpertLinear( + weight: store.mlxArrayCopying(named: "lm_head.weight"), + scales: store.mlxArrayCopying(named: "lm_head.scales"), + biases: store.mlxArrayCopying(named: "lm_head.biases")) + guard finalNorm.shape == [c.hiddenSize], lmHead.inputSize == c.hiddenSize, + lmHead.outputSize == c.vocabularySize else { + throw M1Error.invalid("Final norm or lm-head dimensions disagree with config") + } + progress("final norm and lm-head loaded") + + let bundleCache = ExpertBundleCache(capacity: 64) + self.bundleCache = bundleCache + let loraStore = try loraWeightsURL.map { + try ExpertTensorStore(modelURL: $0, expertCount: c.numExperts) + } + var built: [StreamingLayer] = [] + built.reserveCapacity(c.hiddenLayers) + for layer in 0.. Void = { _, _ in }) throws -> StreamingModelOutput { + let c = configuration + guard tokenID >= 0, tokenID < c.vocabularySize else { + throw M1Error.invalid("Token ID out of vocabulary range") + } + var hidden = try embedding(tokenID: tokenID) + var features: [Int: LingPrerouterFeature] = [:] + for (index, layer) in layers.enumerated() { + hidden = try layer(hidden, predictedRoute: predictedRoutes[index]) + if (7...22).contains(index), let feature = layer.prerouterFeature { + features[index] = feature + } + afterLayer(index, hidden) + } + hidden = MLXFast.rmsNorm(hidden, weight: finalNorm, eps: c.rmsNormEps) + let logits = try lmHead(hidden) + eval(logits) + try updateStagedRoutes(features) + let memory = Memory.snapshot() + return StreamingModelOutput(logits: logits, + activeMemory: memory.activeMemory, + peakMemory: memory.peakMemory, + cacheMemory: memory.cacheMemory) + } + + /// Official chunked prefill path: each sparse layer's complete expert + /// stack is loaded immediately before that layer and dropped when the + /// next layer begins. KDA and MLA process the full token chunk at once. + public func prefill(tokenIDs: [Int], + afterLayer: (Int, MLXArray) -> Void = { _, _ in }) throws + -> StreamingModelOutput { + let c = configuration + guard tokenIDs.count > 1, + tokenIDs.allSatisfy({ (0..