/blog arc-b65-sycl-llama-cpp.md tags.md about.md
main +12 ~3 UTF-8 LF

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

CPUAMD 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 GPUeGPU dock via Oculink
ModeloQwen3.8-27B (denso) , UD-Q4_K_M (15.3GB) + draft model MTP, ctx 131072
llama.cppggml-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)
WARN
O OOM-killer do kernel disparou, mas não conseguiu recuperar a máquina a tempo. Travou de vez, precisei desligar na força. Lição gravada: numa caixa com pouca RAM, um processo de carregamento de modelo por vez, sempre, com um watchdog de memória de verdade — não “vou rodar rapidinho em background”.

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-attentionKV cachetg (tok/s)
desligadof167.92
ligadof167.88
ligadoq8_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:

Backendpp512tg64
Vulkan314.94 tok/s7.88 tok/s
SYCL (primeiro build)186.14 tok/s15.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:

llama-server (build-sycl)GGML_SYCL backendoneAPI DPC++ (icx/icpx)✕ Level-Zero: zeInit() falha✓ fallback silencioso → OpenCLOpenCL (interop, fallback ativo)Driver xe (Mesa, kernel)Intel Arc Pro B6532GB VRAM · 608GB/s

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-cores2032
Compute12.3 TFLOPS22.9 TFLOPS (+86%)
Banda de memória608 GB/s608 GB/s (idêntica)
VRAM32GB32GB

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:

TesteB70 (referência)B65 (medido)RazãoPiso teórico
pp40961301 tok/s992 tok/s76%≥54% (razão de TFLOPS) ✅
tg12826.8 tok/s*25.84 tok/s96%~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.

NOTE
Repare que os dois primeiros valores da SYCL (15.88 tok/s sem 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.
geração (tok/s) por backendVulkan7.88SYCL (sem F16)15.88SYCL + F1625.84

Battlemage B65 · mesmo modelo, mesma máquina

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: device e spec-draft-device trocados de Vulkan1 pra SYCL0.
  • llama-local.service: ExecStart agora aponta pro build-sycl/bin/llama-server, com o runtime oneAPI carregado antes:
llama-local.service ini
1
2
3
4
ExecStart=/bin/bash -c 'source /opt/intel/oneapi/setvars.sh > /dev/null 2>&1; \
  exec /home/neo/llama.cpp/build-sycl/bin/llama-server \
  --models-preset /home/neo/.config/llama/models.ini --models-max 1 \
  --host 127.0.0.1 --port 8080'
  • A unit e o models.ini do 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
}
OK
~9-11 tok/s → 24.24 tok/s em tráfego real de produção — algo entre 2.2× e 2.7×, com 93% de aceitação do speculative decoding em cima disso.

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 -f casa a linha de comando inteira, incluindo a do próprio script. Em wrapper script, -x com 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.
:
NORMAL
main
tags 5
utf-8
markdown
1:1
0%