Artigo

O Whisper não rodava no iPhone: o erro -14 do CoreML e a saída com Metal

O mesmo modelo de Whisper rodava redondo no Mac e simplesmente não rodava no iPhone. O culpado não era o modelo nem o aparelho. Era o caminho de execução: o CoreML não compila o encoder grande no telefone, e forçar a GPU crasha o processo. A saída foi trocar o runtime para o whisper.cpp com Metal.

Artigo original do site. O pacote WhisperMetalKit é Apache 2.0, código aberto.

Em uma leitura

TL;DR

  • O mesmo modelo de Whisper rodava no Mac e não rodava no iPhone. O culpado era o caminho de execução (CoreML), não o modelo nem o aparelho.
  • No iPhone o CoreML falha de dois jeitos: erro -14 na Neural Engine e um crash incapturável no MPSGraph ao forçar a GPU.
  • Trocar o runtime para o whisper.cpp com Metal resolveu: 16,8s de áudio em 1,15s, sem crash. Virou o pacote open source WhisperMetalKit (Apache 2.0).

Contexto

Olá pessoal. Vou contar aqui a história de um problema que me consumiu alguns dias e que tinha uma solução bem menos óbvia do que eu imaginava: o mesmo modelo de Whisper rodava redondo no Mac e simplesmente se recusava a rodar no iPhone. O spoiler é que o culpado não era nem o modelo nem o aparelho. Era o caminho que eu estava usando para rodar o modelo.

Um pouco de contexto antes. Estou construindo o Voxfloy, um app de transcrição (speech-to-text) que roda on-device, ou seja, o áudio não sai do aparelho. Essa restrição é proposital e ela elimina a saída fácil de mandar o áudio para um servidor transcrever. Então eu preciso que o modelo de fato rode no telefone.

A escolha óbvia em plataforma Apple é o WhisperKit. É open source, bem mantido, tem uma API Swift gostosa de usar, roda o Whisper via CoreML e deixa o sistema distribuir as camadas entre Neural Engine, GPU e CPU. No Mac, primeira tentativa, funcionou lindamente. Pinei o large-v3-turbo, a qualidade veio ótima, a latência baixa. Dei o assunto como resolvido na minha cabeça. Aí fui rodar no iPhone. E não rodou.

Onde travou (e de dois jeitos diferentes)

A parte interessante é que não foi um "rodou mais devagar" ou "veio uma transcrição pior". Foi não rodar mesmo, de duas formas diferentes, dependendo de para onde eu empurrava o modelo.

A Neural Engine não consegue compilar o encoder

Com o large-v3-turbo pinado, o CoreML tenta montar o modelo na Neural Engine. No iPhone, o log dava isso:

MILCompilerForANE error: ANECCompile() FAILED
... std::bad_alloc
Failed to build the model execution plan ... error code: -14

Traduzindo: o compilador da Neural Engine estoura a memória ao tentar montar o plano de execução do AudioEncoder. Aquele std::bad_alloc é falta de memória dentro do próprio compilador (não no meu app), e o -14 é o CoreML levantando a mão e desistindo. No Mac, o mesmo modelo compila numa boa, porque sobra fôlego de ANE e memória. No telefone, não sobra.

Forçando a GPU, o app morre

A reação natural foi: se a ANE não dá conta, então manda para a GPU. O CoreML deixa a gente escolher as compute units (MLComputeUnits.cpuAndGPU). E aí foi pior. Em vez de um erro que dá para tratar, veio um assert fatal lá dentro do MetalPerformanceShadersGraph:

MPSGraphComputePackage ... failed assertion 'cannot open input file ... .mpsgraph'

Isso não é um erro que você captura. É um abort(). O processo inteiro cai. Não tem try/catch, não tem timeout, não tem fallback que segure: quando o assert dispara, o app já foi. Ou seja, o meu plano B era pior que o problema original, porque transformava um erro recuperável num crash na cara do usuário.

E aqui um ponto que faço questão de deixar claro, para ser justo: isso não é bug do WhisperKit. É o CoreML batendo nos limites de hardware e de compilador ao tentar montar um encoder grande num telefone. O WhisperKit é excelente onde o CoreML cabe. Esse modelo, nesse aparelho, não cabia.

