Dobrando a geração de tokens na Intel Arc Pro B65 com SYCL
Essa semana chegou minha Arc B65 de 32GB. Comprei ela com um objetivo de treinar e rodar modelos localmente. A escolha foi técnica e financeira (ta, mais financeira eu confesso).
Técnica porque uso Linux (Ubuntu) num mini PC da GMKtec com processador AMD. A única forma de colocar uma GPU dedicada nele é via dock eGPU, por USB-C ou Oculink, e as placas novas da Nvidia não se dão muito bem com Linux nessa combinação. Financeira porque hoje a melhor placa pra rodar LLM local é a 5090, só que essa porcaria custa o preço de um carro usado.
O que define qual modelo tu consegue rodar localmente é quanta VRAM tu tem. E quando digo “rodar” quero dizer conseguir usar o modelo pra fazer alguma coisa útil, não só ele carregar sem estourar memória. A Nvidia nada de braçada nesse mercado, mas já começam a aparecer opções que valem a pena, e a Arc B65 é uma delas, entregando os mesmos 32GB VRAM de uma 5090 por uma fração do preço.
A VRAM é só uma parte do resultado. Se a VRAM limita o tamanho do modelo que tu consegue rodar, a velocidade do barramento de memória define quantos tokens/seg tu vai ter no modelo. Dá pra ter uma ideia bem boa de quantos tokens por segundo esperar de qualquer hardware sabendo só a banda de memória da VRAM: divide a banda pelo tamanho do modelo carregado e tu tem um teto teórico. A geração, token a token, é limitada por banda, porque cada token exige ler os pesos do modelo inteiro pelo menos uma vez (modelos MoE se beneficiam por que ativam apenas uma parte dos pesos, dai faz a mesma conta dividindo pela quantidade ativa).
No caso da B65 a banda é 608GB/s. Na minha conta de padeiro, o modelo Qwen3.8-27B quantizado seria algo perto de 608 ÷ 15.3 ≈ 40 tok/s de teto teórico. Só de comparação, a infeliz da 5090 tem 1792GB/s — quase 3x mais banda — o que daria uns 117 tok/s de teto pro mesmo modelo. É por isso que ela custa o que custa.
O modelo que escolhi pra testar foi o Qwen3.8-27B quantizado pela Unsloth
, variante UD-Q4_K_M (15.3GB). Nessa placa, e sem MTP (falo dele mais adiante), eu devia estar perto desses ~40 tok/s teóricos.
Só que meu primeiro teste, depois de instalar os drivers e subir o llama.cpp, me entregou algo entre 9 e 11 tok/s.
Isso é ridículo.
Então meu final de semana que seria de brincar com modelos fazendo coisa inútil virou uma perseguição por tuning em busca do potencial teórico da placa. Aqui eu registro os perrengues e o que descobri para servir de referência futura.
Não posso deixar de agradecer o field note do sergiiob.dev . Sem aquele post eu ainda estaria batendo cabeça com o Vulkan por que os agentes Claudio e Gepeto acreditavam que o caminho que seguimos depois não era recomentado sabe lá por que.
A máquina
| CPU | AMD Ryzen 7 PRO 6850U (8c/16t), mini PC GMKtec NucBox M7 Ultra |
| RAM | ~13GB total + 4GB swap — pouco, e isso importa mais adiante |
| GPU (ociosa) | AMD Radeon 680M iGPU (Vulkan0) |
| GPU (alvo) | Intel Arc Pro B65, 32GB GDDR6, driver Mesa anv/xe |
| Conexão da GPU | eGPU dock via Oculink |
| Modelo | Qwen3.8-27B (denso) , UD-Q4_K_M (15.3GB) + draft model MTP, ctx 131072 |
| llama.cpp | ggml-org/llama.cpp @ 8172e65 |
O sintoma
Antes de sair atrás da causa, quis confirmar esse número de forma controlada em vez de confiar só no que via em produção. Primeiro conferi o óbvio: a GPU realmente estava sendo usada (-ngl 99, split-mode none, device Vulkan certo, uso de CPU batendo o padrão esperado de “despachar e esperar”, não inferência rodando na CPU por engano). Não era isso. O número mesmo era o problema: 7.88 tok/s num llama-bench limpo, enquanto o processamento do prompt ia bem, 314.94 tok/s na mesma GPU. Ou seja, o prefill estava ótimo — o gargalo estava especificamente na geração.
Como não fazer benchmark
Pra isolar se o problema era flash-attention ou o tipo de quantização do KV cache, subi mais duas rodadas de llama-bench em background — em cima do llama-server que já estava rodando, sem matar o primeiro nem checar memória livre. Três carregamentos de modelo de 15GB+ ao mesmo tempo, numa caixa de 13GB de RAM:
Out of memory: Killed process 18700 (chrome)
Out of memory: Killed process 18794 (llama-server)E ainda peguei um segundo bug, mais bobo, que me custou uma hora de debug: pkill -9 -f llama-bench estava matando o próprio script — o padrão “llama-bench” aparecia mais adiante, na linha do comando que o script ia executar, e o -f casa com a linha inteira, não só o nome do processo. O script morria sozinho antes de rodar qualquer coisa. Resolvido trocando pra pkill -9 -x <nome-exato>.
Descartando os suspeitos óbvios
Com um processo por vez e um piso de memória sendo respeitado, rodei três tg64 limpos no backend Vulkan:
| flash-attention | KV cache | tg (tok/s) |
|---|---|---|
| desligado | f16 | 7.92 |
| ligado | f16 | 7.88 |
| ligado | q8_0 (config de produção) | 7.77 |
Os três dentro da margem de ruído um do outro. Nem flash-attention, nem a quantização do KV cache eram o gargalo — a linha reta entre as três configs apontava pro próprio kernel de decode do Vulkan (a multiplicação matriz-vetor do decode token a token) sendo imaturo pra essa geração de GPU, o que batia com várias issues abertas no repositório do ggml-org/llama.cpp sobre performance de decode em Vulkan/SYCL pra Battlemage.
Primeira tentativa com SYCL: pior, não melhor
Compilei o llama.cpp com -DGGML_SYCL=ON contra o toolchain oneAPI DPC++ da Intel. Primeiro resultado limpo:
| Backend | pp512 | tg64 |
|---|---|---|
| Vulkan | 314.94 tok/s | 7.88 tok/s |
| SYCL (primeiro build) | 186.14 tok/s | 15.88 tok/s |
A geração já tinha dobrado, mas o processamento de prompt caiu ~41%. E tinha algo estranho: sycl-ls não mostrava nenhum dispositivo [level_zero:gpu] — o runtime Level-Zero da Intel falhava o zeInit() nessa placa (ZE_RESULT_ERROR_UNSUPPORTED_FEATURE), então o SYCL caía, silenciosamente, pro caminho de interop via OpenCL. Mesmo nesse fallback a geração dobrou — mas a regressão no prefill precisava de explicação antes de eu colocar isso em produção.
Pra deixar visual o caminho que a inferência estava tomando de fato:
A flag que faltava
Um leitor me apontou pra um field note com benchmark da Arc B70 , a irmã maior da minha B65. A receita de build de lá marcava uma flag como obrigatória:
GGML_SYCL_F16=ON— obrigatória; sem ela, o prefill cai 3.4×.
Exatamente a flag que faltava no meu primeiro build. Reconfigurei e recompilei:
cmake .. -DGGML_SYCL=ON -DCMAKE_C_COMPILER=icx -DCMAKE_CXX_COMPILER=icpx \
-DGGML_SYCL_F16=ON -DLLAMA_OPENSSL=OFF -DCMAKE_BUILD_TYPE=Release
cmake --build . --config Release -j4 --target llama-bench llama-server llama-cli(-j4 em vez de -j$(nproc) — 16 jobs do icpx em paralelo numa caixa de 13GB é pedir o mesmo incidente de RAM de antes; só a instanciação dos templates de flash-attention já é pesada o suficiente em -j4.)
Conferindo contra a referência: B65 vs B70
Pra comparar maçã com maçã, baixei o mesmo modelo usado naquele field note — unsloth/Muse-Glimmer-30B-GGUF, UD-Q4_K_XL — e rodei exatamente a config de llama-bench de lá: -ngl 99 -b 8192 -ub 8192 -ctk q8_0 -ctv q4_1 -fa 1.
A B65 não tem a mesma configuração de die da B70 usada como referência, e isso aparece de um jeito bem previsível:
| Arc Pro B65 (minha) | Arc Pro B70 (referência) | |
|---|---|---|
| Xe-cores | 20 | 32 |
| Compute | 12.3 TFLOPS | 22.9 TFLOPS (+86%) |
| Banda de memória | 608 GB/s | 608 GB/s (idêntica) |
| VRAM | 32GB | 32GB |
Processamento de prompt (pp) é limitado por compute; geração (tg) é limitada por banda de memória. Banda idêntica, quase o dobro de compute — a previsão era que o pp ficasse atrás proporcionalmente ao déficit de compute, e o tg chegasse perto da paridade:
| Teste | B70 (referência) | B65 (medido) | Razão | Piso teórico |
|---|---|---|---|---|
| pp4096 | 1301 tok/s | 992 tok/s | 76% | ≥54% (razão de TFLOPS) ✅ |
| tg128 | 26.8 tok/s* | 25.84 tok/s | 96% | ~100% (limitado por banda) ✅ |
* inclui o speculative decoding (dflash) da referência; os 25.84 tok/s medidos aqui são do modelo cru, sem nenhum speculative decoding — ou seja, minha taxa base já está em 96% do número acelerado deles.
Os dois números caem dentro do que as specs de hardware previam. Nada mais pra investigar aqui — a diferença entre as duas placas é explicada inteiramente pelo silício, não por software.
F16, o gráfico abaixo) são tg64, enquanto o 25.84 tok/s final é tg128 — tamanhos de benchmark diferentes. A evolução continua válida (o salto real veio da flag), só não trate como a mesma régua.Colocando em produção
Comparei Vulkan e SYCL+F16 mais uma vez no modelo de produção de verdade, e migrei:
~/.config/llama/models.ini:deviceespec-draft-devicetrocados deVulkan1praSYCL0.llama-local.service:ExecStartagora aponta probuild-sycl/bin/llama-server, com o runtime oneAPI carregado antes:
| |
- A unit e o
models.inido Vulkan ficaram salvos como.vulkan.bak— rollback de uma linha se precisar.
Validado com uma chat completion de verdade contra o serviço rodando (config de MTP já existente, sem mudar nada nela):
"timings": {
"predicted_per_second": 24.24,
"draft_n": 44,
"draft_n_accepted": 41
}O que fica
Cruzar dados com um benchmark publicado de um SKU parecido só vale a pena se tu também confere se a diferença entre os dois SKUs explica o gap que tu está vendo. Aqui explicou, direitinho, nos dois eixos — compute e banda.
- Numa máquina com pouca RAM, benchmark é sempre um processo de carregamento por vez, com um piso de memória de verdade — não confiar na sorte. Isso me custou um reboot na força pra reaprender.
pkill -fcasa a linha de comando inteira, incluindo a do próprio script. Em wrapper script,-xcom o nome exato do processo.- Flash-attention e quantização do KV cache eram pistas falsas aqui — o gargalo real era maturidade de kernel/backend, não uma flag de runtime qualquer.
- SYCL só ganhou do Vulkan nessa geração de GPU depois de compilado com
GGML_SYCL_F16=ON— sem ela, era regressão, não ganho, no lado limitado por compute. - O Level-Zero nem estava funcionando (caiu pro OpenCL) e o resultado já foi bom assim. Pode ter mais ganho em cima se algum dia isso for corrigido.