SFM/scripts/bench_report.py
Andrey Epifancev 6668858b6f Pick report column values through a reverse path index
build_fixed_row_by_index evaluated ~2 400 candidate paths per report row
(7 million _pick_value calls per 13 800 rows). A reverse index
"path -> (rule, candidate, probe)" is built once at import; per row we
walk only the record's own fields and keep the best hit per rule, with
the same candidate/probe ordering as _pick_value. Differential test
compares the index with _pick_value on random and parsed payloads.

Also: merged lookup reuses the operation lookup when the participant
payload is empty, and index-free paths skip the regex normalisation.
scripts/bench_report.py reproduces the measurement (0.42 -> 0.26 ms/row).

Refs #1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 19:14:43 +04:00

177 lines
8.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Воспроизводимый замер скорости формирования отчёта (без Streamlit и OpenCloud).
Генерирует N синтетических XML-файлов по M операций с двумя участниками
(ФЛИП с адресом и документом, ЮЛ с ЕИО и бенефициаром), прогоняет их через
parse_xml_content + StreamingReportWriter и печатает время по стадиям и
стоимость одной строки отчёта. Это тот же путь, что и в run_batch, минус
распаковка вложений и загрузка результата.
Пример:
python scripts/bench_report.py # 230 файлов × 20 операций
python scripts/bench_report.py --files 50 --ops 100
python scripts/bench_report.py --profile # топ функций по cProfile
Ориентир (Apple M-серия, Python 3.13): ~0.25 мс на строку отчёта. На сервере
с квотой 0.25 CPU (raisa_config.yml, профиль «Малый») ожидайте в 8–12 раз
медленнее.
"""
from __future__ import annotations
import argparse
import cProfile
import pstats
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.pipeline.parser import parse_xml_content # noqa: E402
from app.pipeline.report import ReportRow, StreamingReportWriter # noqa: E402
def _operation(index: int) -> str:
return f"""
<Операция>
<ИдентификаторЗаписи>OP{index}</ИдентификаторЗаписи>
<КодОперации>6001</КодОперации>
<ДатаОперации>2026-06-16</ДатаОперации>
<СуммаОперации>12345.67</СуммаОперации>
<ВалютаОперации>643</ВалютаОперации>
<СуммаОперацииРуб>12345.67</СуммаОперацииРуб>
<НазначениеПлатежа>Оплата по договору №{index} от 01.06.2026</НазначениеПлатежа>
<ХарактеристикаОперации>Перевод денежных средств клиента</ХарактеристикаОперации>
<ОснованиеОп><КодДок>21</КодДок><НомДок>{index}</НомДок><ДатаДок>2026-06-01</ДатаДок></ОснованиеОп>
<ОснованиеОп><КодДок>22</КодДок><НомДок>{index}-b</НомДок><ДатаДок>2026-06-02</ДатаДок></ОснованиеОп>
<СведПереводДС>
<ВидПеревода>1</ВидПеревода>
<СведБанкПлательщик><БИККО>044525111</БИККО><НаимБанк>АО Банк</НаимБанк><НомерСчета>40702810000000000001</НомерСчета></СведБанкПлательщик>
<СведБанкПолучатель><БИККО>044525222</БИККО><НаимБанк>ПАО Банк2</НаимБанк><НомерСчета>40702810000000000002</НомерСчета></СведБанкПолучатель>
</СведПереводДС>
<УчастникОп>
<ТипУчастника>2</ТипУчастника>
<УчастникФЛИП>
<СведФЛИП>
<ФИО><Фамилия>Иванов</Фамилия><Имя>Иван</Имя><Отчество>Иванович</Отчество></ФИО>
<ИННФЛИП>123456789012</ИННФЛИП>
<ДатаРождения>1980-01-01</ДатаРождения>
<КодОКСМ>643</КодОКСМ>
<СведДокУдЛичн><КодДок>21</КодДок><СерияДок>4510</СерияДок><НомДок>123456</НомДок><ДатаДок>2010-01-01</ДатаДок><КемВыдан>ОУФМС России по г. Москве</КемВыдан></СведДокУдЛичн>
<АдрРег Индекс="674500" КодОКСМ="643" КодСубъектаПоОКАТО="45" Район="р-н" Пункт="Москва г" Улица="Ленина ул" Дом="1" Корпус="2" Квартира="3"/>
</СведФЛИП>
</УчастникФЛИП>
</УчастникОп>
<УчастникОп>
<ТипУчастника>1</ТипУчастника>
<УчастникЮЛ>
<СведЮЛ>
<НаимЮЛ>ООО Ромашка</НаимЮЛ><ИННЮЛ>7701234567</ИННЮЛ><КППЮЛ>770101001</КППЮЛ><ОГРН>1027700000001</ОГРН>
<АдрРегЮЛ Индекс="675520" КодОКСМ="643" КодСубъектаПоОКАТО="10" Район="Благовещенский р-н" Пункт="Чигири с" Улица="Зеленая ул" Дом="1"/>
</СведЮЛ>
<СведЕИО><ФЛЕИО><ФИО><Фамилия>Петров</Фамилия><Имя>Петр</Имя></ФИО><ИННФЛИП>111111111111</ИННФЛИП></ФЛЕИО></СведЕИО>
<БенефициарЮЛ><ФЛЕИО><ФИО><Фамилия>Сидоров</Фамилия><Имя>Сидор</Имя></ФИО><ИННФЛИП>222222222222</ИННФЛИП></ФЛЕИО></БенефициарЮЛ>
</УчастникЮЛ>
</УчастникОп>
</Операция>"""
def make_xml(operations: int) -> bytes:
body = "".join(_operation(index) for index in range(operations))
return f"""<?xml version="1.0" encoding="UTF-8"?>
<СообщОперКО xmlns="urn:test">
<СлужЧасть><ИдФайл>ID1</ИдФайл><ДатаФайла>2026-06-16</ДатаФайла></СлужЧасть>
<ИнформЧасть>
<ИнфБанк><БИК>044525111</БИК><НаимКО>АО Банк</НаимКО></ИнфБанк>
<СведКО>
<ИнфФилиал><НомерФилиала>01</НомерФилиала></ИнфФилиал>
{body}
</СведКО>
</ИнформЧасть>
</СообщОперКО>
""".encode(
"utf-8"
)
def run(files: int, operations: int) -> dict[str, float]:
xml = make_xml(operations)
started = time.perf_counter()
writer = StreamingReportWriter()
rows = 0
parse_seconds = 0.0
append_seconds = 0.0
for number in range(files):
file_name = f"SKO115FZ_01_044525111_20260616_{number:06d}.xml"
stage = time.perf_counter()
result = parse_xml_content(file_name=file_name, xml_content=xml)
parse_seconds += time.perf_counter() - stage
stage = time.perf_counter()
for row in result.rows:
writer.append_row(
ReportRow(
file_name=row.file_name,
record_id=row.record_id,
operation_index=row.operation_index,
operation_fields=row.operation_fields,
participant_fields=row.participant_fields,
is_continuation=row.is_continuation,
)
)
append_seconds += time.perf_counter() - stage
rows += len(result.rows)
with tempfile.TemporaryDirectory() as temp_dir:
stage = time.perf_counter()
report_path = Path(temp_dir) / "report.xlsx"
writer.save(report_path)
save_seconds = time.perf_counter() - stage
report_size_mb = report_path.stat().st_size / 2**20
return {
"files": files,
"operations_per_file": operations,
"xml_kb": len(xml) / 1024,
"rows": rows,
"total_seconds": time.perf_counter() - started,
"parse_seconds": parse_seconds,
"append_seconds": append_seconds,
"save_seconds": save_seconds,
"ms_per_row": append_seconds / rows * 1000 if rows else 0.0,
"report_mb": report_size_mb,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--files", type=int, default=230)
parser.add_argument("--ops", type=int, default=20, help="операций в файле")
parser.add_argument("--profile", action="store_true", help="печатать cProfile")
args = parser.parse_args()
profiler = cProfile.Profile() if args.profile else None
if profiler is not None:
profiler.enable()
stats = run(args.files, args.ops)
if profiler is not None:
profiler.disable()
print(
f"files={stats['files']} ops/file={stats['operations_per_file']} "
f"xml={stats['xml_kb']:.0f} KB rows={stats['rows']}"
)
print(
f"total={stats['total_seconds']:.1f}s parse={stats['parse_seconds']:.1f}s "
f"append={stats['append_seconds']:.1f}s save={stats['save_seconds']:.1f}s "
f"report={stats['report_mb']:.1f} MB"
)
print(f"per row (parse excluded): {stats['ms_per_row']:.3f} ms")
if profiler is not None:
pstats.Stats(profiler).sort_stats("cumulative").print_stats(25)
return 0
if __name__ == "__main__":
raise SystemExit(main())