A virada: trocar o runtime, não o modelo

Foi aqui que caiu a ficha. Eu estava insistindo em ajustar o modelo e as configurações do CoreML, quando o que precisava mudar era o motor.

O Whisper não está casado com o CoreML. O whisper.cpp tem um runtime completamente diferente: GGML com backend Metal. Ele não passa por CoreML, não passa pelo compilador da Neural Engine, não passa pelo MPSGraph. Os dois modos de falha lá de cima simplesmente não existem nesse caminho, porque o caminho é outro. São os mesmos pesos do Whisper, só que num motor diferente.

flowchart TD A["Áudio gravado (16kHz mono)"] --> B{"Runtime escolhido"} B -->|"CoreML / Neural Engine"| C["std::bad_alloc · CoreML error -14"]:::fail B -->|"CoreML / GPU"| D["assert fatal no MPSGraph · abort()"]:::fail B -->|"whisper.cpp"| E["GGML + Metal · sem CoreML/ANE/MPSGraph"]:::ok E --> F["transcrição em ~1,15s"]:::ok classDef fail fill:#3b1020,stroke:#ff669f,color:#ffd9e6; classDef ok fill:#0f2e1a,stroke:#46d18b,color:#d7f7e6;
Os dois caminhos do CoreML falham no iPhone. O do whisper.cpp (GGML/Metal) não passa por nenhum deles, então as mesmas falhas não existem.

Troquei o engine do iOS para o whisper.cpp em Metal, usando o large-v3-turbo-q5_0 (a versão quantizada, ~574 MB). Mesmo iPhone 17 Pro Max (A19 Pro), mesma frase de teste:

[WhisperMetal] audio IN: 16.8s
[WhisperMetal] audio OUT: 168 chars in 1147ms

16,8 segundos de áudio transcritos em 1,15 segundo. E sem crash. O turbo ajuda bastante aqui, porque ele tem só 4 camadas de decoder, então o decode voa. E como não tem CoreML no meio do caminho, não tem -14 nem MPSGraph para dar errado.

Os números que vimos

Tudo medido no mesmo iPhone 17 Pro Max (A19 Pro), com whisper.cpp em Metal. Dois detalhes me surpreenderam: o turbo é "large-v3" mas tem só 4 camadas de decoder (contra 24 do medium), por isso é mais rápido apesar de ser maior; e a primeira execução compila os shaders Metal embutidos (~6,2s), mas isso fica em cache e cai para ~0,03s nas próximas.

Modelo: medium vs large-v3-turbo

Modelo (q5_0)TamanhoÁudioTempoDecoder
medium~514 MB14,0s1,9s24 camadas
large-v3-turbo~574 MB16,8s1,15s4 camadas

Jargão técnico: medium vs turbo (mesma frase em PT)

O que faleimedium-q5large-v3-turbo-q5
endpoint"End Pond"endpoint ✅
pull request (PR)"Puri Quest"pr ✅
Supabase"Super Base""super base" ⚠️

O turbo arruma quase todo o jargão sozinho. O que escapa (a grafia de marca, tipo "Supabase") é exatamente o trabalho do boost de vocabulário: injetar os termos como prompt no decode e fazer uma substituição exata no pós-processamento.

O que eu achei pronto (e o que não achei)

Resolver o motor foi metade do problema. A outra metade foi descobrir que, em pleno 2026, não existe um pacote SPM mantido que entregue esse runtime GGML/Metal do whisper.cpp atrás de uma API Swift decente. Fui checar com calma antes de sair reclamando, e o cenário é esse:

  • O próprio whisper.cpp passou a publicar um xcframework oficial com Metal nas releases. Ótimo, mas é C cru: você importa e chama as funções na mão.
  • Os wrappers SPM clássicos (whisper.spm, SwiftWhisper) estão parados desde 2024 e sem Metal (só CPU). O Package.swift do whisper.spm tem, há dois anos, um TODO admitindo que não conseguiram buildar os shaders Metal no SPM.
  • O WhisperKit te dá a API Swift boa, mas é CoreML, ou seja, exatamente o caminho que tinha falhado.

