Benchmarking do DeepSeek R1 no MacBook de um Desenvolvedor

4 de Fevereiro, 202515 min de leitura
Benchmarking do DeepSeek R1 no MacBook de um Desenvolvedor
#ai#go#deepseek#benchmark

Índice

Introdução

Olá,

Com o novo hype de IA (Jan 2025) sobre os modelos open-source de alta qualidade do DeepSeek, uma vontade de explorar modelos LLM self-hosted infectou minha mente.

Portanto, decidi construir uma ferramenta de benchmarking com stress test usando Go (Channels 💙), disparar contra Ollama & DeepSeek, monitorar um monte de métricas e compartilhar os resultados com você.

Este post analisa a capacidade de throughput do modelo DeepSeek-R1-Distill-Qwen-7B rodando no Ollama, no meu MacBook de desenvolvimento pessoal, um M2 Pro com 16GB de Ram e uma GPU de 19 Cores.

A MacBook M2 Pro 16GB Ram full of stickers on a wood table

Ah, o projeto é open source e pode ser encontrado em ocodista/benchmark-deepseek-r1-7b no GitHub (deixe uma ⭐ se você acha que este tipo de conteúdo é útil 😁✌️).

Qual é o objetivo?

Eu queria ver quantas requisições paralelas meu M2 consegue lidar com uma velocidade decente e experimentar com Go + Cursor + Claude Sonnet 3.5.

Foi uma ótima experiência e embora a maioria do código tenha sido escrito por IA, nada da documentação (ou deste texto) foi.

Você pode esperar que este experimento responda as seguintes perguntas:

  • Quantos tokens/s posso obter rodando DeepSeek R1 Gwen 7B localmente com ollama?
  • Quantas requisições paralelas posso servir com throughput razoável?
    • O que é um throughput razoável?
  • Como o número de requisições concorrentes impacta o throughput?
  • Quanta energia minha GPU usou enquanto rodava este estudo?

Agora vamos falar sobre o teste.

O Prompt

Inspirado por um livro recente que li (A Universe From Nothing), selecionei a seguinte questão como prompt para cada requisição:

Que é uma questão muito profunda que requer algum raciocínio. É útil para analisar o processo de Chain of Thought do DeepSeek R1, já que é uma característica central do modelo retornar a resposta em 2 etapas:

<think>{THINK}</think> e {RESPONSE}.

O que é Tempo?

Tempo tem definições abstratas (o fim final, o primeiro começo) e estruturadas (segundos, minutos, horas).

Pode ser usado para expressar uma relação entre eventos não relacionados, para representar algo que sentimos (a passagem do tempo) quando acessamos nossas memórias, e para refletir sobre os grandes mistérios do universo: De onde viemos? Para onde vamos?

A clock similar to Raul Seixas wondering about time limits

A representação estruturada de tempo selecionada foi min:segundos e vamos analisar Tempo de Espera e Tempo de Duração.

A Resposta

Aqui você pode ver uma das respostas geradas pelo modelo 7B durante um dos testes.

Minha opinião (como Engenheiro de Software, não como filósofo nem físico) é que é muito boa.

É maravilhoso ler a seção <think> e observar como o modelo agrupa múltiplos assuntos relacionados à questão antes de fornecer uma resposta final.

Não sou especialista em Data Science, então não posso explicar os funcionamentos internos, mas parece reutilizar esta primeira exploração do prompt para re-prompting o modelo.

Isso é revolucionário, pois é a primeira vez que vejo essa estratégia de resposta em duas etapas construída dentro do modelo.

A estratégia em si não é necessariamente nova, pois já a usei manualmente antes com um Custom GPT chamado Prompt Optimizer, uma espécie de pré-prompting para obter prompts finais melhores, é especialmente útil ao gerar imagens a partir de texto.

De qualquer forma, isso é muito legal!

A diferença de qualidade entre DeepSeek R1 (modelo completo) e ChatGPT para prompts pequenos é notável.

Esta expansão automática do universo de contexto também elimina a necessidade crescente de ser bom em Prompt Engineering. Agora vem de graça, dentro do modelo.

