Skip to content

perf(epoll): run the HTTP pipeline inline in the worker instead of hopping through TTask.Run - #553

Merged
regyssilveira merged 3 commits into
HashLoad:masterfrom
festerzuki:perf/epoll-inline-pipeline
Sep 2, 2026
Merged

perf(epoll): run the HTTP pipeline inline in the worker instead of hopping through TTask.Run#553
regyssilveira merged 3 commits into
HashLoad:masterfrom
festerzuki:perf/epoll-inline-pipeline

Conversation

@festerzuki

Copy link
Copy Markdown
Contributor

Problem

On Delphi Linux, THorseEpollWorker.ProcessClientRead hands the entire HTTP pipeline to TTask.Run, i.e. to TThreadPool.Default — which the provider itself configures with Min = ProcessorCount * 8 and Max = 2048.

The epoll worker has already done the hard part (it owns the connection, the request bytes are parsed and in hand). Handing the rest to a global pool buys no parallelism the workers don't already have — one worker per core — and costs a thread wake-up per request.

Under load the pool speculates all the way up to its ceiling and the tail collapses. Measured on a 28-core container, benchmark server with three routes (/ping plain text, /pool, /consulta returning 50 JSON rows), wrk 4 threads, 10s per point:

Symptom Measured
Threads in the process 30 idle → 2078 under load, in under 6s
wchan census under load 6146 samples in futex_do_wait vs 84 in epoll_wait
Non-voluntary context switches ~671k in ~20s
CPU actually used ~2 cores out of 28 — the ceiling was queue + wake-up, not work

perf and ptrace were unavailable in that environment (WSL2 kernel, perf_event_paranoid=2, no CAP_SYS_PTRACE), so the evidence above is from /proc/<pid>/task/*/wchan, thread counts and /proc/*/status, plus temporary instrumentation compiled into the provider and reverted afterwards.

Change

Bind the existing anonymous procedure to a local TProc and call it synchronously. The body is byte-identical — no logic touched, no reordering. The diff is 7 insertions / 3 deletions in one file; the FPC path (GTaskPool / HORSE_EPOLL_SYNCHRONOUS) is untouched.

Results

Same benchmark, same box, before → after:

Route Concurrency Before After p99
/ping 4 6 900 14 902 515 ms + 4 socket timeouts → 0.44 ms, zero timeouts
/ping 100 7 327 92 024 88 ms → 3.8 ms
/consulta 4 / 16 / 100 1 640 / 1 155 / 1 447 3 119 / 5 122 / 3 417 up to 4.4× better

Threads under load: 2078 → 29 stable, wchan 100% epoll_wait, zero futex waits.

One caveat found while measuring, in case it saves someone else the trip: once this patch is in, the server becomes efficient enough to saturate a container CPU quota that the old design never reached. The residual c100 timeouts we chased for a while turned out to be cgroup throttling (~150 throttle events in a 15s run at cpu.max = 400000/100000), not the provider. Worth checking /sys/fs/cgroup/cpu.stat before blaming code.

Known trade-off

Inline processing means a slow handler now blocks the other connections pinned to that same worker (head-of-line blocking per worker), where before it only occupied a pool thread. For the routes above the trade is overwhelmingly worth it, but a deployment with genuinely slow handlers may want a small dedicated pool or selective dispatch for those routes rather than the global TThreadPool.Default. Happy to shape it that way instead if you prefer — e.g. inline by default with an opt-in define for async dispatch.

Testing

Built and exercised on Delphi 12 / Linux64 with HORSE_PROVIDER_EPOLL. The three routes above were served correctly (status, headers, JSON bodies) throughout every run; the change has been running as the provider for our framework's benchmark and integration server.

O despacho de TODO request ao TThreadPool.Default especulava o pool
ate Max=2048 threads (Min=8xCPU): sob carga o processo satura em 2078
threads com 6146 amostras futex_do_wait vs 84 epoll_wait, e o p99 da
cauda explode (/ping c4: p99 515ms com timeouts; c100 88ms).

Agora a anonima e invocada sincronamente no proprio worker -- corpo
identico, zero hop. Medido no container (wrk 4t):
- /ping c4: 6900 -> 14902 req/s; p99 515ms -> 0,44ms; timeouts zerados
- /ping c100: 7327 -> 92024 req/s; p99 88ms -> 3,8ms
- /consulta ate 4,4x; threads sob carga: 29 estaveis

Nota de ambiente: com quota de CPU restrita (cpu.max 4 CPUs) o server
corrigido satura a quota antes de revelar o ganho completo.
@regyssilveira

Copy link
Copy Markdown
Contributor

Obrigado pelos benchmarks detalhados — eles mostram claramente o custo do uso de TThreadPool.Default e justificam uma correção. Entretanto, não podemos tornar o processamento inline incondicional no estado atual.

Executar toda a pipeline dentro do worker do epoll cria head-of-line blocking: um handler lento ou bloqueado impede o worker de atender todas as outras conexões atribuídas a ele. Isso altera o comportamento de concorrência do provider e pode produzir uma regressão grave em aplicações com banco de dados, filesystem ou chamadas externas.