Então o que faltava não era o binário Metal (esse o upstream já fornece). Faltava a camada de developer experience em Swift por cima desse runtime. Empacotei isso e abri como open source: o WhisperMetalKit (Apache 2.0). É o whisper.xcframework Metal embrulhado como binaryTarget, mais um WhisperModel async (actor), um resampler de áudio em AVFoundation e um downloader de modelo. Instalação em uma linha:

.package(url: "https://github.com/carloshpdoc/WhisperMetalKit.git", from: "0.1.0")
let model = try await WhisperModel(modelPath: url)            // ggml / gguf
let samples = try WhisperAudio.samples(fromFile: recording)  // qualquer arquivo -> 16kHz mono
let result = try await model.transcribe(samples: samples, options: .init(language: "pt"))
print(result.text)

No Voxfloy, o engine de iOS começa no motor da Apple (que carrega na hora) e troca para o Whisper assim que o modelo fica pronto, sem o resto do app precisar saber qual está rodando. O usuário só percebe que a transcrição ficou melhor.

flowchart LR A["Gravação"] --> B{"Modelo Whisper pronto?"} B -->|"não (no boot)"| C["Apple SpeechAnalyzer · instantâneo"] B -->|"sim"| D["whisper.cpp + Metal · large-v3-turbo-q5"] C --> E["Texto"] D --> E["Texto"]
O composite de transcrição: o app responde na hora com o motor da Apple e faz upgrade para o Whisper quando o modelo termina de carregar.

O que ficou de aprendizado

O aprendizado maior aqui nem é sobre Whisper. É sobre separar o modelo do runtime na cabeça.

Quando alguma coisa "não roda", o instinto é mexer no modelo ou trocar de aparelho. Mas com ML on-device existe uma terceira variável quase invisível: o caminho de execução. CoreML, GGML/Metal e o que mais vier por aí são formas diferentes de rodar os mesmos pesos, e cada uma tem limites de compilador e de memória bem diferentes. Trocar o caminho foi mais barato e mais robusto do que ficar brigando com o caminho que estava falhando.

O segundo aprendizado é mais prosaico: vale checar o estado real do ecossistema antes de assumir que algo existe. Eu quase parti do princípio de que "claro que tem um whisper.cpp Metal via SPM bem mantido". Não tinha. O que existia estava parado ou era C cru. O gap era real, só que mais estreito do que parecia, e descrever isso com honestidade vale mais do que vender milagre.

Para ser honesto (os limites)

  • Eu não inventei o binário Metal. O whisper.cpp já publica o xcframework com Metal. A minha parte é a camada Swift por cima e o contexto de por que escolher GGML/Metal em vez de CoreML no iPhone. Quem quiser, pode apontar o binaryTarget direto para a release oficial do whisper.cpp.
  • O benchmark é um aparelho e um trecho de áudio. iPhone 17 Pro Max, A19 Pro, uma frase. Em aparelhos mais antigos o large-v3-turbo vai pesar mais, e a resposta certa provavelmente é cair para medium ou small.
  • O WhisperKit continua sendo uma ótima escolha onde o CoreML cabe. Modelos menores compilam bem na ANE e ganham uma aceleração que o caminho GGML não tem. A decisão não é "um é melhor que o outro", é "qual runtime aguenta o seu modelo no seu aparelho".
  • O modelo é baixado em runtime, não vai dentro do app. São ~574 MB baixados uma vez e cacheados (e eu deixo fora do backup do iCloud). Embutir no bundle estouraria o tamanho do app.

Conclusão

O Whisper não rodava no iPhone porque o CoreML não dava conta de compilar aquele encoder grande naquele hardware, e forçar a GPU só trocava um erro tratável por um crash. A saída foi parar de brigar com o CoreML e trocar o runtime para o GGML/Metal do whisper.cpp, que ignora todo esse caminho e roda o mesmo modelo em cerca de um segundo. E o resíduo útil disso para a comunidade virou um pacote: a API Swift que faltava em cima de um runtime que já existia.

Referências

Para aprofundar