Então, voltando ao teste 😁

Se você não se importa com como os dados foram coletados, você pode viajar no tempo para os Resultados do Benchmark e ver alguns gráficos bonitos.

Como as métricas foram coletadas?

A ideia era executar múltiplas rodadas de requisições HTTP paralelas para o endpoint do Servidor Web Ollama (1, 2, 4, 8, 16, 19, 32, 38, 57, 64, 76, 95, 128 e 256).

Como minha GPU tem 19 cores, selecionei 19 como uma das rodadas (e alguns outros múltiplos) para garantir que cada Core da GPU estivesse ocupado.

Mean Gopher

Diagrama de Sequência

O teste vai em ciclos, cada ciclo contendo um conjunto diferente de requisições concorrentes.

Cada ciclo aguarda 10s após terminar para iniciar o próximo.

Sequence Diagram showcasing how the test works from Client, Ollama and metrics gathering

Monitor de Processos

Ele usa pgrep ollama para encontrar todos os PIDs envolvidos na execução das requisições do modelo e vai monitorar, armazenar e exibir:

  • Thread Count
  • File Descriptors
  • RAM Usage
  • CPU Usage

Monitor de GPU

Ele usa a incrível ferramenta powermetrics para calcular:

  • Power (W)
  • Frequency (MHz)
  • Usage (%)

Em intervalos de 1s.

Métricas de Requisições

Para cada requisição do ciclo, foram analisadas as seguintes propriedades:

  • Throughput (Tokens/s)
  • TTFB (Time To First Byte)
  • WaitingTime
  • TokenCount
  • ResponseDuration
  • TotalDuration

Especificação de Hardware

ComponenteEspecificação
DeviceMacBook Pro 16-inch (M2, 2023)
CPU12-core ARM-Based Processor
Memory16GB RAM
GPUIntegrated M2 Series GPU (19 Cores)
OSMacOS Sonoma

Ferramentas

Ollama

Uma framework CLI de inferência LLM local que é muito fácil de usar.

Para este benchmark, o modelo selecionado foi deepseek-r1:7b, mas poderia ter sido qualquer outro, já que ollama torna ridiculamente simples rodar LLMs localmente.

Golang

A linguagem de programação escolhida, usada para criar o cliente de benchmarking e ferramentas de monitoramento.

Por quê? Bem, Go é uma ferramenta excelente para computação paralela.

Sou um desenvolvedor JS (ainda não sou especialista em Golang), mas consigo reconhecer uma ótima ferramenta paralela/concorrente quando vejo uma.

O Go Scheduler é realmente incrível.

Python

Não há nada melhor que Python para analisar um monte de arquivos CSV e gerar gráficos bonitos.

Para instruções sobre como executar este benchmark, por favor confira how-to-run.md.

Resultados do Benchmark

Como estamos usando requisições HTTP como método de tráfego deste experimento, decidi usar Time To First Byte para representar o Tempo de Espera.

Tempo de Espera (TTFB)

O tempo de espera (TTFB) é o atraso entre apertar a tecla Enter e ver o primeiro caractere na tela.

Como neste experimento o client e o server estão na mesma rede, hardware e computador, estamos na verdade calculando o tempo que leva para o processo Golang se comunicar com o processo Ollama, que vai rodar o modelo DeepSeek R1, que vai gerar as respostas e fazer stream de volta para o processo Golang. Dito isso, vamos contemplar alguns gráficos coloridos:

Time To First Byte 1 - 256 Requests

Ok, parece interessante, mostra que em algum lugar perto de 25 requisições paralelas o Tempo de Espera dispara agressivamente e continua a crescer em uma taxa estável, atingindo incríveis 50 minutos no p99 para 256 requisições/s.

Vamos dar zoom:

Time To First Byte 1 - 32 Requests

Podemos ver que do ciclo 1-19, o TTFB está próximo de 0, variando sua média de 0.62s a 0.83s, isso é praticamente instantâneo para percepção humana.

Faz sentido se você levar em consideração a quantidade disponível de cores da GPU, o que você deveria, caso contrário a flag OLLAMA_MAX_PARALLEL terá seu valor padrão (4), e seus resultados serão envenenados (confie em mim, já estive lá).

Tabela de Dados TTFB

{% details Tabela de Dados TTFB (apenas se você se importa) %}

ParallelAvgP95P99
10.830.930.93
20.360.440.44
40.380.420.42
80.470.510.51
160.640.670.67
190.620.640.64
3284.05227.13234.90
64321.96761.89790.82
128858.271839.991915.72
2561247.023078.623211.85

{% enddetails %}

Velocidade

Esta é a métrica mais importante do experimento.

O gráfico seguinte mostra que ao rodar DeepSeek R1 Gwen 7b, self-hosted com Ollama em um MacBook Pro M2 com 16GB de RAM e 19 cores de GPU, podemos atingir um máximo de 55 tokens/s ao fazer uma única requisição.

Throughput Average

Ao utilizar todo o potencial da GPU com 19 requisições paralelas, porém, o throughput médio caiu para meros 9.1 tokens/s.

Continuou caindo até o menor valor em 256 requisições com 6.3 tokens/s.

Comparando diferentes velocidades de token/s

É difícil visualizar mentalmente o que 55, 9.1 ou 6.3 tokens/s realmente significam, então gravei alguns GIFs para ajudar:

55 tokens por segundo

55 tokens per second

30.7 tokens por segundo

30.7 tokens per second

9.1 tokens por segundo

9.1 tokens per second

Para mim, a velocidade ideal de uma aplicação rápida seria em torno de 100 tokens/s, e o limite inferior lento-mas-usável seria em torno de 20 tokens/s. Quero dizer, velocidade mais rápida nunca é suficiente. É como download/upload de internet ou FPS de vídeo (Frames Por Segundo) ao renderizar jogos, quanto maior, melhor.

100 tokens/s

100 tokens per second

Limiares Aceitáveis

Quais deveriam ser os limiares aceitáveis para uma aplicação do mundo real usável usando DeepSeek + Ollama?

Quanto tempo um usuário médio espera em uma aplicação carregando antes de sair?

Qual é a velocidade aceitável mais lenta para ler um texto sem ficar entediado?

Tempo Máximo de Espera Aceitável

Vou escolher 10s como um valor arbitrário para o tempo máximo de espera aceitável.

Na realidade, os usuários são mais impacientes e o valor pode ser muito menor.

Olhando para os dados da tabela TTFB, 19 é o último ciclo que atende nosso limite de 10s com uma espera de 0.62s. Em 32 requisições paralelas, o Tempo de Espera médio pula para 1 minuto e 25 segundos.

A menos que você esteja usando DeepSeek para tarefas em background sem interação humana, um tempo de espera de 1m25s é inaceitável. Baseado no limite de 10s, o máximo de requisições paralelas para uma aplicação usável deveria ser 19.

Velocidade Mínima de Resposta Aceitável

Acredito que deveria ser ~19.9 tokens/s.

19.9 tokens per second

Esta métrica é totalmente arbitrária e pessoalmente escolhida baseada em como me senti assistindo os gifs de velocidade.

Qualquer coisa menos que 20 tokens/s parece levemente irritante.

Com este novo limite, o máximo de requisições paralelas considerando tempo de resposta aceitável é 4.

Ok, vamos dar uma olhada nas métricas combinadas agora.

Throughput + Tempo de Espera

Velocidade é boa, mas em 2025, Time To First Byte deve ser mínimo para que um produto seja usável.

Ninguém gosta de clicar em um botão e esperar 20 segundos ou 2 minutos para algo acontecer.

Throughput + Wait Time vs Parallel Requests

Tabela de Dados Throughput + Tempo de Espera

{% details Tabela de Dados %}

ParallelAvg t/sP95 t/sP99 t/sWait(s)Errors%DurationP99 Duration
1.053.153.153.10.940.000:39.3300:39.33
2.030.730.930.90.360.001:12.0901:12.27
4.019.920.420.51.540.001:34.3101:47.17
5.019.220.620.70.300.001:39.0101:59.26
8.012.913.513.70.470.002:32.1202:54.62
16.09.810.110.30.640.003:26.8104:41.07
19.09.19.59.60.620.003:43.5505:23.83
32.08.09.39.384.050.004:08.6406:15.66
64.07.19.09.3321.960.004:39.5107:30.13
128.06.58.99.2858.270.005:06.6007:28.05
256.06.38.89.41534.790.004:12.8907:52.97