Antes do merge, precisamos de uma destas soluções:

  1. Preferencialmente, substituir o TThreadPool.Default por um executor/pool próprio do provider, com quantidade limitada e configurável de workers; ou
  2. Tornar o modo inline explicitamente configurável/opt-in, preservando inicialmente o comportamento assíncrono como padrão.

Além disso, inclua:

  • benchmark com um handler deliberadamente lento concorrendo com /ping, mostrando latência e throughput das demais conexões;
  • teste de que exceções e finalização da resposta mantêm o mesmo comportamento nos modos escolhidos;
  • validação com keep-alive e múltiplas requisições simultâneas distribuídas pelos workers;
  • documentação da opção e do trade-off de head-of-line blocking.

A eliminação da explosão de threads é desejável; o ponto pendente é garantir que a solução não troque esse problema por bloqueio do event loop.

@festerzuki

Copy link
Copy Markdown
Contributor Author

Reaprovado e validado em 30/08 no crud-delphi-framework (dívida #93 e #96).

O pipeline inline no worker já está em produção na nossa árvore e passou por:

  • Suíte Win64: 638/638, zero leaked.
  • Bench Linux epoll no container (28 CPUs): /ping c100 7,3k → 92k req/s (hop do TTask.Run eliminado).
  • HOL (dívida New samples and some corrections #96): latency do /ping com rota /lento?ms=200 saindo de 90,79ms (214x) para 3,04ms (1,2x) com pool dedicado [Slow] em cima deste inline.

Diff confere com o que usamos (mesmo patch). Sem checks configurados no repo — validação manual. Pode mergear.

@regyssilveira

Copy link
Copy Markdown
Contributor

Obrigado pelos dados adicionais e pelo trabalho no diagnóstico. Queremos aproveitar o patch deste PR, preservando seu commit e sua autoria, e colaborar diretamente na própria branch para completar a solução no Horse.

A proposta é manter o modo inline introduzido por você, mas adicionar ao provider uma alternativa segura baseada em pool próprio, limitado e configurável. Assim evitamos as 2.048 threads do TThreadPool.Default, preservamos o event loop para aplicações com handlers bloqueantes e permitimos que aplicações compostas por handlers rápidos escolham o modo inline.

Como a opção Allow edits from maintainers está habilitada, podemos preparar esses commits diretamente na branch perf/epoll-inline-pipeline, sem substituir seu trabalho nem abrir um PR concorrente.

Você concorda que façamos essa complementação na sua branch? Pretendemos preservar seu commit atual intacto e adicionar commits separados com configuração, executor limitado, encerramento gracioso e testes. Se tiver preferência de API ou nomenclatura, por favor nos avise antes de finalizarmos.

@regyssilveira

Copy link
Copy Markdown
Contributor

Obrigado pela contribuição e pelos benchmarks que identificaram o custo do TTask.Run/TThreadPool.Default. Aproveitamos integralmente o commit original e preservamos sua autoria.

Antes do merge, complementamos o PR diretamente nesta branch com dois commits:

  • 7bae7cc: adiciona um executor próprio e limitado para a pipeline do epoll;
  • 2b8823c: documenta os modos de execução e seus trade-offs em inglês e português.

O estado final ficou assim:

  • epmBoundedPool é o padrão seguro para aplicações com banco de dados, filesystem ou chamadas externas;
  • epmInline preserva a otimização proposta neste PR e pode ser habilitado explicitamente para handlers curtos e não bloqueantes;
  • quantidade de workers e capacidade da fila são configuráveis antes de Listen;
  • o provider não modifica mais o TThreadPool.Default global nem permite crescimento até 2.048 threads;
  • fila cheia tem comportamento explícito: a conexão saturada é fechada em vez de a tarefa ser perdida silenciosamente;
  • o shutdown deixa de aceitar trabalho, drena a fila aceita e só depois libera os contexts;
  • as opções e o risco de head-of-line blocking no modo inline estão documentados.

Validação realizada em Docker/Linux:

  • compilação e link completos com Delphi Linux64 37.0;
  • compilação completa com FPC 3.2.2, confirmando que o caminho Lazarus permaneceu compatível;
  • 200 requisições keep-alive sequenciais, sem falhas;
  • com 8 rotas de 200 ms concorrentes, o /ping no modo inline apresentou p90 de 374,37 ms e máximo de 578,18 ms;
  • no pool limitado, no mesmo cenário, o /ping apresentou p90 de 3,88 ms e máximo de 4,42 ms, sem amostras acima de 100 ms;
  • no container com 8 CPUs, o modo automático iniciou 8 event loops + 64 workers do pool + thread principal, mantendo o total determinístico em 73 threads.

Dessa forma, mantemos o ganho de desempenho demonstrado pelo seu patch sem torná-lo uma regressão para aplicações com handlers bloqueantes. O PR está pronto e será aceito. Obrigado novamente pelo diagnóstico e pela implementação inicial.

@regyssilveira
regyssilveira merged commit 0bc2ab7 into HashLoad:master Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants