Dimensionamento e ajuste¶
A ferramenta de dimensionamento mede a sua própria implantação do Provisa sob a sua própria carga de trabalho. A partir dessas medições, ela escreve um guia de dimensionamento e ajuste: de quantos núcleos físicos e processos worker a carga precisa e o que cada configuração proporciona. Todo número do guia vem de uma medição, portanto, após uma mudança na implantação ou na carga, você executa a ferramenta de novo e regenera o guia.
A ferramenta fica no repositório do Provisa, em demo/named/perf/bench/. Execute cada comando abaixo a partir desse diretório, com o ambiente Python do repositório (../../../../.venv/bin/python3).
O que ela informa¶
Uma única execução de dimensionamento é composta por várias execuções de benchmark contra a sua implantação. Cada uma varia uma única coisa:
| Execução | O que muda | O que fornece |
|---|---|---|
| Linha de base | Nada: uma coluna, uma linha, sem filtro, sem junção, sem cache | O custo básico de uma requisição |
| Execuções por parâmetro | Um parâmetro de requisição por vez: campos, filtros, linhas, junções na mesma fonte, junções entre fontes | O custo que cada parâmetro acrescenta por unidade |
| Execuções de junção entre fontes | Os valores da junção entre fontes, uma vez com as duas pontas lidas ao vivo, uma com as duas a partir de réplicas, uma com uma de cada | O custo de uma junção entre fontes conforme a leitura de suas pontas e o ponto de equilíbrio entre pontas ao vivo e de réplica |
| Execuções de cache | Toda requisição aderindo ao cache de respostas, depois nenhuma | O custo de um acerto e de uma falha de cache |
| Execuções de replicação | Uma fonte lida ao vivo, depois a partir de uma réplica | A taxa acima da qual uma réplica custa menos |
| Execuções de workers | Processos worker e requisições em andamento por worker | Workers por núcleo físico, requisições em andamento por worker, o que um núcleo entrega |
As execuções por parâmetro, de cache e de replicação são feitas por transporte e por fonte, e uma vez para cada motor de federação indicado. As execuções de workers são feitas por transporte. [tool-verified: demo/named/perf/bench/sweeps.py:plan_sweeps,plan_worker_sweep]
Custo aqui significa o tempo de CPU do Provisa por requisição, em milissegundos. As execuções por parâmetro não usam cache, de modo que o custo de um parâmetro nunca inclui o efeito do cache. [tool-verified: demo/named/perf/bench/sweeps.py:_baseline sets k["cache"] = {"probability": 0.0}]
A partir das execuções, bench_report.py ajusta um modelo de custo por motor, transporte e fonte: um custo básico mais um custo por campo, filtro, linha e junção, com a junção entre fontes custeada conforme a leitura de suas duas pontas. Quando o motor roda como um servidor próprio e a CPU do seu contêiner é medida, o motor também recebe um modelo de custo. [tool-verified: demo/named/perf/bench/cost_model.py:fit] sizing_guide.py transforma o relatório no guia. Dado um contrato que descreve a sua carga de trabalho, ele também dimensiona essa carga.
Escreva um contrato de configuração¶
O contrato de configuração é um único arquivo YAML. Ele nomeia os endpoints da sua implantação, as tabelas que o benchmark pode ler, o formato das requisições e a carga. O benchmark confere o contrato com a implantação antes de executar. Ele recusa uma tabela, coluna ou junção que a implantação não expõe. [tool-verified: demo/named/perf/bench/setup_contract.py:setup_from_dict]
Tabelas e colunas são nomeadas como a sua implantação as registra. O nome que cada transporte lhes dá (nomes SQL, campos GraphQL, rótulos Cypher, caminhos REST, tipos gRPC) é consultado na implantação quando a execução começa.
version: 1
deployment:
name: acme-prod
endpoints:
http: {base_url: "https://provisa.acme.internal"}
pgwire: {host: provisa.acme.internal, port: 5439}
bolt: {host: provisa.acme.internal, port: 7687}
flight: {host: provisa.acme.internal, port: 8815}
grpc: {host: provisa.acme.internal, port: 50051}
# The credential is read from an environment variable; the secret never appears here.
# kind: password (the variable holds the user's password) or token (a ready bearer token).
credentials: {mode: env, kind: password, user: bench-user, env: ACME_BENCH_PASSWORD}
# The roles requests are sent as, with the share of requests each gets.
roles:
- {role: analyst, weight: 0.8}
- {role: regional_manager, weight: 0.2}
sources:
sales-pg:
# What this source's reads are measured under: live, or a replica refreshed every ttl_seconds.
replication: {setting: live, ttl_seconds: 0}
# Optional: a Docker container whose CPU is sampled as the source's CPU.
monitor: {container: sales-postgres}
tables:
- schema: public
table: orders
weight: 1.0 # share of this source's requests that start at this table
base_column: order_id # the one column a baseline request selects
columns:
# A column with a domain can be filtered on; the filter's value is drawn from it.
- {name: order_id, domain: {kind: int_range, min: 1, max: 5000000}}
- {name: region, domain: {kind: set, values: [NA, EMEA, APAC]}}
- {name: amount}
- schema: public
table: order_lines
weight: 0.0 # reached only through the join below
base_column: line_id
columns:
- {name: line_id}
- {name: order_id, domain: {kind: int_range, min: 1, max: 5000000}}
# Joins within the source, by registered table and column. The relationship must be registered.
joins:
- {left: "orders.order_id", right: "order_lines.order_id"}
# Joins across sources: "source:table.column".
cross_source_joins: []
# The rows a request returns when the rows knob does not apply.
base: {rows: 1}
# Each knob is the probability it applies to a request and the distribution its value is drawn
# from when it does. Distributions: constant {value}, uniform {min, max}, zipf {exponent, max},
# geometric {p, max}.
knobs:
fields: {probability: 1, distribution: {kind: uniform, min: 1, max: 3}}
filters: {probability: 0.5, distribution: {kind: constant, value: 1}}
rows: {probability: 1, distribution: {kind: zipf, exponent: 1.2, max: 100}}
joins_same_source: {probability: 0.2, distribution: {kind: constant, value: 1}}
joins_cross_source: {probability: 0, distribution: {kind: constant, value: 0}}
# How often a request repeats one sent shortly before it: the value is how many requests back.
cache_repetition: {probability: 0.3, distribution: {kind: uniform, min: 1, max: 50}}
# Share of requests that opt into the response cache. REST and JSON:API have no opt-in.
cache: {probability: 0.6}
# How a request's cross-source joins are chosen by how their two ends are read: both_live,
# both_replica, or mixed (one of each), as the sources' replication settings above make them.
# Probability 0 chooses joins whatever their class.
join_replication: {probability: 0, weights: {both_live: 1.0}}
# Share of requests whose table is in each source.
source_weights: {sales-pg: 1.0}
# auto lets the deployment choose the route; leave every source on auto to size a workload.
route:
sales-pg: {mode: auto, federated_probability: 0}
# The transports the benchmark runs, one after another. A transport's entry overrides any of the
# knobs above for that transport only; {} means no override.
transports:
graphql: {}
pgwire:
knobs:
rows: {probability: 1, distribution: {kind: uniform, min: 100, max: 1000}}
load:
mode: closed_loop
steps: [1, 16, 64, 256] # concurrent connections, one step after another
window_s: 30 # seconds per step
processes: half_cores # load-generator processes: an integer, or half the client's cores
profile_requests: 0
idle_window_s: 0 # seconds of no load after the ramp; see the replication runs below
seed: 0
# The workload to size: its request rate and each transport's share of it. The knobs above
# describe its requests. A benchmark run does not read this section.
workload:
rate_per_s: 1500
transport_weights: {graphql: 0.7, pgwire: 0.3}
O formato do contrato é definido por setup_contract.py e contract_parse.py; um contrato inválido é recusado com uma mensagem que nomeia o campo. [tool-verified: demo/named/perf/bench/contract_parse.py, SetupError] Os pesos de workload devem somar 1, e cada transporte que citam deve ser um dos que o contrato executa. [tool-verified: demo/named/perf/bench/contract_parse.py:_workload]
Descreva as requisições como elas realmente são. O guia dimensiona exatamente as requisições que este contrato gera: o dimensionamento as extrai do mesmo gerador com semente que uma execução usa. [tool-verified: demo/named/perf/bench/sizing.py:_transport uses request_mix.RequestGenerator]
Execute¶
1. Resolva os nomes uma vez¶
Execute o contrato da linha de base uma vez. A execução pergunta à implantação como cada transporte chama as suas tabelas e grava resolved-names.json ao lado dos seus resultados. [tool-verified: demo/named/perf/bench/optimistic_load.py:resolve_names]
../../../../.venv/bin/python3 run_benchmark.py --engine pg --optimistic \
--setup acme.yaml --server-pid "$PROVISA_PID" --output-dir results/first
--engine nomeia o motor de federação que a sua implantação executa (duckdb, pg ou trino). --server-pid é o id de processo do servidor; a execução lê o tempo de CPU desse processo e de seus processos filhos (os workers). Ela lê o processo diretamente, então só funciona quando o gerador de carga roda no host do servidor. [tool-verified: demo/named/perf/bench/optimistic_load.py:_server_cpu_seconds]
Medindo a CPU do servidor a partir de outro host¶
Execute o gerador de carga em uma máquina própria sempre que puder: assim ele não toma núcleos do servidor. Para medir a CPU do servidor a partir de lá, inicie server_cpu_agent.py no host do servidor e passe à execução a URL dele em vez de --server-pid:
# on the server's host, from demo/named/perf/bench
../../../../.venv/bin/python3 server_cpu_agent.py --pid "$PROVISA_PID" --listen 10.0.0.5:9555
# on the load generator's host
../../../../.venv/bin/python3 run_benchmark.py --engine pg --optimistic \
--setup acme.yaml --server-cpu-url http://10.0.0.5:9555/ --output-dir results/first
O agente responde um único número, o mesmo que --server-pid lê: GET / retorna {"pid": ..., "cpu_seconds": ...}, os segundos de CPU acumulados do processo do servidor e de seus workers. Todos os outros caminhos respondem 404, e todos os outros métodos são recusados. [tool-verified: demo/named/perf/bench/server_cpu_agent.py:handler] Dê a uma execução um de --server-pid ou --server-cpu-url, nunca os dois. [tool-verified: demo/named/perf/bench/optimistic_load.py:server_cpu_source]
--listen é obrigatório: o agente não tem endereço padrão. Vincule-o a um endereço privado que apenas o gerador de carga alcance, nunca a uma interface pública, e interrompa-o (Ctrl+C) quando as execuções terminarem.
2. Planeje as execuções¶
../../../../.venv/bin/python3 sweeps.py plan --setup acme.yaml --out results/sizing \
--resolved-names results/first/optimistic/resolved-names.json \
--physical-cores 16 --shared-host yes \
--workers 8,16,32 --request-threads 1,2,4 \
--replication live,replica --ttl 300 \
--engines pg,duckdb --engine-container pg=perf-engine-postgresql-1
--physical-cores: os núcleos físicos do servidor, não as suas threads de hardware. No Linux,lscpumostra núcleos por soquete e soquetes; multiplique-os. A ferramenta nunca adivinha isso.--shared-host:yesquando as suas fontes ou o gerador de carga rodam no host do servidor. Eles então competem com o Provisa pelos núcleos dele, e o guia informa isso.--workerse--request-threads: os processos worker e as requisições em andamento por worker a testar. Informe os dois ou nenhum.--replicatione--ttl: acrescentam as execuções ao vivo e de réplica para cada fonte, com a réplica atualizada a cada--ttlsegundos. As execuções de junção entre fontes que leem de réplicas também precisam de--ttl.--engines: os motores de federação a medir,pgpor padrão. Toda execução por parâmetro é planejada uma vez por motor; o motor é definido quando o servidor inicia, então as execuções de cada motor exigem reiniciar o servidor com ele.--engine-container:ENGINE=CONTAINERpara um motor que roda como servidor em seu próprio contêiner, cuja CPU cada execução então informa. Omita um motor que roda dentro do Provisa (DuckDB).
O plano grava um contrato por execução e um manifest.json. Uma execução que a implantação não pode atender, como junções via gRPC ou uma tabela que um transporte não expõe, é listada no manifest com o motivo. Ela nunca é descartada em silêncio. [tool-verified: demo/named/perf/bench/sweeps.py:_validate, write_plan]
As execuções de réplica medem o custo de atualização da réplica durante uma janela sem carga. Defina load.idle_window_s no seu contrato (por exemplo, 120) antes de planejá-las. Sem isso, o relatório recusa o ponto de equilíbrio com no idle.json. [tool-verified: demo/named/perf/bench/bench_report.py:_break_even]
3. Execute cada contrato planejado¶
Cada entrada do manifest nomeia o seu arquivo de contrato. Execute cada um em results/sizing/results/<run name>:
../../../../.venv/bin/python3 run_benchmark.py --engine pg --optimistic \
--setup results/sizing/graphql.sales-pg.filters.1.yaml \
--resolved-names results/first/optimistic/resolved-names.json \
--server-pid "$PROVISA_PID" --output-dir results/sizing/results/graphql.sales-pg.filters.1
A partir de outro host, use --server-cpu-url no lugar de --server-pid. Uma execução de workers reinicia o servidor, então o id do processo muda: reinicie o agente também, com o novo --pid.
Acrescente --apply-replication a toda execução cuja entrada no manifest tenha "apply_replication": true (as execuções de replicação e de junção entre fontes). Ele define a replicação que o contrato da execução declara e a restaura depois. [tool-verified: demo/named/perf/bench/optimistic_load.py:add_arguments]
Toda execução traz um server_env no manifest: o servidor deve estar rodando com ele. O de uma execução por parâmetro é o seu motor, {"PROVISA_ENGINE": "duckdb"}; execute todas as execuções de um motor e depois reinicie o servidor com o seguinte. O de uma execução de workers acrescenta os seus workers e o limite de requisições em andamento, por exemplo {"PROVISA_WORKERS": "16", "PROVISA_REQUEST_THREADS": "2", "PROVISA_ENGINE": "pg"}: PROVISA_WORKERS é o número de processos worker e PROVISA_REQUEST_THREADS define concurrency.request_threads, as requisições em andamento que cada worker executa ao mesmo tempo. [tool-verified: provisa/core/settings_catalog.py concurrency.request_threads, env="PROVISA_REQUEST_THREADS"] Aguarde até que todos os workers estejam atendendo (/health informa workers.ready de workers.expected) antes de iniciar a execução.
4. Gere o relatório¶
../../../../.venv/bin/python3 bench_report.py --root results/sizing --out results/sizing/report.json
--tolerance (padrão 0.05) define o quão perto da melhor vazão medida uma configuração de workers deve chegar para ser considerada suficiente. [tool-verified: demo/named/perf/bench/bench_report.py:DEFAULT_TOLERANCE]
5. Gere o guia¶
../../../../.venv/bin/python3 sizing_guide.py --report results/sizing/report.json --out guide.md \
--setup acme.yaml --resolved-names results/first/optimistic/resolved-names.json
Sem --setup, o guia tem apenas as seções de ajuste. Com ele, o guia dimensiona o workload do contrato com o motor que --engine nomeia (pg por padrão); um motor que as execuções não mediram é recusado.
Leia o guia¶
Dimensionando esta carga¶
O guia termina esta seção com uma linha, por exemplo: 12 núcleos físicos, 24 processos worker, 2 requisições em andamento por worker. Acima dela, uma tabela mostra cada transporte e fonte: a parcela das requisições, a média de campos, filtros, linhas e junções, a parcela que são acertos de cache e a CPU que uma requisição custa.
Uma requisição custa o modelo de custo nas médias de seus parâmetros em caso de falha e o custo de acerto medido em caso de acerto. A CPU do Provisa por segundo de um transporte, dividida pelo que um núcleo físico entrega, dá os seus núcleos físicos. O guia soma esses valores entre os transportes e arredonda para cima. Os workers são os núcleos vezes o maior número de workers por núcleo que qualquer um dos transportes exige. As requisições em andamento por worker são, da mesma forma, o maior valor que qualquer um deles exige. Uma única configuração atende a todos. [tool-verified: demo/named/perf/bench/sizing.py:size]
A ferramenta não dimensiona nada que não tenha medido. Uma parte da carga para a qual as execuções não têm modelo interrompe o dimensionamento com uma mensagem que a nomeia [tool-verified: demo/named/perf/bench/sizing.py:SizingRefused]:
- um motor que as execuções não mediram
- um transporte sem execuções de workers
- uma fonte sem modelo de custo nesse transporte
- um parâmetro que as execuções não variaram (varie-o e execute de novo)
- uma dica de rota (
directoufederated) em uma fonte - uma configuração de replicação ou TTL diferente da medida
- acertos de cache quando as execuções não têm números de cache
Processos worker e requisições em andamento¶
Para cada transporte, o guia mostra o menor número de workers cuja vazão máxima fica dentro da tolerância da melhor. Nesse número de workers, ele escolhe então o menor número de requisições em andamento por worker que faz o mesmo. Mostra essa configuração de duas formas: workers por núcleo físico e requisições em andamento por worker. Mais de qualquer um deles não traz vazão. Menos deixa núcleos ociosos. A tabela também informa o pico de requisições por segundo e a latência p99 dele. Todo ponto medido é listado abaixo.
O fator de capacidade é o número de requisições por segundo que um núcleo físico entrega, vezes a CPU que uma requisição custa sozinha, sobre 1000. Acima de 1, o núcleo faz mais que o trabalho de uma thread, como ocorre quando as suas duas threads de hardware atendem. [tool-verified: demo/named/perf/bench/bench_report.py:_server]
O que uma requisição custa¶
Para cada motor, transporte e fonte, lado a lado: o custo básico de uma requisição sem cache, o custo que cada campo, filtro, linha e junção acrescenta e o quanto o modelo se ajusta às medições (R², em que 1 é um ajuste perfeito). Uma junção entre fontes tem três custos, conforme a leitura de suas duas pontas: as duas ao vivo, as duas a partir de réplicas, ou uma de cada. Um traço significa que as execuções não variaram esse parâmetro. Onde o motor roda como um servidor próprio, uma segunda tabela dá da mesma forma a CPU do motor: uma requisição que o motor atende, como uma junção entre fontes, mostra ali o seu tempo de motor. Use esses números para ver quanto custaria uma mudança nas suas requisições antes de fazê-la: dois campos a mais em toda requisição acrescentam o dobro do custo por campo.
A adesão ao cache de respostas¶
Para cada transporte e fonte: a CPU de uma falha, de um acerto e o que um acerto economiza. Uma requisição só é atendida pelo cache quando adere e repete uma requisição anterior, portanto a economia vale para requisições repetidas. Faça as repetidas aderirem. [tool-verified: demo/named/perf/bench/bench_report.py:_cache]
Leituras ao vivo ou de réplica, e o TTL¶
Para cada motor, transporte e fonte com execuções de replicação: o custo de uma leitura ao vivo, de uma leitura de réplica, a CPU de uma atualização, o TTL e a taxa de equilíbrio. Acima da taxa de equilíbrio, uma réplica custa menos CPU, a do Provisa, a da fonte e a do motor juntas, que a leitura ao vivo. Abaixo dela, leia ao vivo. Uma segunda tabela dá o mesmo para junções entre fontes, pelo número de junções na requisição: as duas pontas lidas ao vivo contra as duas a partir de réplicas, com a atualização contando cada fonte que a troca moveu para uma réplica. [tool-verified: demo/named/perf/bench/cost_model.py:break_even]
O ponto de equilíbrio é a CPU da atualização por segundo sobre o que uma leitura economiza:
Dobrar o TTL reduz à metade a taxa de equilíbrio, e os dados podem ter até o dobro da idade. Escolha o TTL conforme o quanto os seus usuários aceitam que os dados estejam desatualizados e depois compare a sua taxa de requisições nessa fonte com o ponto de equilíbrio nesse TTL.
O custo de uma leitura de réplica depende do armazenamento e dos seus índices, que são construídos pouco depois de cada pouso. Meça depois que os índices estiverem prontos e execute de novo após uma troca de armazenamento. Replicas trata da escolha do armazenamento e das estratégias de atualidade.