From 8e1415aa64eaccebb1c198633e2fed525e7129f7 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 10 Jun 2026 18:15:26 +0200 Subject: [PATCH] feat(bench): PureXML vs libxml2 benchmark harness with first baseline scripts/benchmark.sh generates a deterministic corpus, builds PureXML in release and a C driver against the SDK's libxml2, times parse, serialize, and XPath over the same bytes (best-of-N, internal timing, IO excluded), cross-checks the same XPath count on both sides, and prints the comparison. First honest baseline recorded in docs/benchmarks.md: parse ~30x, xpath ~36x, serialize ~5x slower than libxml2 on a 4.2 MB corpus; these are the numbers #139's optimization passes burn down. The SAX stage isolation is blocked on #142 (the public streaming surface is internal, found by this harness). --- Benchmarks/bench-libxml2.c | 84 ++++++++++++++++++++++++++++++++++ Benchmarks/bench-purexml.swift | 55 ++++++++++++++++++++++ docs/benchmarks.md | 37 +++++++++++++++ scripts/benchmark.sh | 65 ++++++++++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 Benchmarks/bench-libxml2.c create mode 100644 Benchmarks/bench-purexml.swift create mode 100644 docs/benchmarks.md create mode 100644 scripts/benchmark.sh diff --git a/Benchmarks/bench-libxml2.c b/Benchmarks/bench-libxml2.c new file mode 100644 index 0000000..bca4b01 --- /dev/null +++ b/Benchmarks/bench-libxml2.c @@ -0,0 +1,84 @@ +/* libxml2 side of the PureXML benchmark (scripts/benchmark.sh). + * Times the same operations the Swift driver times, over the same file, + * with internal timing so process startup never taints a number. + * Output: CSV lines "library,operation,bytes,seconds". + */ +#include +#include +#include +#include +#include +#include + +static double now(void) { + struct timeval tv; + gettimeofday(&tv, NULL); + return (double)tv.tv_sec + (double)tv.tv_usec / 1e6; +} + +int main(int argc, char **argv) { + if (argc < 3) { + fprintf(stderr, "usage: bench-libxml2 \n"); + return 1; + } + const char *path = argv[1]; + int iterations = atoi(argv[2]); + xmlInitParser(); + + /* Read the bytes once so parse timing excludes IO. */ + FILE *f = fopen(path, "rb"); + if (!f) { perror("open"); return 1; } + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + char *buffer = malloc((size_t)size); + fread(buffer, 1, (size_t)size, f); + fclose(f); + + /* Parse. */ + double best_parse = 1e9; + xmlDocPtr doc = NULL; + for (int i = 0; i < iterations; i++) { + if (doc) xmlFreeDoc(doc); + double t0 = now(); + doc = xmlReadMemory(buffer, (int)size, path, NULL, XML_PARSE_NONET); + double dt = now() - t0; + if (dt < best_parse) best_parse = dt; + } + if (!doc) { fprintf(stderr, "parse failed\n"); return 1; } + printf("libxml2,parse,%ld,%.6f\n", size, best_parse); + + /* Serialize. */ + double best_serialize = 1e9; + for (int i = 0; i < iterations; i++) { + xmlChar *out = NULL; + int outLen = 0; + double t0 = now(); + xmlDocDumpMemory(doc, &out, &outLen); + double dt = now() - t0; + if (dt < best_serialize) best_serialize = dt; + xmlFree(out); + } + printf("libxml2,serialize,%ld,%.6f\n", size, best_serialize); + + /* XPath: count a broad selection. */ + double best_xpath = 1e9; + long count = 0; + for (int i = 0; i < iterations; i++) { + double t0 = now(); + xmlXPathContextPtr ctx = xmlXPathNewContext(doc); + xmlXPathObjectPtr res = xmlXPathEvalExpression((const xmlChar *)"count(//item[@kind='even'])", ctx); + count = (long)res->floatval; + double dt = now() - t0; + if (dt < best_xpath) best_xpath = dt; + xmlXPathFreeObject(res); + xmlXPathFreeContext(ctx); + } + printf("libxml2,xpath,%ld,%.6f\n", size, best_xpath); + fprintf(stderr, "libxml2 xpath count: %ld\n", count); + + xmlFreeDoc(doc); + xmlCleanupParser(); + free(buffer); + return 0; +} diff --git a/Benchmarks/bench-purexml.swift b/Benchmarks/bench-purexml.swift new file mode 100644 index 0000000..9a5e447 --- /dev/null +++ b/Benchmarks/bench-purexml.swift @@ -0,0 +1,55 @@ +// PureXML side of the benchmark (scripts/benchmark.sh). Times the same +// operations the C driver times, over the same file, with internal timing. +// Output: CSV lines "library,operation,bytes,seconds". +// Foundation is used for file access and timing only; the measured code is +// the Foundation-free PureXML library. +import Foundation +import PureXML + +let arguments = CommandLine.arguments +guard arguments.count >= 3, let iterations = Int(arguments[2]) else { + FileHandle.standardError.write(Data("usage: bench-purexml \n".utf8)) + exit(1) +} + +let path = arguments[1] +let source = try String(contentsOfFile: path, encoding: .utf8) +let bytes = source.utf8.count + +func best(_ iterations: Int, _ body: () throws -> Void) rethrows -> Double { + var bestTime = Double.greatestFiniteMagnitude + for _ in 0 ..< iterations { + let start = Date() + try body() + bestTime = min(bestTime, -start.timeIntervalSinceNow) + } + return bestTime +} + +// Parse. +var tree: PureXML.Model.TreeNode! +let parseTime = try best(iterations) { + tree = try PureXML.parseTree(source) +} + +print("purexml,parse,\(bytes),\(String(format: "%.6f", parseTime))") + +// Serialize. +let node = tree.node +var serialized = "" +let serializeTime = best(iterations) { + serialized = PureXML.serialize(node) +} + +print("purexml,serialize,\(bytes),\(String(format: "%.6f", serializeTime))") +FileHandle.standardError.write(Data("purexml serialized bytes: \(serialized.utf8.count)\n".utf8)) + +// XPath: the same broad selection the C driver counts. +let query = try PureXML.XPath.Query("count(//item[@kind='even'])") +var count = 0.0 +let xpathTime = try best(iterations) { + count = try query.value(at: tree).number +} + +print("purexml,xpath,\(bytes),\(String(format: "%.6f", xpathTime))") +FileHandle.standardError.write(Data("purexml xpath count: \(Int(count))\n".utf8)) diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..9c665c6 --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1,37 @@ +# Benchmarks: PureXML vs libxml2 (#139) + +`bash scripts/benchmark.sh [items] [iterations]` generates a deterministic +corpus (never vendored), builds PureXML in release and the C driver against +the macOS SDK's libxml2, runs both over the same bytes with internal timing +(best-of-N, IO excluded), and prints the comparison. Both drivers also +cross-check the same XPath count, so every benchmark run doubles as a +cross-implementation correctness check. + +## Baseline (first measurement) + +20000 items, ~4.2 MB, best of 5, Apple Silicon (Studio), release builds: + +| operation | PureXML | libxml2 | ratio | +|---|---|---|---| +| parse | 0.49 s | 0.016 s | ~30x | +| serialize | 0.059 s | 0.012 s | ~5x | +| xpath | 0.42 s | 0.012 s | ~36x | + +These are the honest starting numbers the #139 optimization passes burn +down; the table updates as passes land, never silently. + +## Known structure of the gap + +- Parse and XPath dominate; serialize is closest. +- The scanner-vs-tree split needs the SAX stage, blocked on #142 (the + public streaming surface is internal today). +- Suspects to profile, in order: Character/grapheme-based scanning versus + byte scanning, per-scalar reader buffering (#135), ARC traffic in tree + construction, XPath node-set materialization on every axis step. + +## Rules + +- Optimize only what these numbers indict, one suspect per pass, with the + before/after recorded here. +- The corpus generator changes only with a note here, since ratios are + only comparable over identical corpora. diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh new file mode 100644 index 0000000..b0ec311 --- /dev/null +++ b/scripts/benchmark.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# PureXML vs libxml2 benchmark (#139). Generates a deterministic corpus, +# builds both drivers (PureXML in release, the C driver against the SDK's +# libxml2), runs them over the same file, and prints a comparison table. +# Nothing is vendored; the corpus is regenerated on each run. +# +# Usage: bash scripts/benchmark.sh [items] [iterations] + +set -euo pipefail +cd "$(dirname "$0")/.." + +ITEMS="${1:-20000}" +ITERATIONS="${2:-5}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Deterministic corpus: nested records with attributes, text, and namespaces. +python3 - "$ITEMS" > "$WORK/corpus.xml" << 'PYEOF' +import sys +items = int(sys.argv[1]) +out = sys.stdout +out.write('\n') +out.write('\n') +for i in range(items): + kind = "even" if i % 2 == 0 else "odd" + out.write(f' \n') + out.write(f' Item number {i} & friends\n') + out.write(f' {(i * 37) % 1000}.{i % 100:02d}\n') + out.write(f' line one\nline two for {i}\n') + out.write(' \n') +out.write('\n') +PYEOF +SIZE=$(wc -c < "$WORK/corpus.xml" | tr -d ' ') +echo "corpus: $ITEMS items, $SIZE bytes, best of $ITERATIONS runs" + +echo "==> building PureXML (release)" +swift build -c release > /dev/null +MODULES=".build/release/Modules" +[ -d "$MODULES" ] || MODULES=".build/release" +swiftc -O -I "$MODULES" Benchmarks/bench-purexml.swift .build/release/PureXML.build/*.o -o "$WORK/bench-purexml" 2> /dev/null \ + || swiftc -O -I "$MODULES" Benchmarks/bench-purexml.swift -L .build/release -lPureXML -o "$WORK/bench-purexml" + +echo "==> building libxml2 driver" +SDK="$(xcrun --show-sdk-path)" +clang -O2 -I "$SDK/usr/include/libxml2" -lxml2 Benchmarks/bench-libxml2.c -o "$WORK/bench-libxml2" + +echo "==> running" +"$WORK/bench-purexml" "$WORK/corpus.xml" "$ITERATIONS" > "$WORK/pure.csv" +"$WORK/bench-libxml2" "$WORK/corpus.xml" "$ITERATIONS" > "$WORK/lib.csv" + +python3 - "$WORK/pure.csv" "$WORK/lib.csv" << 'PYEOF' +import sys +def load(path): + rows = {} + for line in open(path): + library, operation, size, seconds = line.strip().split(",") + rows[operation] = float(seconds) + return rows +pure = load(sys.argv[1]) +lib = load(sys.argv[2]) +print(f"{'operation':<12} {'PureXML':>12} {'libxml2':>12} {'ratio':>8}") +for operation in ["parse", "serialize", "xpath"]: + p, l = pure[operation], lib[operation] + print(f"{operation:<12} {p:>11.4f}s {l:>11.4f}s {p / l:>7.2f}x") +PYEOF