{% enddetails %}

Duração

O primeiro ciclo durou menos de um minuto (39 segundos) enquanto o último ciclo levou 8 min e 51 segundos para completar.

Duration for the high parallel requests

Duração Média das Requisições

Duration for the less parallel requests

Para os ciclos iniciais, a duração média de cada requisição cresce lentamente mas notavelmente. Enquanto a requisição única levou apenas 39s para completar, requisições que usaram todos os cores disponíveis (ciclo 19) levaram, em média, 03 minutos e 43 segundos para completar.

{% details Tabela de Dados %}

ParallelMinAvgP99Max
100:39.3300:39.3300:39.3300:39.33
201:11.9201:12.0901:12.2701:12.27
401:19.1401:34.3101:47.1701:47.17
501:24.9901:39.0101:59.2601:59.26
802:06.9702:32.1202:54.6202:54.62
1602:01.1203:26.8104:41.0704:41.07
1902:14.6603:43.5505:23.8305:23.83
3202:41.0504:08.6406:15.6606:15.66
6402:17.0504:39.5107:30.1307:30.13
12802:35.3605:06.6007:28.0508:38.04
25600:00.0004:12.8907:52.9708:51.75

{% enddetails %}

Métricas Combinadas (Tokens/s x Duração x Tempo de Espera)

Combined Metrics

O tempo de espera cresce linearmente após 19 requisições paralelas, tornando-o inutilizável para aplicações interativas.

Também mostra que o throughput cai exponencialmente antes de estabilizar em ciclos muito menores.

Vamos dar zoom em ciclos menores:

Combined Metrics 1-32

Aqui, o gráfico é diferente: o tempo de espera é estável em <1s até o ciclo 19 e é claro ver a conexão entre throughput e duração de requisição p99.

Uso de GPU

Graças ao powermetrics, é possível obter métricas de uso de GPU no MacOS!

O Macbook M2 de 19-GPU provou ser bastante constante, com pequenas variações na Frequência da GPU, estável em 1397MHz, e Uso de Energia, estável em ~20.3W.

O nível de concorrência não pareceu afetar as métricas da GPU. GPU Usage

sudo powermetrics --samplers gpu_power -n1 -i1000

Uso de RAM/CPU/Threads

Para analisar quanto recurso do computador o Ollama + DeepSeek estava consumindo, rastreei os processos ollama (com pgrep, lsof e ps) e monitorei as seguintes métricas:

  • CPU Usage (%)
  • Memory Usage (%)
  • Resident Memory (MB)
  • Thread Count (int)
  • File Descriptors (int)
  • Virtual Memory Size (MB)

Ok, quando você tem ollama serve rodando ocioso, ele usa um único processo.

Quando há 1 ou 256 requisições ativas, ollama usa 2 processos.

Process Monitor

Ao analisar o gráfico, podemos ver que eles se comportam diferentemente um do outro.

Enquanto um deles tem alto uso de memória/cpu e baixa contagem de threads/descritores de arquivo abertos, o outro tem o oposto: baixo uso de cpu/memória com FDs abertos crescendo linearmente e alta contagem de threads.

Se eu tivesse que adivinhar, diria que o processo verde pode ser responsável pelo Servidor Web enquanto o vermelho pelo LLM DeepSeek R1.

Processo do Servidor Web

Enquanto os File Descriptors abertos crescem linearmente conforme o número de requisições concorrentes cresce, a contagem de Threads tem um padrão mais íngreme para cima.

Notavelmente, o uso de CPU e memória deste processo permanecem constantemente baixos, entre 0.2~0.6% para CPU e 82.6 -> 114.1MB para RAM.

{% details Tabela de Dados %}

