Um Build CUDA para o Nosso App
Ate a versao v0.20.0 do Perssua, a versao Windows do nosso modo LLM local rodava o llama-server.exe apenas na CPU. Nenhum suporte a GPU NVIDIA. Usuarios com CPUs com suporte a AVX2 tinham um desempenho razoavel. Usuarios com uma 4090 na mesma maquina tinham o mesmo desempenho razoavel — ou seja, nao o desempenho pelo qual pagaram.
A v0.20.0 muda isso. Agora existe um build CUDA do llama-server.exe. A deteccao e automatica, o fallback para CPU e automatico, e — essa e a parte que quero falar — o bundle CUDA nao esta no instalador. Ele e baixado na primeira vez que voce precisa, entao usuarios sem GPU NVIDIA nao pagam por bytes de download que nunca vao usar.
Essa ultima decisao direcionou a maior parte do trabalho.
Por Que Decidimos Fazer Sob Demanda
A primeira versao desse trabalho incluia o llama-server.exe com CUDA e suas DLLs (cudart64_12, cublas64_12, cublasLt64_12, nvJitLink_120_0, nvfatbin_120_0, nvrtc64_120_0, nvrtc-builtins64_128 — sim, voce precisa incluir todas as sete) como parte do instalador NSIS. Sao muitos megabytes que a maioria dos usuarios Windows nunca usa, ja que a maioria esta em graficos integrados Intel ou AMD.
Entao removemos tudo isso. O instalador base agora contem apenas o binario de CPU. O bundle CUDA vai para um release separado no GitHub como um zip versionado. Em tempo de execucao, quando detectamos uma GPU NVIDIA mas nenhum binario CUDA instalado, baixamos o bundle na pasta de dados do usuario do app, validamos seus metadados embutidos contra a versao esperada, e so entao comecamos a usa-lo.
Compilando llama.cpp no Windows, de Forma Reproduzivel
Vou ser honesto: o pipeline de build demorou mais que o trabalho de runtime.
Fixamos tudo que pode variar. Um commit especifico do llama.cpp. CUDA Toolkit 12.8.1. Driver NVIDIA 572.61 como o minimo que vamos suportar. MSVC localizado via vswhere. Se qualquer um desses flutuar, o build e reproduzivel ate nao ser mais, e o dia em que nao sera e daqui a seis semanas durante um hotfix.
Algumas coisas que o script acabou tratando que nao estavam no plano original:
Bootstrap do CUDA. Se o runner nao tiver o CUDA Toolkit 12.8.1, o script baixa o instalador oficial, extrai com um 7zr.exe portatil e junta os sete pacotes que realmente precisamos (cuda_nvcc, cuda_cudart, libcublas, libnvjitlink, cuda_nvrtc, libnvfatbin, cuda_cccl) em um diretorio de toolkit auto-contido. Sem instalacao admin. Os runners de CI nao precisam de permissoes elevadas.
Codegen multi-arquitetura. Compilamos para 50-virtual; 61-virtual; 70-virtual; 75-virtual; 80-virtual; 86-real; 89-real; 120a-real. Arquiteturas virtuais emitem PTX que o driver compila via JIT na primeira execucao. As reais emitem SASS especifico. O mix e um tradeoff entre tamanho do binario e latencia de inicializacao nas placas que esperamos que os usuarios realmente tenham, que hoje vai de Pascal a Blackwell. Encaixar todas essas em um unico binario e um impacto real no tamanho, e o nvcc e, para dizer o minimo, lento. Na primeira vez que rodamos de ponta a ponta, o build no CI demorou o suficiente para eu ir fazer um cafe e terminar de tomar.
GGML_BACKEND_DL=ON e GGML_CPU_ALL_VARIANTS=ON. O carregador de backend dinamico mais um build de CPU fat-binary significa que um unico llama-server.exe despacha para o caminho SIMD correto (AVX, AVX2, AVX512, AMX, qualquer um) em tempo de execucao. Nao enviamos seis variantes de CPU; enviamos uma e deixamos ela escolher.
Empacotando DLLs de runtime MSVC e OpenMP. O script percorre as pastas VC\Redist\MSVC*\x64 da instalacao do VS procurando MSVCP140.dll, VCRUNTIME140.dll, VCRUNTIME140_1.dll, mais uma entre vcomp140.dll ou libomp140.x86_64.dll. As DLLs CUDA vem direto do bin/ do toolkit resolvido. Um passo de verificacao separado confirma que cada DLL necessaria esta presente e entao roda o binario com --list-devices, fazendo regex no output para a assinatura que esperamos quando o CUDA realmente carregou. Isso pega o modo de falha "as DLLs estao tecnicamente presentes mas o binario morre por um simbolo faltando" antes do bundle ser publicado.
A ultima coisa que adicionamos, e a coisa que tornou esse projeto viavel de iterar, foi o cache. No CI, se um bundle publicado para o mesmo commit do llama.cpp e toolkit CUDA ja existe no release, pulamos o build inteiro e reutilizamos. Builds de quarenta minutos viraram downloads de trinta segundos. Eu deveria ter construido esse cache desde o primeiro dia.
Deteccao e Fallback, Com Motivos
O lado runtime tem tres tarefas: descobrir se o CUDA vai funcionar, instalar o bundle se necessario, e fazer fallback graciosamente se nao. A parte dificil e fazer o "fallback" inteligivel para o usuario quando acontece. A tentacao e logar "Aceleracao de GPU indisponivel" e pronto, mas na pratica isso gera tickets de suporte que nao conseguimos responder.
Entao cada modo de falha e um valor de enum com uma string traduzida voltada ao usuario. Os codigos de razao que mais importaram no uso real:
binary_missing_nvidia_detected. Voce tem uma GPU NVIDIA, mas ainda nao temos o bundle CUDA. Este e o unico que dispara a instalacao automatica.
gpu_missing. Nenhum driver NVIDIA reportou GPU compativel. Nao tente.
cuda_unavailable. Barreira generica. Geralmente significa que o driver e muito antigo para um binario CUDA 12.8.
cuda_gpu_layers_zero. O CUDA iniciou bem, mas o auto-tuning de startup empurrou as camadas de GPU para 0 para caber na VRAM. O binario tem capacidade CUDA mas esta efetivamente rodando na CPU. O indicador de compute corretamente diz CPU. Tivemos que adicionar este depois de pegar a nos mesmos mentindo sobre aceleracao que nao estava realmente acontecendo.
cuda_startup_failed. O binario iniciou e morreu imediatamente.
cuda_bundle_install_failed. Download ou extracao quebrou. Fallback para CPU, mostra o erro.
A orquestracao: verificar disponibilidade, e se o unico problema for binary_missing_nvidia_detected, disparar a instalacao, re-verificar, tentar novamente. Qualquer outra coisa, logar um fallback de backend e enfileirar o plano CPU como a proxima tentativa de lancamento. Se o CUDA realmente iniciar mas o auto-tuning puxar o offload para zero, rebaixar o backend reportado para CPU e carimbar o codigo de razao.
Uma Race Condition Que Nao Viamos Vindo
Dois chamadores podem pedir a disponibilidade do CUDA quase ao mesmo tempo. A aba de configuracoes montando e uma requisicao de inicio de servidor, por exemplo. A verificacao executa nvidia-smi e roda --list-devices contra o binario empacotado, o que leva algumas centenas de milissegundos. O resultado vive em um cache de 30 segundos.
Tinhamos um bug onde uma verificacao mais antiga podia terminar depois de uma mais nova e sobrescrever a entrada fresca do cache com dados obsoletos. Ficava escondido na maior parte do tempo porque o TTL encobre. O sintoma, quando aparecia, era que o painel de configuracoes dizia brevemente "sem CUDA" logo apos o servidor ter iniciado com sucesso no CUDA. Confuso, dificil de reproduzir sob demanda, eventualmente rastreavel.
A correcao e um sentinela de token de verificacao. Cada verificacao cria um Symbol, escreve em um campo do servico como o token "atual", e so comita seu resultado no cache se esse campo ainda corresponder quando a verificacao terminar. Basicamente:
const probeToken = Symbol('cuda-probe');
this.activeProbeToken = probeToken;
const result = await runCudaProbe();
if (this.activeProbeToken === probeToken) {
this.availabilityCache = { value: result, timestamp: Date.now() };
}
Uma verificacao mais nova rotaciona o symbol; a mais antiga resolve mas nunca escreve. O mesmo padrao se repete no cache de telemetria GPU ao vivo.
Telemetria GPU ao Vivo Sem Deixar as Configuracoes Lentas
Ha um card de runtime nas configuracoes que mostra informacoes ao vivo quando o servidor esta rodando no CUDA — versao do driver, utilizacao de GPU e memoria, VRAM usada vs. total, indicador de status. Para popular isso precisamos continuar executando nvidia-smi, e o nvidia-smi tem um cold start lento no Windows. Medimos 200 a 500 milissegundos, ocasionalmente pior.
Entao nao executamos de forma ansiosa. Ha um TTL de 5 segundos na query de runtime, separado do TTL de 30 segundos na deteccao de capacidade. Ha tambem uma protecao rigida: so consultamos quando a plataforma e Windows, o backend e CUDA, o servidor esta vivo e o PID e valido. Servidores ociosos e servidores em modo CPU nao pagam nada. O card le do cache e a atualizacao e debounced.
Uma peculiaridade do Windows que vale mencionar: sob WDDM, mesmo um processo CUDA saudavel pode momentaneamente nao aparecer como um "processo CUDA em execucao" enquanto o modelo esta ocioso. Reportar "Indisponivel" nesse caso seria enganoso. O card tem um indicador explicito de Idle / WDDM para isso: sim, esta tudo bem, o SO so nao ve compute agora.
Coisas Que Nao Entraram
ROCm. A mesma arquitetura funcionaria, mas a historia de runtime AMD no Windows ainda esta em movimento e nao pusemos a mao na massa la.
CUDA no Linux. O pipeline de bundle esta estruturado para suportar. So nao conectamos ainda.
Um planejador de VRAM mais inteligente. No momento o CUDA recebe gpuLayers = 99, que significa "descarregue tudo." Para os modelos que enviamos como padrao esta tudo bem. Modelos maiores com offload parcial poderiam ser mais inteligentes, mas queriamos lancar.
Se eu tivesse que fazer isso do zero, construiria o cache de CI primeiro e o contrato de metadados do bundle em segundo. Todo o resto derivou dessas duas decisoes, e errar qualquer uma delas teria tornado a coisa toda impossivelmente lenta de iterar.