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).
This commit is contained in:
parent
6c0afa3749
commit
8e1415aa64
84
Benchmarks/bench-libxml2.c
Normal file
84
Benchmarks/bench-libxml2.c
Normal file
@ -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 <libxml/parser.h>
|
||||
#include <libxml/tree.h>
|
||||
#include <libxml/xpath.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
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 <file.xml> <iterations>\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;
|
||||
}
|
||||
55
Benchmarks/bench-purexml.swift
Normal file
55
Benchmarks/bench-purexml.swift
Normal file
@ -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 <file.xml> <iterations>\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))
|
||||
37
docs/benchmarks.md
Normal file
37
docs/benchmarks.md
Normal file
@ -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.
|
||||
65
scripts/benchmark.sh
Normal file
65
scripts/benchmark.sh
Normal file
@ -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('<?xml version="1.0" encoding="UTF-8"?>\n')
|
||||
out.write('<catalog xmlns:m="urn:meta">\n')
|
||||
for i in range(items):
|
||||
kind = "even" if i % 2 == 0 else "odd"
|
||||
out.write(f' <item id="i{i}" kind="{kind}" m:rank="{i % 97}">\n')
|
||||
out.write(f' <name>Item number {i} & friends</name>\n')
|
||||
out.write(f' <price currency="EUR">{(i * 37) % 1000}.{i % 100:02d}</price>\n')
|
||||
out.write(f' <note xml:space="preserve">line one\nline two for {i}</note>\n')
|
||||
out.write(' </item>\n')
|
||||
out.write('</catalog>\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
|
||||
Loading…
Reference in New Issue
Block a user