perf(analysis): api_split — размер ответа на проводе (wire_bytes)
Цикл замера вынесен в measure(); добавлен wire_bytes (num_bytes_downloaded, байты после сжатия) рядом с декодированным размером. В .md - колонка «на проводе, МБ». Docstring: актуальные места X-DB-Time-Ms и остаток после задач A (склейка строк, gzip, передача, распаковка; без pydantic). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
604e277371
commit
d9aa47388a
@ -2,16 +2,17 @@
|
||||
|
||||
cd perf && .venv/bin/python analysis/api_split.py --scale 1 --repeats 10 --out report-data/20261026-s1/analysis
|
||||
|
||||
Обработчики листов, сводов и отчётов проектов сами ставят заголовок `X-DB-Time-Ms` (api/src/api/v1/forms.py:184,
|
||||
summary.py:74,126, projects.py:128): время от начала обработчика до получения строк из БД (поиск формы + SQL-функция +
|
||||
разбор jsonb драйвером). Остаток `полное время - X-DB-Time-Ms` - сборка pydantic-моделей (`_rows_to_sheet_response`,
|
||||
forms.py:27), валидация `response_model`, JSON-кодирование, сжатие GZipMiddleware (api/src/main.py:248, compresslevel=1),
|
||||
передача и распаковка на клиенте (httpx).
|
||||
Обработчики листов, сводов и отчётов проектов сами ставят заголовок `X-DB-Time-Ms` (api/src/api/v1/forms.py:198,241,304,
|
||||
368,424,473; summary.py:75,126; projects.py:139,272,305,329): время от начала обработчика до получения строк из БД (поиск
|
||||
формы + SQL-функция + отдача готового JSON драйвером). После задач A ответ собирается из строк jsonb без pydantic, поэтому
|
||||
остаток `полное время - X-DB-Time-Ms` - это склейка строк в `list_json_response` (api/src/api/v1/json_response.py), сжатие
|
||||
GZipMiddleware (api/src/main.py:248, compresslevel=1), передача и распаковка на клиенте (httpx).
|
||||
py-spy на macOS требует root - этот способ его заменяет для вопроса «БД или Python».
|
||||
|
||||
Запросы: самые тяжёлые из bench (api.jsonl) - листы FORM_2/AHR, FORM_4/CAP и FORM_4/AHR (rf), свод 2 GO и RF и свод 1
|
||||
MAIN (dfip), отчёт проекта (rf), сводные проектов (dfip). Напрямую на :8000 (без proxy фронта), httpx, тело читается
|
||||
целиком. Вывод: <out>/api-split.json и api-split.md.
|
||||
целиком. Колонка «на проводе» - `num_bytes_downloaded` httpx (байты после сжатия, как передано по сети), «размер» - декодированное тело.
|
||||
Вывод: <out>/api-split.json и api-split.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@ -26,6 +27,27 @@ import httpx
|
||||
PERF = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def measure(c: httpx.Client, path: str, token: str, repeats: int) -> dict:
|
||||
"""Один запрос-прогрев и `repeats` замеров; медианы времени, размеры из последнего замера."""
|
||||
tot, db, size, wire = [], [], 0, 0
|
||||
rows, r = 0, None
|
||||
for i in range(repeats + 1):
|
||||
t0 = time.perf_counter()
|
||||
r = c.get(path, headers={"Authorization": f"Bearer {token}"})
|
||||
body = r.content
|
||||
dt = (time.perf_counter() - t0) * 1000
|
||||
r.raise_for_status()
|
||||
if i == 0:
|
||||
rows = len(r.json().get("result") or []) # фактическое число строк ответа (манифест - до grow)
|
||||
continue # прогрев
|
||||
tot.append(dt)
|
||||
db.append(float(r.headers.get("x-db-time-ms", "nan")))
|
||||
size = len(body)
|
||||
wire = r.num_bytes_downloaded
|
||||
return {"rows": rows, "total_ms": statistics.median(tot), "db_ms": statistics.median(db), "bytes": size,
|
||||
"wire_bytes": wire, "content_encoding": r.headers.get("content-encoding")}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--scale", type=int, default=1)
|
||||
@ -61,21 +83,10 @@ def main(argv=None):
|
||||
tokens[role] = r.json()["access_token"]
|
||||
res = []
|
||||
for role, label, path in reqs:
|
||||
tot, db, size = [], [], 0
|
||||
for i in range(a.repeats + 1):
|
||||
t0 = time.perf_counter()
|
||||
r = c.get(path, headers={"Authorization": f"Bearer {tokens[role]}"})
|
||||
body = r.content
|
||||
dt = (time.perf_counter() - t0) * 1000
|
||||
r.raise_for_status()
|
||||
if i == 0:
|
||||
rows = len(r.json().get("result") or []) # фактическое число строк ответа (манифест - до grow)
|
||||
continue # прогрев
|
||||
tot.append(dt)
|
||||
db.append(float(r.headers.get("x-db-time-ms", "nan")))
|
||||
size = len(body)
|
||||
row = {"role": role, "request": f"{label} ({rows} строк ответа)", "rows": rows, "path": path, "total_ms": round(statistics.median(tot), 1),
|
||||
"db_ms": round(statistics.median(db), 1), "bytes": size, "content_encoding": r.headers.get("content-encoding")}
|
||||
m = measure(c, path, tokens[role], a.repeats)
|
||||
row = {"role": role, "request": f"{label} ({m['rows']} строк ответа)", "rows": m["rows"], "path": path,
|
||||
"total_ms": round(m["total_ms"], 1), "db_ms": round(m["db_ms"], 1), "bytes": m["bytes"],
|
||||
"wire_bytes": m["wire_bytes"], "content_encoding": m["content_encoding"]}
|
||||
row["python_ms"] = round(row["total_ms"] - row["db_ms"], 1)
|
||||
row["python_share"] = round(row["python_ms"] / row["total_ms"], 2)
|
||||
row["ratio_total_db"] = round(row["total_ms"] / row["db_ms"], 2) if row["db_ms"] else None
|
||||
@ -85,10 +96,10 @@ def main(argv=None):
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / "api-split.json").write_text(json.dumps(res, ensure_ascii=False, indent=1))
|
||||
md = [f"# API: БД против Python (api_split.py, масштаб {a.scale}, медиана {a.repeats} повторов после прогрева, напрямую :8000)", "",
|
||||
"| запрос | роль | полное, мс | X-DB-Time-Ms | остаток (Python + передача), мс | доля остатка | полное / БД | размер, МБ | Content-Encoding |",
|
||||
"|---|---|---|---|---|---|---|---|---|"]
|
||||
"| запрос | роль | полное, мс | X-DB-Time-Ms | остаток (Python + передача), мс | доля остатка | полное / БД | размер, МБ | на проводе, МБ | Content-Encoding |",
|
||||
"|---|---|---|---|---|---|---|---|---|---|"]
|
||||
md += [f"| {r['request']} | {r['role']} | {r['total_ms']} | {r['db_ms']} | {r['python_ms']} | {r['python_share']} | {r['ratio_total_db']} | "
|
||||
f"{r['bytes'] / 1e6:.2f} | {r['content_encoding'] or 'нет'} |" for r in res]
|
||||
f"{r['bytes'] / 1e6:.2f} | {r['wire_bytes'] / 1e6:.2f} | {r['content_encoding'] or 'нет'} |" for r in res]
|
||||
(out / "api-split.md").write_text("\n".join(md) + "\n")
|
||||
|
||||
|
||||
|
||||
28
perf/tests/test_api_split.py
Normal file
28
perf/tests/test_api_split.py
Normal file
@ -0,0 +1,28 @@
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "analysis"))
|
||||
|
||||
from api_split import measure # noqa: E402
|
||||
|
||||
|
||||
def test_measure_reports_wire_and_decoded_bytes():
|
||||
raw = json.dumps({"result": [{"a": 1}] * 50}).encode()
|
||||
gz = gzip.compress(raw)
|
||||
|
||||
def handler(request):
|
||||
assert request.headers["authorization"] == "Bearer tok"
|
||||
# stream, а не content=: у httpx num_bytes_downloaded считается при чтении потока (как у сетевого транспорта)
|
||||
return httpx.Response(200, headers={"content-encoding": "gzip", "x-db-time-ms": "12.50"}, stream=httpx.ByteStream(gz))
|
||||
|
||||
with httpx.Client(transport=httpx.MockTransport(handler), base_url="http://t") as c:
|
||||
m = measure(c, "/x", "tok", repeats=3)
|
||||
assert m["rows"] == 50
|
||||
assert m["bytes"] == len(raw)
|
||||
assert m["wire_bytes"] == len(gz)
|
||||
assert m["db_ms"] == 12.5
|
||||
assert m["content_encoding"] == "gzip"
|
||||
Loading…
x
Reference in New Issue
Block a user