Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
586 lines
36 KiB
Markdown
586 lines
36 KiB
Markdown
# Whistle ASR validation report
|
|
|
|
## Scope
|
|
|
|
- Move Whistle sources and headers into the community-model directories and namespace.
|
|
- Preserve the public `whistle_asr` family, schema-v1 specification, and spec-backed loader.
|
|
- Add community catalog entries, setup instructions, limitations, and source-specific licensing records.
|
|
- Keep model packages empty. No weights, extracted assets, logs, or recordings ship in this change.
|
|
|
|
Affected areas are `src/community_models/whistle_asr/`,
|
|
`include/engine/community_models/whistle_asr/`, `tools/community_models/whistle_asr/`,
|
|
`tests/whistle_asr/`, `model_specs/whistle_asr.json`, loader registration,
|
|
`CMakeLists.txt`, `README.md`, and ASR/community/licensing documentation.
|
|
|
|
Preparation follows [CONTRIBUTING.md](../../CONTRIBUTING.md), the
|
|
[community guidance](../../CONTRIBUTING.md#new-model-prs), and
|
|
[issue #54](https://github.com/0xShug0/audio.cpp/issues/54).
|
|
|
|
## Model setup and build
|
|
|
|
Use the published FP32 checkpoint and `.cact` assets at model revision
|
|
`b358ddadd89b7a713b5aa131f23032d3cca1b251`. Put `whistle.safetensors`,
|
|
`config.json`, and `LICENSE` in a dedicated source directory. Keep `.cact` outside it.
|
|
The converter verifies the pinned input hashes, extracts four assets, and embeds
|
|
those assets, configuration, license, and the current model specification.
|
|
|
|
The following exact PowerShell commands ran from the repository root unless noted.
|
|
The existing `build/whistle` configuration used Ninja, MSVC Release, CPU AVX2,
|
|
`GGML_NATIVE=OFF`, OpenMP, deployment mode, custom model set `whistle_asr`,
|
|
and `ENGINE_BUILD_TESTS=ON`. This configuration reused existing dependencies.
|
|
|
|
```powershell
|
|
python tools/check_loader_catalog_sync.py --self-test
|
|
python tools/check_loader_catalog_sync.py
|
|
& 'C:/Program Files/Microsoft Visual Studio/18/Enterprise/Common7/Tools/Launch-VsDevShell.ps1' -Arch amd64 -HostArch amd64 -SkipAutomaticLocation
|
|
cmake --build build/whistle --target audiocpp_cli audiocpp_gguf whistle_frontend_test whistle_assets_test --parallel 16
|
|
New-Item -ItemType Directory -Force build/whistle/pr-prep/source,build/whistle/pr-prep/model,build/whistle/pr-prep/standalone
|
|
Copy-Item -LiteralPath C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/source/whistle.safetensors,C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/source/config.json,C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/source/LICENSE -Destination build/whistle/pr-prep/source
|
|
python tools/community_models/whistle_asr/convert.py --source build/whistle/pr-prep/source --cact C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/whistle.cact --converter build/whistle/bin/audiocpp_gguf.exe --output build/whistle/pr-prep/model/whistle-f32.gguf
|
|
build/whistle/bin/audiocpp_gguf.exe --inspect build/whistle/pr-prep/model/whistle-f32.gguf
|
|
cmake -S . -B build/whistle -DAUDIOCPP_WHISTLE_TEST_MODEL=C:/Users/adityasharma/Projects/audio.cpp/build/whistle/pr-prep/model/whistle-f32.gguf
|
|
cmake --build build/whistle --target audiocpp_cli audiocpp_gguf whistle_frontend_test whistle_assets_test --parallel 16
|
|
ctest --test-dir build/whistle -R whistle_ --output-on-failure
|
|
build/whistle/bin/audiocpp_cli.exe --task asr --family whistle_asr --backend cpu --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --language en --metrics --log
|
|
build/whistle/bin/audiocpp_cli.exe --task asr --family whistle_asr --backend cpu --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/german_16k.wav --language de --metrics --log
|
|
```
|
|
|
|
## Observed validation
|
|
|
|
Measured on 2026-10-06, local Pacific time.
|
|
|
|
- Catalog self-test passed both tests. Catalog synchronization passed with 115 loaders and 113 specs.
|
|
Existing unrelated Safetensors package warnings remained; no unrelated fixes were made.
|
|
- Both builds succeeded. Reconfiguration initially ran without the MSVC environment and failed with
|
|
`LINK : fatal error LNK1104: cannot open file 'kernel32.lib'`. Repeating after
|
|
`Launch-VsDevShell.ps1` succeeded. Existing dependency/compiler warnings remained.
|
|
- Fresh conversion succeeded. Inspection reported 116 tensors, six embedded sidecars,
|
|
an embedded model specification, and `model_spec_family=whistle_asr`.
|
|
- CTest passed `whistle_frontend_test`, `whistle_asr_integration`, and
|
|
`whistle_asr_full_integration`, 3/3 in 3.59 seconds, using the freshly converted GGUF.
|
|
Frontend vectors are independent feature checks, not full-model FP32 reference parity.
|
|
- Integration exercises decoder reuse, quiet speech, constant-RMS speech, digital silence,
|
|
the 30-second limit, and non-finite input rejection. Decode exhaustion is inspected in code
|
|
and explicitly throws; it was not forced in an executed regression test.
|
|
- Actual CLI ASR passed with forced English and German. A separate same-session CLI batch
|
|
detected English twice and produced identical transcripts.
|
|
- CLI digital silence produced `text_output=`. A 480001-sample, 16 kHz mono recording failed
|
|
with `Whistle audio exceeds the 30-second input limit`.
|
|
Streaming failed with `Whistle currently supports offline ASR only`.
|
|
|
|
Exact CLI limit commands used the ignored generated input script:
|
|
|
|
```powershell
|
|
python build/whistle/pr-prep/create-inputs.py
|
|
build/whistle/bin/audiocpp_cli.exe --task asr --family whistle_asr --model build/whistle/pr-prep/model/whistle-f32.gguf --audio build/whistle/pr-prep/silence.wav --threads 4
|
|
build/whistle/bin/audiocpp_cli.exe --task asr --family whistle_asr --model build/whistle/pr-prep/model/whistle-f32.gguf --audio build/whistle/pr-prep/over-limit.wav --threads 4
|
|
build/whistle/bin/audiocpp_cli.exe --task asr --mode streaming --family whistle_asr --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --threads 4
|
|
```
|
|
|
|
The combined command invocation exited 1 after the expected streaming rejection.
|
|
Individual exit codes were not captured. Successful generation/silence and failed duration/streaming
|
|
are established by the generated inputs and actual CLI output above.
|
|
|
|
The standalone model directory contained only `whistle-f32.gguf` (220831648 bytes).
|
|
With a fresh repository-local `TEMP` cache, CLI reconstruction extracted `config.json`,
|
|
`LICENSE`, `tokenizer.blob`, `mel_filterbank_80.f32`, `hadamard_perm1.f32`, and
|
|
`hadamard_perm2.f32`. Extracted files matched the conversion source hashes.
|
|
CLI recognition also succeeded from `C:/Windows`, without adjacent sidecars or a working-directory
|
|
`model_specs` tree. Source inspection of `find_contract_spec_path()` confirms that the embedded
|
|
v1 contract takes precedence over workspace and builtin fallback.
|
|
An additional metadata check measured the new community documentation path inside the GGUF,
|
|
no stale core documentation path, and matching hashes for all six extracted sidecars.
|
|
|
|
```powershell
|
|
python build/whistle/pr-prep/check_embedded_assets.py | Tee-Object build/whistle/pr-prep/embedded-assets-check.log
|
|
```
|
|
|
|
This exited successfully with seven `PASS` lines. The local check script remains ignored.
|
|
|
|
```powershell
|
|
# Run with working directory C:/Windows. All writes remain in the ignored build directory.
|
|
$env:TEMP='C:/Users/adityasharma/Projects/audio.cpp/build/whistle/pr-prep/standalone'
|
|
$env:TMP=$env:TEMP
|
|
& C:/Users/adityasharma/Projects/audio.cpp/build/whistle/bin/audiocpp_cli.exe --task asr --family whistle_asr --backend cpu --threads 4 --model C:/Users/adityasharma/Projects/audio.cpp/build/whistle/pr-prep/model/whistle-f32.gguf --audio C:/Users/adityasharma/Projects/audio.cpp/assets/resources/sample_16k.wav --language en --metrics --log
|
|
```
|
|
|
|
## Transcript comparison and reference limitations
|
|
|
|
Measured inputs are 16 kHz mono WAVs. English is the complete repository sample, 14.0719 seconds.
|
|
German is the existing local `german_16k.wav`, 2.11594 seconds; its recording provenance is not established.
|
|
Their SHA-256 hashes are respectively
|
|
`a8096a0c2ac136bcaf70143dc80ba576ade8dfbf7f9f30ecdec29b920ab3e3cb` and
|
|
`82c41f2dd56e21fb9407b9d4abf7692d9db8806b41e7134607ea4748411803b1`.
|
|
|
|
FP32 audio.cpp English output was:
|
|
|
|
```text
|
|
Some call me Nature. Others call me Mother Nature. I've been here for over 4.5 billion years, twenty two thousand five hundred times longer than you.
|
|
```
|
|
|
|
The local quantized official `.cact` reference produced:
|
|
|
|
```text
|
|
Some call me Nature. Others call me Mother Nature. I've been here for over 4.5 billion years, 22,500 times longer than you.
|
|
```
|
|
|
|
Both produced this German transcript:
|
|
|
|
```text
|
|
Guten Morgen, wie wird das Wetter heute?
|
|
```
|
|
|
|
Exact reference commands used automatic language detection, four threads, full/default audio decoder
|
|
depth, and no keywords, timestamps, or streaming:
|
|
|
|
```powershell
|
|
& C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/needle.exe --help
|
|
& C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/needle.exe --model C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/whistle.cact --audio assets/resources/sample_16k.wav --threads 4
|
|
& C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/needle.exe --model C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/whistle.cact --audio C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/german_16k.wav --threads 4
|
|
```
|
|
|
|
The `.cact` hash matches the pinned converter asset hash
|
|
`b6e02f048568ac5d01a2042556c658061e699acbc0aa2a1439f52f3d461dffeb`.
|
|
The local executable SHA-256 is
|
|
`e8863ca0c06a47d406777077f1ba728b58e57d77fedff252dbe6827d588acc8c`.
|
|
Its source revision cannot be established from the available files.
|
|
The audio.cpp decoder uses greedy selection, a 320-text-token ceiling, and no sampling.
|
|
This is a transcript comparison with quantized weights, not exact FP32 numerical parity.
|
|
No FP32 reference runtime or seven-language validation corpus was available.
|
|
|
|
## Performance and process memory
|
|
|
|
Measured hardware is AMD Ryzen AI MAX+ 395, 16 cores/32 logical processors.
|
|
OS is Windows 11 Enterprise Insider Preview, build 26704.
|
|
Compiler is MSVC 19.51.36257.0. Final configure used CMake 4.4 and Ninja 1.13.2.
|
|
These are local observations, not a performance guarantee.
|
|
|
|
- Four-thread CLI English request took 319.928 ms for 14.0719 seconds, RTF 0.0227352.
|
|
Peak process working set was 446 MiB; reported peak Windows commit was 232 MiB.
|
|
- Four-thread CLI German request took 78.3542 ms for 2.11594 seconds, RTF 0.0370305.
|
|
Peak working set was 432 MiB; reported peak Windows commit was 219 MiB.
|
|
- One loaded CLI session ran two identical English requests.
|
|
First request took 467.397 ms, RTF 0.0332149, peak working set 447 MiB.
|
|
Repeat took 479.373 ms, RTF 0.0340659, peak working set 448 MiB.
|
|
These are first/repeated requests, not OS-cache-cold measurements.
|
|
- An ignored one-thread scratch probe using the existing test objects separately measured
|
|
asset loading at 23.7752 ms and runtime initialization at 150.822 ms.
|
|
Inference then took 640.984 ms and 661.587 ms, RTF 0.0455505 and 0.0470146.
|
|
`GetProcessMemoryInfo` reported peak working set 467447808 bytes for both requests.
|
|
The scratch probe initially requested four threads from test objects built without OpenMP.
|
|
That unsupported probe failed; the successful probe uses one thread. Production CLI uses OpenMP.
|
|
|
|
CLI metrics time preparation/inference after model/session creation and input loading.
|
|
They exclude model loading. Scratch load timings are separate and use a different one-thread harness.
|
|
RTF is inference seconds divided by input-audio seconds. All measured RTFs are below 1.0.
|
|
Memory figures describe CPU process memory, not GPU memory.
|
|
|
|
```powershell
|
|
$batch = @(@{id='cold';audio='C:/Users/adityasharma/Projects/audio.cpp/assets/resources/sample_16k.wav'},@{id='repeat';audio='C:/Users/adityasharma/Projects/audio.cpp/assets/resources/sample_16k.wav'})
|
|
$batch | ConvertTo-Json | Set-Content build/whistle/pr-prep/batch.json
|
|
build/whistle/bin/audiocpp_cli.exe --task asr --family whistle_asr --backend cpu --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --request-sequence build/whistle/pr-prep/batch.json --metrics
|
|
```
|
|
|
|
The scratch source, compile logs, exact probe command, model, recordings, input/sidecar hashes,
|
|
and run logs remain under ignored `build/whistle/pr-prep/`; they are not PR source artifacts.
|
|
The successful probe command is preserved in `benchmark-command.ps1`, executed from `build/whistle`.
|
|
Original cached inputs remain unchanged under
|
|
`C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/`.
|
|
|
|
## Provenance, licensing, and limitations
|
|
|
|
See [provenance](../../tools/community_models/whistle_asr/PROVENANCE.md),
|
|
[license table](../../docs/model_licenses.md), and
|
|
[model guide](../../docs/models/whistle_asr.md).
|
|
The converter preserves the pinned upstream `LICENSE` inside the GGUF.
|
|
The repository license does not license the weights or extracted assets.
|
|
The contributor confirms that no code was copied or adapted from reference implementations.
|
|
Independent extracted-asset provenance remains unverified in the upstream release.
|
|
No independently audited redistribution rights or maintainer approval is claimed.
|
|
|
|
Backend coverage is offline CPU and Metal with FP32 weights; see the
|
|
[GGML encoder section](#ggml-encoder-rewrite-2026-10-07) below. Supported language codes are
|
|
`en`, `de`, `fr`, `es`, `it`, `nl`, `pl`. English and German inputs above were checked.
|
|
The other five codes each have one [synthetic CLI spot check](MULTILINGUAL_VALIDATION.md),
|
|
with ten successful automatic/forced-language runs and zero normalized prompt word edits.
|
|
The quantized reference matches four clips; Polish differs by `niebo` versus
|
|
`nie bo`. This is documented without a parity claim.
|
|
Their measured four-thread inference RTF ranges from 0.0392522 to 0.0570413;
|
|
peak process working set ranges from 432 to 434 MiB. These are not a broad accuracy corpus.
|
|
Inputs must be 16 kHz mono, at most 30 seconds. There is no streaming, resampling,
|
|
long-form recognition, quantization, keyword biasing, or word timestamps. Metal
|
|
accelerates the encoder graph; decoding stays on the CPU.
|
|
Noise-only audio may produce words. This family is not a speech-activity detector.
|
|
No downloadable package is advertised.
|
|
|
|
## Pre-submission adversarial review and regression checks
|
|
|
|
A fresh-context review of the port found a byte-fallback decoding defect.
|
|
Invalid byte tokens could return invalid UTF-8, and byte-composed metaspace
|
|
markers were not converted to spaces. The model-local decoder now concatenates
|
|
visible bytes, applies UTF-8 replacement decoding, then replaces metaspace globally.
|
|
No framework or GGML code changes are included.
|
|
|
|
`whistle_tokenizer_test` adds literal regressions for valid multibyte fallback,
|
|
invalid and incomplete UTF-8, overlong/surrogate/out-of-range sequences,
|
|
byte-composed metaspace, special-token handling, malformed hex, and ID bounds.
|
|
The focused re-review found no remaining Critical or Important issue.
|
|
|
|
The following checks ran after the fix, in a VS developer shell where applicable:
|
|
|
|
```powershell
|
|
cmake -S . -B build/whistle
|
|
cmake --build build/whistle --target whistle_tokenizer_test whistle_frontend_test whistle_assets_test audiocpp_cli --config Release -j 4
|
|
build/whistle/bin/whistle_tokenizer_test.exe
|
|
ctest --test-dir build/whistle -R whistle_ --output-on-failure
|
|
python -X utf8 build/whistle/pr-prep/tokenizer-fix/run_cli_regressions.py | Tee-Object build/whistle/pr-prep/tokenizer-fix/cli-regressions.log
|
|
```
|
|
|
|
Measured results are a successful build, tokenizer-test pass, and CTest **4/4**.
|
|
All ten French/Spanish/Italian/Dutch/Polish CLI requests exit 0 and produce
|
|
exactly the same transcripts as the earlier recorded automatic/forced runs.
|
|
New logs remain ignored under `build/whistle/pr-prep/tokenizer-fix/`.
|
|
|
|
The reviewer also exercised the advertised extracted-Safetensors directory
|
|
through the production CLI with CPU, one thread, English, and the full sample.
|
|
It exits 0 with the expected English transcript, 786.915 ms inference wall time,
|
|
and peak process working set 447 MiB. This is an additional load-route check,
|
|
not a before/after performance comparison.
|
|
|
|
## GGML encoder rewrite (2026-10-07)
|
|
|
|
Maintainer review asked for framework-module reuse and GGML graphs. This revision moves
|
|
the mel frontend onto `NemoMelFrontend` and the whole encoder (stem, eight mHC layers,
|
|
final norm, positional table, cross-attention key/value projections) into one GGML graph
|
|
built from `Conv2dModule`, `DepthwiseConv2dModule`, `DepthwiseConv1dModule`,
|
|
`LinearModule`, `GemmaRMSNormModule`, `RoPEModule`, `SiluModule`, and `GLUModule`.
|
|
Whistle-specific math (mHC lane mixing with Sinkhorn normalization, the conditioned
|
|
Kronecker-factored Hadamard MLP with fixed permutations, and 48/64-wide grouped-query
|
|
attention) stays in `src/community_models/whistle_asr/encoder.cpp`. The autoregressive
|
|
decoder still runs on the host. The converter and GGUF layout are unchanged; the loader
|
|
transposes the JAX `[in, out]` kernels into the backend weight store.
|
|
|
|
Measured on macOS 27.0.1, Apple M3 Ultra, Apple clang, CMake 4.4.4, Ninja, Release,
|
|
`ENGINE_ENABLE_METAL=ON`, `GGML_METAL=ON`, OpenMP on, deployment build, custom model set
|
|
`whistle_asr`, `ENGINE_BUILD_TESTS=ON`. Exact commands from the repository root:
|
|
|
|
```bash
|
|
scripts/build_metal.sh --build-dir build/whistle --build-type Release --openmp auto --with-tests --deployment-build --model-set custom --models whistle_asr --target audiocpp_cli --target audiocpp_gguf --target whistle_frontend_test --target whistle_tokenizer_test --target whistle_assets_test
|
|
python3 tools/community_models/whistle_asr/convert.py --source build/whistle/pr-prep/source --cact /private/tmp/whistle-src/whistle.cact --converter build/bin/audiocpp_gguf --output build/whistle/pr-prep/model/whistle-f32.gguf
|
|
cmake -S . -B build/whistle -DAUDIOCPP_WHISTLE_TEST_MODEL=$PWD/build/whistle/pr-prep/model/whistle-f32.gguf
|
|
ctest --test-dir build/whistle -R whistle_ --output-on-failure
|
|
build/whistle/bin/whistle_frontend_test build/whistle/pr-prep/source/mel_filterbank_80.f32 build/whistle/pr-prep/oracle/sample_16k.f32
|
|
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf assets/resources/sample_16k.wav --reference build/whistle/pr-prep/oracle --backend cpu --threads 4
|
|
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf assets/resources/sample_16k.wav --reference build/whistle/pr-prep/oracle --backend metal --tolerance 5e-3
|
|
build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend metal --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --metrics
|
|
python3 tools/check_loader_catalog_sync.py --self-test && python3 tools/check_loader_catalog_sync.py
|
|
```
|
|
|
|
The locally converted GGUF has SHA-256
|
|
`80c42e88a1ad07203634b60d50e0763e0478bb6830544434b70f52ab53c9ab42` (220831648 bytes).
|
|
The hosted `bumpyclock/whistle-GGUF` file, SHA-256
|
|
`1167c811abad2fc539a1116e1b7031ba4a07c8f5b2e75ab3a389ae370efede8b`, was converted from an
|
|
earlier spec revision; it loads and produces the same English transcript on CPU and Metal.
|
|
|
|
### Numerical comparison against the host implementation
|
|
|
|
Reference tensors were dumped from the previous host-only implementation at commit
|
|
`6e0a40c4` on the full 14.0719-second repository sample (1407 mel frames, 176 encoder
|
|
frames). That commit has no exporter: the dumps came from a temporary hook that wrote
|
|
`mel.values`, `memory`, `output.cross_k[0]`, `output.cross_v[0]`, `output.cross_k[7]`,
|
|
and `output.cross_v[7]` from `encode()` in its `runtime.cpp` as raw little-endian f32 in
|
|
the layouts those vectors already have (frame-major: `[frames, 80]`, `[frames, 512]`,
|
|
`[frames, 384]`, `[frames, 512]`). Regenerating them means checking out `6e0a40c4`,
|
|
adding such a hook, and running the CLI once on `assets/resources/sample_16k.wav` with
|
|
one thread. The dumps stay in ignored `build/whistle/pr-prep/oracle/`. The current test
|
|
writes the same six tensors from the current implementation with
|
|
`whistle_assets_test <gguf> <wav> --dump <dir>`, so later refactors have a committed
|
|
oracle recipe. `whistle_assets_test --reference` reports the largest absolute difference
|
|
as a fraction of each reference tensor's largest magnitude:
|
|
|
|
| Tensor | Reference max | CPU fraction | Metal fraction |
|
|
|---|---:|---:|---:|
|
|
| mel features (1407 x 80) | 3.42311 | 6.37e-06 | 6.37e-06 |
|
|
| encoder memory (176 x 512) | 5.55704 | 1.24e-06 | 6.79e-04 |
|
|
| cross-attention keys, layer 0 | 7.92314 | 4.46e-06 | 2.57e-03 |
|
|
| cross-attention values, layer 0 | 86.5275 | 1.15e-06 | 5.21e-04 |
|
|
| cross-attention keys, layer 7 | 9.34248 | 1.17e-06 | 1.10e-03 |
|
|
| cross-attention values, layer 7 | 197.274 | 9.57e-07 | 4.25e-04 |
|
|
|
|
Per-layer probes during bring-up showed the Metal drift appears already after the stem
|
|
(2.5e-04 of scale) and stays between 1e-04 and 4e-04 of scale through the eight layers.
|
|
This is consistent with ggml's Metal F32 matrix kernel, `kernel_mul_mm_f32_f32`, which
|
|
stages both operands in half precision before accumulating in float; the Metal backend
|
|
does not read the `ggml_mul_mat_set_prec` flag, so requesting F32 precision changes
|
|
nothing there. The CPU graph tracks the host implementation to about 1e-06 of scale.
|
|
`whistle_frontend_test` passes both its fixed vectors and the official filterbank check
|
|
on the real sample (tolerance 0.02). The default `--reference` tolerance is 2e-3, which
|
|
the CPU graph passes by three orders of magnitude; Metal needs `--tolerance 5e-3`.
|
|
|
|
Whistle's mHC residual logits reach gaps above 200 nats inside one 4x4 Sinkhorn block
|
|
(layer 1), so a float softmax underflows to exact zeros. ggml has no max reduction, so
|
|
the graph recovers each row's log-sum-exp as `sum(p * (x - log p))` from the clamped
|
|
softmax and subtracts it from `x`, which keeps the host implementation's exact
|
|
`x - logsumexp(x)` values for entries far below the row maximum. An earlier bring-up
|
|
variant used `log(clamp(softmax))` instead; that discards differences between
|
|
underflowed entries, which a later column pass can amplify, and was replaced before
|
|
this revision was finalized.
|
|
|
|
### Transcripts and tests
|
|
|
|
CTest passed 4/4 (`whistle_frontend_test`, `whistle_tokenizer_test`,
|
|
`whistle_asr_integration`, `whistle_asr_full_integration`). The full-sample integration
|
|
checks (expected transcript, decoder reuse, quiet and constant-RMS speech, silence,
|
|
30-second rejection, non-finite rejection) also pass with `--backend metal`. The English
|
|
sample transcript is unchanged on CPU with 1 and 4 threads and on Metal:
|
|
|
|
```text
|
|
Some call me Nature. Others call me Mother Nature. I've been here for over 4.5 billion years, twenty two thousand five hundred times longer than you.
|
|
```
|
|
|
|
Seven longer synthetic clips (20 to 26 seconds, one per supported language) produce
|
|
byte-identical transcripts across CPU 1 thread, CPU 4 threads, Metal, automatic and
|
|
forced language; see [the multilingual report](MULTILINGUAL_VALIDATION.md#longer-seven-language-clips-2026-10-07).
|
|
The previous host implementation was not run on those clips; they compare the new
|
|
backends against each other, not against the old code.
|
|
|
|
### Performance and process memory
|
|
|
|
CLI `metrics.wall_ms` covers preparation plus inference per request and excludes model
|
|
loading. Encoder-only times come from `whistle_assets_test --reference` (graph build,
|
|
allocation, upload, compute, readback) for the 176-frame sample; "first" includes
|
|
backend warm-up, "warm" is the second call. The previous host code was rebuilt from
|
|
commit `6e0a40c4` in a worktree with the same build script and run three times per
|
|
configuration on the same binary model; both logs are in ignored
|
|
`build/whistle/pr-prep/baseline/` (`cli-sample-6e0a40c4.log`, `cli-sample-final.log`).
|
|
Single local observations, not guarantees.
|
|
|
|
| Measurement | Previous host code (commit `6e0a40c4`) | GGML encoder revision |
|
|
|---|---:|---:|
|
|
| Sample, CLI wall, CPU 1 thread, 3 runs | 505 to 517 ms | 482 to 516 ms |
|
|
| Sample, CLI wall, CPU 4 threads, 3 runs | 253 to 259 ms | 237 to 238 ms |
|
|
| Sample, CLI wall, Metal, 3 runs | not supported | 157 to 164 ms |
|
|
| Encoder only, CPU 1 thread | not measured separately | 302 ms first, 295 ms warm |
|
|
| Encoder only, CPU 4 threads | not measured separately | 105 ms first, 90 ms warm |
|
|
| Encoder only, Metal | not supported | 143 ms first, 37 ms warm |
|
|
| Sample, peak RSS | 605 to 609 MiB | 758 to 767 MiB |
|
|
|
|
At one CPU thread the end-to-end time is unchanged within run-to-run noise: the GGML
|
|
encoder is not faster than the hand-written loops single-threaded, and the exact
|
|
Sinkhorn normalization adds about 1300 small graph nodes. Four threads are about 7%
|
|
faster, and Metal is about 1.6x faster than four CPU threads. On Metal the encoder is
|
|
about 37 ms of a 160 ms sample request, so the host decoder is the dominant remaining
|
|
cost. Peak RSS grew by about 150 MiB because encoder weights now live in the backend
|
|
weight store while the decoder keeps its host copies of the decoder tensors.
|
|
|
|
Five identical English requests (20.43 s clip) in one loaded CLI session:
|
|
|
|
| Backend | Wall per request | Peak RSS | Peak footprint |
|
|
|---|---|---|---|
|
|
| CPU 4 threads | 392.6, 385.5, 385.0, 379.3, 379.9 ms | 770 to 774 MiB | 537 to 541 MiB |
|
|
| Metal | 275.6, 252.5, 262.2, 261.7, 261.0 ms | 746 to 748 MiB | 802 to 803 MiB |
|
|
|
|
```bash
|
|
build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend metal --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --request-sequence build/whistle/pr-prep/longform/batch5.json --metrics
|
|
```
|
|
|
|
Catalog self-tests passed 2/2; synchronization passed. No framework, GGML, converter, or
|
|
GGUF-format changes are included. CUDA, HIP, and Vulkan remain unvalidated and are
|
|
rejected at session creation.
|
|
|
|
## Readiness checklist
|
|
|
|
- [x] Community layout, namespace, registration, build paths, and discoverability updated.
|
|
- [x] Fresh self-contained GGUF embeds the updated specification and license/assets.
|
|
- [x] Catalog checks, CPU builds, frontend, and opt-in integration tests passed.
|
|
- [x] GGML encoder matches the host implementation on CPU (about 1e-06 of scale) and Metal (at most 1.2e-03 of scale); macOS CTest 4/4 and Metal integration checks pass.
|
|
- [x] GGML decoder step graph matches the host decoder's per-step logits on CPU (about 2e-06 of scale) with identical transcripts on the sample and seven long clips; see the decoder section below.
|
|
- [x] Actual CLI English/German, silence, duration rejection, streaming rejection, and session reuse checked.
|
|
- [x] Local latency, RTF, and CPU process memory recorded without excluding slow requests.
|
|
- [x] Final diff and untracked-file list inspected. No generated model/log/audio artifacts enter source changes.
|
|
- [x] Relative documentation links resolve, old core paths are absent, and `git diff --check` passes.
|
|
- [ ] Resolve documented source-specific licensing uncertainties before redistribution.
|
|
- [ ] Establish the local reference executable's exact source revision for a revision-identified comparison.
|
|
- [ ] Obtain a pinned FP32 reference runtime before claiming FP32 numerical parity.
|
|
- [x] French, Spanish, Italian, Dutch, and Polish synthetic spot checks completed through the actual CLI.
|
|
- [ ] Broaden native-speaker and real-recording coverage; current evidence is not a general accuracy benchmark.
|
|
- [ ] Force decode exhaustion in a focused behavioral test if required by reviewers; currently only code-inspected.
|
|
|
|
These validation runs did not publish model files or download large dependencies.
|
|
The report records preparation evidence before PR submission.
|
|
|
|
## GGML decoder step graph (2026-10-08)
|
|
|
|
### What changed
|
|
|
|
The autoregressive decoder now runs as one persistent GGML step graph. Its
|
|
self-attention keys and values live in a `TransformerKVCache`. The code is in
|
|
`src/community_models/whistle_asr/decoder.cpp`. It uses framework modules and the
|
|
Whistle graph pieces that the encoder also uses (`graph.cpp`, `graph.h`). The host
|
|
decoder math, the host copies of the decoder weights, and the OpenMP handling are
|
|
gone from `runtime.cpp`.
|
|
|
|
Two things stay on the host: the engram table lookup (integer hashing over the token
|
|
history) and the greedy argmax. The engram projections and gates run in the graph.
|
|
|
|
The decoder step graph runs on its own single-thread CPU execution context, whatever
|
|
backend the session uses. The encoder stays on the session backend (CPU or Metal). No framework, GGML, converter, or GGUF changes are included.
|
|
|
|
### Reference dumps
|
|
|
|
Before the port, `whistle_assets_test --dump` (new in this branch) recorded the host
|
|
decoder's per-step logits, input tokens, and transcript. It recorded them for the sample
|
|
(`assets/resources/sample_16k.wav`, 42 steps) and for seven synthetic clips of 20 to 26
|
|
seconds. The clips are the same local files as the long-form section of
|
|
[the multilingual report](MULTILINGUAL_VALIDATION.md), in
|
|
`build/whistle/pr-prep/longform/`.
|
|
|
|
| Clip | en | de | fr | es | it | nl | pl |
|
|
|---|---:|---:|---:|---:|---:|---:|---:|
|
|
| Steps | 89 | 172 | 156 | 177 | 162 | 107 | 212 |
|
|
|
|
The dumps are in `build/whistle/pr-prep/decoder-reference/<clip>/`. They are local and
|
|
git-ignored. The encoder tensors in them came from the merged encoder on `main`.
|
|
|
|
Measured on the same machine as the section above: Apple M3 Ultra, macOS, Release,
|
|
`GGML_METAL=ON`, OpenMP on, custom model set `whistle_asr`. Exact commands from the
|
|
repository root:
|
|
|
|
```bash
|
|
cmake --build build/whistle --target whistle_assets_test audiocpp_cli -j 8
|
|
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf assets/resources/sample_16k.wav --backend cpu --threads 1 --dump build/whistle/pr-prep/decoder-reference/sample
|
|
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf <wav> --backend cpu --threads 1 --reference build/whistle/pr-prep/decoder-reference/<clip> --tolerance 1e-4
|
|
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf <wav> --backend cpu --threads 4 --reference build/whistle/pr-prep/decoder-reference/<clip> --tolerance 1e-4
|
|
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf <wav> --backend metal --threads 4 --reference build/whistle/pr-prep/decoder-reference/<clip> --tolerance 5e-3
|
|
ctest --test-dir build/whistle -R whistle_ --output-on-failure
|
|
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf assets/resources/sample_16k.wav "<sample transcript>" --full --backend metal
|
|
build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend cpu --threads 1 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --language en --metrics
|
|
build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend cpu --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --language en --metrics
|
|
build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend metal --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --language en --metrics
|
|
build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend <cpu|metal> --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --request-sequence build/whistle/pr-prep/batch2.json --metrics
|
|
/usr/bin/time -l build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend <cpu|metal> --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --language en
|
|
```
|
|
|
|
`batch2.json` holds two requests, `cold` and `repeat`, with the same absolute path to
|
|
`sample_16k.wav`.
|
|
|
|
### Results at the final commit
|
|
|
|
Every comparison against the reference dumps passed. Tokens and transcripts are
|
|
identical on every clip and in every configuration. The encoder tensors are bit-identical
|
|
to the dumps on CPU (largest fraction 0).
|
|
|
|
The table gives the largest per-step difference in decoder logits, as a fraction of the
|
|
reference's largest value. CPU with 1 thread and CPU with 4 threads give the same values.
|
|
|
|
| Clip | Steps | CPU | Metal (encoder on Metal, decoder on CPU) |
|
|
|---|---:|---:|---:|
|
|
| sample | 42 | 1.86e-6 | 1.53e-4 |
|
|
| en | 89 | 1.70e-6 | 1.66e-4 |
|
|
| de | 172 | 1.71e-6 | 1.43e-4 |
|
|
| fr | 156 | 1.75e-6 | 3.98e-4 |
|
|
| es | 177 | 1.55e-6 | 1.03e-4 |
|
|
| it | 162 | 1.75e-6 | 6.05e-4 |
|
|
| nl | 107 | 1.75e-6 | 5.43e-4 |
|
|
| pl | 212 | 1.60e-6 | 2.06e-4 |
|
|
|
|
- The CPU difference of about 2e-6 comes from a different floating-point summation order.
|
|
- The largest encoder fraction on Metal is 2.57e-3 on the sample and up to 1.47e-3 on
|
|
the long clips. This Metal encoder drift existed before this branch. See the section
|
|
above. The decoder runs the same graph on both backends, so the larger Metal logits
|
|
difference most likely comes from that drift.
|
|
- `ctest -R whistle_` passed 4/4 (frontend, tokenizer, integration, full integration).
|
|
- The Metal `--full` sample check passed.
|
|
- A two-request session gave identical transcripts on CPU and on Metal.
|
|
- The test now rejects `--dump` and `--reference` on the same directory. It also rejects
|
|
either option without a WAV file.
|
|
|
|
### Timing
|
|
|
|
Sample, 14.07 seconds. CLI `metrics.wall_ms` (preparation plus inference), three runs
|
|
each. "Before" is the merged `main` with the host decoder, measured on this machine on
|
|
2026-10-08 before the port. "Decoder on session backend" is this branch with one
|
|
local change, the decoder built on the session context instead of its own; it is
|
|
measured only for the comparison and is not shipped. In a CPU session with one thread
|
|
it is the same configuration as the shipped build.
|
|
|
|
| Configuration | Before (host decoder) | Decoder on session backend (not shipped) | This branch (decoder on 1 CPU thread) |
|
|
|---|---:|---:|---:|
|
|
| CPU 1 thread | 494 to 528 ms | same as this branch | 465 to 472 ms |
|
|
| CPU 4 threads | 238 to 242 ms | 1933 to 2242 ms | 282 to 283 ms |
|
|
| Metal | 157 to 163 ms | 618 to 629 ms | 204 to 226 ms |
|
|
|
|
Encoder and decoder split in this branch, from the CLI `--log` timing output.
|
|
`whistle_asr.encode_ms` includes the mel frontend and the per-request encoder graph
|
|
build. `whistle_asr.decode_ms` covers the 42 steps.
|
|
|
|
| Configuration | encode_ms | decode_ms |
|
|
|---|---:|---:|
|
|
| CPU 1 thread | 330 to 336 | 134 to 140 |
|
|
| CPU 4 threads | 146 to 148 | 134 to 136 |
|
|
| Metal | 65 to 87 | 136 to 141 |
|
|
|
|
To attribute the difference, `main` was built locally with the same two timing
|
|
scalars added to `runtime.cpp` and measured on the same day, three runs each:
|
|
|
|
| Configuration | Encoder before | Encoder this branch | Decoder before (host, OpenMP) | Decoder this branch (graph, 1 thread) |
|
|
|---|---:|---:|---:|---:|
|
|
| CPU 1 thread | 333 to 339 ms | 330 to 336 ms | 164 to 170 ms | 134 to 140 ms |
|
|
| CPU 4 threads | 140 to 158 ms | 146 to 148 ms | 103 to 106 ms | 134 to 136 ms |
|
|
| Metal | 59 to 70 ms | 65 to 87 ms | 97 to 102 ms | 136 to 141 ms |
|
|
|
|
The encoder path is unchanged within run-to-run variation. The whole difference is
|
|
the decoder: the host decoder spread its matrix-vector products over OpenMP threads,
|
|
and the step graph runs on one thread.
|
|
|
|
The decoder graph has 4738 nodes per step.
|
|
|
|
Why the decoder runs on one CPU thread: a one-token step is thousands of small nodes,
|
|
so its cost is per-node overhead, not arithmetic. `whistle_asr.decode_ms` for the 42
|
|
sample steps, three runs each: 134 to 140 ms on one CPU thread; 550 to 560 ms on Metal;
|
|
1014 to 1111 ms on a private CPU context with 2 threads (Metal session); 1787 to 2098 ms
|
|
on the CPU session backend with 4 threads. Each node synchronizes the threads, so more
|
|
threads cost more.
|
|
|
|
Longer clips on Metal, one run each:
|
|
|
|
| Clip | Audio | Wall | RTF |
|
|
|---|---:|---:|---:|
|
|
| en | 20.43 s | 357 ms | 0.0175 |
|
|
| pl | 22.94 s | 761 ms | 0.0332 |
|
|
|
|
Two requests in one session: CPU with 4 threads took 293 ms, then 284 ms. Metal took
|
|
198 ms, then 184 ms.
|
|
|
|
### Process memory
|
|
|
|
Maximum resident set size from `/usr/bin/time -l`, sample:
|
|
|
|
| Backend | Before (host decoder) | This branch |
|
|
|---|---:|---:|
|
|
| CPU 4 threads | 758 MiB | 729 MiB |
|
|
| Metal | 765 MiB | 720 MiB |
|
|
|
|
The decoder weights no longer have host copies. The engram tables (two 37.7 MB f32
|
|
tables) stay on the host.
|
|
|
|
### Review fixes recorded
|
|
|
|
- An intermediate Sinkhorn form (column potentials, fewer nodes) was replaced by the
|
|
original row-then-column log normalization. In a float check with logits spread far
|
|
wider than the model produces, a column whose entries all underflow gave NaN. With
|
|
the original form the encoder is bit-identical again. The decoder step time is
|
|
unchanged.
|
|
- The decoder state buffer is now freed if construction fails after it was allocated.
|
|
|
|
### Known limitations and follow-ups
|
|
|
|
- Metal and 4-thread CPU requests are slower than with the host decoder (see the timing
|
|
table). One-thread CPU is slightly faster.
|
|
- Cross-attention keys and values are read back from the encoder graph and uploaded to
|
|
the decoder tensors once per request.
|
|
- The engram tables stay on the host.
|
|
- The encoder graph is still built per request.
|
|
- CUDA, Vulkan, and HIP are not validated. Sessions still reject them.
|
|
- The input set is the same as before: the sample plus seven synthetic clips. It is not
|
|
a broad corpus.
|