Skip to content

Path algebra: read a path's structure once instead of three times - #1081

Merged
yogthos merged 1 commit into
mainfrom
perf/path-parse-once
Sep 21, 2026
Merged

yogthos merged 1 commit into
mainfrom
perf/path-parse-once

Conversation

@yogthos

@yogthos yogthos commented Sep 21, 2026

Copy link
Copy Markdown
Member

Follow-up to #1074 (bead jolt-2sp). That change consolidated three
independent answers to "what is this path's root" onto java/io.ss.
What was left is that every helper re-derived the structure from the
string: path-root and path-segments each begin with their own
path-root-end scan, so a single Path method could read the same path
three times — and endsWith against a rooted other, which normalizes
both sides, read two paths seven times between them.

path-parse does it once and hands back a value with the root and the
segments as fields.

On the measurement — this does not make anything reliably faster

That is the headline, because the bead was filed as a performance issue
and it should not be re-opened expecting speed.

getRoot goes through none of the changed code and moved 7–9% between
builds
, which sets the noise floor. Every effect measured here is at or
under it, in both directions. Run-to-run within one binary is tight
(±2%); it is build-to-build that is loose, so more repetitions do not
help — resolving anything smaller needs an A/B inside one binary.

The hypothesis the bead rests on — that the repeated scanning was the
cost — is wrong. Measured on a 9-segment path, 20k iterations:

op ms what it does
getRoot 6.9 root scan + substring, no segment list
startsWith 31.7 two segment lists, no rebuild
getParent 35.4 one segment list + a rebuild

So a root scan is ~6ms of a ~33ms operation and the other ~27ms is
allocation — the segment list and the string being built. Parsing
once removes redundant scans, a fraction of the 6ms. This is recorded on
the bead with the remaining ideas (compare segment-wise without
materializing lists; index pairs instead of substrings).

What it does buy

  • the structure is derived once and named, instead of three times
  • path-segments called path-root-end twice, once for each of two
    loop variables initialized to the same value
  • path-rebuild appended per segment, and each append copies the answer
    built so far — quadratic in segment count, on the function every getter
    ends in. It allocates once now. Checked at 20k segments.

What deliberately does not parse

resolve and getRoot take only the root, and the branch almost every
resolve caller takes concatenates without ever needing segments —
splitting eagerly there would cost work. jolt-path-normalize is
untouched: it runs per entry on every directory listing, allocates
nothing for an already-normal path, and never asks for segments.

The platform is not a field on the parsed value. It reads like it
should be one, but nothing would read it back — every helper already
takes windows?, which is what lets a Linux runner pin the Windows rows.

Verification

Behaviour-preserving by construction: every -for signature is
unchanged, so the existing gate is the regression suite. 24 rows were
added pinning the new invariant directly rather than inferring it
from callers passing — path-parse agrees with path-root and
path-segments across all six root shapes on both platforms, including
roots no caller here builds.

win-platform-test.ss 178 → 238 checks. The 23 File(parent, child) and
as-relative-path cases still match the JVM exactly. Full POSIX set
green: unit, smoke, depsunit, deadhost, portcheck, grenadine, winpath.

deadhost caught npath-rooted-for? going dead when its two callers
switched to the root they already had; it is removed here.

Stacked on #1077 — review that first.

Follow-up to #1074 (jolt-2sp). That change consolidated three independent
answers to "what is this path's root" onto java/io.ss. What was left is
that every helper re-derived the structure from the STRING: path-root and
path-segments each begin with their own path-root-end scan, so a single
Path method could read the same path three times -- and endsWith against
a rooted other, which normalizes both sides, read two paths seven times
between them.

path-parse does it once and hands back a value with the root and the
segments as fields. The helpers that need both read the fields.

The platform is deliberately NOT a field on that value. It reads like it
should be one, but nothing would ever read it back: every helper already
takes `windows?` as a parameter, which is what lets a Linux runner pin
the Windows rows. A field no caller reads is weight on every parse.

Two adjacent things the parse made visible:

  * path-segments called path-root-end twice, once for each of two loop
    variables initialized to the same value.
  * path-rebuild appended per segment, and each append copies the answer
    built so far -- quadratic in segment count, on the function every
    getter ends in. It now allocates once.

Not every helper parses. resolve and getRoot take only the root, and the
branch almost every resolve caller takes concatenates without ever
needing segments; splitting eagerly there would COST work. jolt-path-
normalize is untouched on purpose -- it runs per entry on every directory
listing, allocates nothing for an already-normal path, and never asks for
segments.

On the measurement, honestly: this does not make anything reliably
faster. getRoot goes through none of the changed code and moved 7-9%
between builds, which sets the noise floor, and every real effect here is
at or under it. The hypothesis the bead was filed on -- that the repeated
SCANNING was the cost -- is wrong: scanning a root is ~6ms of a ~33ms
operation, and the rest is allocation, the segment list and the string
being built. That is recorded on the bead so this is not re-opened
expecting speed. What this buys is that the structure is derived once and
named, and the quadratic rebuild is gone.

Behaviour-preserving by construction: every -for signature is unchanged,
so the existing gate is the regression suite. 24 rows were added pinning
the new invariant directly rather than inferring it from callers passing
-- path-parse agrees with path-root and path-segments across all six root
shapes on both platforms, including roots no caller here builds.
win-platform-test.ss 178 -> 238 checks, and the 23 File-join and
as-relative-path cases still match the JVM exactly.

npath-rooted-for? goes: its two callers now read the root they already have,
and the deadhost gate caught it, which is what that gate is for.
@yogthos
yogthos deleted the branch main September 21, 2026 18:10
@yogthos yogthos closed this Sep 21, 2026
@yogthos yogthos reopened this Sep 21, 2026
@yogthos
yogthos changed the base branch from fix/windows-spit-pathsep-spawn to main September 21, 2026 18:11
@yogthos
yogthos merged commit 9b7ac37 into main Sep 21, 2026
15 checks passed
@yogthos
yogthos deleted the perf/path-parse-once branch September 21, 2026 18:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant