Skip to content

feat(parakeet): add Apple Core ML (Neural Engine) encoder sidecar#100

Draft
Zbig9000 wants to merge 5 commits into
tetherto:masterfrom
Zbig9000:QVAC-22470-parakeet-coreml
Draft

feat(parakeet): add Apple Core ML (Neural Engine) encoder sidecar#100
Zbig9000 wants to merge 5 commits into
tetherto:masterfrom
Zbig9000:QVAC-22470-parakeet-coreml

Conversation

@Zbig9000

Copy link
Copy Markdown

Summary

Optional, Apple-only, additive acceleration for Parakeet file transcription. When a <model>-encoder.mlmodelc sits next to the GGUF on Apple hardware and the library is built with -DPARAKEET_COREML=ON, the offline FastConformer encoder runs on the Apple Neural Engine via a generic Core ML MLModel wrapper. Otherwise run_encoder silently falls back to the ggml (Metal/CPU) encoder. Mel preprocessing, the TDT/CTC decode loop and the tokenizer always stay on ggml. Modelled on the existing whisper.cpp Core ML pattern.

Selection is presence-driven (no opt-in flag): missing/corrupt/incompatible sidecar or non-Apple build -> ggml encoder. Non-Apple platforms are unaffected (feature compiles out entirely).

Changes

  • src/coreml/parakeet-encoder.{h,mm}: ObjC++ sidecar (ARC) with layout-adaptive tensor I/O, Float16/Float32 output handling and presence-driven load, folded into libparakeet as an OBJECT library. The .mlmodelc export contract is documented in the header.
  • src/parakeet_ctc.cpp: presence-driven loader + run_encoder routing to Core ML for the full-context encoder, with ggml fallback; backend accessors.
  • include/parakeet/engine.h, src/parakeet_engine.cpp: encoder_backend() / encoder_on_coreml() for stats.
  • CMakeLists.txt: PARAKEET_COREML option. Enables OBJC+OBJCXX before add_subdirectory(ggml) so the .mm gets a compile rule and ggml-metal .m sources stay Objective-C under CMake 4.x.
  • test/test_encoder_coreml_parity.cpp: per-frame cosine-similarity gate vs the ggml encoder; skips when the sidecar is inactive (no-op off Apple / no sidecar).

On-device validation (Apple M3 Ultra, macOS 15.5, Xcode 16.4, CMake 4.1.1)

  • Full build with -DPARAKEET_COREML=ON (Metal + Core ML together): libparakeet.dylib links; parakeet-encoder.mm compiles as ObjC++/ARC against the real CoreML/Foundation SDK.
  • test-encoder-coreml-parity compiles and links on Apple.
  • Wrapper exercised against a synthetic .mlmodelc matching the export contract, driving the real built wrapper object: init backend=coreml, encode rc=0, exact round-trip in FP32 (cos=1.0) and within tolerance in FP16 (cos=0.99999998, exercising the decode_float16 path).

Not covered here (follow-ups, need real assets)

  • Mobius export of the real FastConformer to .mlmodelc + coreml-cli ANE op-placement verification.
  • Real GGUF + wav parity run (loader/run_encoder routing end-to-end) and numerical parity vs ggml.
  • End-to-end WER + RTF benchmark vs ggml-Metal and ANE speedup for long audio.

Related

  • vcpkg coreml feature for Darwin/iOS prebuilds (qvac-registry-vcpkg).
  • Backend reporting (encoderBackend/encoderOnCoreml) in @qvac/transcription-parakeet.

Optional, Apple-only, additive acceleration for file transcription: when a
<model>-encoder.mlmodelc sits next to the GGUF on Apple hardware and the addon
is built with PARAKEET_COREML, the offline FastConformer encoder runs on the
Neural Engine via a generic MLModel wrapper; otherwise run_encoder falls back
to the ggml (Metal/CPU) encoder. Mel preprocessing, TDT/CTC decode and the
tokenizer stay on ggml. Modelled on the whisper.cpp Core ML pattern.

- src/coreml/parakeet-encoder.{h,mm}: ObjC++ sidecar with layout-adaptive I/O,
  Float16/Float32 output handling and presence-driven load, folded into
  libparakeet as an ARC OBJECT library.
- run_encoder: routes the full-context FastConformer through Core ML when the
  sidecar is active; silent ggml fallback otherwise.
- engine: encoder_backend()/encoder_on_coreml() accessors for backend stats.
- CMake: PARAKEET_COREML option; enables OBJC+OBJCXX before add_subdirectory
  (ggml) so the .mm gets a compile rule and ggml-metal .m sources stay
  Objective-C under CMake 4.x.
