Read each file once, and serialise a listing without building a node per line
Measured before changing anything, and the ticket's diagnosis did not survive the measurement. t-0361 says per-page cost grows linearly with repo count. It does not: adding 32 repositories to a 1,780-page render costs -24 ms beyond what those 32 repositories cost alone, against the +131,007 ms that model predicts. Its four points varied the repo LIST over wildly heterogeneous repositories, so repo count, which repositories were in the list, and pages-per-repo all moved together. The rising ms/page is composition, not a quadratic.
Two hypotheses died before a line of this diff was written. The second was git: a shim named git at the front of PATH measures 279 calls totalling 1,951 ms against a 101,444 ms wall -- 1.9%. Batching the four spawns per commit page into one log walk would save 1.5 s in 101 s, so it is not done here, though test/git-batch-equivalence.py establishes it would be byte-identical if anyone wants it later.
Where the time actually goes, from temporary accumulators around each page type on three real repositories:
blob pages 78,773 ms / 1059 pages 68%
exclusion scan 25,543 ms 22%
tree pages 5,823 ms / 330
commit pages 5,717 ms / 60
write path 265 msThe filesystem is 265 ms. The cost is in-process rendering of file content, at 7.4 s per MB of tip source -- cross-checked by an isolated single-file probe at 7.3 s/MB.
So, two things:
EVERY TRACKED FILE WAS READ THREE TIMES. unrenderable-reason ran once to build the excluded list and again to build the leaves list, each call reading the whole file to inspect its first 8000 characters for a NUL, and the page loop read it a third time to render it. file-verdict replaces it and returns (reason . text), so the read happens once and the text travels with the verdict. The NUL search is now one native string-find over an 8000-character prefix rather than a Scheme loop calling string-ref and char->integer per character. The 8000 rule is kept deliberately: a NUL at position 9000 does not make a file binary, and a whole-string search would refuse a file that used to render.
A SOURCE LISTING BUILT ONE SXML NODE PER LINE -- about 335,000 of them for the sigil monorepo alone -- and sxml->xml walks a three-element tree per line while its xml-escape displays ONE CHARACTER AT A TIME into a string port. The listing is now serialised directly into a single raw node: 1,150 ms to 342 ms on a 368 KB source, outputs compared byte for byte. This is the only place in the renderer that bypasses SXML and the comment says why -- a listing is the one surface whose node count scales with the size of the input rather than with the design, and nothing else here is worth the special case.
The residue is xml-escape itself, which lives in sigil-sxml. Written up rather than fixed: another repository, its own release.
Nothing rendered changes. test/render-identical.sh compares a (path, sha256) manifest of every file of two fresh output trees, with the estate descriptor, a quarantine submission and --site-url all supplied so the estate home, the estate report page, the unconfirmed issue page and both feed kinds are in scope. GREEN at 1,794 files over the first three real estate repositories, and at 317 over five small ones. Sabotage-tested: one word changed in the listing markup turns it RED naming every affected page; restored from a hash-verified copy, GREEN again. All ten integration suites pass.
src/docket/render.sgl | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------
test/render-fourpoint.sh | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 230 insertions(+), 36 deletions(-)src/docket/render.sglmodified
;; ;; Paths that are STILL quoted after this (a newline or a quote in the ;; name) are reported by name rather than dropped in silence -- see ;; unrenderable-reason. ;; file-verdict. (define (repo-tip-files dir) (let ((out (process-output->string "git" "-C" dir "-c" "core.quotePath=false" "ls-tree" "-r" "--name-only" "HEAD"))) (car xs)) (else (loop (cdr xs)))))) (define (unrenderable-reason dir rel excludes) ;; EVERY TRACKED FILE IS READ EXACTLY ONCE PER RENDER, and this shape is ;; what enforces it rather than a comment asking callers to be careful. ;; ;; It used to be read THREE times: `unrenderable-reason` was called once to ;; build the excluded list and a second time to build the leaves list, each ;; call reading the WHOLE file to look at its first 8000 characters for a ;; NUL, and then the page loop read it a third time to render it. Measured ;; on three real repositories (1,059 pages): the two scanning passes alone ;; cost 25.5 s of a 116 s render, 22%, for an answer already in hand. ;; ;; So the read happens HERE, once, and the text travels with the verdict. ;; `read-file` returns a pair of (reason . text): the reason is #f when the ;; file gets a page, and the text is #f when there is nothing to render. (define (file-verdict dir rel excludes) (define (contains? s ch) (let ((n (string-length s))) (let loop ((i 0)) (cond ((>= i n) #f) ((eq? (string-ref s i) ch) #t) (else (loop (+ i 1))))))) (cond ((excluded-by? rel excludes) => (lambda (which) (string-append "excluded by .docket.json: " which))) (cons (string-append "excluded by .docket.json: " which) #f))) ((string-starts-with? rel "\"") "git quotes this path, so it cannot be opened") (cons "git quotes this path, so it cannot be opened" #f)) ;; `#` ends a URL path at the fragment and `?` at the query, so an ;; unescaped one makes the page unreachable AND makes the index ;; link somewhere else. Excluded loudly rather than emitted broken. ((or (contains? rel #\#) (contains? rel #\?)) "the path contains # or ?, which cannot appear unescaped in a URL") (cons "the path contains # or ?, which cannot appear unescaped in a URL" #f)) ((not (file-exists? (path-join dir rel))) "no such file on disk at tip") ((binary-string? (read-file-string (path-join dir rel))) "binary") (else #f))) (cons "no such file on disk at tip" #f)) (else (let ((text (read-file-string (path-join dir rel)))) (if (binary-string? text) (cons "binary" #f) (cons #f text)))))) (define (verdict-reason v) (car v)) (define (verdict-text v) (cdr v)) ;; A NUL byte means binary; rendering it as text produces garbage and ;; bloats the tree. Cheaper and more honest than trusting extensions. ;; ;; ONLY THE FIRST 8000 CHARACTERS COUNT, which is the rule this has always ;; applied and is why the search is over a prefix rather than the whole ;; string: a NUL at position 9000 does not make the file binary, and a ;; whole-string search would call one that used to render. The scan is ;; `string-find` -- one native pass -- instead of a Scheme loop calling ;; `string-ref` and `char->integer` per character, which was most of the ;; 25.5 s above. (define (binary-string? text) (let ((n (string-length text))) (let loop ((i 0)) (cond ((>= i n) #f) ((>= i 8000) #f) ((eq? (char->integer (string-ref text i)) 0) #t) (else (loop (+ i 1))))))) (define (line-node i line) (let ((anchor (string-append "L" (number->string i)))) `(div (@ (class "cline") (id ,anchor)) (a (@ (class "lno") (href ,(string-append "#" anchor)) (aria-label ,(string-append "line " (number->string i)))) ,(number->string i)) (code ,line)))) (let* ((n (string-length text)) (head (if (> n 8000) (substring text 0 8000) text))) (if (string-find head (string (integer->char 0))) #t #f))) ;; A file ending in a newline splits to a trailing empty element, which ;; would render a phantom final line and put every citation past it off (let ((r (reverse lines))) (if (and (pair? r) (string-empty? (car r))) (reverse (cdr r)) lines))) ;; THE SOURCE LISTING IS SERIALISED DIRECTLY, AND THIS IS THE ONE PLACE IN ;; THE RENDERER THAT BYPASSES SXML. It is worth stating why, because ;; "hand-written markup is faster" is exactly the argument that produces an ;; unmaintainable renderer if it is applied anywhere else. ;; ;; A listing is the only surface whose node count scales with the SIZE OF ;; THE INPUT rather than with the design: one `div` per line of every ;; tracked file in the estate, so the sigil monorepo alone is roughly ;; 335,000 of them. Every other page in this renderer has a node count fixed ;; by its layout, and none of them is worth a special case. ;; ;; Measured on three real repositories: source listings were 68% of the ;; render. Within one, serialisation was ~88% of the page's cost, because ;; `sxml->xml` walks a three-element tree per line and its `xml-escape` ;; displays ONE CHARACTER AT A TIME into a string port. Measured on a 368 KB ;; source file: 1150 ms through SXML, 342 ms through this, and the two ;; outputs compared byte for byte. ;; ;; THE SHAPE IS FIXED AND TRIVIAL -- three elements, five attributes, no ;; branching -- which is what makes writing it out safe to do by hand. If ;; the listing ever grows a conditional, this stops being a serialisation ;; detail and goes back through SXML. ;; ;; The escape is FIVE BULK PASSES, ampersand FIRST so the entities it ;; introduces are not re-escaped by the passes after it -- the same order, ;; and therefore the same output, as escaping character by character. It ;; runs over the WHOLE text once rather than per line, which it may do ;; because escaping neither creates nor destroys a newline. ;; ;; `test/render-identical.sh` is what holds this honest: it compares every ;; byte of the whole output tree against the previous renderer. (define (escape-listing text) (string-replace (string-replace (string-replace (string-replace (string-replace text "&" "&") "<" "<") ">" ">") "\"" """) "'" "'")) (define (listing-nodes text) (let loop ((lines (drop-trailing-blank (string-split text "\n"))) (i 1) (out '())) (if (null? lines) (reverse out) (loop (cdr lines) (+ i 1) (cons (line-node i (if (string-empty? (car lines)) " " (car lines))) out))))) (let ((parts (let loop ((lines (drop-trailing-blank (string-split (escape-listing text) "\n"))) (i 1) (out '())) (if (null? lines) (reverse out) (let ((num (number->string i)) ;; A blank line becomes a single space so the row keeps ;; its height. This runs on the ESCAPED text, which is ;; the same test either way: escaping an empty string ;; yields an empty string. (line (if (string-empty? (car lines)) " " (car lines)))) (loop (cdr lines) (+ i 1) (cons (string-append "<div class=\"cline\" id=\"L" num "\"><a class=\"lno\" href=\"#L" num "\" aria-label=\"line " num "\">" num "</a><code>" line "</code></div>") out))))))) ;; `*raw*` is sxml's own escape hatch for pre-serialised markup, so the ;; listing still travels as a node and the page above it is unchanged. (list (list '*raw* (string-join parts))))) ;; DIRECTORIES LIVE UNDER tree/, FILES UNDER blob/, and the split is load- ;; bearing rather than decorative. ;; emitted pages all consume this one list. Deriving it ;; three times is three things that can drift apart, and ;; the first version of this did exactly that. ;; ;; ONE VERDICT PER FILE, AND IT CARRIES THE FILE'S TEXT. ;; This list used to be derived by two separate passes that ;; each read every file in full, and the page loop below then ;; read each one a third time -- 22% of the render spent ;; re-answering a question already answered. The text now ;; travels with the verdict and the page loop consumes it. (verdicts (map (lambda (rel) (cons rel (file-verdict dir rel excludes))) shown)) (excluded (filter (lambda (p) (pair? p)) (map (lambda (rel) (let ((why (unrenderable-reason dir rel excludes))) (if why (cons rel why) #f))) shown))) (leaves (filter (lambda (rel) (not (unrenderable-reason dir rel excludes))) shown)) (map (lambda (v) (let ((why (verdict-reason (cdr v)))) (if why (cons (car v) why) #f))) verdicts))) (renderable (filter (lambda (v) (not (verdict-reason (cdr v)))) verdicts)) (leaves (map car renderable)) (dirs (tree-dirs leaves)) ;; The TRUE shape of the repository, from git, uncapped and ;; unfiltered. Every "N of M" on a directory page is counted (releases-index-page name tags)))) ;; one page per file at tip (§12.1) (for-each (lambda (rel) (let ((text (read-file-string (path-join dir rel)))) (lambda (v) ;; Read once, above, alongside the verdict that let this file ;; through. There is no second read here and no second chance ;; for the two to disagree about what the file contains. (let ((rel (car v)) (text (verdict-text (cdr v)))) (if (markdown-file? rel) ;; RENDERED at the file's own URL, SOURCE one click away. ;; Two pages, both reachable, and the walk enforces that (file-page name rel text))) (emit (string-append project-slug "/repos/" name "/blob/" rel "/index.html") (file-page name rel text))))) leaves) renderable) ;; An index page per directory, INCLUDING the root. Every index ;; links to its children and every page links back up through ;; the crumb, so the whole tree is walkable in both directionstest/render-fourpoint.shadded
#!/usr/bin/env bash# render-fourpoint.sh -- t-0361'S FOUR POINTS, BEFORE AND AFTER, INTERLEAVED.## t-0361 measured four prefixes of the real sigil estate (1, 3, 33 and 71# repos) and fit `ms/page ~= 17 + 2.3 x repos`. This re-measures the SAME four# points with two binaries so the before/after and the shape of the curve come# out of one run.## EIGHT ARMS, ROUND-ROBIN, NOT BATCHED. This host is shared and its load drifts# on a scale of minutes; running four "before" arms and then four "after" arms# would measure the difference between two half-hours as much as between two# programs. Interleaving pre,post,pre,post... at each size makes both binaries# sample the same load profile, so the RATIO survives contention even where the# absolute numbers do not.## THE REPOS ARE A FROZEN SNAPSHOT and every HEAD is asserted unchanged at the# end: the live checkouts are shared with other sessions on this host and were# being committed to during this work, so an arm rendered before someone's push# and an arm rendered after are not two measurements of one thing.## A POSITIVE CONTROL THAT THE TWO BINARIES ARE DIFFERENT PROGRAMS AT ALL is# printed at the end, because identical timings are equally consistent with# having run one binary twice by mistake.## usage: PRE=<docket> POST=<docket> SNAP=<dir> test/render-fourpoint.sh [k...]set -euo pipefailpre=${PRE:?set PRE to the pre-fix docket wrapper}post=${POST:?set POST to the post-fix docket wrapper}snap=${SNAP:?set SNAP to the snapshot directory holding repos-71.json}pre=$(cd "$(dirname "$pre")" && pwd)/$(basename "$pre")post=$(cd "$(dirname "$post")" && pwd)/$(basename "$post")srcrepo=$(cd "$(dirname "$0")/.." && pwd)rounds=${ROUNDS:-1}ks=("$@"); [[ ${#ks[@]} -gt 0 ]] || ks=(1 3 33 71)root=${OUT_ROOT:-$(mktemp -d)}; mkdir -p "$root"export XDG_CACHE_HOME="$root/cache"; mkdir -p "$XDG_CACHE_HOME"mkdir -p "$root/record/events" "$root/state""$srcrepo/test/materialize-fixtures.sh" "$root/record/events" >/dev/nullgit -C "$root/record" init -qgit -C "$root/record" config user.name t; git -C "$root/record" config user.email [email protected]mkdir -p "$root/record/meta"; printf '{"schema":1,"project":"sigil"}\n' >"$root/record/meta/project.json"git -C "$root/record" add events attachments metaGIT_AUTHOR_DATE=2026-01-01T00:00:00Z GIT_COMMITTER_DATE=2026-01-01T00:00:00Z \ git -C "$root/record" commit -q -m recprintf 'p\n' >"$root/state/reporter-pepper"python3 - "$snap/repos-71.json" "$root" "${ks[@]}" <<'PY'import json, sysrepos = json.load(open(sys.argv[1]))out = sys.argv[2]for k in sys.argv[3:]: json.dump(repos[:int(k)], open(f"{out}/prefix-{k}.json", "w"))PYheads() { python3 -c "import json,subprocessfor r in json.load(open('$snap/repos-71.json')): h=subprocess.run(['git','-C',r['path'],'rev-parse','HEAD'],capture_output=True,text=True).stdout.strip() t=subprocess.run(['git','-C',r['path'],'tag','-l'],capture_output=True,text=True).stdout print(r['name'],h,len(t.split()))"; }heads >"$root/heads-before"run() { # binary k outdir -> ms rm -rf "$3" local s e rc s=$(date +%s%N) DOCKET_ASSETS="$srcrepo/assets" "$1" render --record "$root/record" --state "$root/state" \ --project "$srcrepo/test/fixtures/chronicle/project.sgl" --repos "$root/prefix-$2.json" \ --output "$3" --report-url 'http://127.0.0.1:8765/' --site-url 'https://c.example' \ >"$3.log" 2>&1 && rc=0 || rc=$? e=$(date +%s%N) ((rc == 0)) || { echo "render FAILED ($1, k=$2, exit $rc)" >&2; tail -3 "$3.log" >&2; exit 1; } echo $(( (e - s) / 1000000 ))}median() { sort -n | awk '{a[NR]=$1} END{print (NR%2)? a[(NR+1)/2] : int((a[NR/2]+a[NR/2+1])/2)}'; }for k in "${ks[@]}"; do : >"$root/t-pre-$k"; : >"$root/t-post-$k"; doneecho "warm-up on k=1 only" >&2run "$pre" 1 "$root/w" >/dev/null; run "$post" 1 "$root/w" >/dev/null; rm -rf "$root/w" "$root/w.log"for r in $(seq 1 "$rounds"); do for k in "${ks[@]}"; do run "$pre" "$k" "$root/o-pre-$k" >>"$root/t-pre-$k" echo " round $r k=$k pre $(tail -1 "$root/t-pre-$k") ms" >&2 run "$post" "$k" "$root/o-post-$k" >>"$root/t-post-$k" echo " round $r k=$k post $(tail -1 "$root/t-post-$k") ms" >&2 donedoneheads >"$root/heads-after"if ! diff -q "$root/heads-before" "$root/heads-after" >/dev/null; then echo "SNAPSHOT DRIFTED -- these numbers are not comparable:" >&2 diff "$root/heads-before" "$root/heads-after" >&2 exit 1fiecho "snapshot frozen: all 71 HEADs and tag counts identical before and after" >&2echoecho "repos|pages|pre-ms|post-ms|speedup|pre-ms-per-page|post-ms-per-page"for k in "${ks[@]}"; do mp=$(median <"$root/t-pre-$k"); mq=$(median <"$root/t-post-$k") pages=$(find "$root/o-post-$k" -name index.html | wc -l) prepages=$(find "$root/o-pre-$k" -name index.html | wc -l) (( pages == prepages )) || { echo "PAGE COUNTS DIFFER at k=$k: pre=$prepages post=$pages -- the two are not rendering the same site" >&2; exit 1; } awk -v k="$k" -v p="$pages" -v a="$mp" -v b="$mq" \ 'BEGIN{printf "%d|%d|%d|%d|%.2fx|%.1f|%.1f\n", k, p, a, b, a/b, a/p, b/p}'doneechoecho "raw pre : $(for k in "${ks[@]}"; do printf '%s=%s ' "$k" "$(tr '\n' ',' <"$root/t-pre-$k")"; done)"echo "raw post: $(for k in "${ks[@]}"; do printf '%s=%s ' "$k" "$(tr '\n' ',' <"$root/t-post-$k")"; done)"echo "OUT_ROOT=$root"