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>
177 lines
8.9 KiB
Python
177 lines
8.9 KiB
Python
"""Воспроизводимый замер скорости формирования отчёта (без 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())
|