- test-encoder-coreml-parity: per-frame cosine-similarity gate vs the ggml
  encoder; skips when the sidecar is inactive.
@github-actions

Copy link
Copy Markdown

Review Status

Current Status: ❌ PENDING
Approvals so far: none

Pending reviews: Needs 1 Management or Team Lead, and 1 more from Management, Team Lead, or Member.

Ichtaca0 added 4 commits July 21, 2026 17:44
…elc)

Produces the offline FastConformer `<model>-encoder.mlmodelc` sidecar consumed
by the PARAKEET_COREML path. Reuses the pure-PyTorch reference encoder
(ref-encoder-from-gguf.py) so the exported graph matches the ggml encoder
numerically, traces it at a fixed mel length (from a sample wav or an explicit
count), and converts to a Core ML package (fp16 for the Neural Engine) with an
optional coremlc compile step. Handles use_bias=False checkpoints (e.g.
parakeet-tdt-0.6b-v3) and keeps the conv module trace-safe.
…tion parity

- Move the sidecar path derivation into its own pure TU
  (src/parakeet_coreml_path.{h,cpp}) and make it quant-independent: strip a
  trailing quant tag whether dot- or dash-separated (`.f16`, `.q8_0`, `-q8_0`),
  so one exported encoder serves every decoder quantisation of a model
  (`parakeet-tdt-0.6b-v3.q8_0.gguf` and `...f16.gguf` -> `...v3-encoder.mlmodelc`).
  The previous helper only stripped the dash form, leaving the dot-form quant in
  the sidecar name.
- test-coreml-paths: portable unit test for the derivation. No model, ggml or
  Apple frameworks, so it runs on every CI regardless of PARAKEET_COREML.
- test-transcribe-coreml-parity: Apple-gated end-to-end gate that asserts
  Engine::transcribe() yields identical text on the Core ML encoder and the ggml
  encoder (each run in its own forked process). Skips when the sidecar is absent.

Validated on Apple M3 Ultra: path unit PASS, encoder cosine 1.000000, and
Core ML vs ggml transcripts identical on jfk.wav.
The Core ML wrapper previously bound only to the exact mel length the
.mlmodelc was traced at: resolve_input_shape returned nil on any other
length, so Engine::transcribe() silently fell back to the ggml encoder
for every utterance whose length differed from the export. Since the
offline encoder runs over the whole utterance in one shot (no fixed
window), the sidecar effectively never engaged for arbitrary-length
audio.

Rebuild the input at the caller's mel length in the model's declared
orientation (new shape_for_length helper) instead of requiring an exact
match. A fixed-shape model at its trace length is still honoured verbatim
(unchanged, including the parity gate); a fixed model at another length
still fails in Core ML prediction and falls back to ggml as before; and a
flexible (RangeDim / enumerated) export is driven at the requested length
across utterances.

This is the consumption half of variable-length support. Producing a
flexible .mlmodelc is a separate exporter change: torch.jit.trace bakes
the sequence length into the positional-embedding slice and attention
rel_shift, so the exporter needs torch.export dynamic shapes (or
scripting) plus on-device (ANE) parity validation, tracked as a follow-up.
Add an opt-in --flexible mode to the Core ML encoder exporter that declares
the mel time axis as a RangeDim, so one exported .mlmodelc serves
variable-length utterances instead of only the single traced length.

torch.jit.trace (the fixed default) bakes the sequence length into the
positional-embedding slice and attention rel_shift, so a RangeDim over that
graph would still only work at the traced length. The flexible path instead:
- captures via torch.export dynamic_shapes, which keeps the time axis
  symbolic through the graph;
- swaps in a masking-free subsampling (ref.subsampling's per-stage masks are
  all-ones for the full-valid offline input, so this is a numerical no-op)
  to keep the stride-2 conv stack polymorphic in time;
- bakes the relative positional table as a fixed buffer sized to the longest
  supported utterance and slices it by the symbolic length, leaving the
  dynamic slice as the only length-dependent op.

The fixed default path is unchanged. Flexible export is best-effort and must
be validated on Apple hardware (test-encoder-coreml-parity at several lengths
+ coreml-cli ANE op-placement) before use, since torch.export / coremltools
dynamic-shape handling for rel_shift can need model-specific tweaks; it
requires coremltools>=8 / torch>=2.3. Pairs with the wrapper change that
drives a flexible encoder at the caller's length.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants