diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index ed7ce5b4ddc..0631782c06b 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -9,6 +9,7 @@ on: - 'README.md' - '.github/workflows/app.yml' push: + branches: [main] paths: - 'app/**' - 'README.md' diff --git a/.github/workflows/no-gpu.yml b/.github/workflows/no-gpu.yml index 26b34ab3ef3..6403551b15f 100644 --- a/.github/workflows/no-gpu.yml +++ b/.github/workflows/no-gpu.yml @@ -13,10 +13,12 @@ on: permissions: contents: read -# A pull request's newer push cancels its older run; every push to main keeps its own. +# A pull request's newer push cancels its older run, and a newer push to main cancels main's older run: a landing train +# pushes one commit a PR (seven runs in four minutes on 13 Nov), and the train's own run already tested the tree they +# end at. The nightly schedule keeps a group of its own. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: ${{ github.workflow }}-${{ github.event_name == 'schedule' && github.run_id || github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name != 'schedule' }} jobs: benchmark-clients: @@ -335,7 +337,7 @@ jobs: strategy: fail-fast: false matrix: - suite: [fast, adapters, deepseek, lanes, deferred] + suite: [fast, adapters, native, deepseek, lanes, deferred] steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: @@ -344,11 +346,29 @@ jobs: with: version: 0.17.0 use-cache: false + - name: Metal toolchain for native engine host checks + if: matrix.suite == 'native' + run: xcrun -sdk macosx metal -v || { xcodebuild -downloadComponent MetalToolchain && xcrun -sdk macosx metal -v; } - name: Host checks and aggregate wall time shell: bash env: SUITE: ${{ matrix.suite }} run: | printf 'Runner class: %s %s\n' "$RUNNER_OS" "$RUNNER_ARCH" + if [ "$SUITE" = native ]; then + printf 'Native source: %s\n' "$GITHUB_SHA" + status=0 + for phase in initial warm; do + TIMEFORMAT="test-native $phase wall seconds: %R" + if { time zig build test-host-ci-macos-native --test-timeout 30m --summary all; }; then + printf 'test-native %s exit: 0\n' "$phase" + else + code=$? + printf 'test-native %s exit: %s\n' "$phase" "$code" + status=$code + fi + done + exit "$status" + fi TIMEFORMAT='Host checks wall seconds: %R' time zig build "test-host-ci-macos-$SUITE" --test-timeout 30m diff --git a/CHANGELOG.md b/CHANGELOG.md index 35274c77bf5..26f7313b43a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,79 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel ## Unreleased +- Metal: the image towers of GLM, Step-5, Gemma 4 and Qwen share one source for bf16 rounding, threadgroup sum and max, row softmax and LayerNorm statistics; the compiled code is unchanged (#2948, part of #2251). Thanks to @jschmied. + +- Tests: the dashboard page's test runs on every OS, so Linux CI covers it, and no longer expects a paged prefix cache tile the page never had; `zig build test` passes on Macs again (#2946). + +- Video input: libtfvideo decodes MPEG-4 Part 2 and H.264 streams that declare no B-frames on up to four frame threads, 2.1x to 3.4x faster on Mac and Linux, with every frame equal to before, damaged clips included; H.264 with B-frames keeps the single-threaded path, because frame threads conceal damage there differently, and a stream that contradicts its own headers falls back to it (#2863). Thanks to @swbiggart. +- Video on Linux: `libtfvideo.so` links with a version script that exports only its four `tf_video_*` functions, so on x86_64 `zig build video` links `tools/zig/build_ffmpeg.sh`'s FFmpeg with its assembly, where before the link stopped on thousands of `R_X86_64_PC32` relocations against FFmpeg's own tables. Its frames equal PyAV 19.0.1's byte for byte on 23 clips. On aarch64 the library stops exporting FFmpeg's API; real video there is unchecked. macOS is unchanged (#2861). Thanks to @swbiggart. +- Qwen 3.5, 3.6 and 3.8 on CUDA: decode windows of 2 to 8 rows run at 4 or 8 rows instead of being padded to 16, about 1.5% faster at four streams on a GB10, with the same tokens (#2934, fixes #2909). + +- Server: `POST /v1/requests/{id}/cancel` cancels a running or queued request by its `chatcmpl-`, `cmpl-` or `resp_` id through the same engine cancel a disconnect sends; the reply ends `cancelled` with the tokens it had, and other streams are unchanged. With API keys only a full or `[admin]` key may cancel; a server without keys answers only direct loopback callers and refuses proxied requests (403 `loopback_only`) (#3193, fixes #3172). + +- Qwen 3.5, 3.6 and 3.8 on CUDA: a served one-row step no longer copies the recurrent state for a rollback that can't happen, so decode is 1-2% faster at one stream and about 4% at four on a GB10, with the same tokens (#2933, fixes #2908). + +- Metal: Recursive's Flint (40B-A2.5B MoE) is served natively from `RecursiveSI/flint-int4` on M5-class Macs, with its kernels and 2048-wide router under the kernel contract, a 262,144-token window, copy drafts and the prompt cache; `FLINT_LOAD_LIMIT_GB` caps its memory, and Step-5 now shares the same load budget, so a `STEP5_LOAD_LIMIT_GB` above the Mac's RAM is refused by name (#1177, fixes #1175). Thanks to @BobClawblaw. + +- CI: a newer push to main cancels main's older No GPU run (a landing train pushes one commit a PR, and the train's own run has tested the tree), the nightly keeps its own run, and the app checks run on pushes to main only (#3230). + +- Intel XPU: Qwen3.8-27B's prompt `gdn_out` kernel keeps its scale bookkeeping in cheaper exact forms, for 22.6% less kernel time on an Arc Pro B70 (734 to 569 ms over a 32K prompt) with byte-identical outputs, its fallback included (#2921, refs #2859). Thanks to @dinadur. + +- OCR: the native HIP engine reads page images through `POST /v1/ocr` in Mistral's OCR contract (its Python and TypeScript SDKs), with dots.mocr's reader (a box for each layout block). Mistral crops share one image budget per request (256 images, 4 times the page pixels, 16 MiB plus 2 MiB a page). An option that changes content and that TensorFold cannot apply, such as annotations, is refused, and so are PDF documents and `pages`. A page cut at the context limit or out of its model's format fails the request with HTTP 422; `docs/api.md` lists the contract's fields, statuses and limits (#2570, #2440). Thanks to @laudney. + +- Video on Linux: `libtfvideo.so` exports only its `tf_video_*` functions, so FFmpeg's x86_64 assembly links into it, and a CI check refuses a build that exports more (#2861). Thanks to @swbiggart. + +- Docs: the Intel Arc (XPU) page notes that its B70 prefill row predates 1.1.0's more precise prompt DeltaNet, which costs about 5% of a 32,768-token prompt with tokens unchanged (#2906, measured in #2859). Thanks to @dinadur. + +- Internal: the CUDA kept prompt-state identity is written once through any hasher, so the coming execution witness can hash the same parts with SHA-256; every kept key stays byte for byte what it was, pinned by tests against 1.2.7's sequence and a fixed key, so saved prompt states still reopen. Nothing a user sees changes (#2903, part of #2741). Thanks to @kotevcode. + +- Tests: the CUDA source check (`tools/zig/check_cuda_sources.py`) declares the SDK names main's kernels use, so it passes all 918 checks again (#2902). Thanks to @Bizuayeu. + +- Qwen3.8-27B on Metal: slot 0's rewind point is now made even beside a prompt cache that can hold a whole-context state, and the warm pass keeps it, so a client that sends the last reply back without its reasoning resumes in place at its history's end instead of a turn further back (on an M5 Max at 32k with a 5 GiB cache, turns 1-6 of a 7-turn chat took 16.5 s and 18.9 s of summed time to first token against 217 s, with identical replies); a client that sends the reply back as generated makes the same fill calls as before; a background pass at depth runs in narrower chunks, so a request arriving mid-pass at 128k waited 0.5 s against 4.1 s; slot 0's 146 MiB rewind buffer counts against `--prompt-cache-gib` room (#2723). Thanks to @ThinkOffApp. + +- Prompt cache on the core pool (the `TF_KV_SHARE` families, Qwen 3.5/3.6 on CUDA, and Qwen 3.5 and Nemotron on HIP): the radix store holds each pool page once, however many entries name it, and pins the node a keep or restore is using. A snapshot save that takes pool pages (Gemma 4, Kolibri-1, Spark2.5, Dory) can no longer make reclaim prune the leaf it is writing to. Replies and cached counts are unchanged. The store also learns checkpoint parts and prefix groups for later steps to use (#2875, part of #2444). Thanks to @laudney. + +- GLM-5.3-Flash on CUDA at four ranks or more: when the default split prefill's start-up exchange with every rank fails, as on a switchless ring of four, the failing rank now prints a refusal naming `TF_GLM_PREFILL_REDUCE=gather`, and rank 0 names it too when another rank fails first. Before, every rank stopped on a bare `ncclGroupEnd` error. Start-ups that succeed are unchanged (#2868). Thanks to @Bizuayeu. + +- HIP on gfx1151: the routed prompt tile runs an expert of at most 8 rows in K-parallel passes inside the same launch, with the matrix path's bytes; with Qwen3.6-35B-A3B on a Radeon 8060S, served cold prompts of 64 to 256 tokens are 11% to 22% faster (1.5% at 1,024), and logits and served tokens equal main's. Other cards' tables leave it off (#2878, fixes #2795). Thanks to @AbrahamCete. + +- Internal: one block-FP8 reader in `core/quant/fp8.zig` loads Flash Next's, Kolibri-1's and Drama 3's FP8 weights and their scales, widening bf16 scales by the same bit shift and copying f32 ones as before; every packed buffer is byte-identical, and only Flash Next refuses non-finite scales, as before (#2892, #2460 item 7). Thanks to @jschmied. + +- CI: the macOS nightly runs `zig build test-native`, the Metal engine host tests, installing the Metal toolchain when the runner lacks it and printing its cold and warm wall time (#2891, fixes #2880). + +- Tests: `qwen27_served`'s reply-history test follows #2699's rule (each conversation's newest state is kept first under pressure), and a new case checks that a regenerate restores at the history mark when the cache has room, so `zig build test-native` passes on main again (#2889, fixes #2881). + +- Serve: a later `--compact-at` replaces an earlier one, as for every other serve flag; `--compact-at auto --compact-at 0.5` used to keep automatic compaction because the fraction never cleared it (#2883). Thanks to @laudney. + +- GLM-5.3-Flash on CUDA: `TF_GLM_CONTEXT_CEILING` (opt-in) lets GLM open windows past 1,048,576 tokens, read once when the model opens; with it unset nothing changes, and cold prompts and decode on two and three GB10s read level with main (#2765, refs #2663). Thanks to @Bizuayeu. + +- Metal on M5: prompt lane projections at every width (8-, 6-, 5-, 3- and 2-bit and the oQ mixes) take a chunk's 16-row blocks in one dispatch, as 4-bit already did; Qwen3.8-27B MLX 8-bit cold prompts are about 33% faster on an M5 Ultra (769 to 1,029 tok/s at 2K and 8K) with identical tokens, and decode is unchanged (#2851, for #2818). + +- GLM-5.3-Flash: the DFlash2 drafter's startup line and docs give its licence as Apache-2.0, as its model card now says (#3084, for #1583). + +- CUDA kernels: a bf16 format for grouped experts runs a checkpoint's own bf16 expert weights in mma fragment blocks, with relu² up and fp32 or bf16 down, in decode and prompt forms; no served checkpoint selects it yet (Nemotron 3.5 Transcribe, #1631, will), and every other expert format's dispatch is unchanged (#1630). Thanks to @BobClawblaw. + +- DeepSeek-V4.1 on CUDA: `zig build dsv41-assets` now builds `tf-dsv41-assets` at ReleaseSafe whatever `-Doptimize` says, so `aot-needs` plans the same Triton variants about 15 times faster (713 s against 45 s at a 65,536-token context on a Mac; a GB10 run at 524,288 tokens went from about 45 minutes to under 7) (#3007, found on #2270). Thanks to @Defilan. + +- Docs: the CUDA memory plan says what each setting decides and which file reads it, including that `--context` is capped at the model's own window and `--context 0` takes it (#3028). Thanks to @Bizuayeu. + +- Qwen3.8-27B with DFlash2 on M5 Macs: one greedy request at the default 16-row window uses the long-copy driver again, so copy-heavy replies such as a whole-file rewrite run at about 792 tok/s instead of 241 on an M5 Ultra (#3013). Every copied token is still verified by the target, so replies are the same tokens. Sampled requests, concurrent streams, wider windows and M1-M4 Macs keep the lane loop; `TF_QWEN27_LANES=1` keeps every request on lanes (#3069). + +- Cortheon: a page link survives whitespace around `href` or inside its URL: `href ="…"` and a quote after a newline are kept, an unquoted link ends at a tab, CR or LF, and a tab or newline wrapped inside a link is not part of the address (#3006). Thanks to @ppardus. + +- Qwen3.8-Flash-Next on HIP: verify windows run their DeltaNet from the round's plan, so a window writes no state snapshot a row, and its keep replays the kept rows through the same scan. Drafted and undrafted replies are unchanged: every drafted reply equals the same request with drafts off, concurrent streams equal solo ones, and a resumed prompt equals a fresh one. Decode is 2–4% faster on Strix Halo (gfx1151) at one to four streams; shapes the plan kernels cannot take keep the per-row path, and no variable selects it (#2728). Thanks to @gilby. +- Slide rollback: a fact graph is removed only when its own folders file names the folder being rolled back, and its folders file goes with it; a graph whose folders file can't be read is left in place and printed (#3073). + +- Chat templates: a template is held to a nesting depth at load, and its render to a step count, stack depth and scratch memory, so a crafted template can't hang or exhaust the server; real templates use a small fraction of each limit, render byte-identically, and render as fast as before (#3072). + +- DeepSeek-V4.1 on Metal (experimental, opt in with `TF_EXPERIMENTAL=deepseek`): Macs now serve the release checkpoint natively with `tensorfold-dsv41` (`zig build dsv41-metal-serve`) for text prompts; image prompts come with #1722. Prompts and replies share rounds through the lane core with DSpark drafts, and the prompt cache reports `cached_tokens`. Rounds with three or more decoding streams decode plain (no draft pass, drafter ingest or copy windows), and when fewer streams decode again the drafter catches up from the target, so replies are unchanged. On an M3 Ultra and an M5 Ultra with 512 GB, drafts make one stream 1.21x to 1.50x faster than `--no-drafts` and two streams up to 1.13x faster, three and four streams are level, and `token_sha256` is equal in every cell (#1720, part of #1676). Thanks to @gilby, and to @3utsu for an independent M5 Ultra receipt. + +- Internal: `Admit` and `Ledger` move unchanged from `core/engine_api.zig` to `core/engine_admission.zig` and keep their names under `engine_api`, making room for the execution witness's hook; nothing a user sees changes (#2896, refs #2741). Thanks to @kotevcode. + +- Qwen3.8-27B on Metal: prompt passes no longer write the GDN tails buffer, which nothing read. Replies are unchanged; on an M5 Ultra, time to first token is about 0.5% shorter at 2K, 8K and 32K prompts, one and four streams (#2915, for #2905). + +- Flash Next on CUDA at `--tp 2`: a prompt pass wider than 64 rows reduce-scatters its routed experts' FP32 slots by row halves, sums each rank's rows and gathers the sums, where it all-reduced every slot; decode rounds and verify windows keep the all-reduce. Every reduced value is the same single add and the slots are summed in the writeback's order, so replies keep their tokens. Over two GB10s' RoCE link rank 0 sends 3.43 MiB a prompt token instead of 5.81, and cold prefill of 2k to 32k tokens is 26.5% to 26.8% faster there and 30.9% to 33.9% faster on two RTX PRO 6000s (an 8,192-token prompt: 7.12 to 5.62 s and 3.61 to 2.70 s) (#2977). Thanks to @swbiggart. + - Tests: `tf-cuda-test flashnext-rank RANK PORT timeout` passes again on two GPUs. NCCL start-up keeps its 30 s bound and only the stalled collective gets 200 ms, and both ranks meet once their communicators are up, so `timeout` and `eof` fail the collective under test, not the start-up (#2976, fixes #2975). Thanks to @swbiggart. - Tokenizer: a split pattern is held to a work allowance per piece (2,048 instructions a byte, counted, not timed) and refused at load when too deep or too large, so a crafted tokenizer or prompt can't hang the pre-tokenizer; every split of the patterns we serve is unchanged (#3071). @@ -60,13 +133,18 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - CUDA: dots.mocr's text decoder serves text prompts on a GB10 from the publisher's BF16 weights, on a new shared engine for dense GQA decoders (`zig/src/cuda_dense/`); `--vision`, prompt caching and fp8 caches are refused by name, and served CUDA checkpoints are unchanged (#2673, part of #1927). Thanks to @laudney. +- The prompt cache can keep a bounded ladder of intermediate checkpoints under a long prompt's history mark, so a later, compacted prompt resumes inside its retained prefix instead of the shared cut. On one GB10 with `nvidia/Qwen3.8-Flash-Next-NVFP4`, a 104K-token request that kept its first 100K resumed 65,536 tokens instead of 2, cutting TTFT from 50.7 s to 19.9 s with the same tokens; a cold 165K prompt paid 0.9 s more, and the 8 GiB cache held two more states. A follow-up after that turn resumed 65,536 tokens too, because the compacted turn evicted the larger states. `TF_PROMPT_INTERMEDIATE=1` enables it for Flash Next on CUDA; it stays off by default (#1909). Its checkpoints go after a conversation's other redundant copies but before any conversation's newest state, and take only the room left after the endpoint, the rewind state and other requests' promised keeps. Thanks to @javierbits. + +- Metal: kernels nothing launches are removed: Qwen3.8-27B's `prompt_glue.metal` and `prompt_state.metal` (six `q27_prompt_*` kernels) and core's `tf_lane_rope` with `Attention.rotate`; saved Qwen3.8-27B prompt states on Metal are recomputed once after upgrading, since their identity covers the kernel sources (#3065, part of #2251). Thanks to @jschmied. - Tests: main's nightly is green again. The Flash Next learn, prompt and compare tools build again on Linux (#2585 had put one file in two modules), and the dense serving and serving-identity pins take the changes #2579, #2585, #2285, #2205 and #2789 made, each named beside its pin. Serving is unchanged (#3002). +- HIP: Qwen3.8-Flash-Next's MTP head takes a prompt chunk on the prompt tiles, not a decode window at a time, so cold prompts finish about 5% sooner at 8K and 32K on a Radeon 8060S (gfx1151). Replies are unchanged; the head's caches and drafts are not bit-identical to before (#1878). Thanks to @gilby. - Cortheon (`--cortheon`): DuckDuckGo results are kept, and its ads still skipped, when only the spelling differs (either quote style, a `class` attribute in any letter case, an unquoted `href`, spaces around its `=`, or a line break before it); a duplicate whose scheme or host differs only in letter case is dropped while paths and queries keep their case, and the PyPI, npm and crates.io allow-list takes the host in any case with the path exact (#2750). Thanks to @ppardus. - GLM-5.3-Flash on CUDA: with `--vision` and no video library, rank 0 says at startup how much each stream sets aside for `--vision-video-tokens` rows that no request can fill (128 MiB a stream at the default 16,384), and that `--vision-video-tokens 64` on every rank cuts it to 512 KiB (#2813). Thanks to @Bizuayeu. - CUDA: `Qwen/Qwen3-Embedding-0.6B`, `-4B` and `-8B` load on the native engine from the published BF16 weights and serve `/v1/embeddings` (last-token pooling, unit-normed, `dimensions` crops). On one GB10 the worst cosine distance to float32 is 1.55e-4, 1.34e-4 and 1.08e-4 (transformers' own BF16: 1.88e-4, 1.79e-4 and 1.77e-4), a request's vectors equal each input sent alone, and a reranker folder or a flag the family does not serve is refused by name (#2285). Thanks to @Arminova. +- CUDA: `Qwen/Qwen3-Reranker-0.6B`, `-4B` and `-8B` load on the native engine from the published BF16 weights and serve `/v1/rerank`, scoring a request's documents as one decision whose shared start runs once a pass. On one GB10 the worst score from float32 is 5.5e-5, 6.2e-4 and 2.9e-4 on 16 documents of up to 2,579 tokens (transformers' own BF16: 3.4e-5, 2.8e-4 and 2.2e-4), a document scored alone equals its score in a request of 16, and a prompt past the window is refused (#2995, part of #2261). Thanks to @Arminova. - Qwen3.8-27B on Metal: the startup line says how many streams draft ("for both streams", "for all 4 streams", "for 8 of 16 streams"), the smaller of the stream count and `TF_Q27_DRAFT_SLOTS`, instead of "for one of them"; log text only (#2806). - Server: `--profile fast` refuses as not planned, since a speed mode that changes output tokens is not shipped; `--profile fast-exact` now names the token gates it still needs, and an unknown spelling is told that `stable` is the only served profile (#2794). Thanks to @devopsfortunato. @@ -103,6 +181,7 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - HIP on gfx12 under `--policy matrix=on`: Qwen3 dense checkpoints load each layer's gate and up weights interleaved as one weight, and one WMMA product writes silu(gate) x up in its epilogue instead of two products and `tf_silu_mul`, with the same output bytes: Qwen3-Reranker-8B ranks 16 documents in 0.657 to 0.661 s instead of 0.687 to 0.697 s and Qwen3-Embedding-8B embeds 100 short inputs in 0.901 to 0.911 s instead of 0.940 to 0.948 s on a Radeon AI PRO R9700. The kernel contract checks both fused blocks byte for byte against the unfused launches (part of #2245). Thanks to @laudney. - Nemotron on Macs: the masked prompt shader calls the shared `tf_paged_row` page helper again, so its prompt sources compile; #2322 had used the name #2283 retired the same day (#2548). + ## 1.2.7 - GLM-5.3-Flash on CUDA: with `TF_GLM_LAYERS=N`, serve's start-up memory check counts the weights by tensor from the shard headers, only those the load reads (the first N layers, the MTP layer when drafts are on, the embedding, norm and head), where it counted every checkpoint file. A 20-layer thin load of the 163.6 GiB checkpoint now counts 66.6 GiB without drafts. Unset, the count is the files' as before (#2788, fixes #2745). Thanks to @wojo. diff --git a/README.md b/README.md index f2ffde0c9d6..42a71ab78ae 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ Conversions are explicitly marked: their upstream weights need the linked conver | Gemma 4 12B, converted MLX 4-bit | `hf download google/gemma-4-12B-it --revision 707f0a3b8a3c7ad586ed01e27eafbad8a27dd0f7 --local-dir ~/models/gemma-4-12b-hf`
`hf download google/gemma-4-12B-it-assistant --revision 46d4c6f13f0ac0ad827b915669b8df9b81c64c51 --local-dir ~/models/gemma-4-12b-assistant` | One Mac (M5 Ultra checked); converted text weights 6.3 GB, image weights 100 MB, plus assistant/caches. MLX 4-bit also served on one GB10. Pre-M5 Metal has a row path; the MTP assistant requires M5 tensor units. | [Convert and serve](docs/recipes/gemma-4-12b-native.md). Upstream download needs `hf`; conversion needs Python, mlx and mlx-lm. Serving is native; [CUDA qualification](CHANGELOG.md#114) includes the MTP assistant. | | Step-5-Preview, converted affine | `hf download stepfun-ai/Step-5-Preview --local-dir ~/models/step5-preview-fp8` | One 256 GB Mac; text 166.9 GiB, MTP adds 2.5 GiB and vision 3.7 GiB. | [Convert and serve](docs/recipes/step-5-preview.md). The documented 256 GB setup serves converted affine weights; converting the upstream FP8 download needs Python and mlx. | | MiniMax-M3.1-Flash, community affine 3/4-bit | `hf download BobClawblaw/MiniMax-M3.1-Flash-MLX-mix34 --revision 9524999057e0d277d26438606e033e2191fb465c --local-dir ~/models/MiniMax-M3.1-Flash-MLX-mix34`
`hf download BobClawblaw/MiniMax-M3.1-Flash-MLX-mix256k --revision 916e7ca1044f1c3e7566163096243c940327224d --local-dir ~/models/MiniMax-M3.1-Flash-MLX-mix256k` | One 256 GB M5 Ultra; 203.7 GiB (32,768-token window) or 189.2 GiB (147,456), plus caches. | [MiniMax-M3.1-Flash](docs/recipes/minimax-m3.1-flash.md). Set `MINIMAX_LOAD_LIMIT_GB`; images with `--vision`. Community checkpoints: lossy conversions of MiniMax's NVFP4 release (KL 0.244 / 0.295). MiniMax Community License. | +| Recursive Flint 40B-A2.5B, MLX INT4 | `hf download RecursiveSI/flint-int4 --revision 3b93f9053b530488405ae9fca51d4900d6ce21f0 --local-dir ~/models/flint-int4` | One M5 Mac; M5 Ultra checked; 21.4 GiB resident plus caches. M1-M4 untested. | [Flint](docs/recipes/flint.md). Text and tools; always reasons; no draft head. | | Kolibri-1, block FP8 (research) | `tensorfold pull Aleph-Alpha/Kolibri-1@e52eb4627d11516b0c01de49210ab5a4e4061444` | One NVIDIA GPU; GB10 measured; 74 GB checkpoint. | [Kolibri](docs/recipes/kolibri-1.md). Text only; copy drafts, no EAGLE-3/neural drafter. | | Liquid AI d1-3B, decisions | Not documented yet. | Native CUDA; RAM minimum not published. | [API section below](#serve-flags). `/v1/systemone` and `/v1/decisions`. | | Fish Audio Drama 3, speech | Not documented yet. | Native Metal or CUDA; GB10 measured. | [Release audio instructions](CHANGELOG.md#110), [reference voices and dialogue speakers](docs/drama-http-voices.md). `/v1/audio/speech` with [sampling controls](docs/speech-sampling.md); checkpoint and `codec.pth` required. | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ee49e41c782..e49a8ebab8e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -292,8 +292,7 @@ below); `tools/zig/bolt_ggml.py` reads them from that file. The IQ2_XXS and Q8_0 Bolt's GGML checkpoints (`zig/kernels/cuda/ggml.cu`, `zig/src/cuda/ggml.zig`) are TensorFold's own. TensorFold ships no model weights. The `z-lab/Qwen3.8-27B-DFlash2` model card states Apache-2.0. -The optional `incoai/GLM-5.3-Flash-DFlash2` model card states CC BY-NC-ND 4.0, for non-commercial use -without derivatives; only the Python engine reads that drafter. Each checkpoint keeps its own license. +The optional `incoai/GLM-5.3-Flash-DFlash2` model card states Apache-2.0 too. Each checkpoint keeps its own license. ## Structured output: libtfgrammar diff --git a/build.zig b/build.zig index 8c57540f1b0..793f435a333 100644 --- a/build.zig +++ b/build.zig @@ -260,6 +260,9 @@ pub fn build(b: *std.Build) void { @import("zig/build/server.zig").cpuArtefacts(b, b.graph.host, optimize, build_options); @import("zig/build/mcdma_pair.zig").add(b, target); @import("zig/build/cache_index_timing.zig").step(b); + // the chat template's nesting, step, stack and memory limits (zig/src/core/template/limits_test.zig), on every host + const template_limits = b.createModule(.{ .root_source_file = b.path("zig/src/core/template/template.zig"), .target = b.graph.host, .optimize = .Debug, .link_libc = true }); + b.step("test-template-limits-host", "Chat template nesting, render steps, stack and memory limits without a GPU").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = template_limits, .filters = &.{"security:"} })).step); sys_build.wire(b); @import("zig/build/checkpoint_config.zig").tests(b); @import("zig/build/checkpoint_config.zig").wire(b); diff --git a/docs/api.md b/docs/api.md index 0f22026e707..ad6538fa14f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -45,7 +45,7 @@ different scopes, or scopes a key the flags or environment already give, is refu | Scope | Routes | | --- | --- | -| `inference` | POST `/v1/chat/completions`, `/v1/completions`, `/v1/responses`, `/v1/messages`, `/v1/messages/count_tokens`, `/v1/tokenize`, `/v1/detokenize`, `/v1/audio/speech`, `/v1/images/generations`, `/v1/embeddings`, `/v1/decisions`, `/v1/systemone`, `/v1/rerank` (also by PUT), and the same paths without `/v1` where the server has them; GET `/v1/models`, `/models`, `/v1/voices`; a WebSocket upgrade on GET `/v1/responses` or `/responses` | +| `inference` | POST `/v1/chat/completions`, `/v1/completions`, `/v1/responses`, `/v1/messages`, `/v1/messages/count_tokens`, `/v1/tokenize`, `/v1/detokenize`, `/v1/audio/speech`, `/v1/images/generations`, `/v1/embeddings`, `/v1/decisions`, `/v1/systemone`, `/v1/rerank` (also by PUT), `/v1/ocr`, and the same paths without `/v1` where the server has them; GET `/v1/models`, `/models`, `/v1/voices`; a WebSocket upgrade on GET `/v1/responses` or `/responses` | | `read` | GET `/metrics`, `/v1/metrics`, `/v1/load`, `/memory`, `/v1/memory`, `/stats`, `/dashboard`, `/v1/benchmark/manifest`, `/v1/models`, `/models`, `/v1/voices` | A scoped key reaches nothing else: not the stored Responses (GET or DELETE `/v1/responses/ID`), Recall, Sliding Weights, @@ -168,11 +168,11 @@ For decisions, `chat_template_kwargs` may be omitted, null, or an object contain ## Rerank `POST /v1/rerank` (also `/rerank`, and by `PUT` as SGLang takes it) scores each document against a query with a -reranker checkpoint. The native HIP engine serves `Qwen/Qwen3-Reranker-8B`: the engine declares the reranker and -its label tokens (`1_LogitScore/config.json`), and its startup line says `qwen3 reranker`. Every other server answers -the route with HTTP 400 "this model is not a reranker", and a reranker server answers chat, completion, Responses, -Messages, speech, decision, System One and embedding requests with HTTP 400 "this model is a reranker: use POST -/v1/rerank". +reranker checkpoint. The native HIP engine serves `Qwen/Qwen3-Reranker-8B`, and the native CUDA engine `-0.6B`, `-4B` +and `-8B`: the engine declares the reranker and its label tokens (`1_LogitScore/config.json`), and its startup line +says `qwen3 reranker`. Every other server answers the route with HTTP 400 "this model is not a reranker", and a +reranker server answers chat, completion, Responses, Messages, speech, decision, System One and embedding requests +with HTTP 400 "this model is a reranker: use POST /v1/rerank". | Field | Meaning | | --- | --- | @@ -193,6 +193,95 @@ context window before the first is scored, so a refused request costs no GPU wor `max_length` above it) returns HTTP 400 with `code` `context_length_exceeded`. An image or video part returns HTTP 400. Errors use the server's envelope, as on every route: `{"error": {"message", "type"}}`. +## OCR + +The native HIP engine reads page images through Mistral's OCR contract when it serves a model with an OCR reader +and `--vision`. + +| Route | Contract | Request | Reply | Written for | +| --- | --- | --- | --- | --- | +| `POST /v1/ocr` | Mistral OCR | JSON `{"model", "document", ...}` | `{"pages", "model", "document_annotation", "usage_info"}` | the `mistralai` Python and `@mistralai/mistralai` TypeScript SDKs, with `server_url` set to the server root | + +One family has a reader. A reader gives Markdown blocks in pixels of the page the client sent (after EXIF +orientation), never in the model's resized frame. + +| Model | What the reader returns | Geometry | +| --- | --- | --- | +| dots.mocr | one block a layout region (title, text, table, formula, picture, header, footer, ...), tables as pipe tables or, when cells span, HTML | a box a block | + +Any other model answers the route with HTTP 400 "this model has no OCR reader; use POST /v1/chat/completions", +and a server started without `--vision` answers "OCR needs image input; start the server with --vision". +Embedding, reranker and readout engines refuse the OCR route as they refuse chat. The route takes the `inference` +key scope. + +### Reader Markdown + +The reader writes block text in one Markdown flavour, the one Mistral's page Markdown uses: + +- Headings with `#` markers: a title is `#`, a section header `##` unless the model gives a level. +- GFM pipe tables, or one HTML `` when a table has row or column spans a pipe table cannot hold; a table + block holds exactly one table. +- Display math as `$$…$$` and inline math as `$…$`, the LaTeX kept as the model wrote it. +- Lists with `-` or `1.` markers, and code in fenced blocks. +- No images: a picture block has empty text, and its caption, if any, is its own block. The contract writes its + own image references. + +### Pages and budgets + +- Images are PNG, JPEG or WebP, one page each, up to the 32 MiB body limit and the reader's decoded-pixel limit: + 16 megapixels and 8192 pixels a side for dots.mocr; a larger image is bad input. An image whose header and + decoded pixels disagree is refused as bad input. +- PDF documents and `pages` are not read: a PDF document is HTTP 400 "PDF documents are not read on POST /v1/ocr; + send each page as a PNG, JPEG or WebP image", and any non-null `pages` is HTTP 400 "pages selects pages of a PDF + document; POST /v1/ocr reads one page image". +- `https://` URLs (`document_url` and `image_url`) are fetched only with `--vision-urls`: images, at most 32 MiB. + `http://` URLs are refused. +- Image budget, for Mistral crops (`include_image_base64`): at most 256 encoded images, 4 times the request's page + pixels, and 16 MiB plus 2 MiB a page of encoded bytes. Crossing one fails the whole request as an unreadable + page, "too many image regions". The encode needs libjpeg-turbo 3.1.4.1 (`TENSORFOLD_LIBTURBOJPEG` names its + file); without it `include_image_base64` is HTTP 400. + +The page is read as one user turn (the page image, then the reader's prompt), greedy, with thinking off and +`max_tokens` equal to all the context the prompt leaves. It succeeds only when the reply stops on its end token +and the reader can parse it. + +### Failures + +| Failure | Mistral | +| --- | --- | +| Bad input: an undecodable or unsupported file, a page or option the server cannot serve | 400 | +| Unreadable page: the reply was cut at the context limit, repeated without end, broke the model's format or gave a box that is not finite; the image budget was crossed | 422 | +| The engine stopped the page (it closed or drained) | 503, `Retry-After: 1` | +| Engine fault or out of memory | 500 | +| Envelope | `{"detail": [{"loc", "msg", "type"}]}`, `type` `bad_input`, `unreadable_page`, `capacity` or `internal` | + +An unreadable page's message names its index, and the model's raw text goes to the server log, never to the +client. 422 means the same request fails the same way again under greedy decoding; a 503 carries `Retry-After`. +A client that disconnects stops the request before its page is read. + +### Mistral: `POST /v1/ocr` + +| Field | Meaning | +| --- | --- | +| `model` | Any string or null. The reply names the requested id when it is the served name or an `--alias`, else the served name, so `--alias mistral-ocr-latest` lets copied examples echo it | +| `document` | `{"type": "document_url", "document_url"}` or `{"type": "image_url", "image_url"}` (a string or `{"url"}`): a `data:` URL, or an `https://` URL as above. The bytes must be a PNG, JPEG or WebP image; a PDF is HTTP 400 (above). `{"type": "file"}` is HTTP 400 | +| `pages` | Any non-null value is HTTP 400 (above): an image has one page | +| `table_format` | Null: tables stay in the page Markdown as pipe tables (a spanned HTML table becomes a pipe table with the spanned text in its first cell). `"markdown"` or `"html"`: each table becomes a `tables[]` entry (`tbl-N.md` or `tbl-N.html`) and the Markdown links it; under `"markdown"` a table with spanned cells stays HTML, the only form that keeps it | +| `extract_header`, `extract_footer` | Move page headers or footers out of `markdown` into `header` and `footer` | +| `include_blocks` | Default `true`: one block for each region with a box. `false` leaves `blocks` out | +| `image_limit`, `image_min_size` | Choose which pictures to keep, in reading order (`image_min_size` against the box's width and height, then the running count against `image_limit`). A dropped picture has no image entry, Markdown link or block | +| `include_image_base64` | Add `image_base64`, a `data:image/jpeg;base64,...` crop of the picture's box, to each kept image | +| `confidence_scores_granularity` | Accepted; no scores are returned | +| `bbox_annotation_format`, `document_annotation_format`, `document_annotation_prompt` | Any non-null value is HTTP 400 "annotations are not supported" | + +`null` means absent for every optional field, and unknown keys are ignored. Each page carries `index`, `markdown` +(blocks joined by a blank line, pictures as `![img-N.jpeg](img-N.jpeg)`), `images`, `tables`, `header`, `footer`, +`dimensions` (`dpi`, `height`, `width`) and `blocks`; `hyperlinks` is left out. A block has `type` (`text`, +`title`, `list`, `table`, `equation`, `code`, `caption`, `header`, `footer` or `image`), an integer box and +`content`, its own Markdown with its tables in the `table_format` form; image blocks add `image_id` and table +blocks `table_id`. `usage_info` gives `pages_processed` and `doc_size_bytes`, the decoded document's size. An +image's `dimensions.dpi` is 72. + ## System One on a Clef engine `POST /v1/systemone` on a Clef checkpoint (Cloudflare's clef-flash: BF16 Qwen3.5 weights with @@ -706,16 +795,40 @@ A startup capacity estimate is not a measured release capacity. On a unified-memory GPU (the DGX Spark's GB10), the CUDA server's allocations come out of the host's RAM but are not charged to a container's memory limit (`docker run --memory`, cgroup `memory.max`): the limit neither caps the -model's weights and cache nor keeps them from crowding other work on the machine. The server sizes its window from the -host's `MemAvailable` less a reserve (a tenth of RAM, at least 4 GiB); to leave room for other containers, start it -with a smaller `--context` or `--parallel`. `TENSORFOLD_MEMORY_RESERVE_GIB` replaces that reserve (at least 2 GiB) -when you know the machine's headroom: a larger one leaves more for other work, a smaller one more for KV caches. The -reserve also carries CUDA context, NCCL and workspace memory the estimate does not count, and exhausting a unified -GPU's memory can freeze the host, so lower it only with room to spare. - -On a discrete card (an RTX 3090, an A100) the server sizes its window from the card's own free memory, as the driver -reports it, less a reserve of a 32nd of the card and at least 1 GiB. `TENSORFOLD_MEMORY_RESERVE_GIB` sets that one from -0.5 GiB up. +model's weights and cache nor keeps them from crowding other work on the machine. The startup budget is the host's +`MemAvailable` less a reserve (a tenth of RAM, at least 4 GiB). On native CUDA a stream's window is `--context`, the +model's own window at `--context 0`, or, when it is unset, the family's default capped at the model's own window; on +the paged families the streams and pages then fill the budget up to the streams' windows plus the cache's pages, so +once those exceed it, the free memory after load is the reserve. To leave room for other containers, start the server +with a smaller `--context` or `--parallel`. +`TENSORFOLD_MEMORY_RESERVE_GIB` replaces that reserve (at least 2 GiB) when you know the machine's headroom: a larger +one leaves more for other work, a smaller one more for KV caches. The reserve also carries CUDA context, NCCL and +workspace memory the estimate does not count, and exhausting a unified GPU's memory can freeze the host, so lower it +only with room to spare. + +On a discrete card (an RTX 3090, an A100) the budget is the card's own free memory, as the driver reports it, less a +reserve of a 32nd of the card and at least 1 GiB, and the plan is the same. `TENSORFOLD_MEMORY_RESERVE_GIB` sets that +one from 0.5 GiB up. + +With pages, the plan takes the engine's own window first, then each other stream's fixed part, then the prompt cache: +a named `--prompt-cache-gib` comes before every window but the engine's own, and an unnamed one after the whole +windows the budget holds, half of what they leave but at least 16 GiB and at most all of it (#2987 shows what that +does to `TF_KV_SHARE`'s states). The cache then holds the kept states (on GLM with `TF_KV_SHARE`, a slot a state, the +rest of it made pages), and the rest of the budget becomes pages, up to the streams' windows plus the cache's pages. +The settings that decide each part: + +| Setting | Kind | Scope | Unset | What it decides | Read in | +|---|---|---|---|---|---| +| `TENSORFOLD_MEMORY_RESERVE_GIB` | environment | the host: every CUDA native family, each rank at load | a tenth of RAM, at least 4 GiB (unified); a 32nd of the card, at least 1 GiB (discrete) | what the budget leaves free | `zig/src/native/cuda_family_memory.zig`, `cuda_memory.zig` | +| `TENSORFOLD_CUDA_MEMORY_LIMIT_GB` | environment | the host: every CUDA native family, each rank at load | none | a cap on the budget, the weights included | `zig/src/native/cuda_family_memory.zig`, `cuda_memory.zig` | +| `--context` | flag | the launch | the family's default, capped at the model's own window | a stream's window; `0` takes the model's own | `zig/src/server/cli.zig`, `zig/src/native/cuda_window.zig` | +| `--parallel` | flag | the launch | `auto`: up to 8 | the streams | `zig/src/server/cli.zig` | +| `--prompt-cache-gib` | flag | the launch | after the whole windows, as above | the prompt cache's budget | `zig/src/server/cli.zig`; unset, `zig/src/native/cuda_kv_pool.zig` (`budgetOf`) and `cuda_memory.zig` | +| `TF_KV_SHARE` | environment, in the profile registry | the launch, on the families of the core page adapter | off: copies | prompts kept as pool pages, with at most this many states past them | `zig/src/core/kv_prompts.zig` | +| `TF_GLM_KV_HOST_GIB` | environment | GLM | one window and 16 pages | GLM's host KV pages with `TF_GLM_KV_HOST=1`, outside the device pool | `zig/src/families/glm/cuda_kv_host.zig` | +| the pool's pages | none | the core plan | — | what is left | `zig/src/core/kv_pool.zig` (`plan`) | + +Measured numbers for one launch are in #3000. ## Compaction @@ -1087,6 +1200,26 @@ server sends the counters described under [Responses](#responses). For memory fi [`/memory`](memory-diagnostics.md) (it needs a key when keys are configured) or `/metrics`' `tensorfold:process_footprint_bytes`. +## Cancel a request + +`POST /v1/requests/{id}/cancel` (or `/requests/{id}/cancel`) cancels a running or queued request by the id its +reply carries: a chat completion's `chatcmpl-`, a completion's `cmpl-`, or a Responses `resp_` id. The engine takes +the same cancel a client disconnect sends, and every other stream's tokens are unchanged. The reply ends well formed +with the tokens it had (none when it was queued): `finish_reason: "cancelled"`, or a Responses `status: "cancelled"` +in its `response.incomplete` event. A Cortheon turn runs no further rounds. The answer is 200 +`{"id": ..., "object": "request.cancel", "cancelled": true}`, or 404 with the code `request_not_found` when no +request holds the id: it was written out, or never existed. An id is held from the request's start until its reply is +written, so a cancel that arrives as the reply ends leaves that ending as it was. The body, if any, is ignored. + +With API keys it is an admin route: a full or `[admin]` key may cancel from any address, a scoped key gets 403 +`insufficient_scope` (see [Key scopes](#key-scopes)) and a missing key 401. A server without keys answers only a +caller whose socket is on the loopback address (127.0.0.0/8 or ::1) and refuses everyone else with 403 +`loopback_only`, so an open server never lets one client cancel another's request. The socket's own address decides; +a forwarded address is never read. Behind a reverse proxy every caller's socket is the proxy's, so a keyless server +also refuses a request that carries `Forwarded`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Real-IP`, `Via`, +`CF-Connecting-IP` or `True-Client-IP`. A proxy that adds none of them cannot be told apart from a local caller: give +a proxied server keys. + ## The Responses API `POST /v1/responses` takes OpenAI's Responses request and runs it as the equivalent chat completion, through the diff --git a/docs/environment.md b/docs/environment.md index 81972702dd9..b1818515681 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -28,6 +28,7 @@ Diagnostics quote and escape the supplied text so it cannot create another termi | `TF_DRAFT_NODES` | Whole native-size nonnegative count, capped by the drafter's window; zero disables trees | | `TF_QMMF_HALF_BELOW` | Whole native-size nonnegative block count; zero disables half tiles; default 96 | | `TENSORFOLD_PROMPT_CACHE_KEEP_OTHERS` | Exactly `0` or `1`, checked when the CUDA backend starts; default off ([profiles](profiles.md)) | +| `TF_PROMPT_INTERMEDIATE` | Exactly `0` or `1`, checked when the CUDA backend starts; default off. `1` keeps Flash Next's ladder of intermediate prompt-cache checkpoints under a long prompt's history | | `TF_HEAD_GEOMETRY` | Whole native-size nonnegative geometry count in the head benchmark | | `TF_ROCE_GID_INDEX` | Whole index 0 through 2147483647; unset searches for a RoCE v2 IPv4 GID | | `TF_GEMMA4_CHUNK_SOLO` | Gemma 4 on CUDA: a multiple of 512 from 512 through 4096; unset, empty or 512 is off. Checked when the model loads, with the family's own message | @@ -42,11 +43,17 @@ Diagnostics quote and escape the supplied text so it cannot create another termi | `TF_GLM_SHARED` | Exactly `0` or `1`; default on (GLM windows share a forward); read on rank 0 | | `TF_GLM_DFLASH2_LOG` | Whole unsigned 64-bit count of DFlash2 rounds whose drafts are logged; default 0 | | `TF_GLM_DRAFT_CONFIDENCE` | Finite number 0 through 1 where a DFlash2 chain's chance product stops it; default 0.7 | +| `TF_GLM_CONTEXT_CEILING` | GLM-5.3-Flash on CUDA: whole token count 1 through 2147483616, the window `--context` may ask for and `--context 0` serves, in place of config.json's 1048576; read at GLM's open. Past that length the model's quality is the operator's to check. Unset keeps config.json's window | | `TF_PROMPT_ROWS` | Native serving: a multiple of 16 from 128 through 2048; unset keeps the chip/family default | | `TF_ANE_COLUMNS` | Whole u32 count; zero disables the experimental share; default zero | | `TF_FLASHNEXT_SCALE_INDEX` | Flash Next on Metal before tensor units: exactly `0` or `1`; `1` has the 4-bit expert kernels read each group's scale and bias through a 16-bit index into its tensor's table of pairs, the same values with fewer weight bytes; default 0 | | `TF_FLASHNEXT_GROUPED_ROWS` | Flash Next on Metal without tensor units: `0`, or the rows (`1` through `32`) from which a decode window runs its 4-bit experts grouped by expert, with the same output bits; default 5; ignored with `--ssd-experts` and in speed-up mode | | `GLM_EHPROJ_NAX` | GLM-5.3-Flash on Metal: exactly `0` or `1`; `0` serves the MTP head's prompt eh_proj on the scalar kernel; default 1, the tile kernel where it builds | +| `TF_DSV41_SMALL` | DeepSeek-V4.1 on Metal: exactly `0` or `1`; `1` reads the checkpoint's config at its own small shapes (the served smoke test's tiny model); default 0 | +| `TF_DSV41_ASSETS` | DeepSeek-V4.1 on Metal: a directory path without control characters that holds the Engram token map; unset or blank reads it beside the checkpoint | +| `TF_DSV41_ENGRAM_DIR` | DeepSeek-V4.1 on Metal: a directory path without control characters that holds the packed Engram tables; unset or blank takes the checkpoint's `engram_table_dir` | +| `TF_DSV41_ENGRAM_VERIFY` | DeepSeek-V4.1 on Metal: exactly `sample` or `full`; `full` also checks every byte of the Engram tables against their manifest at boot; default `sample` | +| `TF_DSV41_ENGRAM_CACHE` | DeepSeek-V4.1 on Metal: whole u32 count of Engram records a layer kept cached; default 65536 | | `XPU_A8_BITS` | Exactly `4` or `8`, for the XPU quantization study; unset keeps the study's existing setting | | `LFM3_FUSE` | Whole u32 fusion mask; default 7 | | `TF_SLIDE_POCKET_KEY` | Exactly `auto`, `reply`, `subject` or `both`; default `auto` | diff --git a/docs/kernel-contract.md b/docs/kernel-contract.md index 40f1f0eee7a..86b36490830 100644 --- a/docs/kernel-contract.md +++ b/docs/kernel-contract.md @@ -401,6 +401,10 @@ CUDA also checks the affine-3 experts' split decode pair, bit-equal to the pair one dropping a last round of K groups short of the warps, one chaining the groups in reverse). CUDA also checks Flash Next's NVFP4 experts on prompt tiles with the decode form's sums (SwiGLU gate-up and fp32 down, each bit-equal to the decode instance it replaces; the scaled-code prompt tile is the planted control). +CUDA also checks the bf16 experts (`experts_bf16.cu`, a checkpoint's own bf16 weights: Nemotron 3.5 Transcribe's routed +and shared experts): relu^2 up, fp32 down and bf16 down, in the decode and prompt forms, against float64 (two controls: +the fp32 down launched one 32-input group short, and a down summing in bf16). nvcc 13.2's prompt forms and 13.3's +decode forms on sm_120 and sm_121 take other registers than 13.0's and have their own records. CUDA also checks Nemotron's pocket (`nemotron_ops.cu`, the learned neurons the living-weights learner adds beside the shared expert): relu^2 up into act rows and down added into the shared expert's slot, a block row a row and tiled, the down to fp32 and bf16, against float64, the neurons past the count NaN so a read of one shows (two controls: the up's diff --git a/docs/living-weights.md b/docs/living-weights.md index b42e2bda92c..a679bd4cfe5 100644 --- a/docs/living-weights.md +++ b/docs/living-weights.md @@ -378,7 +378,12 @@ The rollback: - only then removes what the lessons added: lesson files (`living-weights-00001.safetensors` and on, or an older `living-weights.safetensors`), writes a lesson left cut short (`NAME.tmp` beside a kept or lesson file), and the fact graph files that `--slide-graph` named while the folder learned. A fact graph can live outside the folder; the - rollback removes it all the same, since it describes weights that are gone, and prints every path it removes. + rollback removes it all the same, since it describes weights that are gone, and prints every path it removes. It + removes one only when the `GRAPH.folders` file a `--slide` server writes beside the graph lists this folder, because + the snapshot's own list sits inside the model folder, where a downloaded repository could plant one, and removes + that file with the graph. A graph it leaves for that reason, or because its `GRAPH.folders` file cannot be read, is + printed too: one taught by an earlier version has no such file until a `--slide` server next teaches the folder, so + delete it by hand if you roll back first. - leaves everything else: files you added after the snapshot, files a lesson never writes (the tokenizer, templates, a download's `.cache`), and the snapshot itself, so you can teach the folder again and roll back again. diff --git a/docs/optimization-ledger.md b/docs/optimization-ledger.md index 2b772874bc9..828c041aca0 100644 --- a/docs/optimization-ledger.md +++ b/docs/optimization-ledger.md @@ -375,6 +375,8 @@ below; these are estimates for planning a port, not results. 16. **DFlash and DFlash2 drafters.** An external block drafter reads the target's hidden taps and drafts a block (or a tree) a step. It needs a trained drafter for each target; see [training a drafter](recipes/training-a-drafter.md). + Qwen27 Metal retains its target-verified long-copy tail for an eligible lone greedy request at the M5 + 16-row default; sampled and shared requests keep the lane core ([#3013](https://github.com/ashhart/TensorFold/issues/3013)). 17. **DSpark drafters.** A drafter that ships with the release (DeepSeek-V4.1's `mtp.i` blocks, MiniMax-M3.1's, Kimi K3's) drafts from the target's taps with a confidence schedule. Applies wherever a DSpark drafter exists for the @@ -447,6 +449,10 @@ below; these are estimates for planning a port, not results. 25. **Tensor parallel ranks.** A model splits over two or more GPUs or hosts, so a model that doesn't fit one box runs, and decode reads each rank's share of the weights. Mixed Metal and CUDA ranks are tracked in [#1236]. + | Family | Gain (measured) | Where | + | --- | --- | --- | + | flashnext (C), TP=2 | prompt passes reduce-scatter the experts' slots by rows: rank 0 sends 5.81 → 3.43 MiB a prompt token, cold prefill +26.5..+26.8 % on two GB10s, +30.9..+33.9 % on two RTX PRO 6000s, same replies | [#2977] | + 26. **Pipeline stages.** Layer ranges on different nodes, one hidden row a token over Ethernet, so a GB10 and a Mac can serve one model ([#2005]). The stage link and its protocol are [#2012] and [#2060]; the 27B as a stage is [#2115], logits byte-equal to the whole model at two and three stages. @@ -615,3 +621,4 @@ below; these are estimates for planning a port, not results. [#2634]: https://github.com/ashhart/TensorFold/pull/2634 [#2784]: https://github.com/ashhart/TensorFold/pull/2784 [#2764]: https://github.com/ashhart/TensorFold/issues/2764 +[#2977]: https://github.com/ashhart/TensorFold/pull/2977 diff --git a/docs/recipes/README.md b/docs/recipes/README.md index 50dcf988835..442c9204607 100644 --- a/docs/recipes/README.md +++ b/docs/recipes/README.md @@ -32,6 +32,7 @@ For agent-assisted contributions, read the [TensorFold contributor skill](SKILL. | Spark-X2.6-4B | [Native CUDA](spark-x2.6-4b-cuda.md), [native Metal, the bf16 release, contexts to 1M](spark-x2.6-4b-metal.md) | | Nanbeige4.3-33B | [Native Metal, the bf16 and INT4 releases, the Llama family](nanbeige-4.3-33b-metal.md) | | Underdog Woof | [Native CPU speech on a Mac, int8, non-commercial licence](woof.md) | +| Recursive Flint | [Native Metal, the publisher's affine 4-bit export, reasoning and tool calls](flint.md) | [Persisted serving-state identities](persisted-serving-keys.md) lists the disk owners, their compatibility keys and stale-state host checks. diff --git a/docs/recipes/cuda.md b/docs/recipes/cuda.md index 9790f3262a3..5eb9845aa5f 100644 --- a/docs/recipes/cuda.md +++ b/docs/recipes/cuda.md @@ -358,6 +358,7 @@ Unified-memory GPUs share physical RAM with host buffers and file-backed model d host's available memory, reclaimable page cache included, less a floor of a tenth of RAM (at least 4 GiB) that `TENSORFOLD_MEMORY_RESERVE_GIB` can move, and considers mapped-table residency when sizing an automatic window. It accounts for stream count and retained caches where concurrency is enabled. +The native engine's memory plan and its settings are in the [API reference](../api.md#context-and-errors). Two-rank Flash Next, Nemotron and GLM requests finish on both ranks after a client disconnects, keeping the collective sequence aligned. MLX disk snapshots and cache-budget flags do not configure these CUDA diff --git a/docs/recipes/flash-next-cuda.md b/docs/recipes/flash-next-cuda.md index dd413c25a96..2bbee279424 100644 --- a/docs/recipes/flash-next-cuda.md +++ b/docs/recipes/flash-next-cuda.md @@ -39,6 +39,9 @@ tensorfold-native serve nvidia/Qwen3.8-Flash-Next-NVFP4 --tp 2 --rank 0 --master checkpoint's 124 GiB on a GB10), and both wait up to 30 minutes for the other. `TENSORFOLD_CHECKPOINT_SHA256`, when set the same on both ranks, replaces the digest and keeps the wait at a minute. - `--prompt-cache-gib 0` is required with drafts at `--tp 2`, and `--idle-unload` is refused with `--tp`. +- A prompt pass wider than 64 rows reduce-scatters its routed experts' FP32 slots by row halves, sums each rank's + rows and gathers the sums: rank 0 sends 3.43 MiB a prompt token over two GB10s' RoCE link instead of 5.81, with the + same bytes in every written row. Decode rounds and verify windows keep the all-reduce. - At `--tp 2` each stream's window verifies alone, so concurrent streams do not yet share a verify pass. ## Experimental K32 prompt experts @@ -129,6 +132,10 @@ The family plugs into the core's pieces and adds no scheduler: `tensorfold:prompt_cache_states` and `tensorfold:prompt_cache_bytes`; a successful resume counts toward `tensorfold:prompt_tokens_cached_total`. The chunk and budget host fixtures exercise this policy; real-model resumed/fresh parity and TTFT for this interior state still need the GPU gate. +- `TF_PROMPT_INTERMEDIATE=1` keeps up to four intermediate states at power-of-two positions under a long prompt's + history, so a later request that keeps a compacted prefix resumes inside it. On one GB10 (NVFP4, `--no-drafts`, + an 8 GiB cache), a 104K-token request keeping its first 100K resumed 65,536 tokens instead of 2 (TTFT 19.9 s + against 50.7 s, same tokens); a cold 165K prompt paid 0.9 s more and the cache held two more states. Off by default. - Shared row kernels: the token rows (`embed_rows.cu`, groups of 32 and a copy a hyper-connection stream), the MTP head's RMS rows with their group sums (`norms.cu`), the router logits and softmax top-k (`route_rows.cu`), and the routed experts over a window's or a chunk's rows on the core's plan (`cuda/grouped.zig`, `experts_rows.cu`). diff --git a/docs/recipes/flint.md b/docs/recipes/flint.md new file mode 100644 index 00000000000..cc8973e748f --- /dev/null +++ b/docs/recipes/flint.md @@ -0,0 +1,54 @@ +# Recursive Flint + +This page describes the native Zig engine serving Recursive's Flint (`RecursiveSI/flint`) on a Mac. Flint is a +40B-A2.5B mixture of experts: 49 layers of GQA (32 query heads, 4 key heads, head size 128), 35 of them sliding +(a 513-token window, with RoPE) and 14 full (no positional encoding), every head with a learned attention sink and +per-head q/k norms; two dense SwiGLU layers, then 47 layers of 256 routed experts (6 a token, sigmoid scores +renormalized) beside an always-on shared expert. Text in, text out, with reasoning and tool calls. + +## Weights + +The engine serves Recursive's own MLX export, `RecursiveSI/flint-int4`: affine 4-bit groups of 64 for the +projections, the experts and the head, 8-bit for the embedding (22.6 GB on disk, 21.4 GiB resident). + +Licenses: Recursive publishes Flint's weights (`RecursiveSI/flint` and `RecursiveSI/flint-int4`) under Apache-2.0. +The kernels round where Recursive's MLX engine does (`rec_mlx` in `recursive-oss/rec`, release v0.1.0 at e53663b2, +Apache-2.0). + +```bash +hf download RecursiveSI/flint-int4 --revision 3b93f9053b530488405ae9fca51d4900d6ce21f0 --local-dir ~/models/flint-int4 +``` + +## Serving + +```bash +FLINT_LOAD_LIMIT_GB=48 tensorfold-native serve ~/models/flint-int4 --port 8888 --parallel 4 --prompt-cache-gib 16 \ + --temperature 1.0 --top-p 0.95 --top-k 20 +``` + +The sampling flags are the model card's defaults for requests that set none. Structured output and required tool +calls need `libtfgrammar` beside the binary (release archives ship it; from source, `zig build grammar +-Dxgrammar=DIR` with an xgrammar v0.2.8 checkout). + +- **Context.** 262,144 tokens, the checkpoint's window. Sliding layers keep a ring of their window and one prompt + chunk; the 14 full layers keep every position, 28 KiB a token (7 GiB a stream at the full window). +- **Memory.** Weights, scratch and every stream's caches stay under 75% of RAM, or `FLINT_LOAD_LIMIT_GB` (GB, at most + the Mac's RAM, capped at the GPU's working set); the prompt cache takes what the streams leave, up to + `--prompt-cache-gib`. On a Mac that serves other models, set both. +- **Streams.** `--parallel N` streams share each decode forward, each reply equal to its run alone. +- **Prompt cache.** Kept states hold the full layers' keys and values and the sliding layers' last window; a resumed + prompt gives the same reply as the same prompt sent fresh. +- **Reasoning.** Flint always reasons: its chat template has no switch, so the server advertises `thinking_switch: + false` and refuses a request's `enable_thinking: false` with a 400 (thinking off is not supported by this chat + template); leave the flag out. Give hard requests a large `max_tokens`; the card suggests 32,768. +- **Tools.** The template's `name……` calls are parsed + as GLM-style tool calls, and required or named tool choices are constrained by the grammar. +- **Drafts.** The checkpoint has no draft head; the lane engine's copy drafts apply. + +## Checks + +`tf-flint-run` runs the model outside the server: greedy tokens, every position's logits in 16-row windows, a +prompt's last logits, or forced steps along given tokens, for comparison with a reference forward; `exact` compares +one-row steps with 16-row windows bit for bit; `bench` times decode steps. + +Only the M5's tensor units were checked; M1-M4 are untested. diff --git a/docs/recipes/glm-5.3-flash-cuda.md b/docs/recipes/glm-5.3-flash-cuda.md index 8d5efda9499..e8ed3350863 100644 --- a/docs/recipes/glm-5.3-flash-cuda.md +++ b/docs/recipes/glm-5.3-flash-cuda.md @@ -162,7 +162,7 @@ concurrent one its run alone. | DFlash2 | Prose, 16 streams | 526.0 | 583.6 | One-stream runs are `tools/bench_openai.py` at 512 tokens and stream runs `tools/bench_concurrent.py` at 256. DFlash2's -weights are CC BY-NC-ND 4.0. After uses `TF_GLM_DRAFT_CONFIDENCE=0.4` and before keeps the default 0.7. Cold prompts +weights are Apache-2.0. After uses `TF_GLM_DRAFT_CONFIDENCE=0.4` and before keeps the default 0.7. Cold prompts are unchanged at about 4,430 tok/s from 8K to 131K tokens, and a repeated code reply reaches about 850 tok/s with windows of up to 32 rows. Other GPUs and rank counts are not covered here. @@ -250,6 +250,15 @@ Long prompts and long runs of replies heat a GB10. The runbook's [heat bands](.. 1,035,295-token prompt at TP=3), [CPU pinning](../../RUNBOOK.md#gb10-cpu-pinning-for-long-cuda-runs) and [GPU clock cap](../../RUNBOOK.md#gb10-gpu-clock-cap-for-long-cuda-runs) each carry a measured GLM-5.3-Flash run. +## Windows past 1,048,576 tokens + +GLM-5.3-Flash's attention keeps no rotary positions (`qk_rope_head_dim` is 0, and KDA keeps none), so only its trained +length, config.json's 1,048,576 tokens, bounds the window. `TF_GLM_CONTEXT_CEILING` sets the window `--context` may ask +for and `--context 0` serves. GLM reads it at its open, and the startup log says when the window passes the trained +length, past which the quality is the operator's to check. On three GB10s with `Bizuayeu/GLM-5.3-Flash-XSFP4`, +`TF_GLM_CONTEXT_CEILING=2097152`, `--context 2097152 --parallel 1` and no prompt cache, rank 0 took 4,097 KV pages, and +a 1,996,430-token prompt answered all three of its passphrases. + ## Shared KDA operator `cuda.kda` owns the fused and three-kernel chains and single-layer and batched saved-row replay. diff --git a/docs/recipes/glm-5.3-flash.md b/docs/recipes/glm-5.3-flash.md index 538adf74bdc..f2e7a68c365 100644 --- a/docs/recipes/glm-5.3-flash.md +++ b/docs/recipes/glm-5.3-flash.md @@ -42,8 +42,8 @@ tensorfold serve TensorFold/GLM-5.3-Flash-MLX-4bit-MTP --tp 2 --rank 0 --master ``` Rank 1 starts first and rank 0 serves HTTP. Use the same context and drafting settings on both ranks. -The optional `incoai/GLM-5.3-Flash-DFlash2` model has CC BY-NC-ND 4.0 terms; pull it on both ranks only -when those terms fit the intended use. The CLI uses it automatically once it has been pulled. +The optional `incoai/GLM-5.3-Flash-DFlash2` model is Apache-2.0; pull it on both ranks to use it. +The CLI uses it automatically once it has been pulled. Without it, the engine uses MTP drafts; `--drafter none` explicitly selects MTP-only drafting. Give both ranks the same drafter setting. `--no-drafts` disables all drafting for the serial reference. A checkpoint with neither an MTP head nor a supplied DFlash2 model is refused unless drafts are disabled. diff --git a/docs/recipes/qwen3.8-27b.md b/docs/recipes/qwen3.8-27b.md index 2a426004e4c..19d5212a0c4 100644 --- a/docs/recipes/qwen3.8-27b.md +++ b/docs/recipes/qwen3.8-27b.md @@ -33,8 +33,11 @@ A lone lane request may propose up to 15/31/63 tree nodes at 16/32/64 rows, incl by expected accepted tokens per measured millisecond in the common lane allocator, using its calibrated landing rates. The proposal keeps every candidate available until that decision. Copies compete for the same verification window; `TF_QWEN27_COPIES=0` disables them. The narrow tensor-unit default keeps copies off. -The masked drafter block is unchanged. Both device classes use the lane loop by default, including at 16 rows. -`TF_QWEN27_LANES=0` selects the legacy greedy shortcut; sampled requests retain the shared lane loop. +The masked drafter block is unchanged. At the tensor-unit 16-row default, an eligible lone greedy request +uses the long-copy driver; every copy is target-verified and arrivals yield back to the shared lane loop. +Sampled requests, shared rounds, wider windows and earlier chips retain the lane loop. +`TF_QWEN27_LANES=1` keeps every request on lanes; `0` requests the lone greedy driver on either device class. +The lane copy switches do not size the lone driver's tail; its existing cap is `TF_Q27_COPY_ROWS` (default 48). Startup names the selected lone loop and width beside the lane window and copy policy. `tf-qwen27-bench --model DIR --widths costs` measures rows 1 through 64, with GPU and wall time including keep. The wall price excludes the diagnostic's host logit hash and scan. diff --git a/docs/xpu/README.md b/docs/xpu/README.md index bd649f64412..edddef11648 100644 --- a/docs/xpu/README.md +++ b/docs/xpu/README.md @@ -90,6 +90,11 @@ Options through environment variables: | 2 / 4 / 8 simultaneous streams, chat, greedy (aggregate) | 151 / 239 / 300 | 148 / 215 / 208 | 162 / 230 / 224 | 162 / 249 / 227 | | `bench_openai` decode, 1 / 2 / 4 / 8 requests (aggregate) | 173 / 243 / 343 / 350 | 165 / 300 / 337 / 439 | 165 / 336 / 367 / 456 | 179 / 325 / 364 / 463 | +The prefill row predates 1.1.0, which feeds the prompt DeltaNet's fp32 intermediates to the matrix units at close to fp32 +precision (`489baa2673`, `17facba3f8`). On a B70 with GPTQ g128 that costs about 5% of a 32,768-token prompt in the +raw runner (`xpu-qwen27 --bench=32768`: 1,647 to 1,571 tok/s, tokens unchanged; #2859). The table's sizes have not been +re-measured since. + Before the per-stream DFlash2 drafts (copies only in the shared rounds), 2 / 4 / 8 code streams ran at 64 / 120 / 182 tok/s in GPTQ. Validation: `zig build test`, oracle, drafted == serial, multi-stream == serial (chains and trees), and `bench_concurrent --alone --serial` on 1, 2, 4 and 8 streams, greedy and sampled, for the four diff --git a/tools/zig/check_tfvideo_exports.py b/tools/zig/check_tfvideo_exports.py new file mode 100644 index 00000000000..c1a9d57db62 --- /dev/null +++ b/tools/zig/check_tfvideo_exports.py @@ -0,0 +1,70 @@ +"""An ELF libtfvideo.so exports exactly the four tf_video_* functions, keeps a planted table local, has no TEXTREL.""" +import struct +import sys +from pathlib import Path + +WANT = {'tf_video_open', 'tf_video_frames', 'tf_video_close', 'tf_video_version'} +SHT_SYMTAB, SHT_DYNAMIC, SHT_DYNSYM = 2, 6, 11 +DT_TEXTREL, DT_FLAGS, DF_TEXTREL = 22, 30, 0x4 + + +def sections(data: bytes) -> list[tuple]: + """(type, offset, size, link, entsize) for each section of a little-endian ELF64 file.""" + if data[:4] != b'\x7fELF' or data[4] != 2 or data[5] != 1: + raise SystemExit('not a little-endian ELF64 file') + shoff, = struct.unpack_from(' dict[str, tuple[int, int]]: + """Defined symbols of the given table: name -> (binding, section index).""" + out = {} + for t, off, size, link, ent in secs: + if t != kind: + continue + strtab = secs[link][1] + for at in range(off + ent, off + size, ent): + name, info, _, shndx = struct.unpack_from('> 4, shndx) + return out + + +def textrel(data: bytes, secs: list[tuple]) -> bool: + """True when .dynamic has DT_TEXTREL or DF_TEXTREL in DT_FLAGS.""" + for t, off, size, _, _ in secs: + if t == SHT_DYNAMIC: + for at in range(off, off + size, 16): + tag, val = struct.unpack_from(' int: + failed = 0 + for arg in sys.argv[1:]: + data = Path(arg).read_bytes() + secs = sections(data) + exported = {n for n, (bind, _) in symbols(data, secs, SHT_DYNSYM).items() if bind in (1, 2)} + planted = symbols(data, secs, SHT_SYMTAB).get('tfvideo_planted_table') + problems = [f'exports {sorted(exported ^ WANT)} beyond or short of the four'] if exported != WANT else [] + if planted is None or planted[0] != 0: + problems.append(f'the planted table is not a local symbol: {planted}') + if textrel(data, secs): + problems.append('text relocations') + machine = {62: 'x86_64', 183: 'aarch64'}.get(struct.unpack_from(' @@ -31,6 +32,7 @@ template __device__ T __shfl_xor_sync(unsigned,T,int,int=32); template __device__ T __shfl_up_sync(unsigned,T,unsigned,int=32); template __device__ T __shfl_down_sync(unsigned,T,unsigned,int=32); __device__ void __syncthreads(); __device__ int __syncthreads_count(int); __device__ void __syncwarp(unsigned=~0u); +__device__ int __syncthreads_or(int); __device__ void __threadfence(); __device__ void __threadfence_system(); __device__ void __trap(); __device__ void __nanosleep(unsigned); __device__ unsigned long long clock64(); __device__ unsigned __ballot_sync(unsigned,int); __device__ int __any_sync(unsigned,int); @@ -49,6 +51,7 @@ __device__ int __float_as_int(float); __device__ unsigned __float_as_uint(float); __device__ float __int_as_float(int); __device__ float __uint_as_float(unsigned); __device__ double __longlong_as_double(long long); __device__ long long __double2ll_rz(double); __device__ float __double2float_rn(double); +__device__ float __double2float_rz(double); __device__ int __float2int_rn(float); __device__ float __fadd_rn(float,float); __device__ float __fsub_rn(float,float); __device__ float __fmul_rn(float,float); __device__ float __fdiv_rn(float,float); __device__ float __fsqrt_rn(float); __device__ float __fmaf_rn(float,float,float); __device__ float __expf(float); diff --git a/tools/zig/cuda_host/math.h b/tools/zig/cuda_host/math.h index 4aade403d68..65cf5c4b1c8 100644 --- a/tools/zig/cuda_host/math.h +++ b/tools/zig/cuda_host/math.h @@ -19,5 +19,6 @@ template __host__ __device__ bool isfinite(T); #define M_2_SQRTPI 1.12837916709551257390 #define M_SQRT1_2 0.70710678118654752440 __host__ __device__ float erff(float); __host__ __device__ float rintf(float); +__host__ __device__ float ldexpf(float,int); __host__ __device__ double pow(double,double); __host__ __device__ double sin(double); __host__ __device__ double cos(double); template __host__ __device__ bool signbit(T); diff --git a/tools/zig/dots_mocr_reference.py b/tools/zig/dots_mocr_reference.py index 4c0038cfdb9..a29921aba2e 100644 --- a/tools/zig/dots_mocr_reference.py +++ b/tools/zig/dots_mocr_reference.py @@ -1,4 +1,4 @@ -"""dots.mocr references: the model card's Hugging Face example oracle, processor fixtures, native trace compare.""" +"""dots.mocr references: the Hugging Face oracle, processor and OCR fixtures, our test pages, native trace compare.""" import argparse import ast import ctypes @@ -130,7 +130,7 @@ def oracle(args): if pci != args.pci.lower(): raise RuntimeError(f"selected GPU {pci} differs from requested GPU {args.pci}") args.output.mkdir(parents=True, exist_ok=False) - prompt = example_prompt(args.checkpoint) + prompt = args.prompt.read_text(encoding="utf-8") if args.prompt else example_prompt(args.checkpoint) image_paths = [args.image, *args.extra_image] messages = [{"role": "user", "content": [*({"type": "image", "image": str(p)} for p in image_paths), {"type": "text", "text": prompt}]}] @@ -227,6 +227,69 @@ def hook(_module, _args, output): print(json.dumps({"output_tokens": len(ids), "seconds": seconds, "text": output_text}, ensure_ascii=False), flush=True) +def pages(args): + """Draw the OCR test pages, our own text in Liberation fonts: page.png and one-line.png.""" + from PIL import Image, ImageDraw, ImageFont + + def font(name, size): + return ImageFont.truetype(str(args.fonts / f"Liberation{name}.ttf"), size) + + args.output.mkdir(parents=True, exist_ok=True) + page = Image.new("RGB", (1240, 1754), "white") + d = ImageDraw.Draw(page) + d.text((80, 60), "TENSORFOLD READER NOTES", font=font("Sans-Regular", 22), fill="black") + d.text((1000, 60), "March 2026", font=font("Sans-Regular", 22), fill="black") + d.line((80, 95, 1160, 95), fill="black", width=2) + d.text((80, 150), "Reading a Page Twice", font=font("Sans-Bold", 48), fill="black") + d.text((80, 260), "1. Why the second read matters", font=font("Sans-Bold", 32), fill="black") + for i, line in enumerate(["A reader that sees the same page twice must give the same blocks twice.", + "We feed one scanned page to two readers and count the tokens each", + "one writes. The table below lists the counts for a page and a line."]): + d.text((80, 330 + 44 * i), line, font=font("Serif-Regular", 30), fill="black") + d.text((80, 520), "Table 1: Tokens written per input", font=font("Sans-Regular", 26), fill="black") + x, y = [80, 420, 760, 1100], [570, 630, 690, 750, 810] + for row in y: + d.line((x[0], row, x[3], row), fill="black", width=2) + for column, top in [(x[0], y[0]), (x[1], y[0]), (x[3], y[0]), (x[2], y[1])]: + d.line((column, top, column, y[4]), fill="black", width=2) + d.rectangle((x[0] + 2, y[1] - 1, x[1] - 1, y[1] + 1), fill="white") # "Reader" spans both header rows + for at, cell in [((100, 615), "Reader"), ((520, 585), "Tokens written"), ((560, 645), "Page"), ((900, 645), "Line")]: + d.text(at, cell, font=font("Sans-Bold", 28), fill="black") + for r, row in enumerate([("First reader", "612", "41"), ("Second reader", "598", "39")]): + for c, cell in enumerate(row): + d.text((x[c] + 20, y[2 + r] + 15), cell, font=font("Sans-Regular", 28), fill="black") + d.text((80, 880), "2. What we check", font=font("Sans-Bold", 32), fill="black") + for i, item in enumerate(["The blocks come back in reading order.", "Each box stays inside the page.", + "A table keeps its spanned header cell."]): + d.text((100, 950 + 50 * i), "\u2022 " + item, font=font("Serif-Regular", 30), fill="black") + d.text((612, 1680), "3", font=font("Sans-Regular", 24), fill="black") + page.save(args.output / "page.png") + line = Image.new("RGB", (960, 96), "white") + ImageDraw.Draw(line).text((24, 24), "TensorFold reads one line of text.", font=font("Sans-Regular", 36), fill="black") + line.save(args.output / "one-line.png") + print(json.dumps({name: sha(args.output / name) for name in ["page.png", "one-line.png"]}, indent=2)) + + +def record(args): + """Write an OCR fixture from an oracle run: its input, frame and hashes, without the model's text.""" + result = json.loads((args.reference_dir / "result.json").read_text()) + if len(result["prepared_images"]) != 1: + raise ValueError("an OCR fixture records a run on one image") + from PIL import Image + with Image.open(args.image) as image: + original = list(image.size) + if sha(args.image) != result["image_sha256"]: + raise ValueError(f"{args.image} is not the image the oracle read") + ids = np.asarray(result["output_ids"], dtype=" tuple[int, Info]: + """tf_video_open's return code and the stream's facts.""" + handle, info = ctypes.c_void_p(), Info() + rc = self.lib.tf_video_open(data, len(data), ctypes.byref(handle), ctypes.byref(info)) + if rc == 0: + self.lib.tf_video_close(handle) + return rc, info + + def frames(self, data: bytes, indices: list[int], hashed: bool = True) -> tuple[list, float, int]: + """The frames delivered (hashed, or index only), the seconds tf_video_frames took and its return code.""" + handle, info = ctypes.c_void_p(), Info() + rc = self.lib.tf_video_open(data, len(data), ctypes.byref(handle), ctypes.byref(info)) + if rc != 0: + return [], 0.0, rc + out: list[tuple[int, str]] = [] + + def take(_ctx, _k, index, rgb, width, height, stride): + if not hashed: # timing: the bytes are left alone, so the decoder's threads never wait on Python + out.append((index, "")) + return 0 + raw = ctypes.string_at(rgb, stride * (height - 1) + width * 3) + out.append((index, hashlib.sha256(b"".join(raw[r * stride:r * stride + width * 3] + for r in range(height))).hexdigest())) + return 0 + + callback = FRAME_FN(take) + start = time.perf_counter() + rc = self.lib.tf_video_frames(handle, (ctypes.c_int * len(indices))(*indices), len(indices), callback, None) + seconds = time.perf_counter() - start + self.lib.tf_video_close(handle) + return out, seconds, rc + + +class PyAV: + """transformers' read_video_pyav: decode from the start, keep the wanted indices, to_ndarray(format="rgb24").""" + + def __init__(self): + import av + self.av, self.name, self.version = av, f"PyAV {av.__version__}", str(av.ffmpeg_version_info) + + def open(self, data: bytes) -> tuple[int, None]: + """0 when PyAV opens the container with a video stream, -2 (TF_NO_STREAM) without one, else -1 (TF_INVALID).""" + import io + try: + with self.av.open(io.BytesIO(data)) as c: + return (0 if c.streams.video else -2), None + except self.av.error.FFmpegError: + return -1, None + + def frames(self, data: bytes, indices: list[int], hashed: bool = True) -> tuple[list, float, int]: + """As Library.frames; an FFmpeg error counts as TF_DECODE and an early end as TF_SHORT.""" + import io + want, out, rc = set(indices), [], 0 + start = time.perf_counter() + try: + with self.av.open(io.BytesIO(data)) as c: + c.seek(0) + for i, frame in enumerate(c.decode(video=0)): + if i > indices[-1]: + break + if i in want: + rgb = frame.to_ndarray(format="rgb24") + out.append((i, hashlib.sha256(rgb.tobytes()).hexdigest() if hashed else "")) + except self.av.error.FFmpegError: + rc = TF_DECODE + seconds = time.perf_counter() - start + return out, seconds, rc or (len(out) if len(out) == len(indices) else TF_SHORT) + + +def index_sets(info: Info) -> dict[str, list[int]]: + """Every frame, GLM's 2 frames a second, the first three (an early stop) and the last (the end-of-stream drain).""" + n, rate = int(info.frames), info.rate_num / info.rate_den if info.rate_den else 0.0 + two = sorted({min(n - 1, round(i * rate / 2)) for i in range(int(n / rate * 2) + 1)}) if rate else [0] + return {"all": list(range(n)), "2fps": two, "first3": list(range(min(3, n))), "last": [n - 1]} + + +def best(source, data: bytes, indices: list[int], repeat: int, hashed: bool) -> tuple[list, float, int]: + frames, seconds, rc = source.frames(data, indices, hashed) + for _ in range(repeat - 1): + seconds = min(seconds, source.frames(data, indices, hashed)[1]) + return frames, seconds, rc + + +def compare(lib, ref, clip: Path, repeat: int, rows: dict) -> int: + """One clip's sets against the reference (or timed alone when ref is None); returns the sets that differ.""" + data = clip.read_bytes() + rc, info = lib.open(data) + ref_rc, ref_info = ref.open(data) if ref else (rc, info) + if rc != 0 or ref_rc != 0 or info.frames <= 0: # a damaged clip may not open, or may count no frames + rows["open"] = {"test_rc": rc, "reference_rc": ref_rc, "frames": info.frames} + theirs = (ref_info or info).frames # PyAV counts nothing here: libtfvideo's count stands for both + ok = ref is not None and rc == ref_rc and (info.frames > 0) == (theirs > 0) + print(f"{'ok ' if ok else 'FAIL'} {clip} open: test rc {rc}, reference rc {ref_rc}, {info.frames} frames") + return int(not ok) + failures = 0 + for name, indices in index_sets(info).items(): + mine, mine_s, mine_rc = best(lib, data, indices, repeat, ref is not None) + theirs, theirs_s, theirs_rc = best(ref, data, indices, repeat, True) if ref else (mine, 0.0, mine_rc) + same = sum(a == b for a, b in zip(mine, theirs)) + ok = mine == theirs and mine_rc == theirs_rc and (ref is not None or mine_rc == len(indices)) + failures += not ok + rows[name] = {"indices": indices, "test": mine, "reference": theirs if ref else None, "test_rc": mine_rc, + "reference_rc": theirs_rc, "test_s": mine_s, "reference_s": theirs_s} + codes = f", rc {mine_rc}/{theirs_rc}" if (mine_rc, theirs_rc) != (len(indices), len(indices)) else "" + timing = f"test {mine_s:.3f} s" + (f", reference {theirs_s:.3f} s" if ref else "") + what = "frames equal" if ref else "frames delivered" + print(f"{'ok ' if ok else 'FAIL'} {clip} {name}: {same}/{len(indices)} {what}{codes}, {timing}") + return failures + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--lib", required=True, type=Path, help="the libtfvideo build under test") + ap.add_argument("--against", type=Path, help="another libtfvideo build; PyAV when omitted") + ap.add_argument("--time-only", action="store_true", + help="no reference and no hashing: time tf_video_frames alone (its callback leaves the bytes)") + ap.add_argument("--repeat", type=int, default=1, help="decodes per set; the fastest time is reported") + ap.add_argument("--json", type=Path, help="write every frame's hash, return code and each set's time here") + ap.add_argument("clips", nargs="+", type=Path) + args = ap.parse_args() + lib = Library(args.lib) + ref = None if args.time_only else Library(args.against) if args.against else PyAV() + print(f"test {lib.name} (FFmpeg {lib.version}); reference " + + (f"{ref.name} (FFmpeg {ref.version})" if ref else "none (--time-only)")) + failures, report = 0, {"lib": lib.name, "reference": ref.name if ref else None, "clips": {}} + for clip in args.clips: + rows = report["clips"][str(clip)] = {} + try: + failures += compare(lib, ref, clip, args.repeat, rows) + except Exception as e: # one unreadable clip is a failed clip, not the end of the run + rows["error"] = repr(e) + failures += 1 + print(f"FAIL {clip}: {e!r}") + if args.json: + args.json.write_text(json.dumps(report, indent=1)) + print(f"{failures} set(s) differ" if failures else "every frame equal") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/zig/build/checkpoint_io_host.zig b/zig/build/checkpoint_io_host.zig index 77767ffa710..583d42f9b1e 100644 --- a/zig/build/checkpoint_io_host.zig +++ b/zig/build/checkpoint_io_host.zig @@ -11,6 +11,8 @@ pub fn steps(b: *std.Build, target: std.Build.ResolvedTarget) void { step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = core, .filters = &.{"checkpoint admission:"} })).step); if (mode == .debug) b.step("test-modelopt-part-host", "The shared ModelOpt part reader (core/modelopt_part.zig) without a GPU") .dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = core, .filters = &.{"part:"} })).step); + if (mode == .debug) b.step("test-fp8-block-host", "The shared block-FP8 reader and exact scale widening (core/quant/fp8.zig)") + .dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = core, .filters = &.{"fp8 block:"} })).step); const qwen = b.createModule(.{ .root_source_file = b.path("zig/src/families/qwen3_5/loader.zig"), .target = target, .optimize = mode, .link_libc = true, .imports = &.{.{ .name = "core", .module = core }} }); step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = qwen, .filters = &.{"Qwen shard integrity:"} })).step); } diff --git a/zig/build/cuda_family_host.zig b/zig/build/cuda_family_host.zig index 9c0c6f98332..04650fadaa9 100644 --- a/zig/build/cuda_family_host.zig +++ b/zig/build/cuda_family_host.zig @@ -16,6 +16,7 @@ pub fn steps(b: *std.Build, cuda: *std.Build.Module, mods: anytype, step: *std.B step.dependOn(&wiring.step); b.step("test-qwen35-request-starts-host", "Request-start and shared-KV startup refusal without devices").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = mods.qwen35, .filters = &.{"request starts:"} })).step); + b.step("test-qwen35-shapes-host", "Captured window buckets for one and 2-8 rows without devices").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = mods.qwen35, .filters = &.{"one-row and 2-8-row"} })).step); @import("minimax_cuda.zig").hostTests(b, host, cuda, mods.core, mods.lanes, step); @import("cuda_groups_host.zig").host(b, cuda, mods, step); const prefill = b.addRunArtifact(b.addTest(.{ .root_module = mods.qwen35, .filters = &.{ "Qwen stepped prefill:", "Qwen whole prefill:", "Qwen prefill opening:" } })); diff --git a/zig/build/cuda_kernels.zig b/zig/build/cuda_kernels.zig index c0c8b503336..950d2d5586f 100644 --- a/zig/build/cuda_kernels.zig +++ b/zig/build/cuda_kernels.zig @@ -97,6 +97,7 @@ pub const kernels = [_]Kernel{ .{ .name = "exlr8", .flags = &.{ "-O3", "--fmad=false" } }, // ours: EXLR8 lane projections, the Metal kernels' order .{ .name = "exlr8_dense", .flags = &.{ "-O3", "--fmad=false" } }, // ours: EXLR8 dense matrices by row, the Metal order .{ .name = "nvfp4_experts_tile", .flags = &.{"-O3"}, .min_sm = 80 }, + .{ .name = "experts_bf16", .flags = &.{"-O3"} }, // ours: grouped bf16 experts on the plan, mma fragment blocks .{ .name = "nvfp4_experts", .flags = &.{"-O3"} }, // nvfp4/experts.cu's device code, the NVFP4 expert kernels .{ .name = "nvfp4_experts32", .flags = &.{"-O3"} }, // ours: GLM's routed experts at block 32 with csf0 scales .{ .name = "rank_sum", .flags = &.{"-O3"} }, // ours: the rank-order sum of a reduce-scatter (rank_scatter.zig) diff --git a/zig/build/cuda_server_host.zig b/zig/build/cuda_server_host.zig index f85c933597e..31e7b5314f5 100644 --- a/zig/build/cuda_server_host.zig +++ b/zig/build/cuda_server_host.zig @@ -29,6 +29,7 @@ pub fn steps(b: *std.Build, host: std.Build.ResolvedTarget, cuda: *std.Build.Mod eos_policy.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests, .filters = &.{"EOS policy:"} })).step); eos_policy.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = mods.tokenizer, .filters = &.{"EOS policy:"} })).step); eos_policy.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = kolibri1Module(b, host, .debug, cuda, mods.core, mods.lanes), .filters = &.{"EOS policy:"} })).step); + b.step("test-serve-flags-host", "Serve flag parsing a later flag replaces, without a GPU").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests, .filters = &.{"--compact-at:"} })).step); b.step("test-dory-cli-host", "Dory CUDA loop option forwarding and invalid-value refusals").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests, .filters = &.{"--device and --segments:"} })).step); b.step("test-dory-chat-host", "Dory chat template, EOS, reasoning split and qwen3_coder call on the CPU").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests, .filters = &.{"Dory chat:"} })).step); b.step("test-embeddings-protocol-host", "Embedding request and response contracts without an admitted model or GPU").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests, .filters = &.{"embeddings protocol:"} })).step); @@ -58,6 +59,7 @@ pub fn steps(b: *std.Build, host: std.Build.ResolvedTarget, cuda: *std.Build.Mod b.step("test-lesson-gate-host", "Production server rounds and earlier-fact retention").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests, .filters = &.{"Learning persistence:"} })).step); b.step("test-slide-delete-host", "Single and multiple fact deletion without devices").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests, .filters = &.{ "delete", "a graph file", "allocation that fails anywhere in recording" } })).step); b.step("test-frame-cache-identity-host", "Image frame identities through the actual shared cache").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = native_modules.api, .filters = &.{"Frame cache:"} })).step); + b.step("test-originals-host", "Model folder snapshots, holds and slide rollback without a GPU").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = native_modules.api, .filters = &.{"originals_test."} })).step); const media_gate = b.step("test-media-request-ownership-host", "Shared request ownership, image frame caches and media expansion on the CPU"); media_gate.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = native_modules.api })).step); media_gate.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests, .filters = &.{ "image", "video" } })).step); @@ -65,6 +67,7 @@ pub fn steps(b: *std.Build, host: std.Build.ResolvedTarget, cuda: *std.Build.Mod b.step("test-http-limits-host", "Total HTTP read deadlines and connection admission without shaders").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests, .filters = &.{"HTTP limits:"} })).step); b.step("test-server-recovery-host", "Incomplete replies, token allocation and bounded SSE storage without shaders").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests, .filters = &.{"Server recovery:"} })).step); b.step("test-prompt-interior-host", "Last-user metadata excludes later messages without shaders").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests, .filters = &.{"interior prompt:"} })).step); + b.step("test-ocr-host", "OCR route readers, shared page steps and dialects without a GPU").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests, .filters = &.{"ocr "} })).step); const server_test = b.addRunArtifact(b.addTest(.{ .root_module = server_tests })); @import("request_starts_host.zig").steps(b, native_modules.engines, server_tests); const json_test = b.addRunArtifact(b.addTest(.{ .root_module = server_json })); diff --git a/zig/build/dots_mocr.zig b/zig/build/dots_mocr.zig index aa60eb0c418..90765b85fa1 100644 --- a/zig/build/dots_mocr.zig +++ b/zig/build/dots_mocr.zig @@ -5,13 +5,15 @@ pub fn hostTests(b: *std.Build, test_step: *std.Build.Step) void { const core = b.createModule(.{ .root_source_file = b.path("zig/src/core/root.zig"), .target = b.graph.host, .link_libc = true }); core.addImport("gpu_memory", @import("gpu_memory.zig").module(b, b.graph.host)); core.addImport("tokenizer", b.createModule(.{ .root_source_file = b.path("zig/src/core/tokenizer/tokenizer.zig"), .target = b.graph.host, .link_libc = true })); + const lanes = b.createModule(.{ .root_source_file = b.path("zig/src/core/lanes/lanes.zig"), .target = b.graph.host, .link_libc = true }); + const api = b.createModule(.{ .root_source_file = b.path("zig/src/core/engine_api.zig"), .target = b.graph.host, .link_libc = true, .imports = &.{.{ .name = "lanes", .module = lanes }} }); for ([_]std.builtin.OptimizeMode{ .debug, .fast }) |mode| { const family = b.createModule(.{ .root_source_file = b.path("zig/src/families/dots_mocr/dots_mocr.zig"), .target = b.graph.host, .optimize = mode, .link_libc = true, - .imports = &.{.{ .name = "core", .module = core }}, + .imports = &.{ .{ .name = "core", .module = core }, .{ .name = "engine_api", .module = api } }, }); const run = b.addRunArtifact(b.addTest(.{ .root_module = family, .filters = &.{"dots:"} })); run.has_side_effects = true; diff --git a/zig/build/dsv41_assets.zig b/zig/build/dsv41_assets.zig index 06c612faa61..aa950c2ffa5 100644 --- a/zig/build/dsv41_assets.zig +++ b/zig/build/dsv41_assets.zig @@ -2,7 +2,8 @@ const std = @import("std"); pub fn steps(b: *std.Build) void { const step = b.step("dsv41-assets", "Install CPU-only DeepSeek token-map and AOT-plan generators"); - const asset_module = @import("dsv41_cuda.zig").module(b, b.graph.host, "asset_main.zig", false); + // ReleaseSafe whatever -Doptimize says: a 512K aot-needs pass took ~45 min in Debug on a GB10, under 7 here (#2270) + const asset_module = @import("dsv41_cuda.zig").moduleAt(b, b.graph.host, .ReleaseSafe, "asset_main.zig", false); const assets = b.addExecutable(.{ .name = "tf-dsv41-assets", .root_module = asset_module }); step.dependOn(&b.addInstallArtifact(assets, .{}).step); const token_map = b.addExecutable(.{ .name = "tf-dsv41-token-map", .root_module = b.createModule(.{ @@ -12,6 +13,6 @@ pub fn steps(b: *std.Build) void { .link_libc = true, }) }); step.dependOn(&b.addInstallArtifact(token_map, .{}).step); - const tests = b.addRunArtifact(b.addTest(.{ .root_module = asset_module })); + const tests = b.addRunArtifact(b.addTest(.{ .root_module = @import("dsv41_cuda.zig").module(b, b.graph.host, "asset_main.zig", false) })); b.step("test-dsv41-assets-host", "DeepSeek asset commands refuse GPU operations and malformed arguments").dependOn(&tests.step); } diff --git a/zig/build/dsv41_cuda.zig b/zig/build/dsv41_cuda.zig index 6b64f7d380e..b3e19f0cc64 100644 --- a/zig/build/dsv41_cuda.zig +++ b/zig/build/dsv41_cuda.zig @@ -2,11 +2,14 @@ const std = @import("std"); const shared = @import("dsv41_shared.zig"); const units = @import("dsv41_units.zig"); pub fn runtime(b: *std.Build, target: std.Build.ResolvedTarget, images: bool) *std.Build.Module { + return runtimeAt(b, target, .Debug, images); +} +pub fn runtimeAt(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, images: bool) *std.Build.Module { const options = @import("cuda_runtime.zig").kernelOptions(b, images, null); - const cuda = b.createModule(.{ .root_source_file = b.path("zig/src/cuda/root.zig"), .target = target, .optimize = .Debug, .link_libc = true }); + const cuda = b.createModule(.{ .root_source_file = b.path("zig/src/cuda/root.zig"), .target = target, .optimize = optimize, .link_libc = true }); cuda.addImport("gpu_memory", @import("gpu_memory.zig").module(b, target)); cuda.addOptions("kernel_options", options); - cuda.addImport("stagger", b.createModule(.{ .root_source_file = b.path("zig/src/core/stagger.zig"), .target = target, .optimize = .Debug })); + cuda.addImport("stagger", b.createModule(.{ .root_source_file = b.path("zig/src/core/stagger.zig"), .target = target, .optimize = optimize })); if (images) { const empty = b.addWriteFiles().add("compile-only.fatbin", "SYNTHETIC-COMPILE-ONLY"); const all = @import("cuda.zig").kernels; @@ -15,16 +18,20 @@ pub fn runtime(b: *std.Build, target: std.Build.ResolvedTarget, images: bool) *s return cuda; } pub fn module(b: *std.Build, target: std.Build.ResolvedTarget, file: []const u8, images: bool) *std.Build.Module { - const cuda = runtime(b, target, images); + return moduleAt(b, target, .Debug, file, images); +} +/// `module` with the runtime, kernel and family modules at `optimize`. +pub fn moduleAt(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, file: []const u8, images: bool) *std.Build.Module { + const cuda = runtimeAt(b, target, optimize, images); const opts = b.addOptions(); opts.addOption(bool, "with_kernels", images); opts.addOption(u32, "family_scope", 41); - const dk = b.createModule(.{ .root_source_file = b.path("zig/src/families/deepseek_v41/kernels.zig"), .target = target, .optimize = .Debug, .link_libc = true }); + const dk = b.createModule(.{ .root_source_file = b.path("zig/src/families/deepseek_v41/kernels.zig"), .target = target, .optimize = optimize, .link_libc = true }); dk.addImport("cuda", cuda); dk.addOptions("dsv41_kernel_options", opts); const empty = b.addWriteFiles().add("compile-only-dsv41.fatbin", "SYNTHETIC-COMPILE-ONLY"); for (units.units ++ units.optional) |k| dk.addAnonymousImport(b.fmt("dsv41_fatbin_{s}", .{k.name}), .{ .root_source_file = empty }); - return assemble(b, target, .Debug, file, cuda, dk); + return assemble(b, target, optimize, file, cuda, dk); } /// The family's root `file` over the runtime `cuda` and kernel module `dk`, every family module at `optimize`. pub fn assemble(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, file: []const u8, cuda: *std.Build.Module, dk: *std.Build.Module) *std.Build.Module { diff --git a/zig/build/dsv41_metal.zig b/zig/build/dsv41_metal.zig index 1ee5d6bd149..1de16b75335 100644 --- a/zig/build/dsv41_metal.zig +++ b/zig/build/dsv41_metal.zig @@ -4,7 +4,7 @@ const std = @import("std"); /// The package over the family's neutral host module; `metal` and `sources` are the engine's own modules. pub fn package(b: *std.Build, metal: *std.Build.Module, sources: *std.Build.Module, optimize: std.builtin.OptimizeMode) struct { pkg: *std.Build.Module, host: *std.Build.Module } { const host = @import("dsv41_shared.zig").moduleAt(b, b.graph.host, optimize, "neutral.zig"); - // the lanes and the engine API the backend's modules import + // the lanes, the engine API and the served engine (serve_lanes) the backend's target and pass plug into @import("dsv41_draft.zig").addImportsAt(b, host, b.graph.host, optimize); const pkg = b.createModule(.{ .root_source_file = b.path("zig/src/families/deepseek_v41/metal/metal.zig"), @@ -16,6 +16,20 @@ pub fn package(b: *std.Build, metal: *std.Build.Module, sources: *std.Build.Modu return .{ .pkg = pkg, .host = host }; } +/// The native server's engine over the package: metal/entry.zig, the family's own serving modules beside it. +pub fn engine(b: *std.Build, metal: *std.Build.Module, sources: *std.Build.Module, optimize: std.builtin.OptimizeMode) *std.Build.Module { + const p = package(b, metal, sources, optimize); + const m = b.createModule(.{ + .root_source_file = b.path("zig/src/families/deepseek_v41/metal/entry.zig"), + .target = b.graph.host, + .optimize = optimize, + .link_libc = true, + .imports = &.{ .{ .name = "dsv41_metal", .module = p.pkg }, .{ .name = "dsv41", .module = p.host }, .{ .name = "metal", .module = metal } }, + }); + for ([_][]const u8{ "engine_api", "core", "tokenizer", "dsv41_serve", "lanes", "json" }) |name| m.addImport(name, p.host.import_table.get(name).?); + return m; +} + /// `test-dsv41-metal`: package, layer and forward checks on this GPU; `test-dsv41-metal-real`: a TF_DSV41_REAL layer. pub fn steps(b: *std.Build, metal: *std.Build.Module, sources: *std.Build.Module, test_step: *std.Build.Step) void { const p = package(b, metal, sources, .Debug); @@ -26,13 +40,39 @@ pub fn steps(b: *std.Build, metal: *std.Build.Module, sources: *std.Build.Module .link_libc = true, .imports = &.{ .{ .name = "dsv41_metal", .module = p.pkg }, .{ .name = "metal", .module = metal }, .{ .name = "dsv41", .module = p.host } }, }); + // the package's own checks run with the default test step; its device suites run from their named steps + test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = p.pkg })).step); const step = b.step("test-dsv41-metal", "DeepSeek-V4.1 Metal layer and forward: float64 stages, rows at once == one at a time"); - const pkg_run = &b.addRunArtifact(b.addTest(.{ .root_module = p.pkg })).step; - step.dependOn(pkg_run); + // serve_lanes over the CPU twin, checked here so no CUDA step has to depend on the neutral served engine + const lanes_test = b.createModule(.{ + .root_source_file = b.path("zig/src/families/deepseek_v41/serve_lanes_test.zig"), + .target = b.graph.host, + .optimize = .Debug, + .link_libc = true, + .imports = &.{ .{ .name = "core", .module = p.host.import_table.get("core").? }, .{ .name = "engine_api", .module = p.host.import_table.get("engine_api").? }, .{ .name = "lanes", .module = p.host.import_table.get("lanes").? }, .{ .name = "tokenizer", .module = p.host.import_table.get("tokenizer").? } }, + }); + test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = lanes_test })).step); const run = b.addRunArtifact(b.addTest(.{ .root_module = tests })); run.has_side_effects = true; // a device run: never cached step.dependOn(&run.step); - test_step.dependOn(pkg_run); // the package's host checks only; the device run stays in its own step + // the served engine over the tiny model: LaneHost replies, drafts, the prompt cache, seeds, cancels + const serve = b.createModule(.{ + .root_source_file = b.path("zig/tests/dsv41_metal/serve_test.zig"), + .target = b.graph.host, + .optimize = .Debug, + .link_libc = true, + .imports = &.{ .{ .name = "dsv41_metal", .module = p.pkg }, .{ .name = "metal", .module = metal }, .{ .name = "dsv41", .module = p.host } }, + }); + serve.addImport("dsv41_serve_fixtures", p.host.import_table.get("dsv41_serve").?.import_table.get("dsv41_serve_fixtures").?); + const serve_run = b.addRunArtifact(b.addTest(.{ .root_module = serve })); + serve_run.has_side_effects = true; + step.dependOn(&serve_run.step); + const serve_step = b.step("test-dsv41-metal-serve", "DeepSeek-V4.1 Metal served through LaneHost: concurrent, drafted, cached, seeded, cancelled"); + serve_step.dependOn(&serve_run.step); + // the release-checkpoint steps (TF_DSV41_REAL) stay opt-in, so the umbrella holds the default device suites + const all = b.step("test-dsv41-metal-all", "DeepSeek-V4.1 Metal: every device test step on this GPU (layer, served)"); + all.dependOn(step); + all.dependOn(serve_step); const fast = package(b, metal, sources, .ReleaseSafe); const real = b.createModule(.{ .root_source_file = b.path("zig/tests/dsv41_metal/real_test.zig"), @@ -66,4 +106,7 @@ pub fn steps(b: *std.Build, metal: *std.Build.Module, sources: *std.Build.Module const ptime_run = b.addRunArtifact(b.addTest(.{ .root_module = ptime })); ptime_run.has_side_effects = true; b.step("test-dsv41-metal-prompt-time", "DeepSeek-V4.1 Metal: ms a stage of one release layer's prompt chunks (TF_DSV41_REAL)").dependOn(&ptime_run.step); + // tensorfold-dsv41 over Metal: the native server with this family's lane engine (the CUDA pair's macOS twin) + const exe = @import("dsv41_draft.zig").serverOn(b, b.graph.host, .ReleaseSafe, engine(b, metal, sources, .ReleaseFast)); + b.step("dsv41-metal-serve", "Install tensorfold-dsv41 over Metal (DeepSeek-V4.1 release checkpoints on this Mac)").dependOn(&b.addInstallArtifact(exe, .{}).step); } diff --git a/zig/build/flashnext_reach.zig b/zig/build/flashnext_reach.zig new file mode 100644 index 00000000000..9a4b28562f5 --- /dev/null +++ b/zig/build/flashnext_reach.zig @@ -0,0 +1,13 @@ +//! What the model-free kernel tests need to reach the Flash Next HIP kernels they check against its own plan. +const std = @import("std"); + +/// `kernel_module` gains `core`, the memory sizing, and the Flash Next kernel reach built over both. +pub fn wire(b: *std.Build, target: std.Build.ResolvedTarget, kernel_module: *std.Build.Module, core: *std.Build.Module) void { + kernel_module.addImport("core", core); + kernel_module.addImport("cuda_memory", b.createModule(.{ .root_source_file = b.path("zig/src/native/cuda_memory.zig"), .target = target })); + const reach = b.createModule(.{ .root_source_file = b.path("zig/src/families/flashnext/kernel_reach.zig"), .target = target, .link_libc = true, .imports = &.{ + .{ .name = "hip", .module = kernel_module }, + .{ .name = "core", .module = core }, + } }); + kernel_module.addImport("flashnext_kernel", reach); +} diff --git a/zig/build/hip.zig b/zig/build/hip.zig index 4fca8f58c8e..88c0f6fb363 100644 --- a/zig/build/hip.zig +++ b/zig/build/hip.zig @@ -125,7 +125,7 @@ pub fn steps(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil kernel_module.addIncludePath(.{ .cwd_relative = hip_include }); kernel_module.addCSourceFile(.{ .file = b.path("zig/src/hip/device_arch.c"), .flags = &.{"-D__HIP_PLATFORM_AMD__"} }); kernel_module.addImport("hip_kernels", objects); - kernel_module.addImport("core", coreModule(b, target)); + @import("flashnext_reach.zig").wire(b, target, kernel_module, coreModule(b, target)); const kernel_test = b.addTest(.{ .root_module = kernel_module }); b.step("hip-kernel-build", "Compile the model-free kernels and their GPU tests without running them").dependOn(&kernel_test.step); b.step("hip-kernel-test", "Every model-free kernel against its host reference on the GPU").dependOn(&b.addRunArtifact(kernel_test).step); @@ -518,15 +518,15 @@ const groups = [_]Source{ }; /// What the group sources include, so an edit to one rebuilds them. const headers = [_][]const u8{ - "attention/attention_fa.hip", "attention/prefill.hpp", "decode/pages.hpp", "decode/plan.hpp", "ops/attention.hpp", - "ops/common.hpp", "ops/draw.hpp", "ops/tp.hpp", "ops/elementwise.hpp", "ops/linear.hpp", - "ops/moe.hpp", "ops/norms.hpp", "ops/rope.hpp", "common/arch.hpp", "common/dot2.hpp", - "common/vec.hpp", "common/wmma.hpp", "quant/act.hpp", "quant/mlx.hpp", "quant/mlx_decoder.hpp", - "quant/mlx_pieces.hpp", "quant/mlx_tiles.hpp", "tiles/dot2.hpp", "tiles/epilogue.hpp", "tiles/gemm.hpp", - "tiles/gemm_kp.hpp", "tiles/matrix_gemm.hpp", "tiles/plan.hpp", "tiles/stream.hpp", "flashnext/streams.hpp", - "flashnext/ngram.hpp", "flashnext/indexer.hpp", "flashnext/sparse.hpp", "ops/nemotron_moe.hpp", "quant/bf16_decoder.hpp", - "quant/dense_tiles.hpp", "tiles/wmma12_gemm.hpp", "tiles/wmma12_gemm_k32_ref.hpp", "ops/columns.hpp", "columns_controls.hpp", - "ops/vision.hpp", "ops/dense_text.hpp", "ops/vl_position.hpp", "flashnext/sparse_lanes.hpp", + "attention/attention_fa.hip", "attention/prefill.hpp", "decode/pages.hpp", "decode/plan.hpp", "ops/attention.hpp", + "ops/common.hpp", "ops/draw.hpp", "ops/tp.hpp", "ops/elementwise.hpp", "ops/linear.hpp", + "ops/moe.hpp", "ops/norms.hpp", "ops/rope.hpp", "common/arch.hpp", "common/dot2.hpp", + "common/vec.hpp", "common/wmma.hpp", "quant/act.hpp", "quant/mlx.hpp", "quant/mlx_decoder.hpp", + "quant/mlx_pieces.hpp", "quant/mlx_tiles.hpp", "tiles/dot2.hpp", "tiles/epilogue.hpp", "tiles/gemm.hpp", + "tiles/gemm_kp.hpp", "tiles/matrix_gemm.hpp", "tiles/matrix_item.hpp", "tiles/plan.hpp", "tiles/stream.hpp", + "flashnext/streams.hpp", "flashnext/ngram.hpp", "flashnext/indexer.hpp", "flashnext/sparse.hpp", "ops/nemotron_moe.hpp", + "quant/bf16_decoder.hpp", "quant/dense_tiles.hpp", "tiles/wmma12_gemm.hpp", "tiles/wmma12_gemm_k32_ref.hpp", "ops/columns.hpp", + "columns_controls.hpp", "ops/vision.hpp", "ops/dense_text.hpp", "ops/vl_position.hpp", "flashnext/sparse_lanes.hpp", }; /// The flags of the kernels' first build: no contraction, wave32 on RDNA, C++20. const flags = [_][]const u8{ "-D__HIP_PLATFORM_AMD__=1", "-DUSE_ROCM=1", "-DHIPBLAS_V2", "-fPIC", "-DCUDA_HAS_FP16=1", "-DHIP_ENABLE_WARP_SYNC_BUILTINS=1", "-std=c++20", "-fno-gpu-rdc", "-mno-wavefrontsize64", "-ffp-contract=off" }; diff --git a/zig/build/host_ci.zig b/zig/build/host_ci.zig index fa488c0f38e..bc9f9b000a6 100644 --- a/zig/build/host_ci.zig +++ b/zig/build/host_ci.zig @@ -14,6 +14,7 @@ pub fn steps(b: *std.Build) void { .macos => { wire(b, "test-host-ci-macos-fast", "Nightly host checks without a GPU or shader compiler", &macos_fast); wire(b, "test-host-ci-macos-adapters", "Nightly host checks without a GPU or shader compiler", &macos_adapters); + wire(b, "test-host-ci-macos-native", "Nightly Metal engine host checks with the Metal toolchain", &macos_native); wire(b, "test-host-ci-macos-deepseek", "Nightly host checks without a GPU or shader compiler", &macos_deepseek); wire(b, "test-host-ci-macos-lanes", "Nightly host checks without a GPU or shader compiler", &macos_lanes); wire(b, "test-host-ci-macos-deferred", "Nightly host checks without a GPU or shader compiler", &macos_deferred); @@ -85,6 +86,8 @@ const linux_fast = [_][]const u8{ "test-grammar-error-host", "test-info-config", "test-native-options-host", + "test-template-limits-host", + "test-originals-host", }; const linux_hip = [_][]const u8{ @@ -248,6 +251,7 @@ const macos_adapters = [_][]const u8{ "check-nemotron-prefill-widths-host", "check-qwen35-cache-host", "check-sessions-linux", + "check-video-exports-linux", "check-video-layout-linux", "compile-disk-identity-metal-host", "compile-dsv41-engram-alloc-aarch64", @@ -288,6 +292,8 @@ const macos_adapters = [_][]const u8{ "test-verbs", }; +const macos_native = [_][]const u8{"test-native"}; + const macos_deepseek = [_][]const u8{ "test-dsv41-device-host", "test-dsv41-draft", @@ -301,11 +307,13 @@ const macos_fast = [_][]const u8{ "test-glm-disk-charge-host", "test-living-metal-host", "test-metal-formats-host", + "test-originals-host", "test-persistence-identity-host", "test-qwen27-bank-boundary-host", "test-qwen27-prompt-floor-host", "test-qwen27-reply-cache-host", "test-qwen27-startup-host", + "test-template-limits-host", }; const macos_lanes = [_][]const u8{ diff --git a/zig/build/metal_targets.zig b/zig/build/metal_targets.zig index 3b61e072921..299b4d2f4d2 100644 --- a/zig/build/metal_targets.zig +++ b/zig/build/metal_targets.zig @@ -389,6 +389,7 @@ pub fn metalTargets(b: *std.Build, target: std.Build.ResolvedTarget, optimize: s .{ .name = "tf-minimax-run", .path = "zig/tests/minimax_m3/run.zig", .about = "MiniMax-M3.1-Flash greedy tokens, every position's logits or a prompt's last logits, for the reference's check" }, .{ .name = "tf-minimax-vision", .path = "zig/tests/minimax_m3/vision.zig", .about = "MiniMax-M3.1-Flash's image tower alone: an image's patches and text rows, for tools/vision_ref.py's check" }, .{ .name = "tf-llama-run", .path = "zig/tests/llama/run.zig", .about = "Llama on Metal: greedy tokens, every position's logits or a prompt's last logits, for the reference's check" }, + .{ .name = "tf-flint-run", .path = "zig/tests/flint/run.zig", .about = "Flint greedy tokens, every position's logits or a prompt's last logits, for the reference's check" }, .{ .name = "tf-qwen36-row-seam", .path = "zig/tests/qwen36_row_seam.zig", .about = "Model-free fixture: the row projection's bias sums stay fp32 at every width" }, }; // the tokenizer for the programs that tokenize prompts themselves (the engine module has none) diff --git a/zig/build/qwen27.zig b/zig/build/qwen27.zig index dc2392d3d60..d4358d36f5b 100644 --- a/zig/build/qwen27.zig +++ b/zig/build/qwen27.zig @@ -16,7 +16,7 @@ pub const Shared = struct { }; /// The 27B's Metal sources, embedded by name (zig/kernels/metal/qwen27). -const parts = [_][]const u8{ "xsum", "norm_xs", "norm_input", "mlp_xs", "mlp_ane", "glue", "copy", "moe", "moe_rows", "attention_mpp", "attention_io", "attention_prompt", "attention_prompt_sg", "prompt_glue", "prompt_state" }; +const parts = [_][]const u8{ "xsum", "norm_xs", "norm_input", "mlp_xs", "mlp_ane", "glue", "copy", "moe", "moe_rows", "attention_mpp", "attention_io", "attention_prompt", "attention_prompt_sg" }; /// The shared modules, imported into `engine` (whose `core` is itself) for the server and the release binary. pub fn engineModules(b: *std.Build, target: std.Build.ResolvedTarget, metal: *std.Build.Module, sources: *std.Build.Module, engine: *std.Build.Module) Shared { diff --git a/zig/build/qwen3_cuda.zig b/zig/build/qwen3_cuda.zig index 145f6b8afd2..b099264135d 100644 --- a/zig/build/qwen3_cuda.zig +++ b/zig/build/qwen3_cuda.zig @@ -29,7 +29,7 @@ pub fn steps(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil const run = b.addRunArtifact(gpu); run.has_side_effects = true; // the checkpoints and oracle files named by TF_QWEN3_* are not cached inputs b.step("qwen3-cuda-gpu-build", "Compile the Qwen3 dense CUDA checks without running them").dependOn(&gpu.step); - b.step("qwen3-cuda-gpu-test", "Qwen3 embedding vectors on CUDA against transformers (TF_QWEN3_EMBED_DIR, _REF, _BAR)").dependOn(&run.step); + b.step("qwen3-cuda-gpu-test", "Qwen3 embedding vectors and reranker logits on CUDA against transformers (TF_QWEN3_EMBED_*, TF_QWEN3_RERANK_*)").dependOn(&run.step); all.dependOn(&gpu.step); const host = b.addRunArtifact(b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("zig/src/families/qwen3/checks.zig"), .target = b.graph.host, .optimize = .debug, .link_libc = true }) })); b.step("test-qwen3-checks-host", "Qwen3 checks' oracle pins, references and batching without a GPU").dependOn(&host.step); diff --git a/zig/build/video.zig b/zig/build/video.zig index 3e33b3fae41..e6752a24734 100644 --- a/zig/build/video.zig +++ b/zig/build/video.zig @@ -15,6 +15,7 @@ pub fn steps(b: *std.Build, target: std.Build.ResolvedTarget, host_tests: *std.B b.step("test-video-library-host", "Video candidate order with a recording opener, without real libraries").dependOn(&run.step); host_tests.dependOn(&run.step); const cross = b.step("check-video-layout-linux", "Compile the Linux video probe and stub without execution"); + const exports = b.step("check-video-exports-linux", "Link a planted Linux stub as `video` links; read its exports"); inline for (.{ .aarch64, .x86_64 }) |arch| { const linux = b.resolveTargetQuery(.{ .cpu_arch = arch, .os_tag = .linux, .abi = .gnu }); const probe = layoutProbe(b, linux); @@ -25,6 +26,15 @@ pub fn steps(b: *std.Build, target: std.Build.ResolvedTarget, host_tests: *std.B const lib = b.addLibrary(.{ .name = "tfvideo", .linkage = .dynamic, .root_module = stub }); _ = lib.getEmittedBin(); cross.dependOn(&lib.step); + const planted = b.createModule(.{ .target = linux, .link_libc = true }); + for ([_][]const u8{ "zig/tests/tfvideo_stub.c", "zig/tests/tfvideo_planted.c" }) |c| + planted.addCSourceFile(.{ .file = b.path(c), .flags = &.{ "-std=c11", "-Wall", "-Wextra", "-Werror" } }); + const scripted = b.addLibrary(.{ .name = "tfvideo", .linkage = .dynamic, .root_module = planted }); + scripted.link_gc_sections = false; + exportOnlyTfVideo(b, scripted); + const check = b.addSystemCommand(&.{ "python3", "tools/zig/check_tfvideo_exports.py" }); + check.addArtifactArg(scripted); + exports.dependOn(&check.step); } if (b.graph.host.result.os.tag == .linux) { const layout = b.addSystemCommand(&.{ "python3", "tools/zig/test_tfvideo_layout.py", "--probe" }); @@ -56,6 +66,7 @@ pub fn steps(b: *std.Build, target: std.Build.ResolvedTarget, host_tests: *std.B }) |a| mod.addObjectFile(root.path(b, b.pathJoin(&.{ "lib", a }))); mod.linkSystemLibrary("m", .{}); const lib = b.addLibrary(.{ .name = "tfvideo", .linkage = .dynamic, .root_module = mod }); + exportOnlyTfVideo(b, lib); step.dependOn(&b.addInstallArtifact(lib, .{ .dest_dir = .{ .override = .{ .custom = "native/lib" } } }).step); } @@ -64,3 +75,9 @@ fn layoutProbe(b: *std.Build, target: std.Build.ResolvedTarget) *std.Build.Step. root.addImport("tfvideo", b.createModule(.{ .root_source_file = b.path("zig/src/core/tfvideo.zig"), .target = target, .link_libc = true })); return b.addExecutable(.{ .name = "tfvideo-layout", .root_module = root }); } + +/// ELF exports only tf_video_*: FFmpeg's x86_64 assembly then reaches its C tables PC-relative inside the library. +fn exportOnlyTfVideo(b: *std.Build, lib: *std.Build.Step.Compile) void { + if (lib.rootModuleTarget().ofmt != .elf) return; + lib.setVersionScript(b.addWriteFiles().add("tfvideo.map", "{ global: tf_video_*; local: *; };\n")); +} diff --git a/zig/dense_serving_test.zig b/zig/dense_serving_test.zig index f2432eeebf1..51915aec5d9 100644 --- a/zig/dense_serving_test.zig +++ b/zig/dense_serving_test.zig @@ -17,8 +17,8 @@ test "dense serving: owned CUDA images retain their reviewed lane and decode byt try std.testing.expectEqualStrings("ceff4ee13e97b5aa93cbe7a1edc9a524c78aaa145dd1e5c82dcb05a3011aae23", &hash(group[0..16095])); try std.testing.expectEqualStrings("8780fd710be3ce2f04e53fa1e69eb062809d3bec6391d9bd49983d0588e374d0", &hash(group)); try std.testing.expectEqualStrings("45ab2be277c7c17e6cdc6041db45611f2554c6e88f09a295e88751b1c51be1f5", &hash(@embedFile("kernels/cuda/qmm_prefill.cu"))); - // 01b45cd1d's bytes: tf_q35_norm_shift and tf_q35_head_norm_shift for BF16 checkpoints, under the contract (#2383) - try std.testing.expectEqualStrings("7438579264a99fd0ea99ac242afc68015c8b13a8cd3f10c43b93644376b87205", &hash(@embedFile("kernels/cuda/qwen35.cu"))); + // #2933's bytes: served one-row steps skip the rollback copy nothing reads (#2908); replies unchanged + try std.testing.expectEqualStrings("f16fa3d48336cfe7bcdc071ef791247d8ef3c06e26a9eaa95a54e1febd803332", &hash(@embedFile("kernels/cuda/qwen35.cu"))); try std.testing.expectEqualStrings("7d916f0bb75d636ed15d8f30a7108cf05a6c5a833bd76beb8b627e6567a641db", &hash(@embedFile("kernels/cuda/qwen35.cuh"))); // 67711a6aa7's bytes: the opt-in DSpine draft kernels (#2633); default serving unchanged try std.testing.expectEqualStrings("df06cb0ef07d12ca2296f7d1691678d0649a148f4abcbb3cb69f0d839e45295c", &hash(@embedFile("kernels/cuda/qwen35_draft.cu"))); diff --git a/zig/gemma4_shared_affine_test.zig b/zig/gemma4_shared_affine_test.zig index 33e99b67ad7..4ab73b90968 100644 --- a/zig/gemma4_shared_affine_test.zig +++ b/zig/gemma4_shared_affine_test.zig @@ -109,8 +109,8 @@ test "landed Metal source bytes are pinned without shader compilation" { { var hash: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(ks.core_lane_projection, &hash, .{}); - // 66155d5ee's bytes (8-bit lane codes and producer sums, Refs #2502, under the metal_lane_q8 contract) - try std.testing.expectEqualStrings("159f39224b5bb60fd2ced301496d91662d2958056bd291d13d8a5db5186dd41a", &std.fmt.bytesToHex(hash, .lower)); + // Packed affine lanes with producer sums and independent prompt row blocks. + try std.testing.expectEqualStrings("7befe2028dae5036541f311f230a02d5652e634450ef16dc66dce6e15e935277", &std.fmt.bytesToHex(hash, .lower)); } } test "released lane parameters and conservative scratch size" { diff --git a/zig/kernels/cuda/contract_controls_set.cu b/zig/kernels/cuda/contract_controls_set.cu index e4608a5b9f7..3624ac10b03 100644 --- a/zig/kernels/cuda/contract_controls_set.cu +++ b/zig/kernels/cuda/contract_controls_set.cu @@ -3,3 +3,4 @@ #include "contract_controls.cu" #include "contract_js_controls.cuh" #include "contract_dense_controls.cuh" +#include "contract_experts_bf16_controls.cuh" diff --git a/zig/kernels/cuda/contract_experts_bf16_controls.cuh b/zig/kernels/cuda/contract_experts_bf16_controls.cuh new file mode 100644 index 00000000000..1af8126ec29 --- /dev/null +++ b/zig/kernels/cuda/contract_experts_bf16_controls.cuh @@ -0,0 +1,24 @@ +// Gate 4's control for experts_bf16.cu: its fp32 down with each pair's sum rounded to bf16 at every input. +#pragma once + +extern "C" __global__ void tf_ctl_bf16_experts_bf16_sums(const __nv_bfloat16* X, int x_stride, const uint4* W, int KG, int NB, + const int* items, const int* counts, const int* members, float* out, + int N) { + const int col = blockIdx.x * blockDim.x + threadIdx.x; + if (col >= N) return; + const int cb = col / 32, j = (col % 32) / 8; + for (int it = blockIdx.y; it < counts[0]; it += gridDim.y) { + const int e = items[3 * it], first = items[3 * it + 1], cnt = items[3 * it + 2]; + const __nv_bfloat16* blk = reinterpret_cast(W + (static_cast(e) * NB + cb) * KG * 128); + for (int i = 0; i < cnt; ++i) { + const int pr = members[first + i]; + float s = 0.0f; + for (int k = 0; k < KG * 32; ++k) { + const int q = k % 16, lane = 4 * (col % 8) + q / 4; + const float w = __bfloat162float(blk[(static_cast(k / 32) * 128 + 32 * j + lane) * 8 + 4 * (k % 32 / 16) + q % 4]); + s = __bfloat162float(__float2bfloat16_rn(s + tf_ctl_x(X, static_cast(pr) * x_stride + k) * w)); + } + out[static_cast(pr) * N + col] = s; + } + } +} diff --git a/zig/kernels/cuda/experts_bf16.cu b/zig/kernels/cuda/experts_bf16.cu new file mode 100644 index 00000000000..df6d756ec18 --- /dev/null +++ b/zig/kernels/cuda/experts_bf16.cu @@ -0,0 +1,114 @@ +// Ours: grouped bf16 experts on mma-fragment blocks; a row's sums chain in input order, whatever its tile or form. + +#include +#include + +#include "experts.cuh" + +namespace tf_bf16_experts { + +constexpr int WV = 4; // uint4 a lane a block + +__device__ __forceinline__ uint4 ld_w(const uint4* p) { return tf_common::ld_nc(p); } + +// RT row tiles of 16 pairs share each block's fragments; a unit is one item's 32-column block. +template +__device__ __forceinline__ void body(const __nv_bfloat16* __restrict__ X, int x_stride, int slots, + const uint4* __restrict__ W, int KG, int NB, const int* __restrict__ items, + const int* __restrict__ counts, const int* __restrict__ members, + void* __restrict__ out, int N, int skip) { + const int lane = threadIdx.x & 31, gq = lane >> 2, t = lane & 3; + const int units = __ldg(counts) * NB; + for (int unit = blockIdx.x * WARPS + (threadIdx.x >> 5); unit < units; unit += gridDim.x * WARPS) { + const int it = unit / NB, cb = unit - it * NB; + const int e = __ldg(items + 3 * it), first = __ldg(items + 3 * it + 1), cnt = __ldg(items + 3 * it + 2); + if (e == skip) continue; + const uint4* blk = W + ((size_t)e * NB + cb) * (size_t)KG * (32 * WV) + lane; + for (int r0 = 0; r0 < cnt; r0 += 16 * RT) { + bool v[RT][2]; + int pr[RT][2]; + const __nv_bfloat16* x[RT][2]; +#pragma unroll + for (int r = 0; r < RT; ++r) +#pragma unroll + for (int h = 0; h < 2; ++h) { + const int i = r0 + 16 * r + gq + 8 * h; + v[r][h] = i < cnt; + pr[r][h] = v[r][h] ? __ldg(members + first + i) : 0; + x[r][h] = X + (size_t)(slots ? pr[r][h] / slots : pr[r][h]) * x_stride + 4 * t; + } + float acc[1][RT][NTW][4]; +#pragma unroll + for (int r = 0; r < RT; ++r) +#pragma unroll + for (int j = 0; j < NTW; ++j) acc[0][r][j][0] = acc[0][r][j][1] = acc[0][r][j][2] = acc[0][r][j][3] = 0.f; + const uint2 zero = make_uint2(0u, 0u); + for (int g = 0; g < KG; ++g) { + uint4 w[NTW]; +#pragma unroll + for (int j = 0; j < NTW; ++j) w[j] = ld_w(blk + (size_t)g * (32 * WV) + 32 * j); +#pragma unroll + for (int r = 0; r < RT; ++r) { + uint2 xa[2], xb[2]; +#pragma unroll + for (int q = 0; q < 2; ++q) { + xa[q] = v[r][0] ? __ldg(reinterpret_cast(x[r][0] + 32 * g + 16 * q)) : zero; + xb[q] = v[r][1] ? __ldg(reinterpret_cast(x[r][1] + 32 * g + 16 * q)) : zero; + } +#pragma unroll + for (int q = 0; q < 2; ++q) +#pragma unroll + for (int j = 0; j < NTW; ++j) + mma(acc[0][r][j], xa[q].x, xb[q].x, xa[q].y, xb[q].y, q ? w[j].z : w[j].x, q ? w[j].w : w[j].y); + } + } +#pragma unroll + for (int r = 0; r < RT; ++r) + epilogue(acc, r, out, N, cb * COLS + 2 * t, pr[r][0], pr[r][1], v[r][0], v[r][1], 0.f); + } + } +} + +} // namespace tf_bf16_experts + +// Decode (items of up to 16 pairs) and prompt (up to 64) entries per epilogue: the NVFP4 entries' arguments, no scales. +#define TF_BF16_EXPERTS(NAME, EPI, RT, WARPS) \ + extern "C" __global__ void __launch_bounds__(WARPS * 32) \ + NAME(const __nv_bfloat16* __restrict__ X, int x_stride, int slots, const uint4* __restrict__ W, int KG, int NB, \ + const int* __restrict__ items, const int* __restrict__ counts, const int* __restrict__ members, \ + void* __restrict__ out, int N, int skip) { \ + tf_bf16_experts::body(X, x_stride, slots, W, KG, NB, items, counts, members, out, N, skip); \ + } +TF_BF16_EXPERTS(tf_bf16_experts_up, 1, 1, 4) +TF_BF16_EXPERTS(tf_bf16_experts_down_f32, 0, 1, 4) +TF_BF16_EXPERTS(tf_bf16_experts_down_bf16, 3, 1, 4) +TF_BF16_EXPERTS(tf_bf16_experts_prompt_up, 1, 4, 4) +TF_BF16_EXPERTS(tf_bf16_experts_prompt_down_f32, 0, 4, 4) +TF_BF16_EXPERTS(tf_bf16_experts_prompt_down_bf16, 3, 4, 4) + +// The repack, one thread a uint4: matrix e's [k][n] (in_out, x @ W) or [n][k] (nn.Linear) rows into its blocks. +extern "C" __global__ void tf_bf16_experts_pack(const __nv_bfloat16* __restrict__ src, long long e_stride, int ld, + int n0, int k0, int in_out, int n, int k, uint4* __restrict__ dst) { + const int e = blockIdx.y; + const long long per = (long long)(n / 32) * (k / 32) * 32 * tf_bf16_experts::WV; + const long long q = (long long)blockIdx.x * blockDim.x + threadIdx.x; + if (q >= per) return; + const int lane = (int)(q % 32), j = (int)(q / 32 % tf_bf16_experts::WV); + const long long b = q / (32 * tf_bf16_experts::WV); + const int kg = k / 32, g = (int)(b % kg), cb = (int)(b / kg); + const int row = n0 + cb * 32 + 8 * j + (lane >> 2), col = k0 + g * 32 + 4 * (lane & 3); + const __nv_bfloat16* s = src + e * e_stride; + uint16_t h[8]; +#pragma unroll + for (int i = 0; i < 8; ++i) { + const int c = col + (i & 3) + 16 * (i >> 2); + const __nv_bfloat16 v = in_out ? s[(size_t)c * ld + row] : s[(size_t)row * ld + c]; + h[i] = __bfloat16_as_ushort(v); + } + uint4 o; + o.x = h[0] | (uint32_t)h[1] << 16; + o.y = h[2] | (uint32_t)h[3] << 16; + o.z = h[4] | (uint32_t)h[5] << 16; + o.w = h[6] | (uint32_t)h[7] << 16; + dst[e * per + q] = o; +} diff --git a/zig/kernels/cuda/qwen35.cu b/zig/kernels/cuda/qwen35.cu index c597ea6110a..86fe27c82ca 100644 --- a/zig/kernels/cuda/qwen35.cu +++ b/zig/kernels/cuda/qwen35.cu @@ -268,8 +268,10 @@ extern "C" __global__ void tf_q35_conv_commit(Geo geo, const __nv_bfloat16* __re if (recorded(P)) { // gdn_pre logged each row's tail if (sg.rows != 1) return; - __nv_bfloat16* before = reinterpret_cast<__nv_bfloat16*>(sg.before + geo.nl * geo.rec() * sizeof(float)) + (size_t)layer * geo.conv_rows(); - for (int r = 0; r < TAPS - 1; ++r) before[(size_t)r * C + c] = CS[(size_t)r * C + c]; + if (sg.before != 0) { + __nv_bfloat16* before = reinterpret_cast<__nv_bfloat16*>(sg.before + geo.nl * geo.rec() * sizeof(float)) + (size_t)layer * geo.conv_rows(); + for (int r = 0; r < TAPS - 1; ++r) before[(size_t)r * C + c] = CS[(size_t)r * C + c]; + } } // ascending: slot r takes [r + rows], which is either a later state slot (not yet written) or a row for (int r = 0; r < TAPS - 1; r++) { diff --git a/zig/kernels/cuda/qwen35_slots.cuh b/zig/kernels/cuda/qwen35_slots.cuh index f881afa03d3..87a72a01220 100644 --- a/zig/kernels/cuda/qwen35_slots.cuh +++ b/zig/kernels/cuda/qwen35_slots.cuh @@ -75,7 +75,7 @@ __device__ __forceinline__ void q35_slots(Geo geo, const __nv_bfloat16* q, const } } } - if (sg.rows == 1) + if (sg.rows == 1 && sg.before != 0) #pragma unroll for (int r = 0; r < R; ++r) #pragma unroll diff --git a/zig/kernels/hip/contract_controls.hip b/zig/kernels/hip/contract_controls.hip index 231f769c61c..327da668856 100644 --- a/zig/kernels/hip/contract_controls.hip +++ b/zig/kernels/hip/contract_controls.hip @@ -7,6 +7,7 @@ #include "attention/prefill.hpp" #include "quant/dense_tiles.hpp" +#include "quant/mlx_tiles.hpp" #include "tiles/wmma12_gemm_k32_ref.hpp" __device__ __forceinline__ float tf_ctl_x(const uint16_t* x, int64_t i) { @@ -196,6 +197,18 @@ tf_ctl_fa_paged_unmasked_bf16(const float* q, const hip_bfloat16* k, const hip_b 0, 0, 0, 0, table, count); } +// Gate 3: the routed item tile's few-row passes stop after a wave's first, leaving the block's other columns unwritten. +struct TfCtlItemFewOnePass { + using Dot = tf::rocm::DotBF16; + using Epi = tf::rocm::GemmOut; + static constexpr bool every_pass = false; +}; + +extern "C" __global__ void __launch_bounds__(256) tf_ctl_item_few_one_pass(tf::rocm::Affine a) { + using namespace tf::rocm; + wmma_item_tile, BF16Act, WmmaBF16, GemmOut, 4, TfCtlItemFewOnePass>(a); +} + // Gate 3: the big block loading its operands without the stores' swizzle, and without its K loop's barriers. struct TfCtlUnswizzled { static constexpr bool swizzled_loads = false, ordered = true; diff --git a/zig/kernels/hip/quant/mlx_tiles.hpp b/zig/kernels/hip/quant/mlx_tiles.hpp index a3f6ca68940..70bdd72d1e5 100644 --- a/zig/kernels/hip/quant/mlx_tiles.hpp +++ b/zig/kernels/hip/quant/mlx_tiles.hpp @@ -45,11 +45,18 @@ __global__ void __launch_bounds__(256) affine_wmma_skew(Affine a) { wmma_gemm_tile, BF16Act, WmmaBF16, GemmOut, true>(a); } -// ---- the same on RM x 16 rows a block, for a routed item of few rows ---- +// ---- the same on RM x 16 rows a block, for a routed item of few rows; K-parallel passes for one of very few ---- + +// The item tile's few-row passes as served: BF16 dot2 into the fp32 sums, every pass of a wave's columns. +struct ItemFew { + using Dot = DotBF16; + using Epi = GemmOut; + static constexpr bool every_pass = true; +}; template __global__ void __launch_bounds__(256) affine_wmma_item(Affine a) { - wmma_item_tile, BF16Act, WmmaBF16, GemmOut, RM>(a); + wmma_item_tile, BF16Act, WmmaBF16, GemmOut, RM, ItemFew>(a); } // ---- the decode stream tile ---- diff --git a/zig/kernels/hip/tiles/gemm_kp.hpp b/zig/kernels/hip/tiles/gemm_kp.hpp index d759c7da59f..f4b7da96a48 100644 --- a/zig/kernels/hip/tiles/gemm_kp.hpp +++ b/zig/kernels/hip/tiles/gemm_kp.hpp @@ -12,26 +12,23 @@ namespace rocm { // The lanes a column takes, a group each: 32, or 16 or 8 when it has few groups (a wave then takes more columns). __host__ __device__ inline int kp_lanes(int groups) { return groups > 16 ? 32 : groups > 8 ? 16 : 8; } -// CB columns a lane, R rows a pass, WAVES waves a block; passes span <= RB adjacent row blocks to share weights. -template -__device__ __forceinline__ void gemm_kp_tile(typename Dec::Args& a) { - const int blocks = (a.m + R - 1) / R < RB ? (a.m + R - 1) / R : RB; // of the launch: a routed item has its own rows - if (!Dec::take_item(a, blockIdx.z)) return; +// A lane's row of each of a wave's LDS tables, what `lds(lane)` names: R * CB dots, R sums of x, CB scales, CB biases. +struct KpRows { + float* part; + float* spart; + float* scl; + float* bil; +}; + +// A wave's passes over its columns from `colw`, CB a lane and R rows a pass: rows from `first`, every `step` when LOOP. +template +__device__ __forceinline__ void gemm_kp_wave(typename Dec::Args& a, int colw, int first, int step, Lds lds) { constexpr int NO = R * CB; // outputs of a lane set - constexpr int PS = NO + 1; constexpr int OPL = (4 * NO + 31) / 32; // outputs a lane folds, at most const int groups = a.k / a.group; const int gp = kp_lanes(groups); // the lanes of a set; a wave has 32 / gp sets const int sets = 32 / gp; - const int colw = ((blockIdx.x / blocks) * WAVES + (threadIdx.x >> 5)) * CB * sets; - if (colw >= a.n) return; - __shared__ float part[WAVES][32][PS]; - __shared__ float spart[WAVES][32][R + 1]; - __shared__ float scl[WAVES][32][CB + 1]; - __shared__ float bil[WAVES][32][CB + 1]; - const int lane = threadIdx.x & 31; - const int wave = threadIdx.x >> 5; const int per = a.group / 32; const int rounds = (groups + gp - 1) / gp; const int gi = lane & (gp - 1); @@ -128,14 +125,14 @@ __device__ __forceinline__ void gemm_kp_tile(typename Dec::Args& a) { // this group's results to LDS, then the round's groups in order #pragma unroll for (int r = 0; r < R; ++r) { - spart[wave][lane][r] = sx[r]; + lds(lane).spart[r] = sx[r]; #pragma unroll - for (int c = 0; c < CB; ++c) part[wave][lane][r * CB + c] = d[r][c]; + for (int c = 0; c < CB; ++c) lds(lane).part[r * CB + c] = d[r][c]; } #pragma unroll for (int c = 0; c < CB; ++c) { - scl[wave][lane][c] = Dec::scale_value(a, term[c]); - bil[wave][lane][c] = Dec::bias_value(a, term[c]); + lds(lane).scl[c] = Dec::scale_value(a, term[c]); + lds(lane).bil[c] = Dec::bias_value(a, term[c]); } if (round + 1 < rounds) tables(g + gp); __syncwarp(); @@ -150,8 +147,8 @@ __device__ __forceinline__ void gemm_kp_tile(typename Dec::Args& a) { const int c = o % CB; float v = acc[t]; for (int gg = 0; gg < count; ++gg) { - v = Dec::fold_scale(v, part[wave][base + gg][o], scl[wave][base + gg][c]); - v = Dec::fold_bias(v, spart[wave][base + gg][r], bil[wave][base + gg][c]); + v = Dec::fold_scale(v, lds(base + gg).part[o], lds(base + gg).scl[c]); + v = Dec::fold_bias(v, lds(base + gg).spart[r], lds(base + gg).bil[c]); } acc[t] = v; } @@ -168,11 +165,28 @@ __device__ __forceinline__ void gemm_kp_tile(typename Dec::Args& a) { } }; if constexpr (LOOP) { - for (int row0 = (blockIdx.x % blocks) * R; row0 < a.m; row0 += blocks * R) pass(row0); + for (int row0 = first; row0 < a.m; row0 += step) pass(row0); } else { - pass((blockIdx.x % blocks) * R); + pass(first); } } +// CB columns a lane, R rows a pass, WAVES waves a block; passes span <= RB adjacent row blocks to share weights. +template +__device__ __forceinline__ void gemm_kp_tile(typename Dec::Args& a) { + const int blocks = (a.m + R - 1) / R < RB ? (a.m + R - 1) / R : RB; // of the launch: a routed item has its own rows + if (!Dec::take_item(a, blockIdx.z)) return; + const int sets = 32 / kp_lanes(a.k / a.group); + const int colw = ((blockIdx.x / blocks) * WAVES + (threadIdx.x >> 5)) * CB * sets; + if (colw >= a.n) return; + __shared__ float part[WAVES][32][R * CB + 1]; + __shared__ float spart[WAVES][32][R + 1]; + __shared__ float scl[WAVES][32][CB + 1]; + __shared__ float bil[WAVES][32][CB + 1]; + const int wave = threadIdx.x >> 5; + const auto lds = [wave](int lane) { return KpRows{part[wave][lane], spart[wave][lane], scl[wave][lane], bil[wave][lane]}; }; + gemm_kp_wave(a, colw, (blockIdx.x % blocks) * R, blocks * R, lds); +} + } // namespace rocm } // namespace tf diff --git a/zig/kernels/hip/tiles/matrix_item.hpp b/zig/kernels/hip/tiles/matrix_item.hpp index 06195bb373e..734ccf5a056 100644 --- a/zig/kernels/hip/tiles/matrix_item.hpp +++ b/zig/kernels/hip/tiles/matrix_item.hpp @@ -4,13 +4,19 @@ #include "common/wmma.hpp" #include "tiles/gemm.hpp" +#include "tiles/gemm_kp.hpp" #include "tiles/plan.hpp" namespace tf { namespace rocm { +// The most rows a launch's cut sends to K-parallel passes, the rows a pass takes and the columns a lane takes in it. +constexpr int kItemFewRows = 8; +constexpr int kItemFewPass = 2; +constexpr int kItemFewCols = 2; + // Same staging, stage order and per-element sums as wmma_gemm_tile, so each output equals that tile's byte for byte. -template +template __device__ __forceinline__ void wmma_item_tile(typename Dec::Args& a) { #if TF_DEVICE_WMMA_GFX11 if (!Dec::take_item(a, blockIdx.z)) return; @@ -27,6 +33,28 @@ __device__ __forceinline__ void wmma_item_tile(typename Dec::Args& a) { const int tid = threadIdx.x; const int n0 = bx * kGemmN; + + // An item within the launch's cut takes K-parallel passes with Few's Dot and epilogue: same bytes, no matrix core. + if (a.m <= (a.route.few < kItemFewRows ? a.route.few : kItemFewRows)) { + constexpr int CB = kItemFewCols; + constexpr int R = kItemFewPass; + // a pass's partials, a row a lane: each wave's own slice of the two code stages, which this path leaves unused + constexpr int kLane = (R * CB + 1) + (R + 1) + 2 * (CB + 1); + static_assert(8 * 32 * kLane <= 2 * kGemmN * kLdw, "the few-row passes' partials fit the tile's code stages"); + const int wave = __builtin_amdgcn_readfirstlane(tid >> 5); + const auto lds = [wave](int lane) { + float* const row = reinterpret_cast(&ws[0][0]) + (wave * 32 + lane) * kLane; + return KpRows{row, row + R * CB + 1, row + R * CB + 1 + R + 1, row + R * CB + 1 + R + 1 + CB + 1}; + }; + // The 8 waves walk the block's columns side by side, CB a lane set a pass, R rows of the item a pass. + const int span = CB * (32 / kp_lanes(a.k / a.group)); + const int end = n0 + kGemmN < a.n ? n0 + kGemmN : a.n; + for (int colw = n0 + wave * span; colw < end; colw += 8 * span) { + gemm_kp_wave(a, colw, 0, R, lds); + if constexpr (!Few::every_pass) break; // the contract's control stops after a wave's first + } + return; + } const int words_row = Dec::template row_words(a); const int groups = a.k / a.group; const int stages = a.k / kGemmK; diff --git a/zig/kernels/hip/tiles/plan.hpp b/zig/kernels/hip/tiles/plan.hpp index 6cd355ba754..260d14b3b1e 100644 --- a/zig/kernels/hip/tiles/plan.hpp +++ b/zig/kernels/hip/tiles/plan.hpp @@ -10,6 +10,7 @@ struct Routing { const int* members = nullptr; int x_div = 1; int first = 0; // set by the kernel from its item + int few = 0; // an item of at most this many rows takes its tile's K-parallel passes, where the tile has them }; // Row r of this block's x and out: the plain matrix's r, or the routed item's r-th pair. diff --git a/zig/kernels/metal/core/deltanet.metal b/zig/kernels/metal/core/deltanet.metal index 3d4bf5a3e4f..9e76b801d87 100644 --- a/zig/kernels/metal/core/deltanet.metal +++ b/zig/kernels/metal/core/deltanet.metal @@ -54,7 +54,6 @@ inline void tf_delta_gdn_pre_body(device const bfloat* projection, device bfloat* value, device float* decay, device bfloat* mixing, - device bfloat* tails, uint3 group, uint lane) { const uint row = group.z, head = group.y; @@ -78,11 +77,6 @@ inline void tf_delta_gdn_pre_body(device const bfloat* projection, activated[part] = float(bfloat(rounded * sigmoid)); if (STEP) { for (uint tap = 0; tap < keep; ++tap) next_history[(row_slot[row] * keep + tap) * channels + channel] = bfloat(inputs[tap + 1]); - } else { - for (uint tap = 0; tap < keep; ++tap) { - const uint source = windows[row * p.taps + tap + 1]; - tails[(row * keep + tap) * channels + channel] = source < keep ? history[(row_slot[row] * keep + source) * channels + channel] : projection[(source - keep) * channels + channel]; - } } } if (head < 2 * p.nk) { @@ -126,11 +120,10 @@ kernel void tf_delta_gdn_pre(device const bfloat* projection [[buffer(0)]], device bfloat* value [[buffer(12)]], device float* decay [[buffer(13)]], device bfloat* mixing [[buffer(14)]], - device bfloat* tails [[buffer(15)]], uint3 group [[threadgroup_position_in_grid]], uint lane [[thread_index_in_simdgroup]]) { - if (p.dk == 128 && p.dv == 128) tf_delta_gdn_pre_body<4, false>(projection, a, b, history, weights, decay_weight, time_bias, windows, row_slot, p, query, key, value, decay, mixing, tails, group, lane); - else tf_delta_gdn_pre_body<0, false>(projection, a, b, history, weights, decay_weight, time_bias, windows, row_slot, p, query, key, value, decay, mixing, tails, group, lane); + if (p.dk == 128 && p.dv == 128) tf_delta_gdn_pre_body<4, false>(projection, a, b, history, weights, decay_weight, time_bias, windows, row_slot, p, query, key, value, decay, mixing, group, lane); + else tf_delta_gdn_pre_body<0, false>(projection, a, b, history, weights, decay_weight, time_bias, windows, row_slot, p, query, key, value, decay, mixing, group, lane); } // tf_delta_gdn_pre_body<4, true> over 4 simdgroups a head; each lane's 4 squares summed in order before simd_sum. @@ -206,7 +199,7 @@ kernel void tf_delta_gdn_pre_step(device const bfloat* projection [[buffer(0)]], uint lane [[thread_index_in_simdgroup]]) { threadgroup float shared[129]; if (p.dk == 128 && p.dv == 128) tf_delta_gdn_pre_step4(projection, a, b, history, weights, decay_weight, time_bias, windows, row_slot, p, query, key, value, decay, mixing, shared, group, sg, lane); - else if (sg == 0) tf_delta_gdn_pre_body<0, true>(projection, a, b, history, weights, decay_weight, time_bias, windows, row_slot, p, query, key, value, decay, mixing, nullptr, group, lane); + else if (sg == 0) tf_delta_gdn_pre_body<0, true>(projection, a, b, history, weights, decay_weight, time_bias, windows, row_slot, p, query, key, value, decay, mixing, group, lane); } // One tree node from its parent's state: tf_delta_delta_update and tf_delta_delta_read as the snapshot path runs them. inline float4 tf_delta_tree_node(float4 parent_state, device const bfloat* query, device const bfloat* key, device const bfloat* value, diff --git a/zig/kernels/metal/core/lane_projection.metal b/zig/kernels/metal/core/lane_projection.metal index 2fedb0c13c2..7e65ddb08b6 100644 --- a/zig/kernels/metal/core/lane_projection.metal +++ b/zig/kernels/metal/core/lane_projection.metal @@ -12,6 +12,9 @@ using namespace mpp::tensor_ops; #ifndef TF_PRECOMPUTE_SUMS #define TF_PRECOMPUTE_SUMS 0 #endif +#ifndef TF_ROW_BLOCKS +#define TF_ROW_BLOCKS 0 +#endif #ifndef TF_YS #define TF_YS TF_N #endif @@ -141,12 +144,8 @@ inline void tf_stage_codes(threadgroup uint* stage, thread const tf_raw& raw, in #endif #if TF_PRECOMPUTE_SUMS -#if TF_BITS == 8 #define TF_SUM_VALUE(row, g) ((row) < M ? XS[(g) * mdims[1] + (row)] : 0.0f) #else -#define TF_SUM_VALUE(row, g) XS[(g) * 16 + (row)] -#endif -#else #define TF_SUM_VALUE(row, g) tf_lane_xsum(X, row, g, M) #endif @@ -178,7 +177,21 @@ inline void tf_stage_codes(threadgroup uint* stage, thread const tf_raw& raw, in const device float* XS [[buffer(5)]], #endif uint sgi [[simdgroup_index_in_threadgroup]], uint lanei [[thread_index_in_simdgroup]], - uint tgx [[threadgroup_position_in_grid]]) { +#if TF_ROW_BLOCKS + uint2 tg [[threadgroup_position_in_grid]] +#else + uint tgx [[threadgroup_position_in_grid]] +#endif +) { +#if TF_ROW_BLOCKS + const int row0 = int(tg.y) * 16, rows = mdims[0] - row0; + if (rows <= 0) return; + X += (size_t)row0 * TF_K; + Y += (size_t)row0 * TF_YS; +#if TF_PRECOMPUTE_SUMS + XS += row0; +#endif +#endif const int lane = int(lanei), sg = int(sgi); const short qid = short(lane) >> 2; const short fm = (qid & 4) | ((short(lane) >> 1) & 3); @@ -187,8 +200,13 @@ inline void tf_stage_codes(threadgroup uint* stage, thread const tf_raw& raw, in #else const short fn = ((qid & 2) | (short(lane) & 1)) * 4; #endif +#if TF_ROW_BLOCKS + const int M = min(rows, 16); + const int tile = tf_tile(int(tg.x)); +#else const int M = mdims[0]; const int tile = tf_tile(int(tgx)); +#endif const int n0 = tile * NT; const int g_begin = TF_G0 + (sg * TF_GN) / TF_SK, g_end = TF_G0 + ((sg + 1) * TF_GN) / TF_SK; constexpr auto desc = matmul2d_descriptor(16, NT, GS, false, true, false, matmul2d_descriptor::mode::multiply); diff --git a/zig/kernels/metal/core/moe_route.metal b/zig/kernels/metal/core/moe_route.metal index 428f956529a..0d5df32b2fd 100644 --- a/zig/kernels/metal/core/moe_route.metal +++ b/zig/kernels/metal/core/moe_route.metal @@ -6,20 +6,21 @@ using namespace metal; #define TF_OUT_T float #endif constant constexpr int ITERS = TF_K / 32; +constant constexpr int PERL = ITERS / 32; // the 32-wide slices of k each lane takes constant constexpr int PER = (TF_E + 31) / 32; -// Logits [rows, E] = x W^T, tg 4 experts x 4 rows; simdgroup s: k=32i+4s+tm, lane l: i=4l..4l+3; 8 partials in order. +// Logits [rows, E] = x W^T, tg 4 experts x 4 rows; simdgroup s: k=32i+4s+tm, lane l: PERL i's from PERL*l; 8 partials. [[kernel]] void tf_route_logits(const device bfloat* X [[buffer(0)]], const device uint4* RP [[buffer(1)]], constant int& rows [[buffer(2)]], device TF_OUT_T* OUT [[buffer(3)]], uint2 tg [[threadgroup_position_in_grid]], uint2 tpos [[thread_position_in_threadgroup]], uint lane [[thread_index_in_simdgroup]], uint s [[simdgroup_index_in_threadgroup]]) { threadgroup float part[8][16]; const int q = int(tg.x), r0 = int(tg.y) * 4; - const device uint4* w = RP + ((size_t(q) * 8 + s) * ITERS + 4 * lane) * 2; + const device uint4* w = RP + ((size_t(q) * 8 + s) * ITERS + PERL * lane) * 2; float acc[4][4]; // [row][expert] for (int c = 0; c < 4; c++) for (int tn = 0; tn < 4; tn++) acc[c][tn] = 0.0f; - for (int ii = 0; ii < 4; ii++) { + for (int ii = 0; ii < PERL; ii++) { float inter[4][4]; for (int h = 0; h < 2; h++) { const uint4 v = w[ii * 2 + h]; @@ -30,7 +31,7 @@ constant constexpr int PER = (TF_E + 31) / 32; inter[(e + 1) / 4][(e + 1) % 4] = as_type(words[j] & 0xffff0000u); } } - const int k = 32 * (4 * int(lane) + ii) + 4 * int(s); + const int k = 32 * (PERL * int(lane) + ii) + 4 * int(s); for (int c = 0; c < 4; c++) { const device bfloat* x = X + size_t(min(r0 + c, rows - 1)) * TF_K + k; for (int tm = 0; tm < 4; tm++) { @@ -49,6 +50,9 @@ constant constexpr int PER = (TF_E + 31) / 32; if (o < 16 && r0 + o / 4 < rows) { float v = part[0][o]; for (int k = 1; k < 8; k++) v += part[k][o]; +#ifdef TF_ROUND_LOGITS + v = float(bfloat(v)); +#endif OUT[size_t(r0 + o / 4) * TF_E + 4 * q + o % 4] = static_cast(v); } } diff --git a/zig/kernels/metal/core/qwen_vision.metal b/zig/kernels/metal/core/qwen_vision.metal index a5b3154b904..69e80ecd794 100644 --- a/zig/kernels/metal/core/qwen_vision.metal +++ b/zig/kernels/metal/core/qwen_vision.metal @@ -16,40 +16,6 @@ TF_GEMM_NAX_KERNEL(qwv_gemm_f32_nn_01, float, false, false, false, true) TF_GEMM_NAX_KERNEL(qwv_gemm_f32_nn_10, float, false, false, true, false) TF_GEMM_NAX_KERNEL(qwv_gemm_f32_nn_11, float, false, false, true, true) -inline float qwv_bf(float x) { return float(bfloat(x)); } - -// One threadgroup's sum of every thread's `v` (at most 1,024 threads), returned to all of them. -inline float qwv_sum(float v, threadgroup float* part, uint n, uint lane, uint sg) { - v = simd_sum(v); - if (lane == 0) part[sg] = v; - threadgroup_barrier(mem_flags::mem_threadgroup); - if (sg == 0) { - float s = lane < (n + 31) / 32 ? part[lane] : 0.0f; - s = simd_sum(s); - if (lane == 0) part[32] = s; - } - threadgroup_barrier(mem_flags::mem_threadgroup); - const float out = part[32]; - threadgroup_barrier(mem_flags::mem_threadgroup); - return out; -} - -// One threadgroup's largest `v`, returned to all of them. -inline float qwv_max(float v, threadgroup float* part, uint n, uint lane, uint sg) { - v = simd_max(v); - if (lane == 0) part[sg] = v; - threadgroup_barrier(mem_flags::mem_threadgroup); - if (sg == 0) { - float s = lane < (n + 31) / 32 ? part[lane] : -INFINITY; - s = simd_max(s); - if (lane == 0) part[32] = s; - } - threadgroup_barrier(mem_flags::mem_threadgroup); - const float out = part[32]; - threadgroup_barrier(mem_flags::mem_threadgroup); - return out; -} - // The fp32 residual stream's start: the patch projection p plus its bias and the patch's four learned-grid taps. [[max_total_threads_per_threadgroup(256)]] [[kernel]] void qwv_embed( const device bfloat* p [[buffer(0)]], const device bfloat* b [[buffer(1)]], const device bfloat* table [[buffer(2)]], @@ -73,19 +39,10 @@ struct QwvRows { device bfloat* y [[buffer(3)]], constant QwvRows& p [[buffer(4)]], uint row [[threadgroup_position_in_grid]], uint t [[thread_position_in_threadgroup]], uint n [[threads_per_threadgroup]], uint lane [[thread_index_in_simdgroup]], uint sg [[simdgroup_index_in_threadgroup]]) { - threadgroup float part[33]; const device float* xr = x + ulong(row) * p.width; device bfloat* yr = y + ulong(row) * p.width; - float s = 0.0f; - for (uint i = t; i < p.width; i += n) s += float(xr[i]); - const float mean = qwv_sum(s, part, n, lane, sg) / float(p.width); - float v = 0.0f; - for (uint i = t; i < p.width; i += n) { - const float d = float(xr[i]) - mean; - v += d * d; - } - const float inv = metal::precise::rsqrt(qwv_sum(v, part, n, lane, sg) / float(p.width) + p.eps); - for (uint i = t; i < p.width; i += n) yr[i] = bfloat((float(xr[i]) - mean) * inv * float(w[i]) + float(b[i])); + TF_VIS_LAYER_NORM_STATS(TF_VIS_ROW) + TF_VIS_LAYER_NORM_OUT(TF_VIS_ROW) } struct QwvQkv { @@ -105,14 +62,14 @@ struct QwvQkv { const float v = float(qkv[row + col + d]) + float(bias[col + d]); const ulong out = (ulong(h) * p.rows + r) * p.dim + d; if (which == 2) { - V[out] = qwv_bf(v); + V[out] = tf_vis_bf(v); return; } const uint e = d < mid ? d + mid : d - mid; const float partner = float(qkv[row + col + e]) + float(bias[col + e]); const float c = cs[ulong(r) * p.dim + d], s = sn[ulong(r) * p.dim + d]; const float turned = d < mid ? v * c - partner * s : v * c + partner * s; - (which == 0 ? Q : K)[out] = qwv_bf(turned); + (which == 0 ? Q : K)[out] = tf_vis_bf(turned); } // Each row of S [rows, n] (fp32 scores) as softmax(scale * s) in place, one threadgroup a row. @@ -125,14 +82,14 @@ struct QwvQkv { device float* s = S + ulong(row) * n; float m = -INFINITY; for (uint i = t; i < n; i += tn) m = max(m, s[i] * scale); - m = qwv_max(m, part, tn, lane, sg); + m = tf_vis_max(m, part, tn, lane, sg); float z = 0.0f; for (uint i = t; i < n; i += tn) { const float e = metal::precise::exp(s[i] * scale - m); s[i] = e; z += e; } - const float inv = 1.0f / qwv_sum(z, part, tn, lane, sg); + const float inv = 1.0f / tf_vis_sum(z, part, tn, lane, sg); for (uint i = t; i < n; i += tn) s[i] *= inv; } diff --git a/zig/kernels/metal/core/shared_attention.metal b/zig/kernels/metal/core/shared_attention.metal index 126aef9685c..11ae5ce8910 100644 --- a/zig/kernels/metal/core/shared_attention.metal +++ b/zig/kernels/metal/core/shared_attention.metal @@ -149,34 +149,6 @@ inline void tf_context_row(const device bfloat* Q, const device bfloat* K, const tf_context_row(Q, K, V, (const device uchar*)nullptr, O, 0, head, args.keys, 0, as_type(args.scale_bits), lane); } -// Position is supplied per row. Caller-supplied inverse frequencies preserve the model's RoPE convention. -[[kernel]] void tf_lane_rope(const device bfloat* X [[buffer(0)]], const device uint* positions [[buffer(1)]], - const device float* frequencies [[buffer(2)]], constant tf_context_args& args [[buffer(3)]], - device bfloat* Y [[buffer(4)]], uint index [[thread_position_in_grid]]) { - if ((size_t)index >= (size_t)args.rows * args.heads * TF_D) return; - const uint d = index % TF_D; -#if TF_RD > 0 - if (d >= TF_RD) { Y[index] = X[index]; return; } -#if TF_INTERLEAVED - const uint pair = d / 2; - const uint first = pair * 2, second = first + 1; - const bool high = (d & 1) != 0; -#else - const uint pair = d % (TF_RD / 2); - const uint first = pair, second = first + TF_RD / 2; - const bool high = d >= TF_RD / 2; -#endif - const uint row = index / (args.heads * TF_D); - const size_t base = index - d; - const float angle = float(positions[row]) * frequencies[pair]; - const float c = precise::cos(angle), s = precise::sin(angle); - const float x = float(X[base + first]), y = float(X[base + second]); - Y[index] = bfloat(high ? fma(x, s, y*c) : fma(x, c, -y*s)); -#else - Y[index] = X[index]; -#endif -} - // Only newly produced rows are written. Prompt and previous-round cache slots are never copied or rewritten. [[kernel]] void tf_lane_append_kv(const device bfloat* K [[buffer(0)]], const device bfloat* V [[buffer(1)]], constant uint* slots [[buffer(2)]], constant tf_context_args& args [[buffer(3)]], diff --git a/zig/kernels/metal/flint/attention_nax.metal b/zig/kernels/metal/flint/attention_nax.metal new file mode 100644 index 00000000000..8252893860b --- /dev/null +++ b/zig/kernels/metal/flint/attention_nax.metal @@ -0,0 +1,208 @@ +// Flint's prompt attention on the tensor units (gemma4's prompt kernel, a [slot, KVH, DH] cache, learned sinks). +#include "../nax.h" +using namespace tfp; + +// Defines: DH (128), NQ, G (query heads a key head), KVH, WINDOW (0: full), RING (0 or a multiple of 64), SCALE. + +constant constexpr int LD = KVH * DH; +constant constexpr float kMasked = -3.402823466e+38f; +constant constexpr float kLog2e = 1.44269504089f; + +// Row max (MAX) or sum over a fragment's 32 columns into r[0], r[1] (rows home.y, home.y + 8). +template +inline void fold(thread const frag& f, thread float* r) { + TF_UNROLL + for (short h = 0; h < 2; h++) { + const short b = 4 * h; + float t = MAX ? max(max(f[b], f[b + 1]), max(f[b + 2], f[b + 3])) : (f[b] + f[b + 1]) + (f[b + 2] + f[b + 3]); + const float u = simd_shuffle_xor(t, ushort(1)); + t = MAX ? max(t, u) : t + u; + const float w = simd_shuffle_xor(t, ushort(8)); + t = MAX ? max(t, w) : t + w; + r[h] = MAX ? max(r[h], t) : r[h] + t; + } +} + +constant constexpr int RT = 32; // query rows a tile +constant constexpr int NS = DH / 64; // simdgroups a tile, each its own 64 output columns +constant constexpr int KS = 32; // keys a simdgroup a block +constant constexpr int BKT = KS * NS; // keys a block +constant constexpr int PH = BKT < 128 ? BKT : 128; // keys whose probabilities cross threadgroup memory at once +constant constexpr int PP = PH + 8; // the probabilities' row pitch +using PT = float; + +template +inline frag blk(const device bfloat* p, int ld, int r, int c, short2 home) { +#ifndef TF_SIMD_FRAGS + const device bfloat* q = p + (r + home.y) * ld + c + home.x; + return frag(*(const device bfloat4*)q, *(const device bfloat4*)(q + 8 * ld)); +#else + frag f; + if (TR) { + frag_get_t(f, p, ld, r, c, home); + } else { + frag_get(f, p, ld, r, c, home); + } + return f; +#endif +} + +inline frag prob_frag(const threadgroup PT* pt, int r, int c, short2 home) { +#ifndef TF_SIMD_FRAGS + const threadgroup PT* q = pt + (r + home.y) * PP + c + home.x; + return frag(*(const threadgroup vec*)q, *(const threadgroup vec*)(q + 8 * PP)); +#else + frag f; + frag_get(f, pt, PP, r, c, home); + return f; +#endif +} + +inline void prob_put(threadgroup PT* pt, int r, int c, short2 home, thread const frag& s) { +#ifndef TF_SIMD_FRAGS + threadgroup PT* q = pt + (r + home.y) * PP + c + home.x; + *(threadgroup vec*)q = vec(s[0], s[1], s[2], s[3]); + *(threadgroup vec*)(q + 8 * PP) = vec(s[4], s[5], s[6], s[7]); +#else + TF_UNROLL + for (short e = 0; e < 8; e++) pt[(r + home.y + (e >> 2) * 8) * PP + c + home.x + TF_COL(e)] = PT(s[e]); +#endif +} + +// Q [R, NQ, DH] at positions p0..; K, V the layer's cache; O [R, NQ, DH]. P: rows, p0. A tile is RT rows of a head. +[[max_total_threads_per_threadgroup(256)]] +[[kernel]] void fl_prompt_attention(const device bfloat* Q [[buffer(0)]], const device bfloat* K [[buffer(1)]], + const device bfloat* V [[buffer(2)]], device bfloat* O [[buffer(3)]], + constant int2& P [[buffer(4)]], const device bfloat* SINKS [[buffer(5)]], + uint sg [[simdgroup_index_in_threadgroup]], + uint lane [[thread_index_in_simdgroup]], uint3 tg [[threadgroup_position_in_grid]]) { + const int R = P.x, p0 = P.y; + const int qh = int(tg.y), kv = qh / G; + const int r0 = int(tg.x) * RT; + const int q_rows = min(RT, R - r0); + const int qs = NQ * DH; + const device bfloat* q = Q + r0 * qs + qh * DH; + const device bfloat* kc = K + kv * DH; + const device bfloat* vc = V + kv * DH + int(sg) * 64; + const short2 home = frag_home(ushort(lane)); + threadgroup PT pt[RT * PP]; + threadgroup float rowmax[NS][RT]; + frag acc[8]; + TF_UNROLL + for (short i = 0; i < 8; i++) acc[i] = frag(0); + float top[4] = {kMasked, kMasked, kMasked, kMasked}, total[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + const int first_pos = p0 + r0, last_pos = p0 + r0 + q_rows - 1; // the tile's last key + const int lo = WINDOW ? max(0, first_pos - WINDOW + 1) : 0; + for (int kb = lo / BKT; kb <= last_pos / BKT; kb++) { + const int kpos = kb * BKT + int(sg) * KS; + const int slot = RING ? kpos % RING : kpos; + const device bfloat* kblk = kc + long(slot) * LD; + frag s[4]; + TF_UNROLL + for (short i = 0; i < 4; i++) s[i] = frag(0); + for (int d = 0; d < DH / 16; d++) { + const frag k0 = blk(kblk, LD, 0, 16 * d, home); + const frag k1 = blk(kblk, LD, 16, 16 * d, home); + TF_UNROLL + for (short h = 0; h < 2; h++) { // rows past q_rows read inside the chunk's buffer; their outputs stay unwritten + const frag qf = blk(q, qs, 16 * h, 16 * d, home); + mma_16x32(s[2 * h], s[2 * h + 1], qf, k0, k1); + } + } + TF_UNROLL + for (short i = 0; i < 4; i++) { + TF_UNROLL + for (short e = 0; e < 8; e++) { + const int local = 16 * (i >> 1) + home.y + (e >> 2) * 8, row = first_pos + local; + const int col = kpos + 16 * (i & 1) + home.x + TF_COL(e); + const bool out = col > row || (WINDOW && col < row - WINDOW + 1); + s[i][e] = out ? kMasked : s[i][e] * (SCALE * kLog2e); + } + } + float top_new[4] = {top[0], top[1], top[2], top[3]}; + TF_UNROLL + for (short i = 0; i < 4; i++) fold(s[i], top_new + 2 * (i >> 1)); + if (home.x == 0) { + TF_UNROLL + for (short j = 0; j < 4; j++) rowmax[sg][home.y + 8 * j] = top_new[j]; + } + threadgroup_barrier(mem_flags::mem_threadgroup); + TF_UNROLL + for (short j = 0; j < 4; j++) { + for (int t = 0; t < NS; t++) top_new[j] = max(top_new[j], rowmax[t][home.y + 8 * j]); + } + TF_UNROLL + for (short i = 0; i < 4; i++) { + TF_UNROLL + for (short e = 0; e < 8; e++) { + s[i][e] = s[i][e] == kMasked ? 0.0f : fast::exp2(s[i][e] - top_new[2 * (i >> 1) + (e >> 2)]); + } + } + float keep[4]; + TF_UNROLL + for (short j = 0; j < 4; j++) { + keep[j] = top[j] == kMasked ? 0.0f : fast::exp2(top[j] - top_new[j]); + top[j] = top_new[j]; + total[j] = total[j] * keep[j]; + } + TF_UNROLL + for (short i = 0; i < 4; i++) fold(s[i], total + 2 * (i >> 1)); + TF_UNROLL + for (short i = 0; i < 8; i++) { + TF_UNROLL + for (short e = 0; e < 8; e++) acc[i][e] = acc[i][e] * keep[2 * (i >> 2) + (e >> 2)]; + } + for (int ph = 0; ph < BKT / PH; ph++) { + if (ph > 0) threadgroup_barrier(mem_flags::mem_threadgroup); // the phase before has read the tile + if (int(sg) / (PH / KS) == ph) { + TF_UNROLL + for (short i = 0; i < 4; i++) prob_put(pt, 16 * (i >> 1), int(sg) * KS - ph * PH + 16 * (i & 1), home, s[i]); + } + threadgroup_barrier(mem_flags::mem_threadgroup); + for (int kk = 0; kk < PH / 16; kk++) { + const int kp = kb * BKT + ph * PH + 16 * kk; + if (kp > last_pos) break; // keys past the tile's rows: masked, and their cache rows may hold anything + const int valid = min(16, last_pos + 1 - kp); // a value row never written would poison the tile as 0 x NaN + const device bfloat* vblk = vc + long(RING ? kp % RING : kp) * LD; + frag v0, v1; + TF_UNROLL + for (short d = 0; d < 4; d += 2) { + if (valid == 16) { + v0 = blk(vblk, LD, 0, 16 * d, home); + v1 = blk(vblk, LD, 0, 16 * d + 16, home); + } else { + frag_get_in(v0, vblk, LD, 0, 16 * d, home, valid, DH); + frag_get_in(v1, vblk, LD, 0, 16 * d + 16, home, valid, DH); + } + TF_UNROLL + for (short h = 0; h < 2; h++) { + const frag pf = prob_frag(pt, 16 * h, 16 * kk, home); + mma_16x32(acc[4 * h + d], acc[4 * h + d + 1], pf, v0, v1); + } + } + } + } + } + // the tile's totals: every simdgroup's partial sums, scaled alike by the shared maxes, and the head's sink + threadgroup_barrier(mem_flags::mem_threadgroup); + if (home.x == 0) { + TF_UNROLL + for (short j = 0; j < 4; j++) rowmax[sg][home.y + 8 * j] = total[j]; + } + threadgroup_barrier(mem_flags::mem_threadgroup); + const float sink = float(SINKS[qh]) * kLog2e; + float inv[4]; + TF_UNROLL + for (short j = 0; j < 4; j++) { + float t = fast::exp2(sink - top[j]); + for (int u = 0; u < NS; u++) t += rowmax[u][home.y + 8 * j]; + inv[j] = 1.0f / t; + } + device bfloat* o = O + r0 * qs + qh * DH + int(sg) * 64; + TF_UNROLL + for (short i = 0; i < 8; i++) { + TF_UNROLL + for (short e = 0; e < 8; e++) acc[i][e] = acc[i][e] * inv[2 * (i >> 2) + (e >> 2)]; + frag_put_in(acc[i], o, qs, 16 * (i >> 2), 16 * (i & 3), home, q_rows, 64); + } +} diff --git a/zig/kernels/metal/flint/glue.metal b/zig/kernels/metal/flint/glue.metal new file mode 100644 index 00000000000..e1872235e97 --- /dev/null +++ b/zig/kernels/metal/flint/glue.metal @@ -0,0 +1,376 @@ +// Flint's glue at the reference's rounding points (RecursiveSI's rec_mlx on MLX): bf16 residual, MLX rms_norm and rope. +#include +using namespace metal; + +struct fl_args { uint rows, width, heads, dim, pos, stride, bits, group, ring, rope; float eps, theta; }; + +// Row r's slot: (pos + r) % ring in a ring, else r. +inline size_t fl_slot(constant fl_args& a, uint row) { return a.ring ? (a.pos + row) % a.ring : row; } + +// Row r of residual R = dequantized affine embedding row ids[r]: bf16(scale * q + bias), held in fp32. +[[kernel]] void fl_embed(const device uint* ids [[buffer(0)]], const device uint* W [[buffer(1)]], + const device bfloat* S [[buffer(2)]], const device bfloat* B [[buffer(3)]], constant fl_args& a [[buffer(4)]], + device float* R [[buffer(5)]], uint2 p [[thread_position_in_grid]]) { + if (p.x >= a.width || p.y >= a.rows) return; + const size_t row = ids[p.y]; + const uint per = 32 / a.bits, mask = (1u << a.bits) - 1; + const uint word = W[row * (a.width / per) + p.x / per]; + const float q = float((word >> ((p.x % per) * a.bits)) & mask); + const size_t g = row * (a.width / a.group) + p.x / a.group; + R[(size_t)p.y * a.width + p.x] = float(bfloat(float(S[g]) * q + float(B[g]))); +} + +// MLX's rms_norm of the residual's rows (bf16 values): out = bf16(w * bf16(x * rsqrt(mean(x^2) + eps))). +[[kernel]] void fl_norm(const device float* R [[buffer(0)]], const device bfloat* w [[buffer(1)]], + constant fl_args& a [[buffer(2)]], device bfloat* out [[buffer(3)]], uint row [[threadgroup_position_in_grid]], + uint t [[thread_position_in_threadgroup]], uint n [[threads_per_threadgroup]]) { + threadgroup float part[32]; + const device float* x = R + (size_t)row * a.width; + float sum = 0.0f; + for (uint i = t; i < a.width; i += n) sum = fma(x[i], x[i], sum); + sum = simd_sum(sum); + if (t % 32 == 0) part[t / 32] = sum; + threadgroup_barrier(mem_flags::mem_threadgroup); + float total = 0.0f; + for (uint s = 0; s < n / 32; ++s) total += part[s]; + const float inv = precise::rsqrt(total / float(a.width) + a.eps); + for (uint i = t; i < a.width; i += n) out[(size_t)row * a.width + i] = bfloat(float(w[i]) * float(bfloat(x[i] * inv))); +} + +// MLX's Sigmoid: 1 / (1 + exp(|x|)) below 0, one less that above; on fp32 router logits in fp32. +inline float fl_sigmoid(float x) { + const float y = 1.0f / (1.0f + precise::exp(fabs(x))); + return x < 0.0f ? y : 1.0f - y; +} + +// The same on a bf16 gate as MLX runs it on a bfloat array: every step rounds to bf16. +inline float fl_sigmoid_bf16(float x) { + const float e = float(bfloat(precise::exp(fabs(x)))); + const float y = float(bfloat(1.0f / float(bfloat(1.0f + e)))); + return float(bfloat(x < 0.0f ? y : 1.0f - y)); +} + +// A = silu(gate) * up as MLX rounds it: s = sigmoid(g) in bf16 steps, bf16(bf16(g * s) * u), over rows x width. +[[kernel]] void fl_swiglu(const device bfloat* Gt [[buffer(0)]], const device bfloat* U [[buffer(1)]], + constant fl_args& a [[buffer(2)]], device bfloat* A [[buffer(3)]], uint2 p [[thread_position_in_grid]]) { + if (p.x >= a.width || p.y >= a.rows) return; + const size_t i = (size_t)p.y * a.width + p.x; + const float g = float(Gt[i]), u = float(U[i]); + const float s = fl_sigmoid_bf16(g); + A[i] = bfloat(float(bfloat(g * s)) * u); +} + +// R = bf16(R + X): a sublayer's bf16 output into the residual. +[[kernel]] void fl_add(device float* R [[buffer(0)]], const device bfloat* X [[buffer(1)]], + constant fl_args& a [[buffer(2)]], uint2 p [[thread_position_in_grid]]) { + if (p.x >= a.width || p.y >= a.rows) return; + const size_t i = (size_t)p.y * a.width + p.x; + R[i] = float(bfloat(R[i] + float(X[i]))); +} + +// routed = bf16(sum_k bf16(Y[r, k] * bf16(wts[r, k]))) in pick order; R = bf16(R + bf16(routed + shared)). +[[kernel]] void fl_moe_add(device float* R [[buffer(0)]], const device bfloat* Y [[buffer(1)]], + const device float* wts [[buffer(2)]], const device bfloat* shared [[buffer(3)]], + constant fl_args& a [[buffer(4)]], uint2 p [[thread_position_in_grid]]) { + if (p.x >= a.width || p.y >= a.rows) return; + const uint k = a.heads; // picks a row + float sum = 0.0f; + for (uint j = 0; j < k; ++j) { + const float w = float(bfloat(wts[p.y * k + j])); + sum += float(bfloat(w * float(Y[((size_t)p.y * k + j) * a.width + p.x]))); + } + const size_t i = (size_t)p.y * a.width + p.x; + const float mlp = float(bfloat(float(bfloat(sum)) + float(shared[i]))); + R[i] = float(bfloat(R[i] + mlp)); +} + +// A chunk's a.rows picks by expert (a.width of them): offsets[e] its first pair, order the pairs, each in pick order. +[[kernel]] void fl_sort(const device int* pick [[buffer(0)]], constant fl_args& a [[buffer(1)]], + device int* offsets [[buffer(2)]], device int* order [[buffer(3)]], uint t [[thread_position_in_threadgroup]]) { + threadgroup int counts[1024]; + int n = 0; + if (t < a.width) + for (uint i = 0; i < a.rows; ++i) n += pick[i] == int(t) ? 1 : 0; + counts[t] = n; + threadgroup_barrier(mem_flags::mem_threadgroup); + if (t == 0) { + int sum = 0; + for (uint e = 0; e < a.width; ++e) { const int c = counts[e]; counts[e] = sum; sum += c; } + } + threadgroup_barrier(mem_flags::mem_threadgroup); + if (t >= a.width) return; + int at = counts[t]; + offsets[t] = at; + for (uint i = 0; i < a.rows; ++i) if (pick[i] == int(t)) order[at++] = int(i); +} + +// Y[i] = X[order[i] / a.heads]: each sorted pair's input row (a.width wide). +[[kernel]] void fl_rows_by_order(const device bfloat* X [[buffer(0)]], const device int* order [[buffer(1)]], + constant fl_args& a [[buffer(2)]], device bfloat* Y [[buffer(3)]], uint2 p [[thread_position_in_grid]]) { + if (p.x < a.width && p.y < a.rows) Y[(size_t)p.y * a.width + p.x] = X[(size_t)(order[p.y] / int(a.heads)) * a.width + p.x]; +} + +// A simdgroup a row: the core select's top-k (sigmoid, ties to the lower id) and w / sum(w), no per-Mac lists. +[[kernel]] void fl_route_pick(const device float* L [[buffer(0)]], constant fl_args& a [[buffer(1)]], + device int* PICK [[buffer(2)]], device float* W [[buffer(3)]], uint row [[threadgroup_position_in_grid]], + uint lane [[thread_index_in_simdgroup]]) { + if (row >= a.rows) return; + constexpr uint PER = 8; // experts a lane: a.width <= 256 + float sc[PER]; + uint taken = 0; + for (uint j = 0; j < PER; ++j) { + const uint id = j * 32 + lane; + if (id < a.width) { + sc[j] = fl_sigmoid(L[(size_t)row * a.width + id]); + } else { + sc[j] = 0.0f; + taken |= 1u << j; + } + } + float w[16]; + for (uint k = 0; k < a.heads; ++k) { + float best = -INFINITY; + uint bid = a.width; + for (uint j = 0; j < PER; ++j) { + const uint id = j * 32 + lane; + if (!(taken & (1u << j)) && (sc[j] > best || (sc[j] == best && id < bid))) { best = sc[j]; bid = id; } + } + for (ushort off = 16; off > 0; off /= 2) { + const float ob = simd_shuffle_xor(best, off); + const uint oi = simd_shuffle_xor(bid, off); + if (ob > best || (ob == best && oi < bid)) { best = ob; bid = oi; } + } + w[k] = best; + if (bid % 32 == lane) taken |= 1u << (bid / 32); + if (lane == 0) PICK[row * a.heads + k] = int(bid); + } + if (lane != 0) return; + float total = w[0]; + for (uint k = 1; k < a.heads; ++k) total = total + w[k]; + for (uint k = 0; k < a.heads; ++k) W[row * a.heads + k] = (w[k] / total) * 1.0f; +} + +#define TF_UNROLL_4 _Pragma("clang loop unroll(full)") + +struct fl_vec_args { uint rows, pos, heads, groups, ring, window, blocks, chunk, first; float scale; }; + +// Decode attention partials (sdpa_vector lanes): one (key head, row, absolute key block) a threadgroup, head a sg. +[[kernel]] void fl_decode_vec(const device bfloat* Q [[buffer(0)]], const device bfloat* K [[buffer(1)]], + const device bfloat* V [[buffer(2)]], constant fl_vec_args& a [[buffer(3)]], device float* PM [[buffer(4)]], + device float* PL [[buffer(5)]], device float* PO [[buffer(6)]], uint3 tg [[threadgroup_position_in_grid]], + uint g [[simdgroup_index_in_threadgroup]], uint lane [[thread_index_in_simdgroup]]) { + const uint kv = tg.x, row = tg.y, block = a.first + tg.z; + const uint G = a.heads / a.groups, head = kv * G + g; + const uint p = a.pos + row; + const uint lo = a.window > 0 && p + 1 > a.window ? p + 1 - a.window : 0; + const uint from = max(lo, block * a.chunk), to = min(p + 1, (block + 1) * a.chunk); + const size_t ld = (size_t)a.groups * 128; + const float4 qv = a.scale * float4(*(const device bfloat4*)(Q + ((size_t)row * a.heads + head) * 128 + 4 * lane)); + float4 acc = 0.0f; + float top = -INFINITY, mass = 0.0f; + for (uint i0 = from; i0 < to; i0 += 4) { + float score[4]; + float4 vr[4]; + TF_UNROLL_4 + for (uint u = 0; u < 4; ++u) { + const bool live = i0 + u < to; // past the block's end: a masked key (read at the block's first, weight 0) + const uint i = live ? i0 + u : from; + const size_t at = (size_t)(a.ring ? i % a.ring : i) * ld + kv * 128 + 4 * lane; + vr[u] = float4(*(const device bfloat4*)(V + at)); + const float sc = simd_sum(dot(qv, float4(*(const device bfloat4*)(K + at)))); + score[u] = live ? sc : -INFINITY; + } + const float peak = max(max(top, max(score[0], score[1])), max(score[2], score[3])); + const float rescale = fast::exp(top - peak); + mass *= rescale; + acc *= rescale; + TF_UNROLL_4 + for (uint u = 0; u < 4; ++u) { + const float w = fast::exp(score[u] - peak); + mass += w; + acc += w * vr[u]; + } + top = peak; + } + const size_t slot = ((size_t)head * a.rows + row) * a.blocks + tg.z; + if (lane == 0) { PM[slot] = top; PL[slot] = mass; } + *(device float4*)(PO + slot * 128 + 4 * lane) = acc; +} + +// MLX's sdpa_vector_2pass_2 with the sink in max and denominator: 32 simdgroups a (head, row) fold the blocks. +[[kernel]] void fl_merge_vec(const device float* PM [[buffer(0)]], const device float* PL [[buffer(1)]], + const device float* PO [[buffer(2)]], constant fl_vec_args& a [[buffer(3)]], const device bfloat* sinks [[buffer(4)]], + device bfloat* O [[buffer(5)]], uint3 tg [[threadgroup_position_in_grid]], uint sg [[simdgroup_index_in_threadgroup]], + uint lane [[thread_index_in_simdgroup]]) { + threadgroup float merged[32 * 32]; + const uint head = tg.x, row = tg.y; + const size_t at = (size_t)head * a.rows + row; + const device float* ms = PM + at * a.blocks; + const device float* ls = PL + at * a.blocks; + const float sink = float(sinks[head]); + // lanes and simdgroups take blocks by absolute index (first + b): a row's sums never move with the window's first row + const uint lane0 = (lane + 32 - a.first % 32) % 32, sg0 = (sg + 32 - a.first % 32) % 32; + float top = sink; + for (uint b = lane0; b < a.blocks; b += 32) top = max(top, ms[b]); + top = simd_max(top); + float total = 0.0f; + for (uint b = lane0; b < a.blocks; b += 32) total += ls[b] > 0.0f ? ls[b] * fast::exp(ms[b] - top) : 0.0f; + total = simd_sum(total) + fast::exp(sink - top); + float acc[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + for (uint b = sg0; b < a.blocks; b += 32) { + if (ls[b] <= 0.0f) continue; + const float f = fast::exp(ms[b] - top); + for (uint j = 0; j < 4; ++j) acc[j] += f * PO[(at * a.blocks + b) * 128 + 4 * lane + j]; + } + for (uint j = 0; j < 4; ++j) { + merged[lane * 32 + sg] = acc[j]; + threadgroup_barrier(mem_flags::mem_threadgroup); + acc[j] = simd_sum(merged[sg * 32 + lane]) / total; + threadgroup_barrier(mem_flags::mem_threadgroup); + } + if (lane == 0) + for (uint j = 0; j < 4; ++j) O[(row * a.heads + head) * 128 + 4 * sg + j] = bfloat(acc[j]); +} + +// fl_add then fl_norm on a row, one threadgroup of 256: R = bf16(R + X), out = rms_norm(R) * w, the same sums. +[[kernel]] void fl_add_norm(device float* R [[buffer(0)]], const device bfloat* X [[buffer(1)]], + const device bfloat* w [[buffer(2)]], constant fl_args& a [[buffer(3)]], device bfloat* out [[buffer(4)]], + uint row [[threadgroup_position_in_grid]], uint t [[thread_position_in_threadgroup]], + uint n [[threads_per_threadgroup]]) { + threadgroup float part[32]; + device float* x = R + (size_t)row * a.width; + float sum = 0.0f; + for (uint i = t; i < a.width; i += n) { + const float v = float(bfloat(x[i] + float(X[(size_t)row * a.width + i]))); + x[i] = v; + sum = fma(v, v, sum); + } + sum = simd_sum(sum); + if (t % 32 == 0) part[t / 32] = sum; + threadgroup_barrier(mem_flags::mem_threadgroup); + float total = 0.0f; + for (uint s = 0; s < n / 32; ++s) total += part[s]; + const float inv = precise::rsqrt(total / float(a.width) + a.eps); + for (uint i = t; i < a.width; i += n) out[(size_t)row * a.width + i] = bfloat(float(w[i]) * float(bfloat(x[i] * inv))); +} + +// fl_moe_add then fl_norm on a row (the next layer's input norm), one threadgroup of 256, the same sums. +[[kernel]] void fl_moe_add_norm(device float* R [[buffer(0)]], const device bfloat* Y [[buffer(1)]], + const device float* wts [[buffer(2)]], const device bfloat* shared [[buffer(3)]], const device bfloat* w [[buffer(4)]], + constant fl_args& a [[buffer(5)]], device bfloat* out [[buffer(6)]], uint row [[threadgroup_position_in_grid]], + uint t [[thread_position_in_threadgroup]], uint n [[threads_per_threadgroup]]) { + threadgroup float part[32]; + device float* x = R + (size_t)row * a.width; + const uint k = a.heads; + float sum = 0.0f; + for (uint i = t; i < a.width; i += n) { + float routed = 0.0f; + for (uint j = 0; j < k; ++j) { + const float wj = float(bfloat(wts[row * k + j])); + routed += float(bfloat(wj * float(Y[((size_t)row * k + j) * a.width + i]))); + } + const float mlp = float(bfloat(float(bfloat(routed)) + float(shared[(size_t)row * a.width + i]))); + const float v = float(bfloat(x[i] + mlp)); + x[i] = v; + sum = fma(v, v, sum); + } + sum = simd_sum(sum); + if (t % 32 == 0) part[t / 32] = sum; + threadgroup_barrier(mem_flags::mem_threadgroup); + float total = 0.0f; + for (uint s = 0; s < n / 32; ++s) total += part[s]; + const float inv = precise::rsqrt(total / float(a.width) + a.eps); + for (uint i = t; i < a.width; i += n) out[(size_t)row * a.width + i] = bfloat(float(w[i]) * float(bfloat(x[i] * inv))); +} + +struct fl_prep_args { uint rows, heads, kv_heads, dim, pos, ring, rope, stride; float eps, theta; }; + +// One launch: q normed and rotated to QN; k normed, rotated into the cache and v beside it (rows a.stride apart). +[[kernel]] void fl_prep(const device bfloat* X [[buffer(0)]], const device bfloat* qw [[buffer(1)]], + const device bfloat* kw [[buffer(2)]], constant fl_prep_args& a [[buffer(3)]], device bfloat* QN [[buffer(4)]], + device bfloat* KC [[buffer(5)]], device bfloat* VC [[buffer(6)]], uint2 tg [[threadgroup_position_in_grid]], + uint lane [[thread_index_in_simdgroup]]) { + const uint unit = tg.x, row = tg.y; + if (unit >= a.heads + a.kv_heads || row >= a.rows) return; + const bool is_q = unit < a.heads; + const uint head = is_q ? unit : unit - a.heads; + const device bfloat* src = X + (size_t)row * a.stride + (is_q ? 0 : a.heads * a.dim) + (size_t)head * a.dim; + const device bfloat* w = is_q ? qw : kw; + const size_t slot = a.ring ? (a.pos + row) % a.ring : a.pos + row; + const size_t kvw = (size_t)a.kv_heads * a.dim; + device bfloat* y = is_q ? QN + ((size_t)row * a.heads + head) * a.dim : KC + slot * kvw + (size_t)head * a.dim; + const uint per = a.dim / 32; + float v[8]; + float sum = 0.0f; + for (uint j = 0; j < per; ++j) { + v[j] = float(src[lane + 32 * j]); + sum = fma(v[j], v[j], sum); + } + const float inv = precise::rsqrt(simd_sum(sum) / float(a.dim) + a.eps); + threadgroup float nrm[256]; + for (uint j = 0; j < per; ++j) nrm[lane + 32 * j] = float(bfloat(float(w[lane + 32 * j]) * float(bfloat(v[j] * inv)))); + simdgroup_barrier(mem_flags::mem_threadgroup); + const uint half_dim = a.dim / 2; + const float position = float(a.pos + row); + const float base = precise::log2(a.theta); + for (uint j = 0; j < per; ++j) { + const uint d = lane + 32 * j; + if (a.rope == 0) { y[d] = bfloat(nrm[d]); continue; } + const uint i = d % half_dim; + const float freq = precise::exp2(-(float(i) / float(half_dim)) * base); + const float angle = position * freq; + const float c = precise::cos(angle), s = precise::sin(angle); + const float re = nrm[i], im = nrm[i + half_dim]; + y[d] = bfloat(d < half_dim ? re * c - im * s : re * s + im * c); + } + if (is_q) return; + const device bfloat* vs = X + (size_t)row * a.stride + (a.heads + a.kv_heads) * a.dim + (size_t)head * a.dim; + device bfloat* vd = VC + slot * kvw + (size_t)head * a.dim; + for (uint j = 0; j < per; ++j) vd[lane + 32 * j] = vs[lane + 32 * j]; +} + +// fl_swiglu on a stacked gate|up row (gate at 0, up at a.width): A = bf16(bf16(g * sigmoid(g)) * u), as there. +[[kernel]] void fl_swiglu_gu(const device bfloat* GU [[buffer(0)]], constant fl_args& a [[buffer(1)]], + device bfloat* A [[buffer(2)]], uint2 p [[thread_position_in_grid]]) { + if (p.x >= a.width || p.y >= a.rows) return; + const size_t at = (size_t)p.y * 2 * a.width + p.x; + const float g = float(GU[at]), u = float(GU[at + a.width]); + const float s = fl_sigmoid_bf16(g); + A[(size_t)p.y * a.width + p.x] = bfloat(float(bfloat(g * s)) * u); +} + +inline bool fl_better(float a, uint ia, float b, uint ib) { + if (isnan(a)) return !isnan(b) || ia < ib; + if (isnan(b)) return false; + return a > b || (a == b && ia < ib); +} + +// The greedy draw (nemotron_glue's tf_argmax_bf16): a bf16 logits row's argmax, ties to the lower id, into ids[0]. +[[kernel]] void fl_argmax(const device bfloat* logits [[buffer(0)]], device uint* ids [[buffer(1)]], + constant uint& vocab [[buffer(2)]], uint t [[thread_position_in_threadgroup]], + uint lane [[thread_index_in_simdgroup]], uint sg [[simdgroup_index_in_threadgroup]]) { + threadgroup float vals[32]; + threadgroup uint idxs[32]; + float best = -INFINITY; + uint at = 0xffffffffu; + for (uint i = t; i < vocab; i += 1024) { + const float v = float(logits[i]); + if (fl_better(v, i, best, at)) { best = v; at = i; } + } + for (ushort d = 16; d > 0; d >>= 1) { + const float ov = simd_shuffle_xor(best, d); + const uint oi = simd_shuffle_xor(at, d); + if (fl_better(ov, oi, best, at)) { best = ov; at = oi; } + } + if (lane == 0) { vals[sg] = best; idxs[sg] = at; } + threadgroup_barrier(mem_flags::mem_threadgroup); + if (sg != 0) return; + best = vals[lane]; + at = idxs[lane]; + for (ushort d = 16; d > 0; d >>= 1) { + const float ov = simd_shuffle_xor(best, d); + const uint oi = simd_shuffle_xor(at, d); + if (fl_better(ov, oi, best, at)) { best = ov; at = oi; } + } + if (lane == 0) ids[0] = at; +} diff --git a/zig/kernels/metal/gemma4/vision.metal b/zig/kernels/metal/gemma4/vision.metal index 5855ffeea5f..3c25fd5e7d0 100644 --- a/zig/kernels/metal/gemma4/vision.metal +++ b/zig/kernels/metal/gemma4/vision.metal @@ -15,23 +15,6 @@ TF_GEMM_NAX_KERNEL(g4v_gemm_f32_nn_11, float, false, false, true, true) constant constexpr uint G4V_W = 1152, G4V_HEADS = 16, G4V_DH = 72, G4V_HALF = 36, G4V_QKV = 3 * G4V_W; -inline float g4v_bf(float x) { return float(bfloat(x)); } - -inline float g4v_group_sum(float v, threadgroup float* part, uint n, uint lane, uint sg) { - v = simd_sum(v); - if (lane == 0) part[sg] = v; - threadgroup_barrier(mem_flags::mem_threadgroup); - if (sg == 0) { - float w = lane < (n + 31) / 32 ? part[lane] : 0.0f; - w = simd_sum(w); - if (lane == 0) part[32] = w; - } - threadgroup_barrier(mem_flags::mem_threadgroup); - const float r = part[32]; - threadgroup_barrier(mem_flags::mem_threadgroup); - return r; -} - struct G4vRows { uint width; uint weighted; float eps; }; // The patches' x and y position rows into the projected patches: x = bf16(x + bf16(tx[px] + ty[py])), grid gw wide. @@ -39,7 +22,7 @@ struct G4vRows { uint width; uint weighted; float eps; }; constant uint& gw [[buffer(2)]], uint2 p [[thread_position_in_grid]]) { if (p.x >= G4V_W) return; const uint px = p.y % gw, py = p.y / gw; - const float e = g4v_bf(float(table[ulong(px) * G4V_W + p.x]) + float(table[(10240ul + py) * G4V_W + p.x])); + const float e = tf_vis_bf(float(table[ulong(px) * G4V_W + p.x]) + float(table[(10240ul + py) * G4V_W + p.x])); const ulong i = ulong(p.y) * G4V_W + p.x; x[i] = bfloat(float(x[i]) + e); } @@ -54,7 +37,7 @@ struct G4vRows { uint width; uint weighted; float eps; }; device bfloat* yr = y + ulong(row) * p.width; float s = 0.0f; for (uint i = t; i < p.width; i += n) s += float(xr[i]) * float(xr[i]); - const float r = metal::precise::rsqrt(g4v_group_sum(s, part, n, lane, sg) / float(p.width) + p.eps); + const float r = metal::precise::rsqrt(tf_vis_sum(s, part, n, lane, sg) / float(p.width) + p.eps); for (uint i = t; i < p.width; i += n) yr[i] = bfloat(p.weighted ? float(xr[i]) * r * float(w[i]) : float(xr[i]) * r); } @@ -68,8 +51,8 @@ struct G4vRows { uint width; uint weighted; float eps; }; const device bfloat* yr = y + ulong(row) * G4V_W; float s = 0.0f; for (uint i = t; i < G4V_W; i += n) s += float(yr[i]) * float(yr[i]); - const float r = metal::precise::rsqrt(g4v_group_sum(s, part, n, lane, sg) / float(G4V_W) + eps); - for (uint i = t; i < G4V_W; i += n) xr[i] = bfloat(float(xr[i]) + g4v_bf(float(yr[i]) * r * float(w[i]))); + const float r = metal::precise::rsqrt(tf_vis_sum(s, part, n, lane, sg) / float(G4V_W) + eps); + for (uint i = t; i < G4V_W; i += n) xr[i] = bfloat(float(xr[i]) + tf_vis_bf(float(yr[i]) * r * float(w[i]))); } // qkv [P, 3456] to head-major q, k, v fp32: per-head norms (v unweighted), q and k by the 2D RoPE (x, then y). @@ -86,7 +69,7 @@ struct G4vRows { uint width; uint weighted; float eps; }; float s = 0.0f; for (uint d = lane; d < G4V_DH; d += 32) s += float(x[d]) * float(x[d]); const float r = metal::precise::rsqrt(simd_sum(s) / float(G4V_DH) + eps); - for (uint d = lane; d < G4V_DH; d += 32) nrm[d] = g4v_bf(part == 2 ? float(x[d]) * r : float(x[d]) * r * float(w[d])); + for (uint d = lane; d < G4V_DH; d += 32) nrm[d] = tf_vis_bf(part == 2 ? float(x[d]) * r : float(x[d]) * r * float(w[d])); threadgroup_barrier(mem_flags::mem_threadgroup); device float* out = (part == 0 ? Q : part == 1 ? K : V) + (ulong(head) * P + row) * G4V_DH; for (uint d = lane; d < G4V_DH; d += 32) { @@ -94,7 +77,7 @@ struct G4vRows { uint width; uint weighted; float eps; }; const uint base = d / G4V_HALF * G4V_HALF, j = d % G4V_HALF; const float other = j < G4V_HALF / 2 ? -nrm[base + j + G4V_HALF / 2] : nrm[base + j - G4V_HALF / 2]; const float c = float(cs[row * G4V_DH + d]), sv = float(sn[row * G4V_DH + d]); - out[d] = g4v_bf(g4v_bf(nrm[d] * c) + g4v_bf(other * sv)); + out[d] = tf_vis_bf(tf_vis_bf(nrm[d] * c) + tf_vis_bf(other * sv)); } } @@ -102,29 +85,7 @@ struct G4vRows { uint width; uint weighted; float eps; }; [[kernel]] void g4v_softmax(device float* S [[buffer(0)]], constant uint& n [[buffer(1)]], uint row [[threadgroup_position_in_grid]], uint t [[thread_position_in_threadgroup]], uint tn [[threads_per_threadgroup]], uint lane [[thread_index_in_simdgroup]], uint sg [[simdgroup_index_in_threadgroup]]) { - threadgroup float part[33]; - device float* s = S + ulong(row) * n; - float m = -INFINITY; - for (uint i = t; i < n; i += tn) m = max(m, s[i]); - m = simd_max(m); - if (lane == 0) part[sg] = m; - threadgroup_barrier(mem_flags::mem_threadgroup); - if (sg == 0) { - float v = lane < (tn + 31) / 32 ? part[lane] : -INFINITY; - v = simd_max(v); - if (lane == 0) part[32] = v; - } - threadgroup_barrier(mem_flags::mem_threadgroup); - m = part[32]; - threadgroup_barrier(mem_flags::mem_threadgroup); - float z = 0.0f; - for (uint i = t; i < n; i += tn) { - const float e = metal::precise::exp(s[i] - m); - s[i] = e; - z += e; - } - const float inv = 1.0f / g4v_group_sum(z, part, tn, lane, sg); - for (uint i = t; i < n; i += tn) s[i] *= inv; + TF_VIS_SOFTMAX_BODY() } // O [16, rows, 72] fp32 to rows r0.. of out [P, 1152] bf16. @@ -140,7 +101,7 @@ struct G4vRows { uint width; uint weighted; float eps; }; constant uint& n [[buffer(2)]], uint2 p [[thread_position_in_grid]]) { if (p.x >= n) return; const float g = float(gu[ulong(p.y) * 2 * n + p.x]), u = float(gu[ulong(p.y) * 2 * n + n + p.x]); - const float a = g4v_bf(0.5f * g * (1.0f + metal::precise::tanh(0.7978845608028654f * (g + 0.044715f * g * g * g)))); + const float a = tf_vis_bf(0.5f * g * (1.0f + metal::precise::tanh(0.7978845608028654f * (g + 0.044715f * g * g * g)))); act[ulong(p.y) * n + p.x] = bfloat(a * u); } @@ -153,7 +114,7 @@ struct G4vRows { uint width; uint weighted; float eps; }; float s = 0.0f; for (uint dy = 0; dy < 3; dy++) for (uint dx = 0; dx < 3; dx++) s += float(x[(ulong(3 * ty + dy) * gw + 3 * tx + dx) * G4V_W + p.x]) * (1.0f / 9.0f); - const float h = g4v_bf(s) * 33.941125496954285f; + const float h = tf_vis_bf(s) * 33.941125496954285f; out[ulong(p.y) * G4V_W + p.x] = bfloat((h - float(bias[p.x])) * float(scale[p.x])); } @@ -177,8 +138,8 @@ inline float g4u_in(const device bfloat* x, const device bfloat* extra, constant if (p.add == 1) return v + float(extra[i]); if (p.add == 2) { const uint px = row % p.gw, py = row / p.gw; - const float e = g4v_bf(float(extra[ulong(2 * px) * p.width + i]) + float(extra[ulong(2 * py + 1) * p.width + i])); - return g4v_bf(v + e); + const float e = tf_vis_bf(float(extra[ulong(2 * px) * p.width + i]) + float(extra[ulong(2 * py + 1) * p.width + i])); + return tf_vis_bf(v + e); } return v; } @@ -189,16 +150,9 @@ inline float g4u_in(const device bfloat* x, const device bfloat* extra, constant uint row [[threadgroup_position_in_grid]], uint t [[thread_position_in_threadgroup]], uint n [[threads_per_threadgroup]], uint lane [[thread_index_in_simdgroup]], uint sg [[simdgroup_index_in_threadgroup]]) { - threadgroup float part[33]; - float s = 0.0f; - for (uint i = t; i < p.width; i += n) s += g4u_in(x, extra, p, row, i); - const float mean = g4v_group_sum(s, part, n, lane, sg) / float(p.width); - float d2 = 0.0f; - for (uint i = t; i < p.width; i += n) { - const float d = g4u_in(x, extra, p, row, i) - mean; - d2 += d * d; - } - const float r = metal::precise::rsqrt(g4v_group_sum(d2, part, n, lane, sg) / float(p.width) + p.eps); +#define G4U_IN(i) g4u_in(x, extra, p, row, i) + TF_VIS_LAYER_NORM_STATS(G4U_IN) device bfloat* yr = y + ulong(row) * p.width; - for (uint i = t; i < p.width; i += n) yr[i] = bfloat((g4u_in(x, extra, p, row, i) - mean) * r * float(w[i]) + float(b[i])); + TF_VIS_LAYER_NORM_OUT(G4U_IN) +#undef G4U_IN } diff --git a/zig/kernels/metal/glm_vision.metal b/zig/kernels/metal/glm_vision.metal index ed72d236176..bc516a09f0f 100644 --- a/zig/kernels/metal/glm_vision.metal +++ b/zig/kernels/metal/glm_vision.metal @@ -22,24 +22,6 @@ struct GlmvRows { float eps; }; -inline float glmv_bf(float x) { return float(bfloat(x)); } - -// One threadgroup's sum of every thread's `v` (at most 1024 threads), returned to all of them. -inline float glmv_group_sum(float v, threadgroup float* part, uint t, uint n, uint lane, uint sg) { - v = simd_sum(v); - if (lane == 0) part[sg] = v; - threadgroup_barrier(mem_flags::mem_threadgroup); - if (sg == 0) { - float s = lane < (n + 31) / 32 ? part[lane] : 0.0f; - s = simd_sum(s); - if (lane == 0) part[32] = s; - } - threadgroup_barrier(mem_flags::mem_threadgroup); - const float out = part[32]; - threadgroup_barrier(mem_flags::mem_threadgroup); - return out; -} - // y[r, c] = y + b[c] (a linear layer's bias, added after its product as nn.Linear's bf16 add rounds it) [[kernel]] void glmv_bias(device bfloat* y [[buffer(0)]], const device bfloat* b [[buffer(1)]], constant uint& width [[buffer(2)]], uint2 at [[thread_position_in_grid]]) { @@ -67,9 +49,9 @@ inline float glmv_group_sum(float v, threadgroup float* part, uint t, uint n, ui const float v = float(xr[i]); s += v * v; } - s = glmv_group_sum(s, part, t, n, lane, sg); + s = tf_vis_sum(s, part, n, lane, sg); const float inv = metal::precise::rsqrt(s / float(p.width) + p.eps); - for (uint i = t; i < p.width; i += n) yr[i] = bfloat(float(w[i]) * glmv_bf(float(xr[i]) * inv)); + for (uint i = t; i < p.width; i += n) yr[i] = bfloat(float(w[i]) * tf_vis_bf(float(xr[i]) * inv)); } // mx.fast.layer_norm with weight and bias, one threadgroup a row: w * bf16((x - mean) * rsqrt(var + eps)) + b @@ -83,15 +65,15 @@ inline float glmv_group_sum(float v, threadgroup float* part, uint t, uint n, ui device bfloat* yr = y + ulong(row) * p.width; float s = 0.0f; for (uint i = t; i < p.width; i += n) s += float(xr[i]); - const float mean = glmv_group_sum(s, part, t, n, lane, sg) / float(p.width); + const float mean = tf_vis_sum(s, part, n, lane, sg) / float(p.width); float v = 0.0f; for (uint i = t; i < p.width; i += n) { const float d = float(xr[i]) - mean; v += d * d; } - const float inv = metal::precise::rsqrt(glmv_group_sum(v, part, t, n, lane, sg) / float(p.width) + p.eps); + const float inv = metal::precise::rsqrt(tf_vis_sum(v, part, n, lane, sg) / float(p.width) + p.eps); for (uint i = t; i < p.width; i += n) { - yr[i] = bfloat(glmv_bf(float(w[i]) * glmv_bf((float(xr[i]) - mean) * inv)) + float(b[i])); + yr[i] = bfloat(tf_vis_bf(float(w[i]) * tf_vis_bf((float(xr[i]) - mean) * inv)) + float(b[i])); } } @@ -118,14 +100,14 @@ struct GlmvQkv { } const device bfloat* w = which == 0 ? qn : kn; const float inv = metal::precise::rsqrt(simd_sum(a * a + b * b) / 64.0f + p.eps); - const float na = glmv_bf(float(w[lane]) * glmv_bf(a * inv)); - const float nb = glmv_bf(float(w[lane + 32]) * glmv_bf(b * inv)); + const float na = tf_vis_bf(float(w[lane]) * tf_vis_bf(a * inv)); + const float nb = tf_vis_bf(float(w[lane + 32]) * tf_vis_bf(b * inv)); // cos and sin [L, 64]: the height's 16 frequencies, then the width's, twice const device float* c = cs + ulong(r) * 64; const device float* s = sn + ulong(r) * 64; device float* dst = which == 0 ? Q : K; - dst[out + lane] = glmv_bf(na * c[lane] - nb * s[lane]); - dst[out + lane + 32] = glmv_bf(nb * c[lane + 32] + na * s[lane + 32]); + dst[out + lane] = tf_vis_bf(na * c[lane] - nb * s[lane]); + dst[out + lane + 32] = tf_vis_bf(nb * c[lane + 32] + na * s[lane + 32]); } // Each row of S [rows, n] (fp32 scores) as softmax(scale * s), in place; one threadgroup a row. @@ -133,29 +115,7 @@ struct GlmvQkv { constant float& scale [[buffer(2)]], uint row [[threadgroup_position_in_grid]], uint t [[thread_position_in_threadgroup]], uint tn [[threads_per_threadgroup]], uint lane [[thread_index_in_simdgroup]], uint sg [[simdgroup_index_in_threadgroup]]) { - threadgroup float part[33]; - device float* s = S + ulong(row) * n; - float m = -INFINITY; - for (uint i = t; i < n; i += tn) m = max(m, s[i] * scale); - m = simd_max(m); - if (lane == 0) part[sg] = m; - threadgroup_barrier(mem_flags::mem_threadgroup); - if (sg == 0) { - float v = lane < (tn + 31) / 32 ? part[lane] : -INFINITY; - v = simd_max(v); - if (lane == 0) part[32] = v; - } - threadgroup_barrier(mem_flags::mem_threadgroup); - m = part[32]; - threadgroup_barrier(mem_flags::mem_threadgroup); - float z = 0.0f; - for (uint i = t; i < n; i += tn) { - const float e = metal::precise::exp(s[i] * scale - m); - s[i] = e; - z += e; - } - const float inv = 1.0f / glmv_group_sum(z, part, t, tn, lane, sg); - for (uint i = t; i < n; i += tn) s[i] *= inv; + TF_VIS_SOFTMAX_BODY(* scale) } // O [16, rows, 64] (fp32, one chunk's attention) into rows [r0, r0 + rows) of out [L, 1024] (bf16, heads side by side) @@ -169,10 +129,10 @@ struct GlmvQkv { [[kernel]] void glmv_swiglu(const device bfloat* g [[buffer(0)]], const device bfloat* u [[buffer(1)]], device bfloat* act [[buffer(2)]], constant float& limit [[buffer(3)]], uint i [[thread_position_in_grid]]) { - const float gate = glmv_bf(min(float(g[i]), limit)); - const float up = glmv_bf(clamp(float(u[i]), -limit, limit)); - const float sig = glmv_bf(1.0f / (1.0f + metal::precise::exp(-gate))); - act[i] = bfloat(glmv_bf(gate * sig) * up); + const float gate = tf_vis_bf(min(float(g[i]), limit)); + const float up = tf_vis_bf(clamp(float(u[i]), -limit, limit)); + const float sig = tf_vis_bf(1.0f / (1.0f + metal::precise::exp(-gate))); + act[i] = bfloat(tf_vis_bf(gate * sig) * up); } // erf to fp32 precision (W. J. Cody's rational approximations, as libm's erff). @@ -210,8 +170,8 @@ inline float glmv_erf(float x) { // nn.gelu (exact): x * (1 + erf(x / sqrt(2))) / 2, each op rounded to bf16 as the compiled MLX graph does [[kernel]] void glmv_gelu(device bfloat* x [[buffer(0)]], uint i [[thread_position_in_grid]]) { const float v = float(x[i]); - const float t = glmv_bf(v / 1.41421356237309504880f); - const float e = glmv_bf(glmv_erf(t)); - const float w = glmv_bf(v * glmv_bf(1.0f + e)); + const float t = tf_vis_bf(v / 1.41421356237309504880f); + const float e = tf_vis_bf(glmv_erf(t)); + const float w = tf_vis_bf(v * tf_vis_bf(1.0f + e)); x[i] = bfloat(w / 2.0f); } diff --git a/zig/kernels/metal/prefill/vision_glue.metal b/zig/kernels/metal/prefill/vision_glue.metal new file mode 100644 index 00000000000..a3fb7d748c3 --- /dev/null +++ b/zig/kernels/metal/prefill/vision_glue.metal @@ -0,0 +1,77 @@ +// The image towers' shared glue (after gemm_nax_kernel.metal): bf16 rounding, threadgroup sum and max, row softmax. +inline float tf_vis_bf(float x) { return float(bfloat(x)); } + +// One threadgroup's sum of every thread's `v` (n threads, at most 1024), returned to all of them. +inline float tf_vis_sum(float v, threadgroup float* part, uint n, uint lane, uint sg) { + v = simd_sum(v); + if (lane == 0) part[sg] = v; + threadgroup_barrier(mem_flags::mem_threadgroup); + if (sg == 0) { + float s = lane < (n + 31) / 32 ? part[lane] : 0.0f; + s = simd_sum(s); + if (lane == 0) part[32] = s; + } + threadgroup_barrier(mem_flags::mem_threadgroup); + const float out = part[32]; + threadgroup_barrier(mem_flags::mem_threadgroup); + return out; +} + +// One threadgroup's largest `v` (n threads, at most 1024), returned to all of them. +inline float tf_vis_max(float v, threadgroup float* part, uint n, uint lane, uint sg) { + v = simd_max(v); + if (lane == 0) part[sg] = v; + threadgroup_barrier(mem_flags::mem_threadgroup); + if (sg == 0) { + float s = lane < (n + 31) / 32 ? part[lane] : -INFINITY; + s = simd_max(s); + if (lane == 0) part[32] = s; + } + threadgroup_barrier(mem_flags::mem_threadgroup); + const float out = part[32]; + threadgroup_barrier(mem_flags::mem_threadgroup); + return out; +} + +// A softmax kernel's body: row `row` of S [*, n] (fp32) to softmax(s[i] SCALE) in place, precise exps, tn threads. +#define TF_VIS_SOFTMAX_BODY(SCALE) \ + threadgroup float part[33]; \ + device float* s = S + ulong(row) * n; \ + float m = -INFINITY; \ + for (uint i = t; i < n; i += tn) m = max(m, s[i] SCALE); \ + m = simd_max(m); \ + if (lane == 0) part[sg] = m; \ + threadgroup_barrier(mem_flags::mem_threadgroup); \ + if (sg == 0) { \ + float v = lane < (tn + 31) / 32 ? part[lane] : -INFINITY; \ + v = simd_max(v); \ + if (lane == 0) part[32] = v; \ + } \ + threadgroup_barrier(mem_flags::mem_threadgroup); \ + m = part[32]; \ + threadgroup_barrier(mem_flags::mem_threadgroup); \ + float z = 0.0f; \ + for (uint i = t; i < n; i += tn) { \ + const float e = metal::precise::exp(s[i] SCALE - m); \ + s[i] = e; \ + z += e; \ + } \ + const float inv = 1.0f / tf_vis_sum(z, part, tn, lane, sg); \ + for (uint i = t; i < n; i += tn) s[i] *= inv; + +// LayerNorm, one rounding: STATS the row's mean and inv over IN(i), OUT yr[i] = bf16((IN(i) - mean) * inv * w + b). +#define TF_VIS_LAYER_NORM_STATS(IN) \ + threadgroup float part[33]; \ + float s = 0.0f; \ + for (uint i = t; i < p.width; i += n) s += IN(i); \ + const float mean = tf_vis_sum(s, part, n, lane, sg) / float(p.width); \ + float v = 0.0f; \ + for (uint i = t; i < p.width; i += n) { \ + const float d = IN(i) - mean; \ + v += d * d; \ + } \ + const float inv = metal::precise::rsqrt(tf_vis_sum(v, part, n, lane, sg) / float(p.width) + p.eps); +#define TF_VIS_LAYER_NORM_OUT(IN) \ + for (uint i = t; i < p.width; i += n) yr[i] = bfloat((IN(i) - mean) * inv * float(w[i]) + float(b[i])); +// IN for a kernel whose row of x is xr. +#define TF_VIS_ROW(i) float(xr[i]) diff --git a/zig/kernels/metal/qwen27/prompt_glue.metal b/zig/kernels/metal/qwen27/prompt_glue.metal deleted file mode 100644 index 3ff271a61ab..00000000000 --- a/zig/kernels/metal/qwen27/prompt_glue.metal +++ /dev/null @@ -1,92 +0,0 @@ -// Prompt intermediates round where the declared BF16 graph rounds, rather than at the lane fusion's final store. -#include -using namespace metal; - -struct Q27PromptProduct { uint width, rows, mode, pad; }; -struct Q27PromptQk { uint rows, heads, width, stride, offset; float eps, scale; uint pad; }; -struct Q27PromptDecay { uint rows, heads, weight_flags, pad; }; - -inline float q27_prompt_probability(float value) { - const float magnitude = metal::abs(value); - const float denominator = precise::exp(magnitude) + 1.0f; - const float tail = 1.0f / denominator; - if (value < 0.0f) return tail; - return 1.0f - tail; -} - -inline float q27_prompt_softplus(float value) { - const float small = metal::exp(-metal::abs(value)); - const float shifted = 1.0f + small; - float logarithm = small; - if (shifted != 1.0f) { - const float difference = shifted - 1.0f; - const float ratio = metal::log(shifted) / difference; - logarithm = ratio * small; - } - return metal::max(value, 0.0f) + logarithm; -} - -kernel void q27_prompt_product(device const bfloat* gate [[buffer(0)]], - device const bfloat* factor [[buffer(1)]], - constant Q27PromptProduct& p [[buffer(2)]], - device bfloat* result [[buffer(3)]], - uint at [[thread_position_in_grid]]) { - if (at >= p.rows * p.width) return; - const float x = float(gate[at]); - const float probability = q27_prompt_probability(x); - float activated; - if (p.mode == 0) activated = float(bfloat(x * float(bfloat(probability)))); - else if (p.mode == 1) activated = x * probability; - else activated = x / (1.0f + metal::exp(-x)); - result[at] = bfloat(activated * float(factor[at])); -} - -kernel void q27_prompt_silu(device const bfloat* input [[buffer(0)]], - constant Q27PromptProduct& p [[buffer(1)]], - device bfloat* result [[buffer(2)]], - uint at [[thread_position_in_grid]]) { - if (at >= p.rows * p.width) return; - const float value = float(input[at]); - const bfloat probability = bfloat(q27_prompt_probability(value)); - result[at] = bfloat(value * float(probability)); -} - -kernel void q27_prompt_decay(device const bfloat* a [[buffer(0)]], - device const bfloat* b [[buffer(1)]], - device const void* a_log [[buffer(2)]], - device const void* time_bias [[buffer(3)]], - constant Q27PromptDecay& p [[buffer(4)]], - device float* decay [[buffer(5)]], - device bfloat* beta [[buffer(6)]], - uint at [[thread_position_in_grid]]) { - if (at >= p.rows * p.heads) return; - const uint head = at % p.heads; - const bool wide_bias = (p.weight_flags & 4u) != 0; - const float bias = wide_bias ? ((device const float*)time_bias)[head] : float(((device const bfloat*)time_bias)[head]); - const float logarithm = (p.weight_flags & 2u) != 0 ? ((device const float*)a_log)[head] : float(((device const bfloat*)a_log)[head]); - const float sum = float(a[at]) + bias; - const float shifted = wide_bias ? sum : float(bfloat(sum)); - const float raw = q27_prompt_softplus(shifted); - const float smooth = wide_bias ? raw : float(bfloat(raw)); - const float speed = precise::exp(logarithm); - decay[at] = precise::exp(-speed * smooth); - beta[at] = bfloat(q27_prompt_probability(float(b[at]))); -} - -kernel void q27_prompt_qk(device const bfloat* activation [[buffer(0)]], - constant Q27PromptQk& p [[buffer(1)]], - device bfloat* result [[buffer(2)]], - uint2 group [[threadgroup_position_in_grid]], - uint lane [[thread_index_in_simdgroup]]) { - const uint begin = group.y * p.stride + p.offset + group.x * p.width + lane * 4; - float values[4]; - float square = 0.0f; - for (uint part = 0; part < 4; ++part) { - values[part] = float(activation[begin + part]); - square += values[part] * values[part]; - } - const float inverse = precise::rsqrt(simd_sum(square) / float(p.width) + p.eps); - const float scale = float(bfloat(p.scale)); - const uint output = (group.y * p.heads + group.x) * p.width + lane * 4; - for (uint part = 0; part < 4; ++part) result[output + part] = bfloat(scale * float(bfloat(values[part] * inverse))); -} diff --git a/zig/kernels/metal/qwen27/prompt_state.metal b/zig/kernels/metal/qwen27/prompt_state.metal deleted file mode 100644 index 1f8808136fc..00000000000 --- a/zig/kernels/metal/qwen27/prompt_state.metal +++ /dev/null @@ -1,82 +0,0 @@ -// Prompt state advances through the core tap order and one fixed per-position reduction topology. -#include -using namespace metal; - -struct Q27PromptConv { uint rows, channels, taps, streams; }; -struct Q27PromptRecur { uint rows, nk, nv, dv, streams, pad0, pad1, pad2; }; - -kernel void q27_prompt_conv(device const bfloat* projected [[buffer(0)]], - device const bfloat* history [[buffer(1)]], - device const bfloat* weight [[buffer(2)]], - constant Q27PromptConv& p [[buffer(3)]], - device bfloat* output [[buffer(4)]], - device bfloat* next_history [[buffer(5)]], - uint3 at [[thread_position_in_grid]]) { - if (at.x >= p.channels || at.y >= p.rows || at.z >= p.streams) return; - const uint keep = p.taps - 1; - const uint old_base = at.z * keep * p.channels; - const uint new_base = at.z * p.rows * p.channels; - float sum = 0.0f; - for (uint tap = 0; tap < p.taps; ++tap) { - const uint row = at.y + tap; - const float value = row < keep ? float(history[old_base + row * p.channels + at.x]) : float(projected[new_base + (row - keep) * p.channels + at.x]); - sum += value * float(weight[at.x * p.taps + tap]); - } - output[new_base + at.y * p.channels + at.x] = bfloat(sum); - if (at.y == 0) { - for (uint tail = 0; tail < keep; ++tail) { - const uint row = p.rows + tail; - next_history[old_base + tail * p.channels + at.x] = row < keep ? history[old_base + row * p.channels + at.x] : projected[new_base + (row - keep) * p.channels + at.x]; - } - } -} - -inline float q27_prompt_fold(thread float* partial) { - for (uint stride = 1; stride < 8; stride *= 2) { - for (uint at = 0; at < 8; at += 2 * stride) partial[at] = partial[at] + partial[at + stride]; - } - float total = partial[0]; - for (uint neighbor = 1; neighbor < 4; neighbor *= 2) total = total + simd_shuffle_xor(total, neighbor); - return total; -} - -kernel void q27_prompt_recur(device const bfloat* query [[buffer(0)]], - device const bfloat* key [[buffer(1)]], - device const bfloat* value [[buffer(2)]], - device const float* decay [[buffer(3)]], - device const bfloat* beta [[buffer(4)]], - device const float* committed [[buffer(5)]], - constant Q27PromptRecur& p [[buffer(6)]], - device bfloat* result [[buffer(7)]], - device float* next_state [[buffer(8)]], - uint3 at [[thread_position_in_grid]], - uint lane [[thread_index_in_simdgroup]]) { - const uint stream = at.z / p.nv; - const uint head = at.z % p.nv; - const uint dimension = at.y * 8 + lane / 4; - const uint first = (lane % 4) * 32; - const uint key_head = head / (p.nv / p.nk); - const uint state_base = ((stream * p.nv + head) * p.dv + dimension) * 128 + first; - float state[32]; - for (uint channel = 0; channel < 32; ++channel) state[channel] = committed[state_base + channel]; - for (uint row = 0; row < p.rows; ++row) { - const uint key_base = ((stream * p.rows + row) * p.nk + key_head) * 128 + first; - const uint head_at = (stream * p.rows + row) * p.nv + head; - float partial[8]; - for (uint i = 0; i < 8; ++i) partial[i] = 0.0f; - for (uint channel = 0; channel < 32; ++channel) { - state[channel] = state[channel] * decay[head_at]; - partial[channel / 4] += state[channel] * float(key[key_base + channel]); - } - const float remembered = q27_prompt_fold(partial); - const float correction = (float(value[head_at * p.dv + dimension]) - remembered) * float(beta[head_at]); - for (uint i = 0; i < 8; ++i) partial[i] = 0.0f; - for (uint channel = 0; channel < 32; ++channel) { - state[channel] = state[channel] + float(key[key_base + channel]) * correction; - partial[channel / 4] += state[channel] * float(query[key_base + channel]); - } - const float output = q27_prompt_fold(partial); - if (lane % 4 == 0) result[head_at * p.dv + dimension] = bfloat(output); - } - for (uint channel = 0; channel < 32; ++channel) next_state[state_base + channel] = state[channel]; -} diff --git a/zig/kernels/metal/sources.zig b/zig/kernels/metal/sources.zig index 6343c29d7e1..5c28fc732fc 100644 --- a/zig/kernels/metal/sources.zig +++ b/zig/kernels/metal/sources.zig @@ -24,8 +24,10 @@ pub const gemma4_fused = @embedFile("gemma4/fused.metal"); pub const gemma4_prompt_attention = @embedFile("gemma4/prompt_attention.metal"); /// The prefill GEMM template and its one kernel macro, ahead of every family's gemm_nax instantiations. const gemm_nax = @embedFile("prefill/gemm_nax.metal") ++ @embedFile("prefill/gemm_nax_kernel.metal"); +/// gemm_nax and the image towers' shared glue: bf16 rounding, threadgroup sum and max, row softmax. +const vision_glue = gemm_nax ++ @embedFile("prefill/vision_glue.metal"); /// Gemma 4's image tower: gemm_nax and its glue (families/gemma4/vision.zig). -pub const gemma4_vision = gemm_nax ++ @embedFile("gemma4/vision.metal"); +pub const gemma4_vision = vision_glue ++ @embedFile("gemma4/vision.metal"); /// LFM2-MoE: embedding, norms, the short conv, attention, the route and the experts' glue. pub const lfm2 = @embedFile("lfm2/lfm2.metal") ++ @embedFile("lfm2/lfm2_segments.metal"); @@ -141,9 +143,9 @@ pub const glm_gather = @embedFile("glm_gather.metal"); /// GLM-5.3-Flash's latent attention for rows that read every key: 64 heads as one matrix on the tensor units. pub const glm_attn = @embedFile("glm_attn.metal"); /// GLM-5.3-Flash's image tower: the prefill GEMM template's projections and attention products, then its own steps. -pub const glm_vision = gemm_nax ++ @embedFile("glm_vision.metal"); +pub const glm_vision = vision_glue ++ @embedFile("glm_vision.metal"); /// The Qwen-VL image tower (core/qwen_vl): the prefill GEMM template's products, then its own steps. -pub const core_qwen_vision = gemm_nax ++ @embedFile("core/qwen_vision.metal"); +pub const core_qwen_vision = vision_glue ++ @embedFile("core/qwen_vision.metal"); /// Qwen-Image-2.1's transformer: the prefill attention template, then its own steps (projections: core_qwen_vision). pub const qwen_image = @embedFile("prefill/attention_nax.metal") ++ @embedFile("qwen_image.metal"); /// MOSS-Image: the prefill attention template, then its transformer's and encoder's steps (gemms: core_qwen_vision). @@ -197,12 +199,16 @@ pub const minimax_m3_experts = @embedFile("minimax_m3/experts.metal"); pub const minimax_m3_dflash2 = @embedFile("minimax_m3/dflash2.metal"); /// MiniMax-M3's indexer block scores on the tensor units (a prompt chunk's). pub const minimax_m3_index_nax = @embedFile("minimax_m3/index_nax.metal"); +/// Flint's glue at the reference's rounding points: embed, norms, q/k rotation, SwiGLU, residual and MoE adds. +pub const flint_glue = @embedFile("flint/glue.metal"); +/// Flint's attention on the tensor units: prompt tiles, decode partials and their merge, with sinks. +pub const flint_attention_nax = @embedFile("flint/attention_nax.metal"); /// Step-5's sparse indexer (summaries, scores, top-k) and its sliding, sparse and dense attention. pub const step5_sparse = @embedFile("step5/sparse.metal"); /// Step-5's prompt attention on the tensor units (a KV group's 16 heads one operand; nax.h inlined at load). pub const step5_attend_nax = @embedFile("step5/attend_nax.metal"); /// Step-5's image tower (perception encoder, downsampler convs, projector) on gemm_nax's tensor-unit GEMMs. -pub const step5_vision = gemm_nax ++ @embedFile("step5/vision.metal"); +pub const step5_vision = vision_glue ++ @embedFile("step5/vision.metal"); /// Spark2.5's glue at its PyTorch rounding points: embed, fp32 residual adds and norms, rope, head gate, exact GELU. pub const spark25_glue = @embedFile("core/bf16_matvec.metal") ++ @embedFile("spark25/glue.metal"); /// Spark2.5's prompt attention on the tensor units (gemma4's prompt kernel; nax.h inlined at load). diff --git a/zig/kernels/metal/step5/vision.metal b/zig/kernels/metal/step5/vision.metal index b8ee101c727..60d7660d0b1 100644 --- a/zig/kernels/metal/step5/vision.metal +++ b/zig/kernels/metal/step5/vision.metal @@ -13,23 +13,6 @@ TF_GEMM_NAX_KERNEL(s5v_gemm_f32_nn_01, float, false, false, false, true) TF_GEMM_NAX_KERNEL(s5v_gemm_f32_nn_10, float, false, false, true, false) TF_GEMM_NAX_KERNEL(s5v_gemm_f32_nn_11, float, false, false, true, true) -inline float s5v_bf(float x) { return float(bfloat(x)); } - -inline float s5v_group_sum(float v, threadgroup float* part, uint t, uint n, uint lane, uint sg) { - v = simd_sum(v); - if (lane == 0) part[sg] = v; - threadgroup_barrier(mem_flags::mem_threadgroup); - if (sg == 0) { - float w = lane < (n + 31) / 32 ? part[lane] : 0.0f; - w = simd_sum(w); - if (lane == 0) part[32] = w; - } - threadgroup_barrier(mem_flags::mem_threadgroup); - const float r = part[32]; - threadgroup_barrier(mem_flags::mem_threadgroup); - return r; -} - struct S5vRows { uint rows, width; float eps; }; // y[r, i] = bf16(y + b[i]): a linear layer's bias. @@ -46,7 +29,7 @@ struct S5vRows { uint rows, width; float eps; }; uint2 p [[thread_position_in_grid]]) { if (p.x >= width) return; const ulong i = ulong(p.y) * width + p.x; - x[i] = bfloat(float(x[i]) + s5v_bf(float(y[i]) * float(gamma[p.x]))); + x[i] = bfloat(float(x[i]) + tf_vis_bf(float(y[i]) * float(gamma[p.x]))); } // x = bf16(x + pos): the patches' absolute position embedding. @@ -61,19 +44,10 @@ struct S5vRows { uint rows, width; float eps; }; constant S5vRows& p [[buffer(4)]], uint row [[threadgroup_position_in_grid]], uint t [[thread_position_in_threadgroup]], uint n [[threads_per_threadgroup]], uint lane [[thread_index_in_simdgroup]], uint sg [[simdgroup_index_in_threadgroup]]) { - threadgroup float part[33]; const device bfloat* xr = x + ulong(row) * p.width; device bfloat* yr = y + ulong(row) * p.width; - float s = 0.0f; - for (uint i = t; i < p.width; i += n) s += float(xr[i]); - const float mean = s5v_group_sum(s, part, t, n, lane, sg) / float(p.width); - float v = 0.0f; - for (uint i = t; i < p.width; i += n) { - const float d = float(xr[i]) - mean; - v += d * d; - } - const float inv = metal::precise::rsqrt(s5v_group_sum(v, part, t, n, lane, sg) / float(p.width) + p.eps); - for (uint i = t; i < p.width; i += n) yr[i] = bfloat((float(xr[i]) - mean) * inv * float(w[i]) + float(b[i])); + TF_VIS_LAYER_NORM_STATS(TF_VIS_ROW) + TF_VIS_LAYER_NORM_OUT(TF_VIS_ROW) } // qkv [P, 4608] to q, k, v [16, P, 96] fp32, q and k by the 2D RoPE (interleaved pairs, bf16 cos and sin [P, 96]). @@ -89,7 +63,7 @@ struct S5vRows { uint rows, width; float eps; }; if (part == 2) { out[d] = float(x[d]); continue; } const float c = float(cs[row * 96 + d]), s = float(sn[row * 96 + d]); const float other = (d & 1) ? float(x[d - 1]) : -float(x[d + 1]); // rotate_half: (-x2, x1) per pair - out[d] = s5v_bf(s5v_bf(float(x[d]) * c) + s5v_bf(other * s)); + out[d] = tf_vis_bf(tf_vis_bf(float(x[d]) * c) + tf_vis_bf(other * s)); } } @@ -98,29 +72,7 @@ struct S5vRows { uint rows, width; float eps; }; constant float& scale [[buffer(2)]], uint row [[threadgroup_position_in_grid]], uint t [[thread_position_in_threadgroup]], uint tn [[threads_per_threadgroup]], uint lane [[thread_index_in_simdgroup]], uint sg [[simdgroup_index_in_threadgroup]]) { - threadgroup float part[33]; - device float* s = S + ulong(row) * n; - float m = -INFINITY; - for (uint i = t; i < n; i += tn) m = max(m, s[i] * scale); - m = simd_max(m); - if (lane == 0) part[sg] = m; - threadgroup_barrier(mem_flags::mem_threadgroup); - if (sg == 0) { - float v = lane < (tn + 31) / 32 ? part[lane] : -INFINITY; - v = simd_max(v); - if (lane == 0) part[32] = v; - } - threadgroup_barrier(mem_flags::mem_threadgroup); - m = part[32]; - threadgroup_barrier(mem_flags::mem_threadgroup); - float z = 0.0f; - for (uint i = t; i < n; i += tn) { - const float e = metal::precise::exp(s[i] * scale - m); - s[i] = e; - z += e; - } - const float inv = 1.0f / s5v_group_sum(z, part, t, tn, lane, sg); - for (uint i = t; i < n; i += tn) s[i] *= inv; + TF_VIS_SOFTMAX_BODY(* scale) } // O [16, rows, 96] fp32 to out rows r0.. of [P, 1536] bf16. @@ -134,8 +86,8 @@ struct S5vRows { uint rows, width; float eps; }; // x * sigmoid(1.702 x) in bf16 ops, in place. [[kernel]] void s5v_quick_gelu(device bfloat* x [[buffer(0)]], uint i [[thread_position_in_grid]]) { const float v = float(x[i]); - const float a = s5v_bf(1.702f * v); - const float sg = s5v_bf(1.0f / (1.0f + metal::precise::exp(-a))); + const float a = tf_vis_bf(1.702f * v); + const float sg = tf_vis_bf(1.0f / (1.0f + metal::precise::exp(-a))); x[i] = bfloat(v * sg); } diff --git a/zig/kernels/xpu/qwen27/gdn_chunk.cl b/zig/kernels/xpu/qwen27/gdn_chunk.cl index 225f04daaf8..0b96e7a0c2e 100644 --- a/zig/kernels/xpu/qwen27/gdn_chunk.cl +++ b/zig/kernels/xpu/qwen27/gdn_chunk.cl @@ -280,12 +280,15 @@ KERNEL_WG(16 * (D / RV)) void gdn_state(global const ushort *K, global const ush (private uint *)&s[tj]); } +// rexp with the exponent read from the bits: equal to rexp for every float (subnormals clamp to 100, inf to -100). +static inline int rexp_b(float m) { return m > 0.0f ? clamp(141 - (int)((as_uint(m) >> 23) & 255u), -100, 100) : 0; } + // Output: P = M . Q.K^T and Q go to local memory in fp16 with row exponents, then Y = e^la.Q.S^T + P.U. KERNEL_WG(128) void gdn_out(global const ushort *Q, global const ushort *K, global const float *LA, global const ushort *HS, global const ushort *VN, global ushort *Y, int HK, int HV, int NC) { local ushort pa[2][(CK / 8) * (CK / 16) * 128], qa[(CK / 8) * (D / 16) * 128]; - local float la[CK], qe[CK], pe[CK]; + local float la[CK], qe[CK], pe[CK], el[CK]; const int c = get_group_id(0), h = get_group_id(1), sg = get_sub_group_id(), lane = get_sub_group_local_id(); const int kh = h / (HV / HK), T = NC * CK; const global ushort *Qh = Q + kh * D, *Kh = K + kh * D; @@ -302,7 +305,7 @@ KERNEL_WG(128) void gdn_out(global const ushort *Q, global const ushort *K, glob for (int r = 0; r < 8; r++) { float m; ROWMAX(qf, D / 16, r, m); - eq[r] = rexp(m); + eq[r] = rexp_b(m); if (lane == 0) qe[ti * 8 + r] = (float)eq[r]; } for (int kk = 0; kk < D / 16; kk++) { // a bf16 q scaled by 2^e is exact in fp16 down to 2^-24 @@ -329,9 +332,13 @@ KERNEL_WG(128) void gdn_out(global const ushort *Q, global const ushort *K, glob for (int r = 0; r < 8; r++) { float m; ROWMAX(pf, CK / 16, r, m); - ep[r] = rexp(m); + ep[r] = rexp_b(m); if (lane == 0) pe[ti * 8 + r] = (float)ep[r]; } + for (int r = 0; r < 8; r++) { + const float e = exp(la[ti * 8 + r]); + if (lane == 0) el[ti * 8 + r] = e; + } for (int tj = 0; tj < CK / 16; tj++) { ushort8 o[2]; for (int r = 0; r < 8; r++) SPLIT2(pf[tj][r], ep[r], o[0][r], o[1][r]); @@ -354,8 +361,23 @@ KERNEL_WG(128) void gdn_out(global const ushort *Q, global const ushort *K, glob const global float *HE = (const global float *)(HS + 2L * NC * HV * D * D); const global float *VE = (const global float *)(VN + 2L * NC * HV * D * CK); const int eh = (int)HE[vrow + lane], ev = (int)VE[vrow + lane]; - for (int ti = 0; ti < CK / 8; ti++) - for (int r = 0; r < 8; r++) acc[ti][r] = ldexp(acc[ti][r], -((int)qe[ti * 8 + r] + eh)) * exp(la[ti * 8 + r]); + int qlo = 0, qhi = 0, plo = 0, phi = 0; // the rows' exponent ranges, the same on every lane + for (int j = 0; j < CK / 16; j++) { + const int q = (int)qe[j * 16 + lane], p = (int)pe[j * 16 + lane]; + qlo = min(qlo, sub_group_reduce_min(q)), qhi = max(qhi, sub_group_reduce_max(q)); + plo = min(plo, sub_group_reduce_min(p)), phi = max(phi, sub_group_reduce_max(p)); + } + // ldexp(x, n) is the one multiply x * 2^n for n in [-124, 127]; here n = -(row exponent + lane exponent) + const int one = sub_group_all(eh + qlo >= -127 && eh + qhi <= 124 && ev + plo >= -127 && ev + phi <= 124); + const int bh = 0x3F800000 - (eh << 23), bv = 0x3F800000 - (ev << 23); + if (one) { + for (int ti = 0; ti < CK / 8; ti++) + for (int r = 0; r < 8; r++) + acc[ti][r] = acc[ti][r] * as_float(bh - ((int)qe[ti * 8 + r] << 23)) * el[ti * 8 + r]; + } else { + for (int ti = 0; ti < CK / 8; ti++) + for (int r = 0; r < 8; r++) acc[ti][r] = ldexp(acc[ti][r], -((int)qe[ti * 8 + r] + eh)) * el[ti * 8 + r]; + } float8 pu[CK / 8]; for (int ti = 0; ti < CK / 8; ti++) pu[ti] = 0.0f; for (int kk = 0; kk < CK / 16; kk++) { // P·U: B = (U[2p][v], U[2p+1][v]), transposed read of VN @@ -367,8 +389,13 @@ KERNEL_WG(128) void gdn_out(global const ushort *Q, global const ushort *K, glob pu[ti] = DPH3(pv, b, pu[ti]); } } - for (int ti = 0; ti < CK / 8; ti++) - for (int r = 0; r < 8; r++) acc[ti][r] += ldexp(pu[ti][r], -((int)pe[ti * 8 + r] + ev)); + if (one) { + for (int ti = 0; ti < CK / 8; ti++) + for (int r = 0; r < 8; r++) acc[ti][r] += pu[ti][r] * as_float(bv - ((int)pe[ti * 8 + r] << 23)); + } else { + for (int ti = 0; ti < CK / 8; ti++) + for (int r = 0; r < 8; r++) acc[ti][r] += ldexp(pu[ti][r], -((int)pe[ti * 8 + r] + ev)); + } for (int ti = 0; ti < CK / 8; ti++) { ushort8 o; for (int r = 0; r < 8; r++) o[r] = bf(acc[ti][r]); diff --git a/zig/qwen27_quant_metal.zig b/zig/qwen27_quant_metal.zig index 02a97ba26d5..9684fb4bba7 100644 --- a/zig/qwen27_quant_metal.zig +++ b/zig/qwen27_quant_metal.zig @@ -32,9 +32,11 @@ pub const Record = struct { released: usize = 0, live_bytes: usize = 0, fail_pipeline: bool = false, + tensor_units: bool = true, current: usize = 0, rows: u32 = 0, - launches: [256]struct { pipeline: usize, rows: u32 } = undefined, + launches: [256]struct { pipeline: usize, rows: u32, grid: Size, stride: i32 } = undefined, + sums_stride: i32 = 0, count: usize = 0, pub fn device(r: *Record) Device { return .{ .r = r }; @@ -65,8 +67,8 @@ pub const Buffer = struct { }; pub const Device = struct { r: *Record, - pub fn tensorUnits(_: Device) bool { - return true; + pub fn tensorUnits(d: Device) bool { + return d.r.tensor_units; } pub fn generation(_: Device) usize { return 17; @@ -111,10 +113,13 @@ pub const ComputeEncoder = struct { } pub fn setBuffer(_: ComputeEncoder, _: Buffer, _: usize, _: usize) void {} pub fn setValue(e: ComputeEncoder, v: anytype, at: usize) void { - if (@TypeOf(v) == [2]i32 and at == 3) e.r.rows = @intCast(v[0]); + if (@TypeOf(v) == [2]i32 and at == 3) { + e.r.rows = @intCast(v[0]); + e.r.sums_stride = v[1]; + } } - pub fn dispatchGroups(e: ComputeEncoder, _: Size, _: Size) void { - e.r.launches[e.r.count] = .{ .pipeline = e.r.current, .rows = e.r.rows }; + pub fn dispatchGroups(e: ComputeEncoder, grid: Size, _: Size) void { + e.r.launches[e.r.count] = .{ .pipeline = e.r.current, .rows = e.r.rows, .grid = grid, .stride = e.r.sums_stride }; e.r.count += 1; } pub fn dispatchThreads(_: ComputeEncoder, _: Size, _: Size) void {} diff --git a/zig/qwen27_quant_test.zig b/zig/qwen27_quant_test.zig index 73d432c8705..8fcfa9a5bcf 100644 --- a/zig/qwen27_quant_test.zig +++ b/zig/qwen27_quant_test.zig @@ -132,3 +132,127 @@ test "qwen27 quant: preparation skips projections that cannot take a decode lane try t.expectEqual(@as(u32, 0), k.lanes.count()); try t.expectEqual(@as(usize, 0), r.libraries); } + +const wide_formats = [_]lane_projection.Format{ + .{ .bits = 8, .group = 64 }, .{ .bits = 6, .group = 32 }, .{ .bits = 5, .group = 64 }, + .{ .bits = 3, .group = 32 }, .{ .bits = 2, .group = 64 }, .{ .bits = 8, .group = 128 }, +}; + +test "qwen27 quant: non-reg layouts admit whole prompt chunks with row-block source variants" { + for (wide_formats) |format| { + const long = lane_projection.Layout{ .n = 64, .k = 5120, .format = format, .rows = 2048, .row_blocks = true }; + try long.validate(); + var short = long; + short.rows = 16; + const text = try lane_projection.source(a, long); + defer a.free(text); + const old = try lane_projection.source(a, short); + defer a.free(old); + try t.expect(std.mem.indexOf(u8, old, "#define TF_ROW_BLOCKS 0\n") != null); + try t.expect(std.mem.indexOf(u8, text, "#define TF_ROW_BLOCKS 1\n") != null); + for ([_]usize{ 0, 15, 17, 2064 }) |rows| { + short.rows = rows; + try t.expectError(error.InvalidSchedule, short.validate()); + } + } +} + +test "qwen27 quant: each non-reg whole prompt is one projection dispatch" { + for (wide_formats) |format| for ([_]u32{ 17, 1065, 2048 }) |rows| { + var r: mtl.Record = .{}; + var k = quant.Kernels{ .allocator = a, .device = r.device(), .long_prompt = true, .prompt = true }; + defer k.deinit(); + var p = matrix(&r, 64); + p.bits = format.bits; + p.group = @intCast(format.group); + const ref = quant.Ref{ .buffer = r.virtual() }; + const producer: ?quant.Sums = if (format.bits == 8 and format.group == 64) .{ .ref = ref, .stride = 2080 } else null; + try k.quant(r.encoder(), p, ref, producer, ref, ref, rows); + try t.expectEqual(@as(usize, 1), r.count); + try t.expectEqual(rows, r.launches[0].rows); + try t.expectEqual(@as(usize, (rows + 15) / 16), r.launches[0].grid.height); + try t.expectEqual(@as(i32, if (producer != null) 2080 else @intCast((rows + 15) / 16 * 16)), r.launches[0].stride); + var entries = k.lanes.valueIterator(); + const entry = entries.next().?; + try t.expectEqual(@as(u32, 2048), entry.max_rows); + if (entry.sums_buffer) |b| try t.expectEqual(@as(usize, 2048 * (5120 / format.group) * 4), b.length()); + // A decode or verify row still uses the short key and its original 16-row steps. + k.prompt = false; + r.count = 0; + try k.quant(r.encoder(), p, ref, producer, ref, ref, 17); + try t.expectEqual(@as(usize, 2), r.count); + try t.expectEqual(@as(u32, 16), r.launches[0].rows); + try t.expectEqual(@as(u32, 1), r.launches[1].rows); + try t.expectEqual(@as(usize, 1), r.launches[0].grid.height); + try t.expectEqual(@as(u32, 2), k.lanes.count()); + k.prompt = true; + k.long_prompt = false; + r.count = 0; + try k.quant(r.encoder(), p, ref, producer, ref, ref, 2048); + try t.expectEqual(@as(usize, 128), r.count); + for (r.launches[0..r.count]) |call| { + try t.expectEqual(@as(u32, 16), call.rows); + try t.expectEqual(@as(usize, 1), call.grid.height); + } + }; +} + +test "qwen27 quant: cooperative and older-chip projections keep their 16-row limits" { + var r: mtl.Record = .{}; + var layout = lane_projection.Layout{ .n = 64, .k = 512, .format = .{ .bits = 4, .group = 64 }, .cooperative = true }; + const coop = try lane_projection.Projection.init(a, r.device(), layout); + defer coop.deinit(); + try t.expectEqual(@as(u32, 16), coop.max_rows); + const ref = lane_projection.Ref{ .buf = r.virtual() }; + try coop.encode(r.encoder(), ref, ref, ref, ref, 16); + try t.expectEqual(@as(usize, 1), r.launches[0].grid.height); + try t.expectError(error.InvalidRows, coop.encode(r.encoder(), ref, ref, ref, ref, 17)); + layout.rows = 2048; + try t.expectError(error.InvalidSchedule, layout.validate()); + layout.cooperative = false; + layout.format = .{ .bits = 6, .group = 32 }; + r.tensor_units = false; + const old = try lane_projection.Projection.init(a, r.device(), layout); + defer old.deinit(); + try t.expectEqual(@as(u32, 16), old.max_rows); + try t.expectError(error.InvalidRows, old.encode(r.encoder(), ref, ref, ref, ref, 17)); + const over_budget = lane_projection.Layout{ .n = 2048, .k = 5120, .format = .{ .bits = 8, .group = 128 }, .sk = 8 }; + try t.expectError(error.ThreadgroupMemoryLimit, over_budget.validate()); +} + +test "qwen27 quant: default non-reg callers retain scalar source and 16-row capacity" { + for (wide_formats) |format| { + var r: mtl.Record = .{}; + const layout = lane_projection.Layout{ .n = 64, .k = 5120, .format = format }; + const text = try lane_projection.source(a, layout); + defer a.free(text); + try t.expect(std.mem.indexOf(u8, text, "#define TF_ROW_BLOCKS 0\n") != null); + const p = try lane_projection.Projection.init(a, r.device(), layout); + defer p.deinit(); + try t.expectEqual(@as(u32, 16), p.max_rows); + const ref = lane_projection.Ref{ .buf = r.virtual() }; + try p.encode(r.encoder(), ref, ref, ref, ref, 16); + try t.expectEqual(@as(usize, 1), r.launches[0].grid.height); + try t.expectError(error.InvalidRows, p.encode(r.encoder(), ref, ref, ref, ref, 17)); + } +} + +test "qwen27 quant: non-reg prompt key opts in only above 16 rows" { + for (wide_formats) |format| for ([_]u32{ 16, 17 }) |rows| { + var r: mtl.Record = .{}; + var k = quant.Kernels{ .allocator = a, .device = r.device(), .long_prompt = true, .prompt = true }; + defer k.deinit(); + var p = matrix(&r, 64); + p.bits = format.bits; + p.group = @intCast(format.group); + const ref = quant.Ref{ .buffer = r.virtual() }; + const sums: ?quant.Sums = if (format.bits == 8 and format.group == 64) .{ .ref = ref, .stride = 32 } else null; + try k.quant(r.encoder(), p, ref, sums, ref, ref, rows); + var entries = k.lanes.iterator(); + const entry = entries.next().?; + try t.expectEqual(rows > 16, entry.key_ptr.long); + try t.expectEqual(@as(u32, if (rows > 16) 2048 else 16), entry.value_ptr.max_rows); + try t.expectEqual(@as(usize, 1), r.count); + try t.expectEqual(@as(usize, if (rows > 16) 2 else 1), r.launches[0].grid.height); + }; +} diff --git a/zig/qwen35_live_host.zig b/zig/qwen35_live_host.zig index 164b0c6f1d7..3ecaff87891 100644 --- a/zig/qwen35_live_host.zig +++ b/zig/qwen35_live_host.zig @@ -157,14 +157,15 @@ test "startup warms shared shapes once but measures all live widths independentl try q.native.warm.time(fakeIo(), &probe, &probe, &cache, &seen); var calls: usize = 0; for (probe.calls) |n| calls += n; - try std.testing.expectEqual(@as(usize, 10 + 64 * reps), calls); + // seven distinct buckets (1, 4, 8, 16, 32, 48, 64) warm twice each + try std.testing.expectEqual(@as(usize, 14 + 64 * reps), calls); try std.testing.expectEqual(@as(usize, 2 + reps), probe.calls[1]); try std.testing.expectEqual(@as(usize, 2 + reps), probe.calls[2]); try std.testing.expectEqual(@as(usize, reps), probe.calls[3]); for (probe.costs) |measured| try std.testing.expect(measured); // The injected clock gives every measured window one tick, so none of them can be zero. for (probe.ms) |m| try std.testing.expectEqual(@as(f64, 1.0), m); - try std.testing.expectEqual(@as(usize, 5), seen.len); + try std.testing.expectEqual(@as(usize, 7), seen.len); for (1..65) |rows| try std.testing.expect(seen.contains(Shape.init(rows, 1, true, true, 1))); var again: usize = 0; for (1..129) |segments| { @@ -189,7 +190,8 @@ test "shared warmups count actual passes without replacing any width measurement try q.native.warm.time(fakeIo(), &probe, &probe, &cache, &seen); var calls: usize = 0; for (probe.calls) |n| calls += n; - try std.testing.expectEqual(@as(usize, 9 + 64 * reps), calls); + // the pre-marked bucket 1 shape needs one more pass; the other six buckets two each + try std.testing.expectEqual(@as(usize, 13 + 64 * reps), calls); try std.testing.expectEqual(@as(usize, 1 + reps), probe.calls[1]); try std.testing.expect(!cache.advanced); for (probe.costs) |measured| try std.testing.expect(measured); diff --git a/zig/qwen35_recurrent_host.zig b/zig/qwen35_recurrent_host.zig index f88cd90460d..1ce91b88f03 100644 --- a/zig/qwen35_recurrent_host.zig +++ b/zig/qwen35_recurrent_host.zig @@ -96,7 +96,7 @@ fn finish(e: *q.engine.Engine, c: *q.engine.Cache) void { fn forwardDone(e: *q.engine.Engine, c: *q.engine.Cache, rows: usize) !void { try rec.mark(c, false); if (rows == 1) try rec.mark(c, true); - c.advanced(rows, true); + c.advanced(rows, true, true); _ = Driver.sync(e.stream.handle); rec.completed(e); } @@ -128,10 +128,10 @@ test "Shared verification: all window transitions keep log capacity within the o for (1..65) |old| for (1..65) |rows| { try c.reset(&e); rec.completed(&e); - try rec.prepare(&e, &c, old, true); + try rec.prepare(&e, &c, old, true, true); try forwardDone(&e, &c, old); if (old == 1) try c.keep(&e, &.{}) else try c.keep(&e, &.{0}); - try rec.prepare(&e, &c, rows, true); + try rec.prepare(&e, &c, rows, true, true); try std.testing.expect(c.log.len >= e.g.logBytes(rows)); try std.testing.expect((c.before != null) == (rows == 1)); try std.testing.expect(e.rec_scratch.residentBytes() <= bound); @@ -141,7 +141,7 @@ test "Shared verification: all window transitions keep log capacity within the o try forwardDone(&e, &c, rows); if (rows == 1) { try c.settle(&e); - try rec.prepare(&e, &c, 1, true); + try rec.prepare(&e, &c, 1, true, true); try std.testing.expectEqual(e.g.logBytes(1), c.log.len); } }; @@ -156,7 +156,7 @@ test "Shared verification: a real empty keep retains its snapshot until the queu engine(&e, &d); var c = try q.engine.Cache.init(a, &e, 128); defer finish(&e, &c); - try rec.prepare(&e, &c, 1, true); + try rec.prepare(&e, &c, 1, true, true); try forwardDone(&e, &c, 1); const before = c.before.?.ptr; try c.keep(&e, &.{}); @@ -174,6 +174,41 @@ test "Shared verification: a real empty keep retains its snapshot until the queu try std.testing.expectEqual(@as(usize, 0), e.rec_scratch.residentBytes()); } +test "Shared verification: a served one-row step leases no rollback copy and an empty keep refuses" { + Driver.reset(); + var d: cuda.Driver = undefined; + var e: q.engine.Engine = undefined; + e.kit = null; + engine(&e, &d); + var c = try q.engine.Cache.init(a, &e, 2); + defer finish(&e, &c); + // a fresh cache: no copy leased, and the staged segment reads none + try rec.prepare(&e, &c, 1, true, false); + try std.testing.expect(c.before == null); + try std.testing.expectEqual(@as(u64, 0), q.engine.Engine.stagedSeg(&c, &e, c.len, 0, 1, false).before); + try rec.mark(&c, false); + c.advanced(1, true, false); + _ = Driver.sync(e.stream.handle); + rec.completed(&e); + try std.testing.expectError(error.QwenNoRollbackCopy, c.keep(&e, &.{})); + try std.testing.expectEqual(@as(usize, 1), c.len); + try c.keep(&e, &.{0}); + try std.testing.expectEqual(@as(usize, 1), c.len); + try std.testing.expect(c.last == null); + // an earlier window's lease stays in the cache; the served step still stages no copy of it + try rec.prepare(&e, &c, 1, true, true); + try std.testing.expect(c.before != null); + try rec.prepare(&e, &c, 1, true, false); + try std.testing.expect(c.before != null); + try std.testing.expectEqual(@as(u64, 0), q.engine.Engine.stagedSeg(&c, &e, c.len, 0, 1, false).before); + try rec.mark(&c, false); + c.advanced(1, true, false); + _ = Driver.sync(e.stream.handle); + rec.completed(&e); + try std.testing.expectError(error.QwenNoRollbackCopy, c.keep(&e, &.{})); + try std.testing.expectEqual(@as(usize, 2), c.len); +} + test "Shared verification: allocation refusal retains a queued keep's old log owner" { Driver.reset(); var d: cuda.Driver = undefined; @@ -187,16 +222,16 @@ test "Shared verification: allocation refusal retains a queued keep's old log ow var other_driver: cuda.Driver = undefined; engine(&foreign, &other_driver); defer rec.shutdown(&foreign); - try std.testing.expectError(error.ForeignRecurrentQueue, rec.prepare(&foreign, &c, 8, true)); - try std.testing.expectError(error.InvalidRecurrentWindow, rec.prepare(&e, &c, 65, true)); + try std.testing.expectError(error.ForeignRecurrentQueue, rec.prepare(&foreign, &c, 8, true, true)); + try std.testing.expectError(error.InvalidRecurrentWindow, rec.prepare(&e, &c, 65, true, true)); try std.testing.expectEqual(@as(usize, 0), e.rec_scratch.residentBytes()); - try rec.prepare(&e, &c, 64, true); + try rec.prepare(&e, &c, 64, true, true); try forwardDone(&e, &c, 64); try c.keep(&e, &.{0}); const usage = cuda.memory.usage(false); const prior = try cuda.setAllocationLimit(usage.device + usage.host); defer _ = cuda.setAllocationLimit(prior) catch unreachable; - try std.testing.expectError(error.OutOfDeviceMemory, rec.prepare(&e, &c, 32, true)); + try std.testing.expectError(error.OutOfDeviceMemory, rec.prepare(&e, &c, 32, true, true)); try std.testing.expectEqual(e.g.logBytes(64), e.rec_scratch.residentBytes()); try std.testing.expectEqual(@as(usize, 0), Driver.unsafe_frees); _ = Driver.sync(e.stream.handle); @@ -227,12 +262,12 @@ test "Shared verification: streams cycling window widths reuse their scratch ins for (0..4) |cycle| { for (widths, 0..) |rows, i| { const other = widths[(i + 3) % widths.len]; - try rec.prepare(&e, &c, rows, true); - try rec.prepare(&e, &o, other, true); + try rec.prepare(&e, &c, rows, true, true); + try rec.prepare(&e, &o, other, true, true); try forwardDone(&e, &c, rows); try rec.mark(&o, false); if (other == 1) try rec.mark(&o, true); - o.advanced(other, true); + o.advanced(other, true, true); try std.testing.expect(e.rec_scratch.residentBytes() <= e.rec_scratch.cap); } if (cycle == 0) settled = Driver.allocs; diff --git a/zig/qwen35_shared_host.zig b/zig/qwen35_shared_host.zig index 06a6699086b..0637bd3fcf1 100644 --- a/zig/qwen35_shared_host.zig +++ b/zig/qwen35_shared_host.zig @@ -102,17 +102,17 @@ test "Shared verification: empty multi-row keep preserves the root and completed var cache: q.engine.Cache = .{ .state = b, .kv = b, .log = b, .cap = 256, .len = 7, .logits = &.{} }; Driver.launches = 0; Driver.copies = 0; - cache.advanced(64, true); + cache.advanced(64, true, true); try cache.keep(&e, &.{}); try std.testing.expectEqual(@as(usize, 7), cache.len); try std.testing.expectEqual(@as(usize, 0), Driver.copies); - cache.advanced(1, true); + cache.advanced(1, true, true); try std.testing.expect(cache.last != null); try cache.settle(&e); try std.testing.expectEqual(@as(usize, 8), cache.len); try std.testing.expect(cache.last == null); try std.testing.expectEqual(@as(usize, 0), Driver.launches); - cache.advanced(128, false); + cache.advanced(128, false, true); try std.testing.expect(cache.last == null); try std.testing.expectEqual(@as(usize, 136), cache.len); // the before copy sits inside the log's bytes (one overlaid block), so a stream holds one state beside it diff --git a/zig/runtime_sources/core.zig b/zig/runtime_sources/core.zig index dbc6cb491fd..6472609c1bc 100644 --- a/zig/runtime_sources/core.zig +++ b/zig/runtime_sources/core.zig @@ -46,10 +46,10 @@ fn projections(s: *Set) !void { try s.fragments("core-lane", "prepared", try lane.source(s.a, l)); }; const attention = @import("../src/core/shared_attention.zig"); - for (std.enums.values(attention.Rope)) |rope| for (std.enums.values(attention.Output)) |out| { - const text = try attention.source(s.a, .{ .query_heads = 8, .kv_heads = 2, .head_dim = 128, .rotary_dim = 64, .capacity = 4096, .rope = rope, .output = out }); + for (std.enums.values(attention.Output)) |out| { + const text = try attention.source(s.a, .{ .query_heads = 8, .kv_heads = 2, .head_dim = 128, .capacity = 4096, .output = out }); try s.add("core", "shared-attention", text, .{ .language = mtl.CompileOptions.mlx().language, .math_functions = .precise }); - }; + } const route = @import("../src/core/moe_route.zig"); for ([_]route.Shape{ .{ .hidden = 4096, .experts = 288, .topk = 8 }, .{ .hidden = 4096, .experts = 128, .topk = 8, .out_bf16 = true }, .{ .hidden = 6144, .experts = 128, .topk = 4 } }) |shape| try s.raw("core", "route", try route.source(s.a, shape)); diff --git a/zig/runtime_sources/qwen27.zig b/zig/runtime_sources/qwen27.zig index 83327917957..1cb40bfe3d3 100644 --- a/zig/runtime_sources/qwen27.zig +++ b/zig/runtime_sources/qwen27.zig @@ -3,7 +3,7 @@ const std = @import("std"); const ks = @import("qwen_runtime_sources"); const Set = @import("set.zig").Set; pub fn add(s: *Set) !void { - inline for (.{ "glue", "copy", "moe", "prompt_glue", "prompt_state" }) |name| + inline for (.{ "glue", "copy", "moe" }) |name| try s.raw("qwen27", name, @field(ks, name)); try s.raw("qwen27", "moe-rows", @import("../src/families/qwen27/moe_gpu.zig").rows_source); const quant = @import("../src/families/qwen27/quant_gpu.zig"); diff --git a/zig/src/cli/hub.zig b/zig/src/cli/hub.zig index 373975760eb..c579c75da45 100644 --- a/zig/src/cli/hub.zig +++ b/zig/src/cli/hub.zig @@ -23,6 +23,7 @@ const titles = [_]struct { model_type: []const u8, title: []const u8 }{ .{ .model_type = "spark2_5", .title = "Spark-X2.6-4B" }, .{ .model_type = "minimax_m3_vl", .title = "MiniMax-M3.1-Flash" }, .{ .model_type = "llama", .title = "Llama architecture (Nanbeige4.3-33B)" }, + .{ .model_type = "flint", .title = "Flint" }, .{ .model_type = "lfm2_moe", .title = "LFM2.5-24B-A2B" }, .{ .model_type = "woof", .title = "Underdog Woof" }, }; diff --git a/zig/src/core/contract.zig b/zig/src/core/contract.zig index d6f78eb731f..a942e5ab3ed 100644 --- a/zig/src/core/contract.zig +++ b/zig/src/core/contract.zig @@ -48,6 +48,7 @@ test { _ = @import("contract/moss_image_records.zig"); _ = @import("contract/metal_pair_records.zig"); _ = @import("contract/affine_gather_records.zig"); + _ = @import("contract/flint_records.zig"); _ = spec; _ = gates; _ = rows; diff --git a/zig/src/core/contract/experts_bf16_records.zig b/zig/src/core/contract/experts_bf16_records.zig new file mode 100644 index 00000000000..7b4c4bf7784 --- /dev/null +++ b/zig/src/core/contract/experts_bf16_records.zig @@ -0,0 +1,37 @@ +//! The bf16 experts' CUDA launches (experts_bf16.cu): decode and prompt rows by epilogue, and the repack. +const registry = @import("registry.zig"); + +/// nvcc 13.0's usage, the compiler of the release fatbins. +pub const cuda = [_]registry.CudaRecord{ + .{ .launch = .{ .name = "experts_bf16 tf_bf16_experts_up", .symbol = "tf_bf16_experts_up", .image = "experts_bf16", .threads = 128 }, .usage = .{ .{ .regs = 76, .static = 0, .bound = 128 }, .{ .regs = 84, .static = 0, .bound = 128 }, .{ .regs = 84, .static = 0, .bound = 128 }, .{ .regs = 72, .static = 0, .bound = 128 }, .{ .regs = 72, .static = 0, .bound = 128 } } }, + .{ .launch = .{ .name = "experts_bf16 tf_bf16_experts_down_f32", .symbol = "tf_bf16_experts_down_f32", .image = "experts_bf16", .threads = 128 }, .usage = .{ .{ .regs = 76, .static = 0, .bound = 128 }, .{ .regs = 84, .static = 0, .bound = 128 }, .{ .regs = 84, .static = 0, .bound = 128 }, .{ .regs = 72, .static = 0, .bound = 128 }, .{ .regs = 72, .static = 0, .bound = 128 } } }, + .{ .launch = .{ .name = "experts_bf16 tf_bf16_experts_down_bf16", .symbol = "tf_bf16_experts_down_bf16", .image = "experts_bf16", .threads = 128 }, .usage = .{ .{ .regs = 76, .static = 0, .bound = 128 }, .{ .regs = 84, .static = 0, .bound = 128 }, .{ .regs = 84, .static = 0, .bound = 128 }, .{ .regs = 72, .static = 0, .bound = 128 }, .{ .regs = 72, .static = 0, .bound = 128 } } }, + .{ .launch = .{ .name = "experts_bf16 tf_bf16_experts_prompt_up", .symbol = "tf_bf16_experts_prompt_up", .image = "experts_bf16", .threads = 128 }, .usage = .{ .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 162, .static = 0, .bound = 128 }, .{ .regs = 162, .static = 0, .bound = 128 } } }, + .{ .launch = .{ .name = "experts_bf16 tf_bf16_experts_prompt_down_f32", .symbol = "tf_bf16_experts_prompt_down_f32", .image = "experts_bf16", .threads = 128 }, .usage = .{ .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 162, .static = 0, .bound = 128 }, .{ .regs = 162, .static = 0, .bound = 128 } } }, + .{ .launch = .{ .name = "experts_bf16 tf_bf16_experts_prompt_down_bf16", .symbol = "tf_bf16_experts_prompt_down_bf16", .image = "experts_bf16", .threads = 128 }, .usage = .{ .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 162, .static = 0, .bound = 128 }, .{ .regs = 162, .static = 0, .bound = 128 } } }, + .{ .launch = .{ .name = "experts_bf16 tf_bf16_experts_pack", .symbol = "tf_bf16_experts_pack", .image = "experts_bf16", .threads = 256 }, .usage = .{ .{ .regs = 32, .static = 0, .bound = 0 }, .{ .regs = 40, .static = 0, .bound = 0 }, .{ .regs = 40, .static = 0, .bound = 0 }, .{ .regs = 36, .static = 0, .bound = 0 }, .{ .regs = 36, .static = 0, .bound = 0 } } }, +}; + +/// nvcc 13.2's prompt forms on sm_120 and sm_121 (164 registers where 13.0 takes 162); the rest compile as 13.0's. +pub const cuda_132 = [_]registry.Override{ + .{ .name = "experts_bf16 tf_bf16_experts_prompt_up", .usage = .{ .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 164, .static = 0, .bound = 128 }, .{ .regs = 164, .static = 0, .bound = 128 } } }, + .{ .name = "experts_bf16 tf_bf16_experts_prompt_down_f32", .usage = .{ .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 164, .static = 0, .bound = 128 }, .{ .regs = 164, .static = 0, .bound = 128 } } }, + .{ .name = "experts_bf16 tf_bf16_experts_prompt_down_bf16", .usage = .{ .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 168, .static = 0, .bound = 128 }, .{ .regs = 164, .static = 0, .bound = 128 }, .{ .regs = 164, .static = 0, .bound = 128 } } }, +}; + +/// nvcc 13.3's decode forms on sm_120 and sm_121 (70 registers where 13.0 and 13.2 take 72). +pub const cuda_133 = [_]registry.Override{ + .{ .name = "experts_bf16 tf_bf16_experts_up", .usage = .{ .{ .regs = 76, .static = 0, .bound = 128 }, .{ .regs = 84, .static = 0, .bound = 128 }, .{ .regs = 84, .static = 0, .bound = 128 }, .{ .regs = 70, .static = 0, .bound = 128 }, .{ .regs = 70, .static = 0, .bound = 128 } } }, + .{ .name = "experts_bf16 tf_bf16_experts_down_f32", .usage = .{ .{ .regs = 76, .static = 0, .bound = 128 }, .{ .regs = 84, .static = 0, .bound = 128 }, .{ .regs = 84, .static = 0, .bound = 128 }, .{ .regs = 70, .static = 0, .bound = 128 }, .{ .regs = 70, .static = 0, .bound = 128 } } }, + .{ .name = "experts_bf16 tf_bf16_experts_down_bf16", .usage = .{ .{ .regs = 76, .static = 0, .bound = 128 }, .{ .regs = 84, .static = 0, .bound = 128 }, .{ .regs = 84, .static = 0, .bound = 128 }, .{ .regs = 70, .static = 0, .bound = 128 }, .{ .regs = 70, .static = 0, .bound = 128 } } }, +}; + +test "bf16 experts CUDA: every launch is in the live registry and fits every SM" { + const t = @import("std").testing; + for (cuda) |r| { + var found: usize = 0; + for (registry.all_cuda) |active| found += @intFromBool(@import("std").mem.eql(u8, active.launch.name, r.launch.name)); + try t.expectEqual(@as(usize, 1), found); + try t.expectEqual(@as(usize, 0), try registry.checkCuda(t.allocator, &.{r}, true)); + } +} diff --git a/zig/src/core/contract/flint_records.zig b/zig/src/core/contract/flint_records.zig new file mode 100644 index 00000000000..e636e7a690b --- /dev/null +++ b/zig/src/core/contract/flint_records.zig @@ -0,0 +1,31 @@ +//! Flint's Metal launches (tests/contract/metal_flint*.zig) and core moe_route's router logits at each family's width. +const std = @import("std"); +const M = @import("limits.zig").MetalLaunch; +pub const records = [_]M{ + .{ .name = "flint glue embed fl_embed", .threads = 256, .tg_memory = 0, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue norm fl_norm", .threads = 256, .tg_memory = 128, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue swiglu fl_swiglu", .threads = 256, .tg_memory = 0, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue add fl_add", .threads = 256, .tg_memory = 0, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue moe_add fl_moe_add", .threads = 256, .tg_memory = 0, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue sort fl_sort", .threads = 1024, .tg_memory = 4096, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue rows fl_rows_by_order", .threads = 256, .tg_memory = 0, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue route pick fl_route_pick", .threads = 32, .tg_memory = 0, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue decode partials fl_decode_vec", .threads = 256, .tg_memory = 0, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue decode merge fl_merge_vec", .threads = 1024, .tg_memory = 4096, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue add_norm fl_add_norm", .threads = 256, .tg_memory = 128, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue moe_add_norm fl_moe_add_norm", .threads = 256, .tg_memory = 128, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue prep fl_prep", .threads = 32, .tg_memory = 1024, .m1 = 928, .m2 = 928 }, + .{ .name = "flint glue swiglu_gu fl_swiglu_gu", .threads = 256, .tg_memory = 0, .m1 = 1024, .m2 = 1024 }, + .{ .name = "flint glue argmax fl_argmax", .threads = 1024, .tg_memory = 256, .m1 = 1024, .m2 = 1024 }, + // tensor ops before the M5 too (nax.h's simdgroup fragments still multiply through mpp): no offline build + .{ .name = "flint nax attention sliding fl_prompt_attention", .threads = 64, .tg_memory = 9472, .reserved = 256, .offline = false }, + .{ .name = "flint nax attention full fl_prompt_attention", .threads = 64, .tg_memory = 9472, .reserved = 256, .offline = false }, + .{ .name = "moe_route logits flint tf_route_logits", .threads = 256, .tg_memory = 512, .m1 = 1024, .m2 = 1024 }, + .{ .name = "moe_route logits glm tf_route_logits", .threads = 256, .tg_memory = 512, .m1 = 1024, .m2 = 1024 }, + .{ .name = "moe_route logits glm igate tf_route_logits", .threads = 256, .tg_memory = 512, .m1 = 1024, .m2 = 1024 }, + .{ .name = "moe_route logits step5 tf_route_logits", .threads = 256, .tg_memory = 512, .m1 = 1024, .m2 = 1024 }, +}; + +test "Flint's launches and the routers' fit every Apple family" { + try std.testing.expectEqual(@as(usize, 0), try @import("registry.zig").checkMetal(std.testing.allocator, &records, true)); +} diff --git a/zig/src/core/contract/hip_records_item.zig b/zig/src/core/contract/hip_records_item.zig index e7d154bb843..265209c83c7 100644 --- a/zig/src/core/contract/hip_records_item.zig +++ b/zig/src/core/contract/hip_records_item.zig @@ -18,8 +18,8 @@ pub const records = [1]Record{ .launch = itemTile(4), .usage = .{ .{ .vgprs = 1, .sgprs = 1, .lds = 0, .private = 0, .bound = 256, .wave = 32 }, - .{ .vgprs = 176, .sgprs = 55, .lds = 34304, .private = 0, .bound = 256, .wave = 32 }, - .{ .vgprs = 176, .sgprs = 55, .lds = 34304, .private = 0, .bound = 256, .wave = 32 }, + .{ .vgprs = 190, .sgprs = 68, .lds = 34304, .private = 0, .bound = 256, .wave = 32 }, + .{ .vgprs = 190, .sgprs = 68, .lds = 34304, .private = 0, .bound = 256, .wave = 32 }, .{ .vgprs = 1, .sgprs = 1, .lds = 0, .private = 0, .bound = 256, .wave = 32 }, }, }, diff --git a/zig/src/core/contract/lane_q8_records.zig b/zig/src/core/contract/lane_q8_records.zig index 16bdd8bd2c1..0e734861227 100644 --- a/zig/src/core/contract/lane_q8_records.zig +++ b/zig/src/core/contract/lane_q8_records.zig @@ -1,4 +1,4 @@ -//! M5 byte-lane sources retain their launch envelopes before and after removing code staging. +//! Byte-lane launch envelopes; the standalone sums compiled for M1/M2 even when tensor ops cannot. const M = @import("limits.zig").MetalLaunch; pub const Bad = enum { none, group, stride }; pub const Source = struct { name: []const u8, n: usize, k: usize, sk: usize, group: usize = 64, sums: bool = false, pf: usize = 1, f32_out: bool = false, bad: Bad = .none }; @@ -35,7 +35,24 @@ pub const records = blk: { const groups: usize = @max(@as(usize, s.sk) -| 1, @as(usize, 1)); const threads: usize = if (sum) 256 else 32 * s.sk; const memory: usize = if (sum) 0 else groups * 16 * 32 * 4; - out[i] = .{ .name = s.name, .threads = @intCast(threads), .tg_memory = @intCast(memory), .offline = false }; + out[i] = .{ .name = s.name, .threads = @intCast(threads), .tg_memory = @intCast(memory), .m1 = if (sum) 1024 else 0, .m2 = if (sum) 1024 else 0, .offline = sum }; } break :blk out; }; + +test "byte lane records: standalone sums have measured offline limits; tensor ops stay unmeasured" { + const std = @import("std"); + var count: usize = 0; + for (records) |r| { + const sum = std.mem.endsWith(u8, r.name, "tf_lane_sums"); + try std.testing.expectEqual(sum, r.offline); + try std.testing.expectEqual(@as(u32, if (sum) 1024 else 0), r.m1); + try std.testing.expectEqual(r.m1, r.m2); + if (sum) { + count += 1; + try std.testing.expectEqual(@as(u32, 256), r.threads); + try std.testing.expectEqual(@as(u32, 0), r.tg_memory); + } + } + try std.testing.expectEqual(@as(usize, 4), count); +} diff --git a/zig/src/core/contract/registry_joins.zig b/zig/src/core/contract/registry_joins.zig index e9a2875d153..1e60067bc12 100644 --- a/zig/src/core/contract/registry_joins.zig +++ b/zig/src/core/contract/registry_joins.zig @@ -13,6 +13,7 @@ pub const cuda_after = @import("registry_media.zig").cuda ++ @import("modelopt_lane_records.zig").cuda ++ @import("nvfp4_fact_records.zig").cuda ++ @import("nvfp4_experts32_records.zig").cuda ++ + @import("experts_bf16_records.zig").cuda ++ @import("lane_half_records.zig").cuda ++ @import("pocket_records.zig").cuda ++ @import("minimax_cuda_records.zig").cuda ++ @@ -30,6 +31,7 @@ pub const nvcc_132_after = @import("registry_learning.zig").cuda_132 ++ @import("qwen_served_records.zig").cuda_132 ++ @import("flashnext_tile_records.zig").cuda_132 ++ @import("nvfp4_fact_records.zig").cuda_132 ++ + @import("experts_bf16_records.zig").cuda_132 ++ @import("pocket_records.zig").cuda_132 ++ @import("minimax_cuda_records.zig").cuda_132; pub const nvcc_133_after = @import("flashnext_tile_records.zig").cuda_133 ++ @@ -37,7 +39,8 @@ pub const nvcc_133_after = @import("flashnext_tile_records.zig").cuda_133 ++ @import("nvcc_133_records.zig").cuda_133 ++ @import("minimax_cuda_records.zig").cuda_133 ++ @import("nvfp4_experts32_records.zig").cuda_133 ++ - @import("paging_pending.zig").cuda_133; + @import("paging_pending.zig").cuda_133 ++ + @import("experts_bf16_records.zig").cuda_133; pub const cuda_pending = @import("qwen_whole_records.zig").records ++ @import("exl3_prompt_pending.zig").records ++ @import("cuda_lane_pending.zig").records ++ @@ -76,7 +79,8 @@ pub const all_metal = @import("lane_q8_records.zig").records ++ @import("minimax_records.zig").records ++ @import("ling_route_records.zig").records ++ @import("qwen27_moe_records.zig").records ++ - @import("affine_gather_records.zig").records; + @import("affine_gather_records.zig").records ++ + @import("flint_records.zig").records; pub const landed_cuda = registry.cuda ++ @import("dory_cuda_records.zig").cuda ++ registry.gradient_pending ++ diff --git a/zig/src/core/contract/registry_sets_test.zig b/zig/src/core/contract/registry_sets_test.zig index 743c4b1a8cf..43979dd1ab3 100644 --- a/zig/src/core/contract/registry_sets_test.zig +++ b/zig/src/core/contract/registry_sets_test.zig @@ -54,4 +54,5 @@ test { _ = @import("exl3_grouped_records.zig"); _ = @import("proposal_sampling_records.zig"); _ = @import("pocket_records.zig"); + _ = @import("experts_bf16_records.zig"); } diff --git a/zig/src/core/engine_admission.zig b/zig/src/core/engine_admission.zig new file mode 100644 index 00000000000..46acdebe1ba --- /dev/null +++ b/zig/src/core/engine_admission.zig @@ -0,0 +1,12 @@ +//! How an engine admits a request beside its running streams: a fit check, or a byte budget it promises from. +const kv_ledger = @import("kv_ledger.zig"); + +pub const Admit = struct { ctx: *anyopaque, fits: *const fn (ctx: *anyopaque, prompt: u32, max_tokens: u32) bool }; + +/// The bytes the lane host may promise at once, and what one request's caches cost at most (core/kv_ledger.zig). +pub const Ledger = struct { + ctx: *anyopaque, + total: u64, + cost: *const fn (ctx: *anyopaque, prompt: u32, max_tokens: u32) u64, + standing: kv_ledger.Standing = .{}, // streams paid at load, in `total`: the engine's own sequence (#1943) +}; diff --git a/zig/src/core/engine_api.zig b/zig/src/core/engine_api.zig index 13ba2d9913b..00b7ef014c2 100644 --- a/zig/src/core/engine_api.zig +++ b/zig/src/core/engine_api.zig @@ -116,6 +116,8 @@ pub const FrameSink = vision_types.FrameSink; pub const PreparedVideo = vision_types.PreparedVideo; pub const VideoOffer = vision_types.VideoOffer; pub const VisionOpen = vision_types.VisionOpen; +pub const Size = vision_types.Size; +pub const ocr = @import("engine_ocr.zig"); pub const SpeechRequest = @import("speech_api.zig").SpeechRequest; pub const SpeechResult = @import("speech_api.zig").SpeechResult; @@ -223,6 +225,7 @@ pub const Info = struct { pictures: bool = false, /// Image input (--vision); null: text only. vision: ?Vision = null, + ocr: ?ocr.Ocr = null, // OCR reader for /v1/ocr; null: no OCR route /// The engine decodes greedily only: the server refuses a request with a temperature before admitting it. greedy_only: bool = false, /// The learner reads conversation prompts at any length through taps. @@ -248,15 +251,8 @@ pub const Info = struct { } }; -pub const Admit = struct { ctx: *anyopaque, fits: *const fn (ctx: *anyopaque, prompt: u32, max_tokens: u32) bool }; - -/// The bytes the lane host may promise at once, and what one request's caches cost at most (core/kv_ledger.zig). -pub const Ledger = struct { - ctx: *anyopaque, - total: u64, - cost: *const fn (ctx: *anyopaque, prompt: u32, max_tokens: u32) u64, - standing: kv_ledger.Standing = .{}, // streams paid at load, in `total`: the engine's own sequence (#1943) -}; +pub const Admit = @import("engine_admission.zig").Admit; +pub const Ledger = @import("engine_admission.zig").Ledger; pub const PromptCacheCapacity = @import("engine_cache_plan.zig").PromptCacheCapacity; pub const PromptCachePlan = @import("engine_cache_plan.zig").PromptCachePlan; @@ -380,18 +376,12 @@ pub const Opened = struct { follow: ?*const fn (*anyopaque) anyerror!void = null, }; -pub const Memory = struct { active: u64 = 0, cache: u64 = 0, peak: u64 = 0, pinned: u64 = 0, limit: ?u64 = null }; - /// A backend's words for its own refusals (a request it cannot serve); null: the error's name. pub const Explain = struct { ctx: ?*anyopaque = null, text: *const fn (ctx: ?*anyopaque, err: anyerror) ?[]const u8 }; -/// A backend's own memory counts for LaneHost; read from HTTP threads, so it must not touch the GPU. -pub const MemorySource = struct { - ctx: ?*anyopaque = null, - read: *const fn (ctx: ?*anyopaque, reset_peak: bool) ?Memory, -}; - pub const HealthSource = @import("engine_status.zig").HealthSource; +pub const Memory = @import("engine_status.zig").Memory; +pub const MemorySource = @import("engine_status.zig").MemorySource; pub const Status = @import("engine_status.zig").Status; pub const SubmitError = error{ Closed, Busy, InvalidSpans, Stale, InvalidRequest, MemoryPressure }; // Stale: put-back has not reached the device diff --git a/zig/src/core/engine_api_test.zig b/zig/src/core/engine_api_test.zig index 74a4aadc51d..efadf7dc452 100644 --- a/zig/src/core/engine_api_test.zig +++ b/zig/src/core/engine_api_test.zig @@ -15,4 +15,5 @@ test { _ = @import("keepalive.zig"); _ = @import("originals_test.zig"); _ = @import("image_api_test.zig"); + _ = @import("engine_ocr.zig"); } diff --git a/zig/src/core/engine_ocr.zig b/zig/src/core/engine_ocr.zig new file mode 100644 index 00000000000..a00315b2d50 --- /dev/null +++ b/zig/src/core/engine_ocr.zig @@ -0,0 +1,48 @@ +//! An engine's OCR reader (``Info.ocr``): its prompt and the parse from generated text to Markdown blocks. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Size = @import("engine_vision.zig").Size; + +/// The HTML table subset readers and the server share (ocr_html.zig). +pub const html = @import("ocr_html.zig"); + +/// TL, TR, BR, BL of the text in its upright reading frame, in original-image pixels (rotated text keeps its order). +pub const Quad = [4][2]f32; + +/// What a block holds. +pub const Kind = enum { + text, + title, + section_header, + list, + table, + formula, + code, + caption, + footnote, + page_header, + page_footer, + picture, +}; + +/// One block of a page in reading order. +pub const OcrBlock = struct { + content: []const u8, // normalized Markdown (docs/api.md OCR: Reader Markdown); empty only for .picture + quad: Quad, + kind: Kind, +}; + +/// The generated text does not follow the model's grammar. +pub const Error = error{Unreadable}; + +/// OCR an engine serves: the prompt after the page image, and the reader of the generated text. +pub const Ocr = struct { + /// Text parts that follow the image in the single user turn (may be empty). + prompt: []const u8, + /// Text to blocks in pixels of ``original``; ``frame`` is the tower input size the prepare step used. + parse: *const fn (a: Allocator, text: []const u8, original: Size, frame: Size) anyerror![]OcrBlock, +}; + +test { + _ = @import("ocr_html_test.zig"); +} diff --git a/zig/src/core/engine_status.zig b/zig/src/core/engine_status.zig index 3a024ac4243..0700fd90a23 100644 --- a/zig/src/core/engine_status.zig +++ b/zig/src/core/engine_status.zig @@ -12,6 +12,14 @@ pub const FsrSource = struct { read: *const fn (*anyopaque) ?Status.FsrCache, }; +pub const Memory = struct { active: u64 = 0, cache: u64 = 0, peak: u64 = 0, pinned: u64 = 0, limit: ?u64 = null }; + +/// A backend's own memory counts for LaneHost; read from HTTP threads, so it must not touch the GPU. +pub const MemorySource = struct { + ctx: ?*anyopaque = null, + read: *const fn (ctx: ?*anyopaque, reset_peak: bool) ?Memory, +}; + pub const Status = struct { healthy: bool = true, /// Engine-thread publication; all retained states, including long-message interior cuts and spare storage. diff --git a/zig/src/core/engine_vision.zig b/zig/src/core/engine_vision.zig index a4d4e819da5..285b697bbe0 100644 --- a/zig/src/core/engine_vision.zig +++ b/zig/src/core/engine_vision.zig @@ -13,8 +13,13 @@ pub const PreparedImage = struct { hash: u64, /// The image as tower inputs in order, each image tokens between `before` and `after`; empty: `pixels` alone. pieces: []const Piece = &.{}, + /// Pixel size of the tower input the image was resized to; set by every family with an OCR reader. + frame: ?Size = null, }; +/// An image's pixel size. +pub const Size = struct { width: u32, height: u32 }; + /// One tower input of an image, and the tokens the placeholder expands to around its rows. pub const Piece = struct { pixels: []f32, gh: u32, gw: u32, tokens: u32, before: []const u32 = &.{}, after: []const u32 = &.{} }; diff --git a/zig/src/core/kv_pool_checks.zig b/zig/src/core/kv_pool_checks.zig index fb0678a4a73..a938dcc392d 100644 --- a/zig/src/core/kv_pool_checks.zig +++ b/zig/src/core/kv_pool_checks.zig @@ -1,6 +1,7 @@ //! The page pool's checks beside engine_api: under the paged prompt store and a lane host (kv_pool is its own module). test { _ = @import("kv_pool_store_test.zig"); + _ = @import("kv_pool_parts_test.zig"); _ = @import("kv_pool_host_test.zig"); _ = @import("kv_pool_ledger_test.zig"); } diff --git a/zig/src/core/kv_pool_parts_test.zig b/zig/src/core/kv_pool_parts_test.zig new file mode 100644 index 00000000000..70a567f8997 --- /dev/null +++ b/zig/src/core/kv_pool_parts_test.zig @@ -0,0 +1,411 @@ +//! The paged store's checkpoint parts (C3, #2444) over a real pool: made all or nothing, held once, reclaimed, pinned. +const std = @import("std"); +const kv = @import("kv_pool"); +const radix = @import("engine_api").prompt_radix; +const Pool = kv.Pool; +const Lease = kv.Lease; +const testing = std.testing; +const expect = testing.expect; +const expectEqual = testing.expectEqual; +const expectEqualSlices = testing.expectEqualSlices; + +const page = 4; // tokens a page of the prefix group +const page_bytes = 8; + +/// A family whose device memory is one word a page: a copy writes the live state's word, a restore reads it back. +const Family = struct { + pool: *Pool, + mem: [64]u32 = @splat(0), + live: [2]u32 = .{ 0, 0 }, // each group's live state + restored: [2]?u32 = .{ null, null }, + fences: u32 = 0, + copies: u32 = 0, + rows_copied: u32 = 0, + fail_copy: bool = false, + fail_rows: bool = false, + queued: []const u32 = &.{}, // the pages the last copy wrote (queued work, until a fence) + held_at_fence: bool = true, // every fence saw the queued pages still held: nothing went back before it + hold_on_restore: usize = 0, // pages the restore takes from the pool (its reclaim runs) + + fn of(p: *anyopaque) *Family { + return @ptrCast(@alignCast(p)); + } + fn bytes(_: *anyopaque, _: u32, _: u32) u64 { + return 20; // three pages of 8 bytes + } + fn copy(p: *anyopaque, _: ?*anyopaque, group: u32, _: u32, dst: radix.Parts.Part) anyerror!void { + const f = of(p); + for (dst.ids) |id| f.mem[id] = f.live[group]; + f.queued = dst.ids; + if (f.fail_copy) return error.CopyFailed; // after its work is queued + f.copies += 1; + } + fn restore(p: *anyopaque, _: ?*anyopaque, group: u32, src: radix.Parts.Part) anyerror!void { + const f = of(p); + if (f.hold_on_restore > 0) { + var ids: [8]u32 = undefined; + if (!f.pool.hold(ids[0..f.hold_on_restore])) return error.NoPages; + for (ids[0..f.hold_on_restore]) |id| f.pool.release(id); + } + f.restored[group] = f.mem[src.ids[0]]; + } + fn copyRows(p: *anyopaque, _: ?*anyopaque, _: u32, src: u32, dst: u32, rows: u32) anyerror!void { + const f = of(p); + if (f.fail_rows) return error.CopyFailed; + f.mem[dst] = f.mem[src]; + f.rows_copied += rows; + } + fn fence(p: *anyopaque, _: ?*anyopaque) void { + const f = of(p); + f.fences += 1; + for (f.queued) |id| f.held_at_fence = f.held_at_fence and f.pool.holders(id) > 0; + f.queued = &.{}; + } + fn hooks(f: *Family) radix.PartHooks { + return .{ .ptr = f, .vtable = &.{ .bytes = bytes, .copy = copy, .restore = restore, .copy_rows = copyRows, .fence = fence } }; + } + + fn snapBytes(_: *anyopaque, _: u32) u64 { + return 0; + } + fn save(_: *anyopaque, _: ?*anyopaque, _: u32) anyerror!radix.Saved { + return error.NoLegacy; + } + fn snapRestore(_: *anyopaque, _: ?*anyopaque, _: radix.Saved) anyerror!void {} + fn drop(_: *anyopaque, _: radix.Saved) void {} + fn snapshots(f: *Family) radix.Snapshots { + return .{ .ptr = f, .vtable = &.{ .bytes = snapBytes, .save = save, .restore = snapRestore, .drop = drop } }; + } +}; + +const copied_state = [_]radix.Parts.PartSpec{.{ .kind = .state, .make = .copied }}; +const window3 = [_]radix.Parts.PartSpec{.{ .kind = .window, .make = .retained, .rows = 3 }}; +const both = [_]radix.Parts.PartSpec{ .{ .kind = .window, .make = .retained, .rows = 3 }, .{ .kind = .state, .make = .copied } }; + +const rules: radix.Rules = .{ .page = page, .lookahead = 0, .min_gap = 1, .min_prompt = 0 }; + +const World = struct { + pool: Pool, + f: Family = undefined, + store: radix.Store = undefined, + reclaims: u32 = 0, // the pool asked the store + + fn init(w: *World, specs: []const radix.Parts.PartSpec, pages: usize) !void { + w.pool = try Pool.init(testing.allocator, pages, page_bytes); + w.f = .{ .pool = &w.pool }; + w.store = try radix.Store.initGroups(testing.allocator, w.f.snapshots(), radix.pagesOf(Pool, &w.pool, page_bytes), rules, .{ .parts = specs, .legacy = false }, w.f.hooks()); + w.store.limit(.{ .pages = pages, .snaps = 8 }); + try w.pool.joinReclaim(.{ .ctx = &w.store, .call = reclaim }); + } + fn deinit(w: *World) void { + w.pool.leaveReclaim(&w.store); + w.store.deinit(); + expect(w.pool.idle() and w.pool.available() == w.pool.total()) catch @panic("pages left after close"); + w.pool.deinit(); + } + fn reclaim(ctx: *anyopaque, want: usize) bool { + const s: *radix.Store = @ptrCast(@alignCast(ctx)); + @as(*World, @fieldParentPtr("store", s)).reclaims += 1; + return s.reclaim(want); + } + /// A stream's lease of `n` pages. + fn lease(w: *World, n: usize) !Lease { + var l: Lease = .{}; + try w.pool.promise(&l, n); + try w.pool.cover(&l, n, null); + return l; + } +}; + +const prompt = [_]u32{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 }; + +test "a copied part: held from the pool at keep, restored and fenced at a resume, back at eviction" { + var w: World = .{ .pool = undefined }; + try w.init(&copied_state, 16); + defer w.deinit(); + var l = try w.lease(2); + w.f.live[0] = 77; + var path: [2]u32 = undefined; + const kept = w.store.keepParts(.of(&prompt), 8, null, l.ids.items, &.{.{ .base = 0, .ids = &.{} }}, &path); + try expect(kept.held); + try expectEqual(@as(usize, 5), w.store.tree.heldPages()); // two path pages, three part pages + w.pool.finish(&l); + var adopt: std.ArrayList(u32) = .empty; + defer adopt.deinit(testing.allocator); + const plan = try w.store.begin(testing.allocator, .of(&prompt), 14, &.{}, null, &adopt); + defer testing.allocator.free(plan.marks); + try expectEqual(@as(u32, 8), plan.from); + try expectEqual(@as(?u32, 77), w.f.restored[0]); + try expect(w.f.fences >= 2); // the keep's copies, then the restore's + for (adopt.items) |id| w.pool.release(id); + const before = w.pool.available(); + w.store.limit(.{ .pages = 16, .snaps = 0 }); // the entry goes, its parts with it + try expectEqual(before + 5, w.pool.available()); // the part pages and the path the entry alone kept +} + +test "a retained window with other rows a page: whole pages shared, the partial one copied, mapped at their logical index" { + var w: World = .{ .pool = undefined }; + try w.init(&window3, 32); + defer w.deinit(); + var l = try w.lease(2); // the prefix group + var win = try w.lease(3); // the window: rows 0..8 on 3-row pages, the last one partial (2 rows) + for (win.ids.items, 0..) |id, i| w.f.mem[id] = @intCast(100 + i); + var path: [2]u32 = undefined; + const r = [_]radix.Retained{.{ .base = 1, .ids = win.ids.items[1..3] }}; // its window omits page 0 + try expect(w.store.keepParts(.of(&prompt), 8, null, l.ids.items, &r, &path).held); + try expectEqual(@as(u32, 2), w.f.rows_copied); + try expectEqual(@as(u32, 2), w.pool.holders(win.ids.items[1])); // the stream and the store + try expectEqual(@as(u32, 1), w.pool.holders(win.ids.items[2])); // the stream's partial page: never the store's + const last_page = win.ids.items[2]; + w.f.mem[last_page] = 999; // the stream writes on: the checkpoint does not change + w.pool.finish(&l); + w.pool.finish(&win); + var adopt: std.ArrayList(u32) = .empty; + defer adopt.deinit(testing.allocator); + const plan = try w.store.begin(testing.allocator, .of(&prompt), 14, &.{}, null, &adopt); + defer plan.deinit(testing.allocator); + try expectEqual(@as(usize, 1), plan.parts.len); + try expectEqual(@as(u32, 1), plan.parts[0].base); + try expectEqual(@as(usize, 1), plan.parts[0].ids.len); // page 1: whole + try expectEqual(@as(usize, 1), plan.copies.len); + try expectEqual(@as(u32, 2), plan.copies[0].index); // logical page 2, rows 0..2 + try expectEqual(@as(u32, 2), plan.copies[0].rows); + try expectEqual(@as(u32, 102), w.f.mem[plan.copies[0].src]); // the rows as they were at the keep + for (adopt.items) |id| w.pool.release(id); + for (plan.parts[0].ids) |id| w.pool.release(id); + const fences = w.f.fences; + w.store.finish(null, plan.copies); + try expectEqual(fences + 1, w.f.fences); +} + +test "a part copy that fails publishes nothing: the path stays, the part pages go back after a fence" { + var w: World = .{ .pool = undefined }; + try w.init(&both, 32); + defer w.deinit(); + var l = try w.lease(2); + defer w.pool.finish(&l); + var win = try w.lease(3); + defer w.pool.finish(&win); + const r = [_]radix.Retained{ .{ .base = 0, .ids = win.ids.items }, .{ .base = 0, .ids = &.{} } }; + var path: [2]u32 = undefined; + for ([_]bool{ true, false }) |rows_fail| { + w.f.fail_rows = rows_fail; + w.f.fail_copy = !rows_fail; + const fences = w.f.fences; + const kept = w.store.keepParts(.of(&prompt), 8, null, l.ids.items, &r, &path); + try expect(!kept.held); + try expectEqual(@as(usize, 2), kept.shared); // the path is the store's + try expectEqual(@as(usize, 2), w.store.tree.heldPages()); // and nothing else + try expectEqual(fences + 1, w.f.fences); + try expect(w.f.held_at_fence); // the fence came before the pages went back + try expectEqual(@as(usize, 0), w.store.tree.snaps); + for (win.ids.items) |id| try expectEqual(@as(u32, 1), w.pool.holders(id)); + } + try expectEqual(@as(u64, 2), w.store.tree.counts.failed); +} + +test "two entries whose windows overlap hold each shared window page once" { + var w: World = .{ .pool = undefined }; + try w.init(&window3, 32); + defer w.deinit(); + var l = try w.lease(3); + defer w.pool.finish(&l); + var win = try w.lease(4); // rows 0..12 on 3-row pages + defer w.pool.finish(&win); + var path: [3]u32 = undefined; + try expect(w.store.keepParts(.of(&prompt), 4, null, l.ids.items[0..1], &.{.{ .base = 0, .ids = win.ids.items[0..2] }}, path[0..1]).held); + try expect(w.store.keepParts(.of(&prompt), 12, null, l.ids.items[0..3], &.{.{ .base = 0, .ids = win.ids.items[0..4] }}, path[0..3]).held); + try expectEqual(@as(u32, 2), w.pool.holders(win.ids.items[0])); // named by both entries, held once by the store +} + +test "reclaim frees a copied part while its path and retained part are a stream's; the stream keeps its pages" { + var w: World = .{ .pool = undefined }; + try w.init(&both, 32); + defer w.deinit(); + var l = try w.lease(2); + defer w.pool.finish(&l); + var win = try w.lease(3); + defer w.pool.finish(&win); + var path: [2]u32 = undefined; + const r = [_]radix.Retained{ .{ .base = 0, .ids = win.ids.items[0..3] }, .{ .base = 0, .ids = &.{} } }; + try expect(w.store.keepParts(.of(&prompt), 8, null, l.ids.items, &r, &path).held); + const before = w.pool.available(); + try expect(w.store.reclaim(before + 3)); // the copied part's 3 pages (and the partial copy) are store-only + try expect(w.pool.available() >= before + 3); + try expectEqual(@as(usize, 0), w.store.tree.snaps); + for (l.ids.items) |id| try expect(w.pool.holders(id) >= 1); // the stream's pages stay + for (win.ids.items) |id| try expectEqual(@as(u32, 1), w.pool.holders(id)); +} + +test "a restore that takes pool pages runs reclaim, which never evicts the entry being restored" { + var w: World = .{ .pool = undefined }; + try w.init(&copied_state, 8); + defer w.deinit(); + var l = try w.lease(2); + w.f.live[0] = 5; + var path: [2]u32 = undefined; + try expect(w.store.keepParts(.of(&prompt), 8, null, l.ids.items, &.{.{ .base = 0, .ids = &.{} }}, &path).held); + w.pool.finish(&l); + w.f.hold_on_restore = w.pool.available() + 1; // one more than free: the pool asks the store + var adopt: std.ArrayList(u32) = .empty; + defer adopt.deinit(testing.allocator); + const plan = try w.store.begin(testing.allocator, .of(&prompt), 14, &.{}, null, &adopt); + defer testing.allocator.free(plan.marks); + try expectEqual(@as(u32, 0), plan.from); // the restore could not get its pages: a prefill from the start + try expectEqual(@as(?u32, null), w.f.restored[0]); + try expectEqual(@as(usize, 0), adopt.items.len); +} + +test "parts need hooks, a pool that holds pages, and rows for a retained group" { + var pool = try Pool.init(testing.allocator, 4, page_bytes); + defer pool.deinit(); + var f: Family = .{ .pool = &pool }; + const pages = radix.pagesOf(Pool, &pool, page_bytes); + try testing.expectError(error.PartsNeedHooks, radix.Store.initGroups(testing.allocator, f.snapshots(), pages, rules, .{ .parts = &copied_state, .legacy = false }, null)); + var no_hold = pages; + var vt = pages.vtable.*; + vt.hold = null; + no_hold.vtable = &vt; + try testing.expectError(error.PartsNeedHold, radix.Store.initGroups(testing.allocator, f.snapshots(), no_hold, rules, .{ .parts = &copied_state, .legacy = false }, f.hooks())); + const zero = [_]radix.Parts.PartSpec{.{ .kind = .window, .make = .retained }}; + try testing.expectError(error.RetainedRows, radix.Store.initGroups(testing.allocator, f.snapshots(), pages, rules, .{ .parts = &zero, .legacy = false }, f.hooks())); +} + +test "an allocation failing at each step of a keep with parts: before the path, nothing changes; after it, the path stays alone" { + var k: usize = 0; + while (true) : (k += 1) { + var pool = try Pool.init(testing.allocator, 32, page_bytes); + defer pool.deinit(); + var f: Family = .{ .pool = &pool }; + var fa = std.testing.FailingAllocator.init(testing.allocator, .{}); + var store = try radix.Store.initGroups(fa.allocator(), f.snapshots(), radix.pagesOf(Pool, &pool, page_bytes), rules, .{ .parts = &both, .legacy = false }, f.hooks()); + store.limit(.{ .pages = 32, .snaps = 8 }); + var l: Lease = .{}; + try pool.promise(&l, 2); + try pool.cover(&l, 2, null); + var win: Lease = .{}; + try pool.promise(&win, 3); + try pool.cover(&win, 3, null); + const r = [_]radix.Retained{ .{ .base = 0, .ids = win.ids.items }, .{ .base = 0, .ids = &.{} } }; + var path: [2]u32 = undefined; + fa.fail_index = fa.alloc_index + k; + const kept = store.keepParts(.of(&prompt), 8, null, l.ids.items, &r, &path); + const failed = fa.has_induced_failure; + if (failed) { + try expect(!kept.held); + try expectEqual(@as(usize, 0), store.tree.snaps); + const path_pages: usize = if (kept.shared == 2) 2 else 0; // after the insert the path is the store's + try expectEqual(path_pages, store.tree.heldPages()); + for (win.ids.items) |id| try expectEqual(@as(u32, 1), pool.holders(id)); // no part outlived the failure + } else try expect(kept.held); + pool.finish(&l); + pool.finish(&win); + store.deinit(); + try expect(pool.idle() and pool.available() == pool.total()); + if (!failed) break; + } + try expect(k > 3); // the insert, the parts, the copies and the prompt each failed once +} + +test "a retained group without its pages through the mark is refused, not counted" { + var w: World = .{ .pool = undefined }; + try w.init(&window3, 16); + defer w.deinit(); + var l = try w.lease(2); + defer w.pool.finish(&l); + var path: [2]u32 = undefined; + try expect(!w.store.keepParts(.of(&prompt), 8, null, l.ids.items, &.{.{ .base = 3, .ids = &.{} }}, &path).held); + try expectEqual(@as(usize, 0), w.store.tree.heldPages()); +} + +test "a keep whose parts need more than the free pages reclaims, but never the path it is building" { + var w: World = .{ .pool = undefined }; + try w.init(&copied_state, 6); + defer w.deinit(); + var l = try w.lease(2); + var path: [2]u32 = undefined; + w.f.fail_copy = true; // a failed checkpoint leaves its path alone in the tree + try expect(!w.store.keepParts(.of(&prompt), 8, null, l.ids.items, &.{.{ .base = 0, .ids = &.{} }}, &path).held); + w.pool.finish(&l); // the path is now the store's alone: idle, reclaimable + var other = try w.lease(2); // 6 pages: 2 the path's, 2 this stream's, 2 free; the part needs 3 + defer w.pool.finish(&other); + w.f.fail_copy = false; + const asked = w.reclaims; + const kept = w.store.keepParts(.of(&prompt), 8, null, other.ids.items, &.{.{ .base = 0, .ids = &.{} }}, &path); + try expect(w.reclaims > asked); // the part's hold ran the reclaim chain + try expect(!kept.held); // the only reclaimable pages were the path being built: refused, not pruned + try expectEqual(@as(usize, 2), w.store.tree.heldPages()); // the path is still the tree's + try expectEqual(@as(usize, 2), kept.shared); +} + +test "a plan's retained ids are its own: an eviction between begin and adoption leaves them valid" { + var w: World = .{ .pool = undefined }; + try w.init(&both, 32); + defer w.deinit(); + var l = try w.lease(2); + var win = try w.lease(3); + var path: [2]u32 = undefined; + const r = [_]radix.Retained{ .{ .base = 0, .ids = win.ids.items }, .{ .base = 0, .ids = &.{} } }; + try expect(w.store.keepParts(.of(&prompt), 8, null, l.ids.items, &r, &path).held); + const whole = [_]u32{ win.ids.items[0], win.ids.items[1] }; + w.pool.finish(&l); + w.pool.finish(&win); + var adopt: std.ArrayList(u32) = .empty; + defer adopt.deinit(testing.allocator); + const plan = try w.store.begin(testing.allocator, .of(&prompt), 14, &.{}, null, &adopt); + defer plan.deinit(testing.allocator); + w.store.limit(.{ .pages = 32, .snaps = 0 }); // the entry goes before the stream maps anything + try expectEqualSlices(u32, &whole, plan.parts[0].ids); + for (plan.parts[0].ids) |id| try expectEqual(@as(u32, 1), w.pool.holders(id)); // the stream's holds alone + for (plan.parts[0].ids) |id| w.pool.release(id); + w.store.finish(null, plan.copies); + for (adopt.items) |id| w.pool.release(id); +} + +test "a begin whose hand-out allocation fails gives back the pages it adopted" { + var w: World = .{ .pool = undefined }; + try w.init(&window3, 32); + defer w.deinit(); + var l = try w.lease(2); + var win = try w.lease(3); + var path: [2]u32 = undefined; + try expect(w.store.keepParts(.of(&prompt), 8, null, l.ids.items, &.{.{ .base = 0, .ids = win.ids.items }}, &path).held); + w.pool.finish(&l); + w.pool.finish(&win); + var k: usize = 0; + while (true) : (k += 1) { + var fa = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = k }); + var adopt: std.ArrayList(u32) = .empty; + defer adopt.deinit(testing.allocator); + const census = w.pool.census(); + const plan = w.store.begin(fa.allocator(), .of(&prompt), 14, &.{}, null, &adopt) catch { + try expectEqual(@as(usize, 0), adopt.items.len); + try expectEqual(census, w.pool.census()); // every hold this begin took went back + continue; + }; + for (adopt.items) |id| w.pool.release(id); + for (plan.parts[0].ids) |id| w.pool.release(id); + w.store.finish(null, plan.copies); + plan.deinit(fa.allocator()); + break; + } + try expect(k >= 3); // the marks, the parts, their ids and the copies each failed once +} + +test "a mark whose path and parts pass the page limit is refused when planned, so it never evicts a checkpoint first" { + var w: World = .{ .pool = undefined }; + try w.init(&copied_state, 16); + defer w.deinit(); + w.store.limit(.{ .pages = 5, .snaps = 8 }); + var l = try w.lease(1); + var path: [1]u32 = undefined; + try expect(w.store.keepParts(.of(&prompt), 4, null, l.ids.items, &.{.{ .base = 0, .ids = &.{} }}, &path).held); + w.pool.finish(&l); // one prefix page and three copied: 4 of 5 + const other = [_]u32{ 9, 9, 9, 9, 8, 8, 8, 8, 7, 7, 7, 7, 6, 6 }; + var adopt: std.ArrayList(u32) = .empty; + defer adopt.deinit(testing.allocator); + const plan = try w.store.begin(testing.allocator, .of(&other), 14, &.{}, null, &adopt); + defer plan.deinit(testing.allocator); + try expectEqual(@as(usize, 0), plan.marks.len); // its mark at 12 needs 3 path pages and 3 part pages: 6 of 5 + try expectEqual(@as(usize, 1), w.store.tree.snaps); // the kept checkpoint was not evicted for it +} diff --git a/zig/src/core/kv_pool_store_test.zig b/zig/src/core/kv_pool_store_test.zig index 7260b1836a7..7f3699267dc 100644 --- a/zig/src/core/kv_pool_store_test.zig +++ b/zig/src/core/kv_pool_store_test.zig @@ -79,7 +79,7 @@ const World = struct { fn admit(w: *World, a: Allocator, prompt: []const u32, history: u32, max_new: u32) !void { var adopt: std.ArrayList(u32) = .empty; defer adopt.deinit(w.gpa); - const plan = try w.store.begin(a, prompt, history, &.{}, null, &adopt); + const plan = try w.store.begin(a, .of(prompt), history, &.{}, null, &adopt); var l: Lease = .{}; errdefer w.pool.finish(&l); try w.pool.adopt(&l, adopt.items); @@ -89,7 +89,7 @@ const World = struct { for (plan.marks) |m| { var path: [64]u32 = undefined; const n = m / page; - const kept = w.store.keep(prompt, m, null, l.ids.items[0..n], path[0..n]); + const kept = w.store.keep(.of(prompt), m, null, l.ids.items[0..n], path[0..n]); for (path[0..kept.shared], 0..) |id, i| w.pool.share(&l, i, id); } try w.streams.append(w.gpa, l); @@ -212,3 +212,77 @@ test "page accounting: random admits, keeps, resumes, finishes, cancels and fail } try expect(refused > 0 and failed > 0 and adopted > 0); // the runs met full pools, failed uploads and resumes } + +/// A legacy family whose save holds pool pages for its copy (as the CUDA ring families' blobs do), one more than free. +const HoldingFamily = struct { + pool: *Pool, + fail: bool = false, + live: usize = 0, + + const State = struct { ids: [8]u32, n: usize }; + + fn of(ptr: *anyopaque) *HoldingFamily { + return @ptrCast(@alignCast(ptr)); + } + fn snapshots(f: *HoldingFamily) radix.Snapshots { + return .{ .ptr = f, .vtable = &.{ .bytes = bytes, .save = save, .restore = restore, .drop = drop } }; + } + fn bytes(_: *anyopaque, _: u32) u64 { + return 1; + } + fn save(ptr: *anyopaque, _: ?*anyopaque, _: u32) anyerror!radix.Saved { + const f = of(ptr); + if (f.fail) return error.CopyFailed; + const st = try std.testing.allocator.create(State); + st.n = f.pool.available() + 1; // the pool must reclaim from the store to give them + if (!f.pool.hold(st.ids[0..st.n])) { + std.testing.allocator.destroy(st); + return error.NoCachePages; + } + f.live += 1; + return st; + } + fn restore(_: *anyopaque, _: ?*anyopaque, _: radix.Saved) anyerror!void {} + fn drop(ptr: *anyopaque, saved: radix.Saved) void { + const f = of(ptr); + const st: *State = @ptrCast(@alignCast(saved)); + for (st.ids[0..st.n]) |id| f.pool.release(id); + std.testing.allocator.destroy(st); + f.live -= 1; + } +}; + +fn reclaimStore(ctx: *anyopaque, want: usize) bool { + const s: *radix.Store = @ptrCast(@alignCast(ctx)); + return s.reclaim(want); +} + +test "a legacy save that reclaims pool pages while keeping onto a leaf with no entry never prunes that leaf" { + var pool = try Pool.init(std.testing.allocator, 6, 1); + defer pool.deinit(); + var f: HoldingFamily = .{ .pool = &pool }; + var store = try radix.Store.init(std.testing.allocator, f.snapshots(), radix.pagesOf(Pool, &pool, 1), .{ .page = page, .min_gap = 1, .min_prompt = 0 }); + store.limit(.{ .pages = 6, .snaps = 4 }); + try pool.joinReclaim(.{ .ctx = &store, .call = reclaimStore }); + const prompt = [_]u32{ 1, 2, 3, 4, 5, 6, 7, 8, 9 }; + var path: [2]u32 = undefined; + var first: Lease = .{}; + try pool.promise(&first, 2); + try pool.cover(&first, 2, null); + f.fail = true; // a failed save leaves the path in the tree, a leaf with no entry + try expect(!store.keep(.of(&prompt), 8, null, first.ids.items, &path).held); + pool.finish(&first); // the leaf's pages are the store's alone: the only ones reclaim could free + try expectEqual(@as(usize, 2), store.tree.heldPages()); + var second: Lease = .{}; + try pool.promise(&second, 2); + try pool.cover(&second, 2, null); + f.fail = false; + const kept = store.keep(.of(&prompt), 8, null, second.ids.items, &path); // onto that leaf; its save reclaims + try expect(!kept.held); // the pinned leaf could not be taken, so the save found no page + try expectEqual(@as(usize, 2), store.tree.heldPages()); // the leaf survived the keep that was writing to it + try expectEqual(@as(usize, 0), f.live); + pool.finish(&second); + pool.leaveReclaim(&store); + store.deinit(); + try expect(pool.idle() and pool.available() == pool.total()); +} diff --git a/zig/src/core/kv_prompts.zig b/zig/src/core/kv_prompts.zig index 19518d71b40..9f5f696336d 100644 --- a/zig/src/core/kv_prompts.zig +++ b/zig/src/core/kv_prompts.zig @@ -5,6 +5,19 @@ const kv = @import("kv_pool.zig"); /// Where a pass resumes and the marks it keeps states at (`marks` the store's allocation, freed by `free`). pub const Plan = struct { from: u32 = 0, marks: []const u32 = &.{} }; +/// What a pass asks the store about (prompt_radix.Query as a family on the core pool gives it). +pub const Query = struct { + /// The words the store compares: the request's cache key, one word a model row; for text, the prompt. + key: []const u32, + /// The prompt's length; a key of another length reuses nothing. + prompt_len: u32, + + /// A query of `key` alone, as long as its prompt. + pub fn of(key: []const u32) Query { + return .{ .key = key, .prompt_len = @intCast(key.len) }; + } +}; + /// The store as a family sees it: a prompt pass begins, keeps at its marks, and reports once its prompt is in. pub const Prompts = struct { ctx: *anyopaque, @@ -12,20 +25,20 @@ pub const Prompts = struct { pub const VTable = struct { /// The deepest state `prompt` resumes: its pages into `adopt` (held once more), the rest restored into `owner`. - begin: *const fn (ctx: *anyopaque, prompt: []const u32, history_len: u32, shared: []const u32, owner: *anyopaque, adopt: *std.ArrayList(u32)) anyerror!Plan, + begin: *const fn (ctx: *anyopaque, q: Query, history_len: u32, shared: []const u32, owner: *anyopaque, adopt: *std.ArrayList(u32)) anyerror!Plan, /// At `at` the store takes the pages of `mine` it lacks and the state; `path`'s first n replace the stream's. - keep: *const fn (ctx: *anyopaque, prompt: []const u32, at: u32, owner: *anyopaque, mine: []const u32, path: []u32) usize, + keep: *const fn (ctx: *anyopaque, q: Query, at: u32, owner: *anyopaque, mine: []const u32, path: []u32) usize, /// One log line after a prompt pass: where it resumed and how many states it kept. report: *const fn (ctx: *anyopaque, prompt: usize, from: u32, kept: u64) void, free: *const fn (ctx: *anyopaque, marks: []const u32) void, }; - pub fn begin(p: Prompts, prompt: []const u32, history_len: u32, shared: []const u32, owner: *anyopaque, adopt: *std.ArrayList(u32)) !Plan { - return p.vtable.begin(p.ctx, prompt, history_len, shared, owner, adopt); + pub fn begin(p: Prompts, q: Query, history_len: u32, shared: []const u32, owner: *anyopaque, adopt: *std.ArrayList(u32)) !Plan { + return p.vtable.begin(p.ctx, q, history_len, shared, owner, adopt); } - pub fn keep(p: Prompts, prompt: []const u32, at: u32, owner: *anyopaque, mine: []const u32, path: []u32) usize { - return p.vtable.keep(p.ctx, prompt, at, owner, mine, path); + pub fn keep(p: Prompts, q: Query, at: u32, owner: *anyopaque, mine: []const u32, path: []u32) usize { + return p.vtable.keep(p.ctx, q, at, owner, mine, path); } pub fn report(p: Prompts, prompt: usize, from: u32, kept: u64) void { @@ -37,8 +50,8 @@ pub const Prompts = struct { } /// `begin`, its marks in `gpa` for a lane to own; a failure gives `adopt`'s pages back. - pub fn plan(p: Prompts, gpa: std.mem.Allocator, pool: *kv.Pool, prompt: []const u32, history_len: u32, shared: []const u32, owner: *anyopaque, adopt: *std.ArrayList(u32)) !Plan { - const got = try p.begin(prompt, history_len, shared, owner, adopt); + pub fn plan(p: Prompts, gpa: std.mem.Allocator, pool: *kv.Pool, q: Query, history_len: u32, shared: []const u32, owner: *anyopaque, adopt: *std.ArrayList(u32)) !Plan { + const got = try p.begin(q, history_len, shared, owner, adopt); defer p.free(got.marks); const marks = gpa.dupe(u32, got.marks) catch |e| { for (adopt.items) |id| pool.release(id); @@ -48,13 +61,13 @@ pub const Prompts = struct { } /// At mark `at` the store takes the stream's pages and state; pages it held replace `l`'s, the whole table written. - pub fn keepPages(p: Prompts, gpa: std.mem.Allocator, pool: *kv.Pool, l: *kv.Lease, prompt: []const u32, at: usize, owner: *anyopaque, page_tokens: usize, table: kv.Table) !bool { + pub fn keepPages(p: Prompts, gpa: std.mem.Allocator, pool: *kv.Pool, l: *kv.Lease, q: Query, at: usize, owner: *anyopaque, page_tokens: usize, table: kv.Table) !bool { const n = at / page_tokens; const path = try gpa.alloc(u32, n); defer gpa.free(path); const mine = try gpa.dupe(u32, l.ids.items); defer gpa.free(mine); - const shared = p.keep(prompt, @intCast(at), owner, mine[0..n], path); + const shared = p.keep(q, @intCast(at), owner, mine[0..n], path); if (std.mem.eql(u32, mine[0..shared], path[0..shared])) return shared == n; @memcpy(mine[0..shared], path[0..shared]); // the same tokens' rows: a resume from any row is exact try table.write(table.ctx, mine, 0); @@ -163,10 +176,10 @@ const Stub = struct { fn of(ctx: *anyopaque) *Stub { return @ptrCast(@alignCast(ctx)); } - fn begin(_: *anyopaque, _: []const u32, _: u32, _: []const u32, _: *anyopaque, _: *std.ArrayList(u32)) anyerror!Plan { + fn begin(_: *anyopaque, _: Query, _: u32, _: []const u32, _: *anyopaque, _: *std.ArrayList(u32)) anyerror!Plan { return .{ .from = 256, .marks = try testing.allocator.dupe(u32, &.{512}) }; } - fn keep(ctx: *anyopaque, _: []const u32, _: u32, _: *anyopaque, mine: []const u32, path: []u32) usize { + fn keep(ctx: *anyopaque, _: Query, _: u32, _: *anyopaque, mine: []const u32, path: []u32) usize { @memcpy(path, Stub.of(ctx).held[0..mine.len]); return mine.len; } @@ -203,7 +216,7 @@ test "keeping swaps the store's pages in and writes the whole table: a table of const p: Prompts = .{ .ctx = &stub, .vtable = &Stub.vtable }; var w: Written = .{}; var owner: u8 = 0; - const whole = try p.keepPages(testing.allocator, &pool, &l, &.{ 1, 2, 3 }, 512, &owner, 256, .{ .ctx = &w, .write = Written.write }); + const whole = try p.keepPages(testing.allocator, &pool, &l, .of(&.{ 1, 2, 3 }), 512, &owner, 256, .{ .ctx = &w, .write = Written.write }); try testing.expect(whole); try testing.expectEqual(@as(usize, 0), w.from); try testing.expectEqual(@as(usize, 4), w.cap); @@ -221,7 +234,7 @@ test "a plan's marks are the lane's own; a blob's pages come from the pool and g var adopt: std.ArrayList(u32) = .empty; defer adopt.deinit(testing.allocator); var owner: u8 = 0; - const got = try p.plan(testing.allocator, &pool, &.{1}, 1, &.{}, &owner, &adopt); + const got = try p.plan(testing.allocator, &pool, .of(&.{1}), 1, &.{}, &owner, &adopt); defer testing.allocator.free(got.marks); try testing.expectEqual(@as(u32, 256), got.from); try testing.expectEqualSlices(u32, &.{512}, got.marks); diff --git a/zig/src/core/lane_projection.zig b/zig/src/core/lane_projection.zig index a12f9d52f2b..14e6029d370 100644 --- a/zig/src/core/lane_projection.zig +++ b/zig/src/core/lane_projection.zig @@ -1,4 +1,4 @@ -//! Affine lane projections for 1-16 bf16 rows on Metal. No family or model dependency. +//! Affine lane projections in independent 16-row blocks on Metal. No family or model dependency. const std = @import("std"); const mtl = @import("metal"); const ks = @import("kernel_sources"); @@ -36,8 +36,10 @@ pub const Layout = struct { cooperative: bool = false, /// 4-bit g64 codes in reg_layout, unpacked into tensor-op registers; every word equals the cooperative kernel's. reg: bool = false, - /// Rows one reg encode may take, `reg_rows` to `max_reg_rows`: it sizes the sums scratch, never the kernel source. + /// Row capacity for reg layouts and opted-in row blocks; cooperative and scalar layouts take 16. rows: usize = reg_rows, + /// Non-reg prompt callers opt into independent 16-row blocks on the grid's y. + row_blocks: bool = false, /// Elements between output rows (default N): a member of a fused stack writes its columns of the fused rows. y_stride: ?usize = null, @@ -50,7 +52,9 @@ pub const Layout = struct { if (l.n == 0 or l.n % 16 != 0 or l.k == 0 or l.k % l.format.group != 0 or l.n > std.math.maxInt(i32) / 16 or l.k > std.math.maxInt(i32) / 16) return error.InvalidShape; if (l.sk == 0 or l.sk > 32 or (l.pf != 1 and l.pf != 2)) return error.InvalidSchedule; - if (l.rows != reg_rows and (!l.reg or l.rows < reg_rows or l.rows % 16 != 0 or l.rows > max_reg_rows)) return error.InvalidSchedule; + if (l.cooperative) { + if (l.rows != reg_rows) return error.InvalidSchedule; + } else if (l.rows < @as(usize, if (l.reg) reg_rows else 16) or l.rows % 16 != 0 or l.rows > max_reg_rows) return error.InvalidSchedule; const g = l.groupCut(); if (g[1] == 0 or g[0] >= l.k / l.format.group or g[1] > l.k / l.format.group - g[0]) return error.InvalidGroupCut; if (l.groups != null and l.output != .f32) return error.PartialOutputNeedsFloat; @@ -118,6 +122,7 @@ pub fn source(a: std.mem.Allocator, l: Layout) ![]u8 { } try text.appendSlice(a, if (l.ranges.len == 0) " return j;\n}\n" else " return 0;\n}\n"); try text.print(a, "#define TF_PRECOMPUTE_SUMS {d}\n", .{@intFromBool(l.precompute_sums)}); + try text.print(a, "#define TF_ROW_BLOCKS {d}\n", .{@intFromBool(l.row_blocks and !l.reg and !l.cooperative and l.rows > 16)}); if (l.reg) try text.appendSlice(a, "#define TF_REG 1\n"); if (l.y_stride) |s| try text.print(a, "#define TF_YS {d}\n", .{s}); try text.appendSlice(a, ks.core_lane_projection); @@ -238,13 +243,18 @@ pub const Projection = struct { sums_buffer: ?mtl.Buffer = null, threads_per_slice: usize = 32, reg: bool = false, - /// Rows one encode takes at most: the layout's rows for reg, 16 otherwise. + cooperative: bool = false, + /// Capacity is the reg or opted-in row count on tensor units, otherwise 16. max_rows: u32 = reg_rows, pub fn init(a: std.mem.Allocator, device: mtl.Device, l: Layout) !Projection { try l.validate(); - if (!device.tensorUnits() and (l.format.bits != 6 or l.format.group != 32 or l.cooperative or l.reg or l.precompute_sums)) return error.TensorUnitsRequired; - const text = try source(a, l); + const units = device.tensorUnits(); + if (!units and (l.format.bits != 6 or l.format.group != 32 or l.cooperative or l.reg or l.precompute_sums)) return error.TensorUnitsRequired; + const capacity: usize = if (l.cooperative or !units or (!l.reg and !l.row_blocks)) 16 else l.rows; + var kernel_layout = l; + if (!units and !l.cooperative and !l.reg) kernel_layout.rows = capacity; + const text = try source(a, kernel_layout); defer a.free(text); const adapted = try frags.source(device, a, text); defer a.free(adapted); @@ -258,9 +268,9 @@ pub const Projection = struct { if (l.precompute_sums) { sums_pipe = try mtl.Pipeline.init(device, lib, "tf_lane_sums", false); errdefer sums_pipe.?.deinit(); - sums_buffer = try device.buffer(@as(usize, if (l.reg) l.rows else 16) * (l.k / l.format.group) * 4, mtl.ResourceOptions.shared | mtl.ResourceOptions.untracked); + sums_buffer = try device.buffer(capacity * (l.k / l.format.group) * 4, mtl.ResourceOptions.shared | mtl.ResourceOptions.untracked); } - return .{ .pipe = p, .external_sums = l.precompute_sums and l.format.bits == 8 and l.format.group == 64 and !l.cooperative, .n = l.n, .k = l.k, .group_count = l.k / l.format.group, .weight_bytes = l.weightWords() * 4, .metadata_bytes = l.metadataElements() * 2, .output_bytes = if (l.output == .f32) 4 else 2, .y_stride = l.y_stride orelse l.n, .tile_count = l.tiles(), .sk = l.sk, .sums_pipe = sums_pipe, .sums_buffer = sums_buffer, .threads_per_slice = if (l.cooperative) 64 else 32, .reg = l.reg, .max_rows = @intCast(if (l.reg) l.rows else 16) }; + return .{ .pipe = p, .external_sums = l.precompute_sums and l.format.bits == 8 and l.format.group == 64 and !l.cooperative, .n = l.n, .k = l.k, .group_count = l.k / l.format.group, .weight_bytes = l.weightWords() * 4, .metadata_bytes = l.metadataElements() * 2, .output_bytes = if (l.output == .f32) 4 else 2, .y_stride = l.y_stride orelse l.n, .tile_count = l.tiles(), .sk = l.sk, .sums_pipe = sums_pipe, .sums_buffer = sums_buffer, .threads_per_slice = if (l.cooperative) 64 else 32, .reg = l.reg, .cooperative = l.cooperative, .max_rows = @intCast(capacity) }; } pub fn deinit(p: Projection) void { @@ -306,7 +316,7 @@ pub const Projection = struct { e.setValue([2]i32{ @intCast(rows), @intCast(stride) }, 3); e.setBuffer(y.buf, y.off, 4); if (given) |g| e.setBuffer(g.ref.buf, g.ref.off, 5) else if (p.sums_buffer) |buffer| e.setBuffer(buffer, 0, 5); - e.dispatchGroups(mtl.Size.of(p.tile_count, if (p.reg) blocks else 1, 1), mtl.Size.of(p.threads_per_slice * p.sk, 1, 1)); + e.dispatchGroups(mtl.Size.of(p.tile_count, if (p.cooperative) 1 else blocks, 1), mtl.Size.of(p.threads_per_slice * p.sk, 1, 1)); if (given == null and p.sums_buffer != null) e.barrier(); } }; @@ -341,7 +351,7 @@ test "a reg layout's rows size its sums scratch, never its kernel source" { } var plain = base; plain.reg = false; - plain.rows = 256; + plain.rows = max_reg_rows + 16; try std.testing.expectError(error.InvalidSchedule, plain.validate()); } diff --git a/zig/src/core/lanes/config.zig b/zig/src/core/lanes/config.zig index 3d4470c510f..5dd0a1fe0ee 100644 --- a/zig/src/core/lanes/config.zig +++ b/zig/src/core/lanes/config.zig @@ -46,6 +46,7 @@ pub const Model = struct { granted_depth: bool = false, // with draft_probabilities: every stream drafts its last grant plus a slack draft_slack: u32 = 0, // drafts past a stream's last grant, short or not (0: the Constants' own) draft_streams: bool = false, // the head drafts every stream of a shared round in one batch + plain_streams: u32 = 0, // a shared round with this many streams or more drafts none (0: off) copy_first: bool = false, // a copy ahead takes the round: the head holds no drafts for it (a drafter's own pass) hold_costs: []const @import("hold_price.zig").HoldCost = &.{}, // a drafter's own hold pass, timed at load head_trees: bool = false, // the head drafts trees of lanes (DraftRequest.lanes) and holds them @@ -80,6 +81,7 @@ pub const Config = struct { batch_streams: u32, node_probabilities: bool, granted_depth: bool = false, // Model.granted_depth + plain_streams: u32 = 0, // Model.plain_streams fill_lanes: u32 = 0, // rows a window may grow to with suffix-match branches (0: off; lanes/fill.zig) fill_match: u32 = 8, // context tokens a suffix-match branch must match (shorter ones are coincidental phrases) fill_floor: f64 = 0.35, // the share of grafted rows a stream must keep to go on reading its chain back every round @@ -167,6 +169,7 @@ pub const Config = struct { .batch_streams = if (streams) (if (m.max_streams != 0) m.max_streams else 32) else 1, .node_probabilities = m.draft_probabilities or m.draft_confidence != null, .granted_depth = m.granted_depth and m.draft_probabilities, + .plain_streams = m.plain_streams, .draft_streams = m.draft_streams, .head_trees = m.head_trees, .join_tail = m.join_tail, diff --git a/zig/src/core/lanes/depth.zig b/zig/src/core/lanes/depth.zig index fd65b614991..258d86b170e 100644 --- a/zig/src/core/lanes/depth.zig +++ b/zig/src/core/lanes/depth.zig @@ -4,9 +4,7 @@ const Allocator = std.mem.Allocator; const Table = @import("table.zig").Table; const alloc = @import("allocate.zig"); pub const pricedBatched = alloc.pricedBatched; - pub const Constants = @import("depth_constants.zig").Constants; - pub const HoldCost = @import("hold_price.zig").HoldCost; const holdMs = @import("hold_price.zig").holdMs; @@ -59,8 +57,9 @@ pub const Rule = struct { node_probabilities: bool, granted_depth: bool = false, // node_probabilities: a stream drafts its last grant plus draft_slack, lone ones too confidence_floor: ?f64 = null, + plain_streams: u32 = 0, // a shared round with this many streams or more drafts none (0: off; a family's switch) batch_rows: u32, - batched_heads: bool = false, // one batch drafts every stream of a shared round (its steps: the deepest stream's) + batched_heads: bool = false, // one batch drafts every stream of a shared round (steps: the deepest stream's) row_cost: @import("row_cost.zig").RowCost = .{}, // what a row costs past shared_costs here, under k.learn_rows round_ms: Table = .{}, // a round's wall time by drafts, every stream's: what sampled streams price by overhead: std.ArrayList(Overhead) = .empty, // a shared round's ms past its forward, by streams (insertion order) @@ -298,6 +297,8 @@ pub const Rule = struct { /// Head drafts for the streams of a shared round, sized by allocation over their chain chances. pub fn budgets(r: *Rule, whos: []Who, probe: Probe, sampled: bool, out: []i64) !void { + // drafts lose here: none asked, no head pass + if (r.plain_streams > 0 and whos.len >= r.plain_streams) return @memset(out, 0); if (r.confidence_floor != null) { for (whos, out) |*w, *o| o.* = try r.headDepth(w, null, probe); return; diff --git a/zig/src/core/lanes/drafted_copy_test.zig b/zig/src/core/lanes/drafted_copy_test.zig index 9f4833503d5..46a4b64408c 100644 --- a/zig/src/core/lanes/drafted_copy_test.zig +++ b/zig/src/core/lanes/drafted_copy_test.zig @@ -47,6 +47,7 @@ const Watch = struct { var streams: [4]?*sm.Stream = @splat(null); var wasted: u32 = 0; var copies: u32 = 0; + var wide: u32 = 0; // copy windows verified in rounds of three streams fn slot(s: *sm.Stream) usize { for (&streams, 0..) |*x, i| if (x.* == null or x.* == s) { @@ -65,19 +66,21 @@ const Watch = struct { fn verify(ptr: *anyopaque, windows: []const be.Window, out: []be.Verified) anyerror!void { for (windows) |w| if (w.held == 0 and w.tokens.len > 0) { copies += 1; + wide += @intFromBool(windows.len >= 3); if (at[slot(w.stream)] == w.positions[0]) wasted += 1; }; return inner_verify(ptr, windows, out); } }; -const Run = struct { tokens: [3][64]u32 = undefined, lens: [3]usize = @splat(0), wasted: u32 = 0, copies: u32 = 0 }; +const Run = struct { tokens: [3][64]u32 = undefined, lens: [3]usize = @splat(0), wasted: u32 = 0, copies: u32 = 0, wide: u32 = 0 }; -fn run(n: usize, drafts: bool, copy_first: bool) !Run { +fn run(n: usize, drafts: bool, copy_first: bool, plain_streams: u32) !Run { Watch.at = @splat(null); Watch.streams = @splat(null); Watch.wasted = 0; Watch.copies = 0; + Watch.wide = 0; var target: fake.Fake = .{ .gpa = gpa }; defer target.deinit(); var head: dt.FakeDrafter = .{ .facts_ = .{ .depth = 4, .step_ms = 0.5, .batched = true, .copy_first = copy_first } }; @@ -95,7 +98,7 @@ fn run(n: usize, drafts: bool, copy_first: bool) !Run { Watch.inner_verify = verified.verify; verified.verify = Watch.verify; seen.vtable = &verified; - const m: @import("config.zig").Model = .{ .exact_width = 16, .gpu_tokens = true, .hidden_rows = true, .batch_rows = 32, .max_streams = 4 }; + const m: @import("config.zig").Model = .{ .exact_width = 16, .gpu_tokens = true, .hidden_rows = true, .batch_rows = 32, .max_streams = 4, .plain_streams = plain_streams }; var cfg = try Config.init(gpa, if (drafts) wrapped.facts(m) else m, 16, 15); defer cfg.deinit(gpa); var clock: fake.FixedClock = .{}; @@ -107,7 +110,7 @@ fn run(n: usize, drafts: bool, copy_first: bool) !Run { defer for (streams[0..n]) |*s| s.deinit(gpa); for (streams[0..n]) |*s| try engine.addStream(s); while (engine.activeCount() > 0) try engine.step(); - var out: Run = .{ .wasted = Watch.wasted, .copies = Watch.copies }; + var out: Run = .{ .wasted = Watch.wasted, .copies = Watch.copies, .wide = Watch.wide }; for (streams[0..n], 0..) |*s, i| { out.lens[i] = s.emitted().len; @memcpy(out.tokens[i][0..out.lens[i]], s.emitted()); @@ -117,9 +120,9 @@ fn run(n: usize, drafts: bool, copy_first: bool) !Run { test "a copy ahead takes the round: with copy_first no hold is asked for it, and replies equal one-token decode" { for ([_]usize{ 1, 3 }) |n| { - const plain = try run(n, false, false); - const before = try run(n, true, false); - const after = try run(n, true, true); + const plain = try run(n, false, false, 0); + const before = try run(n, true, false, 0); + const after = try run(n, true, true, 0); for (0..n) |i| { try std.testing.expectEqualSlices(u32, plain.tokens[i][0..plain.lens[i]], before.tokens[i][0..before.lens[i]]); try std.testing.expectEqualSlices(u32, plain.tokens[i][0..plain.lens[i]], after.tokens[i][0..after.lens[i]]); @@ -128,3 +131,47 @@ test "a copy ahead takes the round: with copy_first no hold is asked for it, and try std.testing.expectEqual(@as(u32, 0), after.wasted); } } + +test "plain_streams: rounds that wide verify no copy, narrower ones still copy; replies equal one-token decode" { + const plain = try run(3, false, false, 0); + const wide = try run(3, true, false, 3); + const narrow = try run(3, true, false, 4); + for (0..3) |i| { + try std.testing.expectEqualSlices(u32, plain.tokens[i][0..plain.lens[i]], wide.tokens[i][0..wide.lens[i]]); + try std.testing.expectEqualSlices(u32, plain.tokens[i][0..plain.lens[i]], narrow.tokens[i][0..narrow.lens[i]]); + } + try std.testing.expectEqual(@as(u32, 0), wide.wide); + try std.testing.expect(narrow.wide > 0); +} + +/// A row policy that keeps every window as planned (a family's trim in place of the built-in allocation). +const KeepAll = struct { + const trim = @import("trim.zig"); + fn choose(_: *anyopaque, _: []const trim.Window, _: bool, _: std.mem.Allocator, _: []trim.Choice) anyerror!void {} + fn commit(_: *anyopaque, _: trim.Commit) anyerror!void {} + const vtable: trim.Trim.VTable = .{ .choose = choose, .commit = commit }; +}; + +test "plain_streams: a family's row policy keeps copies below the switch, and none from it on" { + const win = @import("windows.zig"); + const copied = [_]u32{ 5, 6, 7, 8, 9, 10, 11 }; + var cfg = try Config.init(gpa, .{ .exact_width = 16, .hidden_rows = true, .batch_rows = 32, .plain_streams = 3 }, 16, 15); + defer cfg.deinit(gpa); + var target: fake.Fake = .{ .gpa = gpa }; + defer target.deinit(); + var clock: fake.FixedClock = .{}; + var e = Engine.init(gpa, &cfg, target.backend(), clock.clock()); + defer e.deinit(); + var keep: u8 = 0; + e.trim = .{ .ptr = &keep, .vtable = &KeepAll.vtable }; + var streams: [3]sm.Stream = undefined; + for (&streams) |*s| s.* = try sm.Stream.init(gpa, .{ .id = "copied", .prompt = &.{ 1, 2 }, .max_new = 64 }); + defer for (&streams) |*s| s.deinit(gpa); + for ([_]usize{ 2, 3 }) |n| { + _ = e.arena.reset(.retain_capacity); + var plans: [3]win.Plan = undefined; + for (plans[0..n], streams[0..n]) |*p, *s| p.* = .{ .stream = s, .position = 2, .kind = .copy, .tokens = &copied }; + try win.allocate(&e, plans[0..n]); + for (plans[0..n]) |p| try std.testing.expectEqual(@as(usize, if (n < 3) copied.len else 0), p.count()); + } +} diff --git a/zig/src/core/lanes/engine.zig b/zig/src/core/lanes/engine.zig index 018ae4a72eb..ced3f315aeb 100644 --- a/zig/src/core/lanes/engine.zig +++ b/zig/src/core/lanes/engine.zig @@ -53,7 +53,6 @@ pub const Engine = struct { grammar_workers: @import("grammar_parallel.zig").State = .{}, arena: std.heap.ArenaAllocator, // a round's temporaries unread: std.ArrayList(Unread) = .empty, // queued draws the log reads at the step's end - pub fn init(gpa: Allocator, cfg: *const Config, backend: be.Backend, clock: be.Clock) Engine { return .{ .gpa = gpa, @@ -74,6 +73,7 @@ pub const Engine = struct { .node_probabilities = cfg.node_probabilities, .granted_depth = cfg.granted_depth, .confidence_floor = cfg.draft_confidence, + .plain_streams = cfg.plain_streams, .batch_rows = cfg.batch_rows, .batched_heads = cfg.draft_streams, }, diff --git a/zig/src/core/lanes/windows.zig b/zig/src/core/lanes/windows.zig index b3758b7c384..0d93234bfee 100644 --- a/zig/src/core/lanes/windows.zig +++ b/zig/src/core/lanes/windows.zig @@ -233,6 +233,14 @@ pub fn who(s: *Stream) depth.Who { return .{ .state = &s.depth, .draft_room = s.draftRoom(), .finished = s.finished, .forced = s.force.items.len > 0, .granted = s.granted, .grant_short = s.grant_short, .gain = s.grant_gain, .stream = s, .sampled = s.sampling != null }; } +/// cfg.plain_streams: a shared round this wide drafts none, its copies included (each window its pending row). +fn plainCopies(e: *const Engine, plans: []Plan) void { + if (e.cfg.plain_streams == 0 or plans.len < e.cfg.plain_streams) return; + for (plans) |*p| if (p.kind == .copy) { + p.* = .{ .stream = p.stream, .position = p.position, .kind = .none }; + }; +} + /// Whether any of a round's streams draws keyed samples (its rows carry their host work). pub fn anySampled(plans: []const Plan) bool { for (plans) |p| if (p.stream.sampling != null) return true; @@ -241,6 +249,7 @@ pub fn anySampled(plans: []const Plan) bool { /// Trim draft prefixes by landing probability to the shared width with the most expected tokens a ms. pub fn allocate(e: *Engine, plans: []Plan) !void { + plainCopies(e, plans); if (e.trim) |t| return trimmed(e, plans, t); const a = e.arena.allocator(); const fixed = try a.alloc(u32, plans.len); diff --git a/zig/src/core/load_budget.zig b/zig/src/core/load_budget.zig new file mode 100644 index 00000000000..5198c678b6f --- /dev/null +++ b/zig/src/core/load_budget.zig @@ -0,0 +1,59 @@ +//! What a Mac family's load may hold: weights, scratch and every stream's caches under a limit below the panic line. +const std = @import("std"); + +/// The default share of RAM: past about 80% wired, macOS stalls until its watchdog restarts the Mac. +pub const default_share = 0.75; +/// Kept free inside the limit for the command buffers, the keepalive and the host. +pub const margin: u64 = 2 << 30; + +/// The limit: `ram` times the share, or `text`'s GB capped at the GPU's working set; more GB than `ram` is refused. +pub fn limit(ram: u64, working_set: u64, text: ?[]const u8) error{BadLoadLimit}!u64 { + const t = std.mem.trim(u8, text orelse "", " \t"); + if (t.len == 0) return @intFromFloat(@as(f64, @floatFromInt(ram)) * default_share); + const gb = std.fmt.parseFloat(f64, t) catch return error.BadLoadLimit; + // compared as floats, so a value past u64's range never reaches @intFromFloat + if (!std.math.isFinite(gb) or gb <= 0 or gb * 1e9 > @as(f64, @floatFromInt(ram))) return error.BadLoadLimit; + return @min(@as(u64, @intFromFloat(gb * 1e9)), @min(ram, working_set)); +} + +/// Bytes for caches: the limit less what stays resident and the margin. +pub fn caches(ram: u64, working_set: u64, text: ?[]const u8, resident: u64) error{BadLoadLimit}!u64 { + const most = try limit(ram, working_set, text); + return if (most > resident + margin) most - resident - margin else 0; +} + +/// The longest context one stream's caches (`cacheBytes(ctx, n)` for `n` tokens) fit in `bytes`, at most `most`. +pub fn longest(ctx: anytype, comptime cacheBytes: fn (@TypeOf(ctx), usize) usize, bytes: u64, most: usize) usize { + if (cacheBytes(ctx, 1) > bytes) return 0; + var lo: usize = 1; + var hi: usize = most; + while (lo < hi) { + const mid = lo + (hi - lo + 1) / 2; + if (cacheBytes(ctx, mid) <= bytes) lo = mid else hi = mid - 1; + } + return lo; +} + +test "load budget: a share of RAM less the weights, a set limit capped at the working set, bad or huge text refused" { + const gib: u64 = 1 << 30; + try std.testing.expectEqual(@as(u64, 192 * gib - 24 * gib - margin), try caches(256 * gib, 240 * gib, null, 24 * gib)); + try std.testing.expectEqual(@as(u64, 0), try caches(256 * gib, 240 * gib, null, 191 * gib)); + try std.testing.expectEqual(@as(u64, 64 * gib - 24 * gib - margin), try caches(256 * gib, 240 * gib, "68.719476736", 24 * gib)); + try std.testing.expectEqual(@as(u64, 240 * gib - 24 * gib - margin), try caches(256 * gib, 240 * gib, "270", 24 * gib)); + try std.testing.expectError(error.BadLoadLimit, caches(256 * gib, 240 * gib, "lots", 24 * gib)); + try std.testing.expectError(error.BadLoadLimit, caches(256 * gib, 240 * gib, "900", 24 * gib)); + try std.testing.expectError(error.BadLoadLimit, caches(256 * gib, 240 * gib, "1e30", 24 * gib)); + try std.testing.expectError(error.BadLoadLimit, caches(256 * gib, 240 * gib, "inf", 24 * gib)); + try std.testing.expectError(error.BadLoadLimit, caches(256 * gib, 240 * gib, "-1", 24 * gib)); +} + +test "load budget: the longest context is the last whose caches fit" { + const per = struct { + fn bytes(row: u64, n: usize) usize { + return @intCast(row * n); + } + }.bytes; + try std.testing.expectEqual(@as(usize, 4096), longest(@as(u64, 1024), per, 4096 * 1024, 1 << 20)); + try std.testing.expectEqual(@as(usize, 512), longest(@as(u64, 1024), per, 4096 * 1024, 512)); + try std.testing.expectEqual(@as(usize, 0), longest(@as(u64, 1024), per, 1000, 512)); +} diff --git a/zig/src/core/moe_route.zig b/zig/src/core/moe_route.zig index a4d596883fb..de5e94a5571 100644 --- a/zig/src/core/moe_route.zig +++ b/zig/src/core/moe_route.zig @@ -3,13 +3,14 @@ const std = @import("std"); const mtl = @import("metal"); const ks = @import("kernel_sources"); -/// The router's shape: hidden width, experts (a multiple of 4, at most 512), picks a row, rows a launch at most. -pub const Shape = struct { hidden: u32, experts: u32, topk: u32, max_rows: u32 = 16, out_bf16: bool = false }; +/// The router's shape: hidden (1024s), experts (4s, to 512), picks, rows a launch; round_logits: MLX's bf16 logits. +pub const Shape = struct { hidden: u32, experts: u32, topk: u32, max_rows: u32 = 16, out_bf16: bool = false, round_logits: bool = false }; /// The kernel source with `shape`'s constants. pub fn source(a: std.mem.Allocator, s: Shape) ![]u8 { - if (s.experts % 4 != 0 or s.experts > 512 or s.hidden % 64 != 0 or s.max_rows > 16 or s.topk * s.max_rows > 512) return error.UnsupportedRouterShape; - return std.fmt.allocPrint(a, "#define TF_K {d}\n#define TF_E {d}\n#define TF_TOPK {d}\n#define TF_MAXR {d}\n{s}{s}", .{ s.hidden, s.experts, s.topk, s.max_rows, if (s.out_bf16) "#define TF_OUT_T bfloat\n" else "", ks.core_moe_route }); + // each lane sums hidden / 1024 slices of 32: the hidden width is a multiple of 1024 + if (s.experts % 4 != 0 or s.experts > 512 or s.hidden == 0 or s.hidden % 1024 != 0 or s.max_rows > 16 or s.topk * s.max_rows > 512) return error.UnsupportedRouterShape; + return std.fmt.allocPrint(a, "#define TF_K {d}\n#define TF_E {d}\n#define TF_TOPK {d}\n#define TF_MAXR {d}\n{s}{s}{s}", .{ s.hidden, s.experts, s.topk, s.max_rows, if (s.out_bf16) "#define TF_OUT_T bfloat\n" else "", if (s.round_logits) "#define TF_ROUND_LOGITS 1\n" else "", ks.core_moe_route }); } pub const names = [2][:0]const u8{ "tf_route_logits", "tf_route_select" }; @@ -60,6 +61,8 @@ test "the router's shape is checked and the pack is a permutation" { const src = try source(arena.allocator(), .{ .hidden = 4096, .experts = 288, .topk = 8 }); try std.testing.expect(std.mem.startsWith(u8, src, "#define TF_K 4096\n#define TF_E 288\n#define TF_TOPK 8\n#define TF_MAXR 16\n")); try std.testing.expectError(error.UnsupportedRouterShape, source(arena.allocator(), .{ .hidden = 4096, .experts = 290, .topk = 8 })); + // a hidden width off the 1024s (1088 passed the old 32-wide check) or empty + for ([_]u32{ 1088, 0 }) |hidden| try std.testing.expectError(error.UnsupportedRouterShape, source(arena.allocator(), .{ .hidden = hidden, .experts = 288, .topk = 8 })); const E = 8; const K = 64; var in: [E * K]u16 = undefined; diff --git a/zig/src/core/ocr_html.zig b/zig/src/core/ocr_html.zig new file mode 100644 index 00000000000..39dab703d1a --- /dev/null +++ b/zig/src/core/ocr_html.zig @@ -0,0 +1,413 @@ +//! The HTML table subset OCR models write: cells and spans, and the table as GFM pipes, clean HTML or text rows. +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// Most HTML bytes, grid rows and grid columns ``parse`` reads; a table past them is ``error.Malformed``. +pub const max_bytes = 64 * 1024; +pub const max_rows = 512; +pub const max_cols = 64; + +/// One cell at its top-left grid position, covering ``rows`` x ``cols``; ``text`` holds '\n' where the HTML had
. +pub const Cell = struct { text: []const u8, row: u32, col: u32, rows: u32, cols: u32, head: bool }; + +/// A table's grid and its cells in row-major order; ``spanned``: some cell covers more than one position. +pub const Table = struct { + rows: u32, + cols: u32, + cells: []Cell, + spanned: bool, + + /// Frees what ``parse`` allocated in ``a``. + pub fn deinit(t: Table, a: Allocator) void { + for (t.cells) |c| a.free(c.text); + a.free(t.cells); + } +}; + +pub const ParseError = error{ Malformed, OutOfMemory }; + +/// The one
in ``html``, with only whitespace and comments around it; cells and texts allocated in ``a``. +pub fn parse(a: Allocator, html: []const u8) ParseError!Table { + if (html.len > max_bytes) return error.Malformed; + var p: Parser = .{ .a = a, .src = html }; + defer p.text.deinit(a); + errdefer { + for (p.cells.items) |c| a.free(c.text); + p.cells.deinit(a); + } + try p.run(); + var cols: u32 = 0; + var spanned = false; + for (p.cells.items) |*c| { + c.rows = @min(c.rows, p.rows - c.row); // a rowspan past the last row ends there, as in HTML + cols = @max(cols, c.col + c.cols); + spanned = spanned or c.rows > 1 or c.cols > 1; + } + if (cols == 0) return error.Malformed; + return .{ .rows = p.rows, .cols = cols, .cells = try p.cells.toOwnedSlice(a), .spanned = spanned }; +} + +/// The table as a GFM pipe table: first row the header, cell text escaped to read literally (``cellText``); spans lost. +pub fn pipe(a: Allocator, t: Table) Allocator.Error![]const u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(a); + var k: usize = 0; + for (0..t.rows) |r| { + for (0..t.cols) |c| { + try out.appendSlice(a, if (c == 0) "| " else " | "); + try cellText(a, &out, if (topLeft(t, &k, r, c)) |cell| cell.text else ""); + } + try out.appendSlice(a, " |"); + if (r == 0) { + try out.append(a, '\n'); + for (0..t.cols) |_| try out.appendSlice(a, "| --- "); + try out.append(a, '|'); + } + if (r + 1 < t.rows) try out.append(a, '\n'); + } + return out.toOwnedSlice(a); +} + +/// The table as one clean
: rows of
/, rowspan and colspan only above 1, text entity-escaped. +pub fn normalized(a: Allocator, t: Table) Allocator.Error![]const u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(a); + try out.appendSlice(a, ""); + var k: usize = 0; + for (0..t.rows) |r| { + try out.appendSlice(a, ""); + while (k < t.cells.len and t.cells[k].row == r) : (k += 1) { + const cell = t.cells[k]; + const name = if (cell.head) "th" else "td"; + try out.print(a, "<{s}", .{name}); + if (cell.rows > 1) try out.print(a, " rowspan=\"{d}\"", .{cell.rows}); + if (cell.cols > 1) try out.print(a, " colspan=\"{d}\"", .{cell.cols}); + try out.append(a, '>'); + for (cell.text) |ch| switch (ch) { + '&' => try out.appendSlice(a, "&"), + '<' => try out.appendSlice(a, "<"), + '>' => try out.appendSlice(a, ">"), + '"' => try out.appendSlice(a, """), + '\n' => try out.appendSlice(a, "
"), + else => try out.append(a, ch), + }; + try out.print(a, "", .{name}); + } + try out.appendSlice(a, ""); + } + try out.appendSlice(a, "
"); + return out.toOwnedSlice(a); +} + +/// A cell's text in a pipe row: math as written (only `|` escaped), markup escaped elsewhere, to read back literally. +fn cellText(a: Allocator, out: *std.ArrayList(u8), text: []const u8) Allocator.Error!void { + var no_close = false; // a `$` found no closer, so no later `$` in the cell can (markdown.zig's rule) + var i: usize = 0; + while (i < text.len) { + const ch = text[i]; + if (ch == '$') { + if (mathEnd(text, i, &no_close)) |end| { + try mathText(a, out, text[i..end]); + i = end; + continue; + } + // a `$` that opens no math is escaped, so a reader never pairs it with another + const lone: usize = if (i + 1 < text.len and text[i + 1] == '$') 2 else 1; + for (0..lone) |_| try out.appendSlice(a, "\\$"); + i += lone; + continue; + } + switch (ch) { + '\n' => try out.append(a, ' '), + '|', '`', '*', '[', ']' => try out.appendSlice(a, &[_]u8{ '\\', ch }), + // CommonMark reads a backslash as an escape only before ASCII punctuation; elsewhere it is literal + '\\' => try out.appendSlice(a, if (i + 1 < text.len and isPunct(text[i + 1])) "\\\\" else "\\"), + // `_` between two word characters can neither open nor close emphasis, so snake_case stays readable + '_' => try out.appendSlice(a, if (i > 0 and i + 1 < text.len and isWord(text[i - 1]) and isWord(text[i + 1])) "_" else "\\_"), + '<' => try out.appendSlice(a, if (opensTag(text[i + 1 ..])) "<" else "<"), + '&' => try out.appendSlice(a, if (opensEntity(text[i + 1 ..])) "&" else "&"), + else => try out.append(a, ch), + } + i += 1; + } +} + +/// Math as written, each `|` as `\|`: GFM's table step removes exactly that backslash, whatever precedes it. +fn mathText(a: Allocator, out: *std.ArrayList(u8), m: []const u8) Allocator.Error!void { + for (m) |ch| switch (ch) { + '|' => try out.appendSlice(a, "\\|"), + '\n' => try out.append(a, ' '), + else => try out.append(a, ch), + }; +} + +/// The end of the `$$…$$` or `$…$` math opening at ``s[i]``, by markdown.zig's ``mathSpan`` rule; null when none. +fn mathEnd(s: []const u8, i: usize, no_close: *bool) ?usize { + if (i + 1 < s.len and s[i + 1] == '$') return if (std.mem.indexOfPos(u8, s, i + 2, "$$")) |j| j + 2 else null; + if (i + 1 >= s.len or isSpace(s[i + 1]) or no_close.*) return null; + var j = i + 2; + while (j < s.len) : (j += 1) { + if (s[j] != '$' or isSpace(s[j - 1]) or escaped(s, j)) continue; + if (j + 1 < s.len and std.ascii.isDigit(s[j + 1])) continue; + return j + 1; + } + no_close.* = true; + return null; +} + +/// Whether ``s[i]`` follows an odd run of backslashes. +fn escaped(s: []const u8, i: usize) bool { + var n: usize = 0; + while (n < i and s[i - 1 - n] == '\\') n += 1; + return n % 2 == 1; +} + +fn isSpace(c: u8) bool { + return c == ' ' or c == '\t' or c == '\n' or c == '\r'; +} + +fn isPunct(c: u8) bool { + return c < 0x80 and std.ascii.isPrint(c) and !std.ascii.isAlphanumeric(c) and c != ' '; +} + +/// A byte of a word for emphasis rules: neither whitespace nor ASCII punctuation (UTF-8 letters count). +fn isWord(c: u8) bool { + return !isSpace(c) and !isPunct(c); +} + +/// Whether text after a literal `<` would start raw HTML in Markdown: a tag, an end tag, a comment or a declaration. +fn opensTag(rest: []const u8) bool { + return rest.len > 0 and (std.ascii.isAlphabetic(rest[0]) or std.mem.indexOfScalar(u8, "/!?", rest[0]) != null); +} + +/// Whether text after a literal `&` reads as an entity reference in Markdown (`name;`, `#digits;` or `#xhex;`). +fn opensEntity(rest: []const u8) bool { + var n: usize = 0; + while (n < rest.len and n < 33 and (std.ascii.isAlphanumeric(rest[n]) or (n == 0 and rest[n] == '#'))) n += 1; + return n > 0 and n < rest.len and rest[n] == ';'; +} + +/// The cell whose top-left is (r, c), walking ``k`` through the row-major cells; null: covered or empty. +fn topLeft(t: Table, k: *usize, r: usize, c: usize) ?Cell { + if (k.* >= t.cells.len or t.cells[k.*].row != r or t.cells[k.*].col != c) return null; + k.* += 1; + return t.cells[k.* - 1]; +} + +const Tag = enum { table, tr, td, th, br, other }; + +fn tagKind(name: []const u8) Tag { + inline for (.{ Tag.table, Tag.tr, Tag.td, Tag.th, Tag.br }) |t| { + if (std.ascii.eqlIgnoreCase(name, @tagName(t))) return t; + } + return .other; +} + +/// A span attribute's value as HTML reads it: leading digits, and 1 when there are none or they read 0. +fn spanValue(v: []const u8) u32 { + var i: usize = 0; + while (i < v.len and std.ascii.isWhitespace(v[i])) i += 1; + var n: u32 = 0; + while (i < v.len and std.ascii.isDigit(v[i])) : (i += 1) n = @min(n * 10 + (v[i] - '0'), 1_000_000); + return @max(n, 1); +} + +/// The code point a named or numeric entity body (between '&' and ';') stands for; null: not one this reader knows. +fn entityPoint(name: []const u8) ?u21 { + const named = .{ .{ "amp", '&' }, .{ "lt", '<' }, .{ "gt", '>' }, .{ "quot", '"' }, .{ "apos", '\'' } }; + if (std.mem.eql(u8, name, "nbsp")) return 0xa0; + inline for (named) |e| if (std.mem.eql(u8, name, e[0])) return e[1]; + if (name.len < 2 or name[0] != '#') return null; + const hex = name[1] == 'x' or name[1] == 'X'; + const base: u8 = if (hex) 16 else 10; + const digits = name[if (hex) 2 else 1..]; + if (digits.len == 0 or digits.len > 7) return null; + var v: u32 = 0; + for (digits) |d| v = v * base + (std.fmt.charToDigit(d, base) catch return null); + return if (v == 0 or v > 0x10ffff or (v >= 0xd800 and v <= 0xdfff)) 0xfffd else @intCast(v); +} + +/// Whitespace or one of ``ends``: where an attribute's name or unquoted value stops. +fn stops(ch: u8, ends: []const u8) bool { + return std.ascii.isWhitespace(ch) or std.mem.indexOfScalar(u8, ends, ch) != null; +} + +const Parser = struct { + a: Allocator, + src: []const u8, + i: usize = 0, + where: enum { before, inside, after } = .before, + cells: std.ArrayList(Cell) = .empty, + cell: ?Cell = null, // the open cell; its text gathers in ``text`` until it closes + text: std.ArrayList(u8) = .empty, + space: bool = false, // whitespace met in the open cell since its last character + row_open: bool = false, + rows: u32 = 0, // rows opened so far; the open row is the last + col: u32 = 0, // the open row's next column to try + until: [max_cols]u32 = @splat(0), // each column is covered by earlier cells through row until - 1 + + fn run(p: *Parser) ParseError!void { + while (p.i < p.src.len) { + const ch = p.src[p.i]; + const next = if (p.i + 1 < p.src.len) p.src[p.i + 1] else 0; + const after = if (p.i + 2 < p.src.len) p.src[p.i + 2] else 0; + if (ch == '<' and (next == '!' or next == '?')) { + try p.skipMarkup(); + } else if (ch == '<' and std.ascii.isAlphabetic(if (next == '/') after else next)) { + try p.tag(); + } else if (ch == '&') { + try p.entity(); + } else { + try p.char(ch); + p.i += 1; + } + } + if (p.where != .after) return error.Malformed; + } + + /// A comment, a doctype or a processing instruction: skipped whole. + fn skipMarkup(p: *Parser) ParseError!void { + if (std.mem.startsWith(u8, p.src[p.i..], "") orelse return error.Malformed; + p.i = end + 3; + } else { + const end = std.mem.indexOfScalarPos(u8, p.src, p.i, '>') orelse return error.Malformed; + p.i = end + 1; + } + } + + fn entity(p: *Parser) ParseError!void { + const rest = p.src[p.i..]; + const semi = std.mem.indexOfScalar(u8, rest[0..@min(rest.len, 12)], ';'); + const point = if (semi) |s| entityPoint(rest[1..s]) else null; + const cp = point orelse { + p.i += 1; + return p.char('&'); + }; + p.i += semi.? + 1; + if (cp == 0xa0) return p.char(' '); + var buf: [4]u8 = undefined; + const n = std.unicode.utf8Encode(cp, &buf) catch unreachable; + for (buf[0..n]) |b| try p.char(b); + } + + /// One text byte: whitespace runs fold to one space between words; outside a cell only whitespace may stand. + fn char(p: *Parser, ch: u8) ParseError!void { + const blank = std.ascii.isWhitespace(ch); + if (p.cell == null) { + if (!blank) return error.Malformed; + return; + } + if (blank) { + p.space = true; + return; + } + if (p.space and p.text.items.len > 0 and p.text.getLast() != '\n') try p.text.append(p.a, ' '); + p.space = false; + try p.text.append(p.a, ch); + } + + /// A start or end tag at ``i``: its name, its span attributes in any quoting, then its effect. + fn tag(p: *Parser) ParseError!void { + const src = p.src; + var i = p.i + 1; + const close = src[i] == '/'; + if (close) i += 1; + const start = i; + while (i < src.len and (std.ascii.isAlphanumeric(src[i]) or std.mem.indexOfScalar(u8, "-_:.", src[i]) != null)) i += 1; + const kind = tagKind(src[start..i]); + var span = [2]u32{ 1, 1 }; // rowspan, colspan + while (true) { + while (i < src.len and (std.ascii.isWhitespace(src[i]) or src[i] == '/')) i += 1; + if (i >= src.len) return error.Malformed; + if (src[i] == '>') break; + const name_start = i; + while (i < src.len and !stops(src[i], "=>/")) i += 1; + const name = src[name_start..i]; + while (i < src.len and std.ascii.isWhitespace(src[i])) i += 1; + if (i >= src.len or src[i] != '=') continue; + i += 1; + while (i < src.len and std.ascii.isWhitespace(src[i])) i += 1; + if (i >= src.len) return error.Malformed; + var value: []const u8 = undefined; + if (src[i] == '"' or src[i] == '\'') { + const end = std.mem.indexOfScalarPos(u8, src, i + 1, src[i]) orelse return error.Malformed; + value = src[i + 1 .. end]; + i = end + 1; + } else { + const value_start = i; + while (i < src.len and !stops(src[i], ">")) i += 1; + value = src[value_start..i]; + } + if (std.ascii.eqlIgnoreCase(name, "rowspan")) span[0] = spanValue(value); + if (std.ascii.eqlIgnoreCase(name, "colspan")) span[1] = spanValue(value); + } + p.i = i + 1; + try p.apply(kind, close, span); + } + + fn apply(p: *Parser, kind: Tag, close: bool, span: [2]u32) ParseError!void { + switch (p.where) { + .before => { + if (kind != .table or close) return error.Malformed; + p.where = .inside; + return; + }, + .after => return error.Malformed, + .inside => {}, + } + switch (kind) { + .table => { + if (!close) return error.Malformed; // a table inside a cell + try p.endCell(); + p.row_open = false; + p.where = .after; + }, + .tr => { + try p.endCell(); + p.row_open = false; + if (!close) try p.openRow(); + }, + .td, .th => { + try p.endCell(); + if (!close) try p.openCell(kind == .th, span); + }, + .br => if (p.cell != null) { + p.space = false; + try p.text.append(p.a, '\n'); + }, + .other => {}, + } + } + + fn openRow(p: *Parser) ParseError!void { + if (p.rows == max_rows) return error.Malformed; + p.rows += 1; + p.row_open = true; + p.col = 0; + } + + /// A cell at the open row's first free column; its spans clipped to the grid and to columns earlier rows cover. + fn openCell(p: *Parser, head: bool, span: [2]u32) ParseError!void { + if (!p.row_open) return error.Malformed; + const row = p.rows - 1; + while (p.col < max_cols and p.until[p.col] > row) p.col += 1; + if (p.col == max_cols) return error.Malformed; + var cols: u32 = 0; + while (cols < span[1] and p.col + cols < max_cols and p.until[p.col + cols] <= row) cols += 1; + const rows = @min(span[0], max_rows - row); + for (p.until[p.col..][0..cols]) |*u| u.* = row + rows; + p.cell = .{ .text = "", .row = row, .col = p.col, .rows = rows, .cols = cols, .head = head }; + p.col += cols; + } + + fn endCell(p: *Parser) ParseError!void { + var cell = p.cell orelse return; + p.cell = null; + p.space = false; + defer p.text.clearRetainingCapacity(); + try p.cells.ensureUnusedCapacity(p.a, 1); + cell.text = try p.a.dupe(u8, std.mem.trim(u8, p.text.items, " \n")); + p.cells.appendAssumeCapacity(cell); + } +}; diff --git a/zig/src/core/ocr_html_test.zig b/zig/src/core/ocr_html_test.zig new file mode 100644 index 00000000000..6ec8517cbf8 --- /dev/null +++ b/zig/src/core/ocr_html_test.zig @@ -0,0 +1,252 @@ +//! ocr_html.zig against tables dots.mocr and GLM-OCR wrote, and the subset's edges. +const std = @import("std"); +const html = @import("ocr_html.zig"); +const t = std.testing; + +// dots.mocr, TABLE II of the layout oracle (run008), first body rows kept. +const dots_table = "" ++ + "" ++ + "" ++ + "" ++ + "" ++ + "" ++ + "
ControlsHodgkin lymphomaNon-Hodgkin lymphoma
CasesOR95% CICasesOR95% CI
Never Exposed (reference group)2,2733151.00—1,8231.00—
>1 year9070.840.37-1.91861.170.87-1.59
"; + +// GLM-OCR, oracle case table-007: no header row. +const glm_table = "" ++ + "
ItemQuantity
Apples3
Pears5
"; + +fn expectCell(got: html.Cell, text: []const u8, row: u32, col: u32, rows: u32, cols: u32, head: bool) !void { + const want: html.Cell = .{ .text = text, .row = row, .col = col, .rows = rows, .cols = cols, .head = head }; + try t.expectEqualDeep(want, got); +} + +test "ocr html: the dots.mocr table keeps its spans" { + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const table = try html.parse(a, dots_table); + try t.expectEqual(@as(u32, 4), table.rows); + try t.expectEqual(@as(u32, 8), table.cols); + try t.expect(table.spanned); + try expectCell(table.cells[0], "", 0, 0, 2, 1, true); + const nhl = html.Cell{ .text = "Non-Hodgkin lymphoma", .row = 0, .col = 5, .rows = 1, .cols = 3, .head = true }; + try t.expectEqualDeep(nhl, table.cells[3]); + try expectCell(table.cells[4], "Cases", 1, 2, 1, 1, true); + try t.expect(!table.cells[10].head); +} + +test "ocr html: the GLM-OCR table has no header row and becomes a pipe table with its first row the header" { + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const table = try html.parse(a, glm_table); + try t.expect(!table.spanned); + for (table.cells) |c| try t.expect(!c.head); + const want = "| Item | Quantity |\n| --- | --- |\n| Apples | 3 |\n| Pears | 5 |"; + try t.expectEqualStrings(want, try html.pipe(a, table)); + try t.expectEqualStrings("" ++ + "
ItemQuantity
Apples3
Pears5
", try html.normalized(a, table)); +} + +test "ocr html: entities decode and unknown ones stay as written" { + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const src = "
a & b <c> "q" ABC x y z" ++ + " &bogus; & � � � � &
"; + const table = try html.parse(a, src); + const want = "a & b \"q\" ABC x y z &bogus; & \u{fffd} \u{fffd} \u{fffd} � &"; + try t.expectEqualStrings(want, table.cells[0].text); +} + +test "ocr html: other tags drop and keep their text; names and attributes in any case and quoting" { + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const src = "\n " ++ + "
" ++ + "Bold and it2!
xy
\n"; + const table = try html.parse(a, src); + try t.expectEqual(@as(u32, 3), table.cols); + try expectCell(table.cells[0], "Bold and it2!", 0, 0, 1, 2, true); + try expectCell(table.cells[2], "y", 1, 1, 1, 2, false); +} + +test "ocr html:
is a line break in the cell and a space in pipe cells" { + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const src = "

line one
line two
\n three
b
"; + const table = try html.parse(a, src); + try t.expectEqualStrings("line one\nline two\nthree", table.cells[0].text); + try t.expectEqualStrings("| line one line two three | b |\n| --- | --- |", try html.pipe(a, table)); + const clean = "
line one
line two
three
b
"; + try t.expectEqualStrings(clean, try html.normalized(a, table)); +} + +test "ocr html: pipe cells escape |" { + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const table = try html.parse(a, "
a|b
|c
"); + try t.expectEqualStrings("| a\\|b | |\n| --- | --- |\n| \\| | c |", try html.pipe(a, table)); +} + +test "ocr html: pipe cells escape a literal < that would start a tag, and keep other < as written" { + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const table = try html.parse(a, "
<em>x</em>a < b
"); + try t.expectEqualStrings("| <em>x</em> | a < b |\n| --- | --- |", try html.pipe(a, table)); +} + +test "ocr html: pipe cells escape a literal & that would start an entity, and keep other & as written" { + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const table = try html.parse(a, "
&lt; &#38; &#x26;AT&T & b; &;
"); + try t.expectEqualStrings("| &lt; &#38; &#x26; | AT&T & b; &; |\n| --- | --- |", try html.pipe(a, table)); +} + +test "ocr html: an unknown tag whose name starts like a table tag is dropped" { + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const table = try html.parse(a, "
xy
"); + try t.expectEqual(@as(usize, 1), table.cells.len); + try t.expectEqualStrings("xy", table.cells[0].text); +} + +test "ocr html: malformed tables and tables past the limits" { + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const bad = [_][]const u8{ + "", // unclosed table + "
a
", // unclosed quote + "
a
a