ConcurrencyAvg CPU%Max CPU%Avg Mem%Max Mem%Avg ThreadsAvg FDsAvg RAM(MB)Max RAM(MB)
10.622.80.20.517.018.936.482.6
20.00.80.50.518.021.083.483.6
80.21.70.60.620.031.790.490.9
160.21.50.50.621.042.579.495.2
190.22.20.50.521.044.982.082.6
320.22.20.50.521.048.985.686.2
640.22.90.60.621.069.192.194.6
1280.22.70.60.737.0102.3103.8108.4
2560.24.20.70.772.0145.4108.2114.1

{% enddetails %}

Processo do DeepSeek

Se os FDs Abertos e a Contagem de Threads delataram o processo do Servidor Web, o Consumo de Memória e a contagem máxima de Threads de 18 delataram o Processo do DeepSeek.

O fato de o número de threads não ultrapassar o número de Cores da GPU, mesmo sob ciclo de concorrência mais alto, indica que este processo pode ser o responsável pelo DeepSeek, que usa a GPU, que tem 19 cores.

O uso médio de RAM deste processo é notável: de 2.2 a 2.3GB, representando 13.7 a 14.6% de toda RAM disponível, o uso de CPU também é alto para um processo, consumindo 5.7% para uma única requisição e 13.1% para 256.

{% details Tabela de Dados %}

ConcurrencyAvg CPU%Max CPU%Avg Mem%Max Mem%Avg ThreadsAvg FDsAvg RAM(MB)Max RAM(MB)
14.65.713.713.712.122.02248.12250.6
24.25.213.813.816.023.02259.92263.1
84.97.914.014.116.028.52299.62303.3
165.711.614.014.316.434.12299.12335.5
195.814.314.214.217.035.32327.42330.6
325.012.014.414.417.034.82359.12366.3
645.514.214.514.617.137.72374.22390.0
1285.513.214.414.618.038.82366.52397.7
2565.513.114.414.618.039.52364.12385.5

{% enddetails %}

Resultados Resumidos

Estes resultados foram gerados rodando Ollama + DeepSeek em um Macbook M2 Pro, 16GB de RAM e GPU de 19-Core, provavelmente será diferente em uma configuração diferente.

Quantos tokens/s posso obter rodando DeepSeek R1 Gwen 7B localmente com ollama?

Para uma única requisição: 53.1 tokens/s.

Para 19 requisições paralelas -> 9.1 tokens/s.

Para 256 requisições concorrentes -> 6.3 tokens/s.

Você pode conferir os Dados da Tabela se quiser.

Quantas requisições paralelas posso servir com throughput razoável?

Assumindo 19.9 tokens/s como um throughput razoável, esta máquina pode servir até 4 requisições em paralelo.

Isso pode ser suficiente para tarefas de rotina diária de uma única pessoa, mas definitivamente não é suficiente para rodar um servidor de API comercial.

O que é um throughput razoável?

Durante o curso da escrita deste artigo, criei uma CLI para mostrar diferentes velocidades de token/s, você pode conferir aqui.

Como o número de requisições concorrentes impacta a performance?

Muito!

Para mais de 19 requisições concorrentes, o tempo de espera se torna insuportável, e para mais de 5 requisições paralelas, a velocidade de resposta é muito baixa.

Throughput vs Average Wait Time

Dados da Tabela

Conclusão

Considerando que esta é a versão do modelo 7b, e pode atingir até 55 tokens/s ao servir uma única requisição, eu diria que é rápido e bom o suficiente para conversar interativamente durante tarefas diárias, com um uso de energia razoavelmente baixo (mesmo que uma lâmpada de led).

Quero dizer, a qualidade está longe de ser ótima quando comparada à versão 671b (que é o modelo que supera os modelos OpenAI), mas acredito que isso é apenas o começo.

Estratégias de quantização se tornarão mais efetivas, e em breve seremos capazes de escolher assuntos para treinar modelos menores, que demonstramos ser possível rodar no computador de um desenvolvedor. É possível, estamos na Indústria de Tecnologia.

Aconteceu com o processador, o disco e a memória, é questão de tempo até acontecer com chips de inferência de IA e modelos LLM.

É isso por hoje, obrigado pela leitura 😁✌️!