1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184
| """Portable pdfLaTeX/XeLaTeX/LuaLaTeX benchmark and report generator."""
from __future__ import annotations
import argparse import datetime as dt import json import os import platform import re import shutil import statistics import subprocess import sys import time from dataclasses import asdict, dataclass from pathlib import Path from typing import Iterable
ROOT = Path(__file__).resolve().parent DOCUMENTS = ("english", "chinese") DOCUMENT_LABELS = {"english": "纯英文", "chinese": "中文"} MODE_LABELS = {"cold": "从零编译", "incremental": "增量编译"} ENGINE_FLAGS = { "pdflatex": "-pdf", "xelatex": "-pdfxe", "lualatex": "-pdflua", } SUPPORTED_ENGINES = { "english": ("pdflatex", "xelatex", "lualatex"), "chinese": ("xelatex", "lualatex"), }
ENGLISH_TEMPLATE = r"""\documentclass[11pt]{article} \usepackage[a4paper,margin=24mm]{geometry} \usepackage{lmodern} \usepackage[T1]{fontenc} \usepackage{amsmath,amssymb} \usepackage{booktabs} \usepackage{hyperref} \input{units.tex} \input{mutable.tex}
\newcount\benchmarkunit \newcommand{\benchmarktext}{% A portable benchmark should exercise paragraph construction, line breaking, mathematical typesetting, table layout, cross references, and PDF output. This paragraph is intentionally repeated so that the measured document is large enough to reduce timer noise without relying on external data files.}
\begin{document} \title{English LaTeX Engine Benchmark} \author{Portable benchmark suite -- revision \BenchmarkVariant} \date{} \maketitle \tableofcontents \benchmarkunit=1 \loop \section{Benchmark unit \the\benchmarkunit} \label{sec:unit-\the\benchmarkunit} \benchmarktext\ \benchmarktext\ \benchmarktext \subsection{Mathematics} \begin{equation} \sum_{k=1}^{n} k^2 = \frac{n(n+1)(2n+1)}{6}, \qquad \int_0^1 x^m(1-x)^2\,dx = \frac{2}{(m+1)(m+2)(m+3)}. \end{equation} \begin{align} A_{ij} &= \frac{1}{1+i+j}, & 1 \leq i,j \leq 8,\\ y_i &= \sum_{j=1}^{8} A_{ij}x_j, & \lVert y\rVert_2 &\leq \lVert A\rVert_2\lVert x\rVert_2. \end{align} \subsection{Table and cross reference} Table~\ref{tab:unit-\the\benchmarkunit} and Section~\ref{sec:unit-\the\benchmarkunit} exercise auxiliary-file handling. \begin{table}[htbp] \centering \begin{tabular}{rrrr} \toprule Index & Linear & Quadratic & Cubic \\ \midrule 1 & 1 & 1 & 1 \\ 2 & 2 & 4 & 8 \\ 3 & 3 & 9 & 27 \\ 4 & 4 & 16 & 64 \\ 5 & 5 & 25 & 125 \\ \bottomrule \end{tabular} \caption{Synthetic values for unit \the\benchmarkunit.} \label{tab:unit-\the\benchmarkunit} \end{table} \benchmarktext\ \benchmarktext \clearpage \advance\benchmarkunit by 1 \ifnum\benchmarkunit<\numexpr\BenchmarkUnits+1\relax \repeat \end{document} """
CHINESE_TEMPLATE = r"""\documentclass[UTF8,11pt,fontset=fandol]{ctexart} \usepackage[a4paper,margin=24mm]{geometry} \usepackage{amsmath,amssymb} \usepackage{booktabs} \usepackage{hyperref} \input{units.tex} \input{mutable.tex}
\newcount\benchmarkunit \newcommand{\benchmarktext}{% 可移植的基准测试需要覆盖段落构造、自动断行、数学公式排版、表格布局、 交叉引用以及 PDF 输出。这里有意重复同一段中文,使文档规模足以降低计时 噪声,同时不依赖任何外部数据文件。中文字体固定使用 TeX Live 自带的 Fandol 字体,从而减少不同操作系统默认字体造成的差异。}
\begin{document} \title{中文 LaTeX 引擎基准测试} \author{可移植测试套件——修订号 \BenchmarkVariant} \date{} \maketitle \tableofcontents \benchmarkunit=1 \loop \section{测试单元 \the\benchmarkunit} \label{sec:unit-\the\benchmarkunit} \benchmarktext\ \benchmarktext\ \benchmarktext \subsection{数学公式} \begin{equation} \sum_{k=1}^{n} k^2 = \frac{n(n+1)(2n+1)}{6}, \qquad \int_0^1 x^m(1-x)^2\,dx = \frac{2}{(m+1)(m+2)(m+3)}. \end{equation} \begin{align} A_{ij} &= \frac{1}{1+i+j}, & 1 \leq i,j \leq 8,\\ y_i &= \sum_{j=1}^{8} A_{ij}x_j, & \lVert y\rVert_2 &\leq \lVert A\rVert_2\lVert x\rVert_2. \end{align} \subsection{表格与交叉引用} 表~\ref{tab:unit-\the\benchmarkunit} 和 第~\ref{sec:unit-\the\benchmarkunit} 节用于测试辅助文件的读写与复用。 \begin{table}[htbp] \centering \begin{tabular}{rrrr} \toprule 序号 & 一次方 & 二次方 & 三次方 \\ \midrule 1 & 1 & 1 & 1 \\ 2 & 2 & 4 & 8 \\ 3 & 3 & 9 & 27 \\ 4 & 4 & 16 & 64 \\ 5 & 5 & 25 & 125 \\ \bottomrule \end{tabular} \caption{测试单元 \the\benchmarkunit 的合成数据。} \label{tab:unit-\the\benchmarkunit} \end{table} \benchmarktext\ \benchmarktext \clearpage \advance\benchmarkunit by 1 \ifnum\benchmarkunit<\numexpr\BenchmarkUnits+1\relax \repeat \end{document} """
DOCUMENT_TEMPLATES = { "english": ENGLISH_TEMPLATE, "chinese": CHINESE_TEMPLATE, }
def write_text_lf(path: Path, text: str) -> None: """Write UTF-8 text with LF endings on Python 3.8+ and all platforms.""" with path.open("w", encoding="utf-8", newline="\n") as stream: stream.write(text)
@dataclass class Measurement: document: str engine: str mode: str run: int seconds: float pages: int | None pdf_bytes: int
@dataclass class Summary: document: str engine: str mode: str count: int mean: float median: float minimum: float maximum: float stdev: float
def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="跨平台测试 pdfLaTeX、XeLaTeX 和 LuaLaTeX 的冷编译与增量编译速度。" ) parser.add_argument("--runs", type=int, default=5, help="每个测试组合的计时次数(默认:5)") parser.add_argument( "--units", type=int, default=40, help="测试文档中重复的固定内容单元数量(默认:40)", ) parser.add_argument( "--output", type=Path, default=ROOT / "results", help="结果目录(默认:脚本目录下的 results)", ) parser.add_argument( "--engines", nargs="+", choices=tuple(ENGINE_FLAGS), default=list(ENGINE_FLAGS), help="要测试的引擎", ) parser.add_argument( "--documents", nargs="+", choices=DOCUMENTS, default=list(DOCUMENTS), help="要测试的文档类型", ) parser.add_argument("--no-warmup", action="store_true", help="跳过每个组合一次不计时的预热") parser.add_argument("--no-report", action="store_true", help="只输出 JSON,不生成中文 PDF 报告") parser.add_argument( "--report-only", action="store_true", help="读取输出目录中已有的 results.json,只重新生成中文报告", ) args = parser.parse_args() if args.no_report and args.report_only: parser.error("--no-report 与 --report-only 不能同时使用") if args.runs < 1: parser.error("--runs 必须至少为 1") if args.units < 1: parser.error("--units 必须至少为 1") return args
def command_version(command: str) -> str: try: result = subprocess.run( [command, "--version"], capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) except OSError: return "不可用" text = result.stdout or result.stderr return text.splitlines()[0].strip() if text else "未知"
def latexmk_version() -> str: result = subprocess.run( ["latexmk", "-v"], capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) for line in (result.stdout + result.stderr).splitlines(): if "Latexmk" in line and "Version" in line: return line.strip() return "latexmk(版本未知)"
def cpu_name() -> str: name = platform.processor() or os.environ.get("PROCESSOR_IDENTIFIER", "") if name: return name.strip() cpuinfo = Path("/proc/cpuinfo") if cpuinfo.exists(): for line in cpuinfo.read_text(encoding="utf-8", errors="replace").splitlines(): if line.lower().startswith("model name"): return line.split(":", 1)[-1].strip() return "未知"
def require_commands(engines: Iterable[str], report: bool) -> None: commands = ["latexmk", "kpsewhich", *engines] if report and "xelatex" not in commands: commands.append("xelatex") missing = [command for command in commands if shutil.which(command) is None] if missing: raise SystemExit("缺少必需命令:" + ", ".join(missing))
def detect_tex_live_version(version_texts: Iterable[str]) -> str: for text in version_texts: match = re.search(r"TeX Live\s+(\d{4})", text, flags=re.IGNORECASE) if match: return "TeX Live " + match.group(1) return "TeX Live(版本未知)"
def required_tex_files(documents: Iterable[str], report: bool) -> list[str]: selected = set(documents) files = { "amsmath.sty", "amssymb.sty", "booktabs.sty", "geometry.sty", "hyperref.sty", } if "english" in selected: files.update({"fontenc.sty", "lmodern.sty"}) if "chinese" in selected or report: files.update({"ctexart.cls", "FandolSong-Regular.otf"}) if report: files.update({"enumitem.sty", "pgfplots.sty", "tikz.sty"}) return sorted(files)
def check_tex_files(files: Iterable[str]) -> None: missing = [] for filename in files: result = subprocess.run( ["kpsewhich", filename], capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) if result.returncode != 0 or not result.stdout.strip(): missing.append(filename) if missing: raise SystemExit("TeX Live 缺少必需文件:" + ", ".join(missing))
def preflight_environment( engines: list[str], documents: list[str], report: bool ) -> dict[str, object]: if sys.version_info < (3, 8): raise SystemExit( "Python 版本过低:需要 3.8 或更高版本,当前为 " + platform.python_version() ) require_commands(engines, report) version_engines = list(engines) if report and "xelatex" not in version_engines: version_engines.append("xelatex") engine_versions = { engine: command_version(engine) for engine in version_engines } latexmk_text = latexmk_version() tex_files = required_tex_files(documents, report) check_tex_files(tex_files) tex_live = detect_tex_live_version( list(engine_versions.values()) + [latexmk_text] )
print("=== 环境预检 ===", flush=True) print( f"Python: {platform.python_version()} ({sys.executable})", flush=True ) print("命令: latexmk, kpsewhich, " + ", ".join(version_engines), flush=True) print(f"发行版: {tex_live}", flush=True) print(f"TeX 文件: {len(tex_files)} 项检查通过", flush=True) print("================", flush=True) return { "latexmk": latexmk_text, "engine_versions": engine_versions, "tex_live": tex_live, "checked_tex_files": tex_files, }
def safe_remove_tree(path: Path, output_root: Path) -> None: resolved = path.resolve() root = output_root.resolve() if resolved == root or root not in resolved.parents: raise RuntimeError(f"拒绝删除结果目录之外的路径:{resolved}") if resolved.exists(): shutil.rmtree(resolved)
def safe_remove_file(path: Path, output_root: Path) -> None: resolved = path.resolve() root = output_root.resolve() if root != resolved.parent and root not in resolved.parents: raise RuntimeError(f"拒绝删除结果目录之外的文件:{resolved}") if resolved.exists(): resolved.unlink()
def prepare_workspace( document: str, workspace: Path, output_root: Path, units: int ) -> Path: safe_remove_tree(workspace, output_root) source = workspace / "source" source.mkdir(parents=True) write_text_lf(source / "main.tex", DOCUMENT_TEMPLATES[document]) write_text_lf(source / "units.tex", f"\\def\\BenchmarkUnits{{{units}}}\n") write_text_lf(source / "mutable.tex", "\\def\\BenchmarkVariant{baseline}\n") return source
def latexmk_command(engine: str, source: Path, workspace: Path, force: bool = False) -> list[str]: aux = workspace / ".aux" aux.mkdir(parents=True, exist_ok=True) command = [ "latexmk", "-cd", "-file-line-error", "-halt-on-error", "-interaction=nonstopmode", "-synctex=0", ENGINE_FLAGS[engine], f"-auxdir={aux.resolve()}", f"-outdir={workspace.resolve()}", ] if force: command.append("-g") command.append(str((source / "main.tex").resolve())) return command
def parse_pages(log_text: str) -> int | None: matches = re.findall( r"Output written on .*?\((\d+) pages?", log_text, flags=re.IGNORECASE | re.DOTALL, ) return int(matches[-1]) if matches else None
def run_build( engine: str, source: Path, workspace: Path, log_path: Path, force: bool = False, ) -> tuple[float, int | None, int]: command = latexmk_command(engine, source, workspace, force) log_path.parent.mkdir(parents=True, exist_ok=True) started = time.perf_counter() result = subprocess.run( command, cwd=ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) elapsed = time.perf_counter() - started combined = result.stdout + "\n" + result.stderr write_text_lf(log_path, combined) if result.returncode != 0: tail = "\n".join(combined.splitlines()[-30:]) raise RuntimeError(f"{engine} 编译失败,日志:{log_path}\n{tail}") pdf = workspace / "main.pdf" if not pdf.exists(): raise RuntimeError(f"{engine} 未生成预期 PDF:{pdf}") engine_log = workspace / ".aux" / "main.log" engine_log_text = ( engine_log.read_text(encoding="utf-8", errors="replace") if engine_log.exists() else combined ) return elapsed, parse_pages(engine_log_text), pdf.stat().st_size
def summarize(rows: list[Measurement]) -> list[Summary]: summaries: list[Summary] = [] keys = sorted({(row.document, row.engine, row.mode) for row in rows}) for document, engine, mode in keys: values = [ row.seconds for row in rows if (row.document, row.engine, row.mode) == (document, engine, mode) ] summaries.append( Summary( document=document, engine=engine, mode=mode, count=len(values), mean=statistics.fmean(values), median=statistics.median(values), minimum=min(values), maximum=max(values), stdev=statistics.stdev(values) if len(values) > 1 else 0.0, ) ) return summaries
def tex_escape(value: object) -> str: text = str(value) replacements = { "\\": r"\textbackslash{}", "&": r"\&", "%": r"\%", "$": r"\$", "#": r"\#", "_": r"\_", "{": r"\{", "}": r"\}", "~": r"\textasciitilde{}", "^": r"\textasciicircum{}", } return "".join(replacements.get(char, char) for char in text)
def find_summary( summaries: list[Summary], document: str, engine: str, mode: str ) -> Summary | None: return next( ( item for item in summaries if (item.document, item.engine, item.mode) == (document, engine, mode) ), None, )
def benchmark_findings( summaries: list[Summary], documents: list[str], engines: list[str] ) -> dict[str, object]: """Derive report and console findings from the same summary statistics.""" document_findings = [] speedups = [] for document in documents: cold_items = [ item for item in summaries if item.document == document and item.mode == "cold" ] incremental_items = [ item for item in summaries if item.document == document and item.mode == "incremental" ] if not cold_items or not incremental_items: continue cold_fastest = min(cold_items, key=lambda item: item.median) incremental_fastest = min(incremental_items, key=lambda item: item.median) document_speedups = [] for engine in engines: cold = find_summary(summaries, document, engine, "cold") incremental = find_summary(summaries, document, engine, "incremental") if cold and incremental and incremental.median > 0: value = cold.median / incremental.median speedups.append(value) document_speedups.append(value) document_findings.append( { "document": document, "cold_fastest": cold_fastest, "incremental_fastest": incremental_fastest, "speedup_min": min(document_speedups), "speedup_max": max(document_speedups), } ) return { "documents": document_findings, "speedup_min": min(speedups) if speedups else 0.0, "speedup_max": max(speedups) if speedups else 0.0, }
def print_key_findings( summaries: list[Summary], documents: list[str], engines: list[str] ) -> None: findings = benchmark_findings(summaries, documents, engines) print("\n=== 引擎速度对比(按中位数 P50)===") for document in documents: for mode in ("cold", "incremental"): candidates = sorted( ( item for item in summaries if item.document == document and item.mode == mode ), key=lambda item: item.median, ) if not candidates: continue fastest = candidates[0].median ranking = [] for index, item in enumerate(candidates): gap = 100.0 * (item.median / fastest - 1.0) suffix = "领先" if index == 0 else f"慢 {gap:.1f}%" ranking.append(f"{item.engine} {item.median:.3f}s({suffix})") print( f"{DOCUMENT_LABELS[document]} / {MODE_LABELS[mode]}:" + " < ".join(ranking) ) if findings["speedup_max"]: print( f"附带指标:同一引擎从冷启动到增量构建的加速比为 " f"{findings['speedup_min']:.2f}--{findings['speedup_max']:.2f}x。" ) print("说明:结论仅代表本机、本次工作负载;跨机器比较应保持参数与 TeX Live 版本一致。") print("=" * 35)
def chart_coordinates( summaries: list[Summary], document: str, engines: list[str], mode: str ) -> str: coordinates = [] for engine in engines: item = find_summary(summaries, document, engine, mode) if item: coordinates.append(f"({engine},{item.median:.4f})") return " ".join(coordinates)
def generate_report_tex( report_tex: Path, rows: list[Measurement], summaries: list[Summary], metadata: dict[str, object], engines: list[str], documents: list[str], ) -> None: report_tex.parent.mkdir(parents=True, exist_ok=True) display_engines = ("pdflatex", "xelatex", "lualatex") engine_labels = { "pdflatex": "pdfLaTeX", "xelatex": "XeLaTeX", "lualatex": "LuaLaTeX", } result_rows = [] executive_points = [] for document in documents: for mode in ("cold", "incremental"): candidates = sorted( ( item for item in summaries if item.document == document and item.mode == mode ), key=lambda item: item.median, ) if not candidates: continue fastest = candidates[0].median cells = [] for engine in display_engines: item = find_summary(summaries, document, engine, mode) if not item: cells.append("--") continue gap = 100.0 * (item.median / fastest - 1.0) if gap < 0.05: cells.append(f"\\textbf{{{item.median:.3f}}}") else: cells.append(f"{item.median:.3f} {{\\color{{Muted}}(+{gap:.1f}\\%)}}") ranking_parts = [] for index, item in enumerate(candidates): gap = 100.0 * (item.median / fastest - 1.0) ranking_parts.append( f"{engine_labels[item.engine]} 领先" if index == 0 else f"{engine_labels[item.engine]} 慢 {gap:.1f}\\%" ) result_rows.append( f"{DOCUMENT_LABELS[document]} & {MODE_LABELS[mode]} & " + " & ".join(cells) + " \\\\" ) executive_points.append( f"{DOCUMENT_LABELS[document]}{MODE_LABELS[mode]}:" + ";".join(ranking_parts) + "。" )
chart_keys = [] chart_labels = [] engine_coordinates = {engine: [] for engine in display_engines} language_short = {"english": "英", "chinese": "中"} mode_short = {"cold": "冷", "incremental": "增"} for document in documents: for mode in ("cold", "incremental"): key = f"c{len(chart_keys)}" chart_keys.append(key) chart_labels.append( f"{language_short[document]}-{mode_short[mode]}" ) for engine in display_engines: item = find_summary(summaries, document, engine, mode) if item: engine_coordinates[engine].append( f"({key},{item.median:.4f}) " f"+= (0,{item.maximum - item.median:.4f}) " f"-= (0,{item.median - item.minimum:.4f})" ) created_at = dt.datetime.fromisoformat(str(metadata["timestamp"])) created = f"{created_at:%Y}年{created_at.month}月{created_at.day}日 {created_at:%H:%M:%S}" engine_versions = metadata["engine_versions"] version_rows = "\n".join( f"{engine} & {tex_escape(engine_versions[engine])} \\\\" for engine in engines ) result_rows_tex = "\n".join(result_rows) chart_keys_tex = ",".join(chart_keys) chart_labels_tex = ",".join(chart_labels) pdflatex_coordinates_tex = " ".join(engine_coordinates.get("pdflatex", [])) xelatex_coordinates_tex = " ".join(engine_coordinates.get("xelatex", [])) lualatex_coordinates_tex = " ".join(engine_coordinates.get("lualatex", [])) speedup_keys = [] speedup_labels = [] english_speedup_coordinates = [] chinese_speedup_coordinates = [] for document in documents: for engine in display_engines: cold = find_summary(summaries, document, engine, "cold") incremental = find_summary(summaries, document, engine, "incremental") if cold and incremental and incremental.median: key = f"s{len(speedup_keys)}" speedup_keys.append(key) speedup_labels.append( f"{language_short[document]}-{engine_labels[engine]}" ) coordinate = ( f"({cold.median / incremental.median:.4f},{key})" ) if document == "english": english_speedup_coordinates.append(coordinate) else: chinese_speedup_coordinates.append(coordinate) speedup_keys_tex = ",".join(speedup_keys) speedup_labels_tex = ",".join(speedup_labels) english_speedup_coordinates_tex = " ".join(english_speedup_coordinates) chinese_speedup_coordinates_tex = " ".join(chinese_speedup_coordinates) executive_points_tex = "\n".join( " \\item " + text for text in executive_points ) python_version_tex = tex_escape(str(metadata["python"]).split()[0]) platform_tex = tex_escape(metadata["platform"]) cpu_tex = tex_escape(metadata["cpu"]) latexmk_value = str(metadata["latexmk"]) latexmk_match = re.search( r"Version\s+([0-9.]+[A-Za-z]?)", latexmk_value, flags=re.IGNORECASE ) latexmk_tex = tex_escape( "latexmk " + latexmk_match.group(1) if latexmk_match else latexmk_value ) tex_live_tex = tex_escape( metadata.get("tex_live") or detect_tex_live_version(metadata.get("engine_versions", {}).values()) ) warmup_tex = "关闭" if metadata["no_warmup"] else "1 次且不计时" tex = rf"""\documentclass[UTF8,10pt,fontset=fandol]{{ctexart}} \usepackage[a4paper,top=11mm,bottom=10mm,left=13mm,right=13mm]{{geometry}} \usepackage{{booktabs,array,tabularx,xcolor,colortbl,float,enumitem}} \usepackage{{tikz,pgfplots}} \pgfplotsset{{compat=1.18}} \definecolor{{Ink}}{{HTML}}{{172033}} \definecolor{{Muted}}{{HTML}}{{64748B}} \definecolor{{Rule}}{{HTML}}{{CBD5E1}} \definecolor{{Panel}}{{HTML}}{{F1F5F9}} \definecolor{{PdfBlue}}{{HTML}}{{2563EB}} \definecolor{{XeTeal}}{{HTML}}{{14B8A6}} \definecolor{{LuaOrange}}{{HTML}}{{F97316}} \color{{Ink}} \pagestyle{{empty}} \setlength{{\parindent}}{{0pt}} \setlength{{\parskip}}{{0pt}} \setlength{{\tabcolsep}}{{5pt}} \renewcommand{{\arraystretch}}{{1.12}} \setlist{{nosep,leftmargin=1.45em,labelsep=0.45em}} \newcommand{{\reportsection}}[1]{{\vspace{{0.45em}}\noindent{{\large\bfseries #1}}\par\vspace{{0.15em}}\color{{Rule}}\hrule\color{{Ink}}\vspace{{0.35em}}}}
\begin{{document}} \begin{{tabularx}}{{\textwidth}}{{@{{}}Xr@{{}}}} {{\fontsize{{19}}{{22}}\selectfont\bfseries LaTeX Engine Benchmark}} & {{\small\bfseries PERFORMANCE BRIEF}} \\ {{\large 跨平台编译性能评估}} & {{\footnotesize {created}}} \end{{tabularx}} \vspace{{0.35em}} \color{{PdfBlue}}\hrule height 1.2pt\color{{Ink}}
\reportsection{{执行摘要}} \colorbox{{Panel}}{{\parbox{{0.975\textwidth}}{{ \small \begin{{itemize}} {executive_points_tex} \end{{itemize}} }}}}
\reportsection{{测试设计}} \footnotesize \begin{{tabularx}}{{\textwidth}}{{@{{}}>{{\bfseries}}p{{0.09\textwidth}}X >{{\bfseries}}p{{0.08\textwidth}}X@{{}}}} 工作负载 & 英文:pdfLaTeX/XeLaTeX/LuaLaTeX;中文:XeLaTeX/LuaLaTeX & 指标 & 墙钟时间,P50 为主指标 \\ 协议 & 每组合 {metadata['runs']} 次;文档包含 {metadata['units']} 个重复的固定内容单元;预热 {warmup_tex} & 构建 & 冷启动清空辅助目录;增量仅修改小文件 \\ 环境 & {tex_live_tex};Python {python_version_tex};{latexmk_tex} & 主机 & {cpu_tex};{metadata['logical_cpus']} 逻辑核 \\ \end{{tabularx}} \vspace{{0.2em}} {{\color{{Muted}}平台:{platform_tex}。脚本生成等价源码,latexmk 完成必要遍数;中文使用 Fandol 字体。}}
\reportsection{{基准记分板}} \begin{{center}} \small \begin{{tabular}}{{llrrr}} \toprule 工作负载 & 构建模式 & pdfLaTeX / s & XeLaTeX / s & LuaLaTeX / s \\ \midrule {result_rows_tex} \bottomrule \end{{tabular}} \end{{center}} \vspace{{-0.35em}} {{\footnotesize\color{{Muted}}粗体为同一行最快结果;括号为相对最快引擎的耗时增幅。}}
\reportsection{{引擎速度剖面}} \begin{{figure}}[H] \centering \begin{{tikzpicture}} \begin{{axis}}[ ybar=1.2pt, bar width=17pt, width=0.965\textwidth, height=6.5cm, ylabel={{P50 耗时 / s}}, symbolic x coords={{{chart_keys_tex}}}, xtick={{{chart_keys_tex}}}, xticklabels={{{chart_labels_tex}}}, xticklabel style={{font=\footnotesize}}, tick label style={{font=\footnotesize}}, ylabel style={{font=\footnotesize}}, ymin=0, enlarge x limits=0.10, axis line style={{draw=Rule}}, tick style={{draw=Rule}}, ymajorgrids=true, grid style={{Rule,dashed}}, legend style={{at={{(0.5,0.98)}},anchor=north,legend columns=3, font=\footnotesize,draw=none,fill=white}}, error bars/y dir=both, error bars/y explicit, error bars/error bar style={{line width=0.6pt,draw=Ink}} ] \addplot+[fill=PdfBlue,draw=PdfBlue!80!black] coordinates {{{pdflatex_coordinates_tex}}}; \addplot+[fill=XeTeal,draw=XeTeal!70!black] coordinates {{{xelatex_coordinates_tex}}}; \addplot+[fill=LuaOrange,draw=LuaOrange!80!black] coordinates {{{lualatex_coordinates_tex}}}; \legend{{pdfLaTeX,XeLaTeX,LuaLaTeX}} \end{{axis}} \end{{tikzpicture}} \end{{figure}} \vspace{{-0.9em}} {{\footnotesize\color{{Muted}}柱高为中位数,误差线覆盖本次样本的最小值至最大值;每个分组直接比较相同语言、相同构建模式下的引擎速度。}}
\reportsection{{增量复用收益(附带指标)}} \begin{{figure}}[H] \centering \begin{{tikzpicture}} \begin{{axis}}[ xbar, bar width=7pt, width=0.965\textwidth, height=3.75cm, xlabel={{加速比(冷启动 P50 / 增量 P50)}}, symbolic y coords={{{speedup_keys_tex}}}, ytick={{{speedup_keys_tex}}}, yticklabels={{{speedup_labels_tex}}}, tick label style={{font=\scriptsize}}, xlabel style={{font=\footnotesize}}, xmin=0, xmax=3.05, enlarge y limits=0.16, axis line style={{draw=Rule}}, tick style={{draw=Rule}}, xmajorgrids=true, grid style={{Rule,dashed}}, legend style={{at={{(0.99,0.03)}},anchor=south east,legend columns=2, font=\scriptsize,draw=none,fill=white}}, nodes near coords={{\pgfmathprintnumber[fixed,precision=2]{{\pgfplotspointmeta}}$\times$}}, nodes near coords style={{font=\scriptsize\bfseries,anchor=west}} ] \addplot+[fill=PdfBlue,draw=PdfBlue!80!black] coordinates {{{english_speedup_coordinates_tex}}}; \addplot+[fill=XeTeal,draw=XeTeal!70!black] coordinates {{{chinese_speedup_coordinates_tex}}}; \legend{{英文,中文}} \end{{axis}} \end{{tikzpicture}} \end{{figure}} \vspace{{-0.8em}} {{\footnotesize\color{{Muted}}数值越大,表示同一引擎复用辅助文件后相对冷启动节省的编译时间越多。}}
\vfill \color{{Rule}}\hrule\color{{Ink}}\vspace{{0.25em}} {{\scriptsize\color{{Muted}}数据出处:\texttt{{results.json}},其中包含逐轮测量、汇总统计和环境元数据。本报告不使用合成评分,避免掩盖绝对耗时。}} \end{{document}} """ write_text_lf(report_tex, tex)
def compile_report(report_tex: Path, output_root: Path) -> Path: pdf_dir = output_root aux_dir = output_root / "report" / ".aux" pdf_dir.mkdir(parents=True, exist_ok=True) aux_dir.mkdir(parents=True, exist_ok=True) command = [ "latexmk", "-cd", "-file-line-error", "-halt-on-error", "-interaction=nonstopmode", "-synctex=0", "-pdfxe", f"-auxdir={aux_dir.resolve()}", f"-outdir={pdf_dir.resolve()}", str(report_tex.resolve()), ] result = subprocess.run( command, cwd=ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) log = result.stdout + "\n" + result.stderr write_text_lf(output_root / "report" / "report-build.log", log) if result.returncode != 0: raise RuntimeError("中文报告编译失败:\n" + "\n".join(log.splitlines()[-40:])) pdf = pdf_dir / f"{report_tex.stem}.pdf" if not pdf.exists(): raise RuntimeError(f"中文报告 PDF 不存在:{pdf}") return pdf
def main() -> int: args = parse_args() output = args.output.resolve() output.mkdir(parents=True, exist_ok=True) safe_remove_file(output / "timings.csv", output)
if args.report_only: result_file = output / "results.json" if not result_file.exists(): raise FileNotFoundError(f"找不到已有结果:{result_file}") payload = json.loads(result_file.read_text(encoding="utf-8")) metadata = payload["metadata"] preflight = preflight_environment([], [], True) metadata_changed = False if not metadata.get("tex_live"): historical_versions = list( metadata.get("engine_versions", {}).values() ) tex_live = detect_tex_live_version(historical_versions) if "版本未知" in tex_live: tex_live = str(preflight["tex_live"]) metadata["tex_live"] = tex_live payload["metadata"] = metadata metadata_changed = True measurements = [Measurement(**item) for item in payload["measurements"]] summaries = [Summary(**item) for item in payload["summary"]] engines = list(metadata["engines"]) documents = list(metadata["documents"]) repaired_pages = False for document in documents: for engine in engines: missing = [ row for row in measurements if row.document == document and row.engine == engine and row.pages is None ] if not missing: continue engine_log = output / "work" / document / engine / ".aux" / "main.log" if engine_log.exists(): pages = parse_pages( engine_log.read_text(encoding="utf-8", errors="replace") ) if pages is not None: for row in missing: row.pages = pages repaired_pages = True if repaired_pages or metadata_changed: payload["measurements"] = [asdict(item) for item in measurements] write_text_lf( result_file, json.dumps(payload, ensure_ascii=False, indent=2) + "\n" ) print_key_findings(summaries, documents, engines) report_tex = output / "report" / "latex-engine-benchmark-report.tex" generate_report_tex( report_tex, measurements, summaries, metadata, engines, documents ) pdf = compile_report(report_tex, output) print(f"报告:{pdf}") return 0
cases = [ (document, engine) for document in args.documents for engine in args.engines if engine in SUPPORTED_ENGINES[document] ] if not cases: raise ValueError("没有可运行的组合;中文测试只支持 xelatex 和 lualatex") tested_engines = [ engine for engine in args.engines if any(case[1] == engine for case in cases) ] tested_documents = [ document for document in args.documents if any(case[0] == document for case in cases) ] skipped = [ (document, engine) for document in args.documents for engine in args.engines if (document, engine) not in cases ] for document, engine in skipped: print(f"跳过:{DOCUMENT_LABELS[document]} / {engine}", flush=True)
preflight = preflight_environment( tested_engines, tested_documents, not args.no_report )
metadata: dict[str, object] = { "timestamp": dt.datetime.now().astimezone().isoformat(timespec="seconds"), "platform": platform.platform(), "cpu": cpu_name(), "logical_cpus": os.cpu_count() or "未知", "python": sys.version.replace("\n", " "), "latexmk": preflight["latexmk"], "engine_versions": preflight["engine_versions"], "tex_live": preflight["tex_live"], "checked_tex_files": preflight["checked_tex_files"], "runs": args.runs, "units": args.units, "no_warmup": args.no_warmup, "engines": tested_engines, "documents": tested_documents, }
measurements: list[Measurement] = [] total = len(cases) case_number = 0 for document, engine in cases: case_number += 1 print(f"[{case_number}/{total}] {DOCUMENT_LABELS[document]} / {engine}", flush=True) workspace = output / "work" / document / engine log_dir = output / "logs" / document / engine safe_remove_tree(log_dir, output)
if not args.no_warmup: source = prepare_workspace(document, workspace, output, args.units) print(" 预热编译", flush=True) run_build(engine, source, workspace, log_dir / "warmup.log")
for run in range(1, args.runs + 1): source = prepare_workspace(document, workspace, output, args.units) print(f" 从零编译 {run}/{args.runs}", flush=True) elapsed, pages, size = run_build( engine, source, workspace, log_dir / f"cold-{run}.log" ) measurements.append( Measurement(document, engine, "cold", run, elapsed, pages, size) )
source = workspace / "source" for run in range(1, args.runs + 1): variant = source / "mutable.tex" write_text_lf( variant, f"\\def\\BenchmarkVariant{{incremental-{run:03d}}}\n" ) print(f" 增量编译 {run}/{args.runs}", flush=True) elapsed, pages, size = run_build( engine, source, workspace, log_dir / f"incremental-{run}.log", force=True, ) measurements.append( Measurement(document, engine, "incremental", run, elapsed, pages, size) )
final_pdf = output / "test-pdfs" / f"{document}-{engine}.pdf" final_pdf.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(workspace / "main.pdf", final_pdf)
summaries = summarize(measurements) payload = { "metadata": metadata, "summary": [asdict(item) for item in summaries], "measurements": [asdict(item) for item in measurements], } write_text_lf( output / "results.json", json.dumps(payload, ensure_ascii=False, indent=2) + "\n", ) print_key_findings(summaries, tested_documents, tested_engines)
if not args.no_report: report_tex = output / "report" / "latex-engine-benchmark-report.tex" generate_report_tex( report_tex, measurements, summaries, metadata, tested_engines, tested_documents, ) pdf = compile_report(report_tex, output) print(f"报告:{pdf}") print(f"数据:{output / 'results.json'}") return 0
if __name__ == "__main__": try: raise SystemExit(main()) except KeyboardInterrupt: print("\n测试已由用户中断。", file=sys.stderr) raise SystemExit(130) except Exception as exc: print(f"错误:{exc}", file=sys.stderr) raise SystemExit(1)
|