fluree rdf convert: the parallel path scales and the memory is attributable - #1578
Open
aaj3f wants to merge 37 commits into
Open
fluree rdf convert: the parallel path scales and the memory is attributable#1578aaj3f wants to merge 37 commits into
aaj3f wants to merge 37 commits into
Conversation
The pre-scan is single-threaded and runs to completion before the first worker
starts, so its cost is the ceiling on parallel conversion — measured at 2.85 s
of a 3.38 s run at 16 threads, which capped the whole parallel path at 2.29x no
matter how many threads it was given.
It was not a slow scanner. `chunk_in_memory` handed the ENTIRE document to
`extract_prefix_block_from_bytes`, which runs `find_safe_header` — last newline
in the whole buffer — and then `tokenize()`, the real Turtle lexer, over all of
it, to recover a 35-byte prefix block. The two file-based callers already read
no more than PREFIX_SCAN_SIZE; the in-memory one never capped, and the cost was
charged to the chunker, so it looked like byte-scanning was slow.
The cap now lives inside `extract_prefix_block_from_bytes`, where no future
caller can forget it. A header longer than the cap behaves exactly as it
already does on the file path: the directives past it are not recognised,
chunking refuses with PrefixAfterData, and the run converts serially. Loud
rather than silently wrong.
The scanner itself gets a fast path, worth a further 2.2x. Most bytes cannot
change anything — literal bodies, IRI interiors, comment text, name characters
— so `skip_len` reports how many of them start a slice in the current state and
`absorb` accounts for the run in one step. `feed` remains the only place scan
semantics live: the fast path can only skip, never decide, and a `[u8; 256]`
table enumerates the bytes each state must stop on. All three call sites use
it, including the streaming one bulk import runs.
Best-of-5 on a 143.5 MiB corpus, via the committed `scan_probe` example:
no fast path fast path
no cap 49.6 MiB/s 55.5 MiB/s
1 MB cap 303.2 MiB/s 680.8 MiB/s
13.7x end to end, 2.89 s to 0.211 s. Building the fast path first and measuring
it at 55.5 MiB/s is why the cap was found at all: a 7% result on a hypothesis
that predicted 10x is a sign the hypothesis is wrong, not that the constant
needs tuning.
Three tests pin the fast path to the slow one: a differential over 25 hostile
fixtures, a differential over every prefix of a hostile document — which puts a
buffer cut at every byte — and a table-contract test asserting the stop set
equals the bytes the scanner actually branches on. Dropping `#` from the table
fails four tests, one of them pre-existing.
The scan also gets its own `Phase::Chunk` lane, because it spent a whole bucket
inside `unattributed_ns` where nothing owned it and nobody optimized it. A
parallel run must now report a non-zero chunk phase and a serial run must not
report one at all — a lane that never fires is worth as little as no lane.
The streaming reader runs on its own thread and surfaces scan failures — a mid-file `@prefix` redefinition, an I/O error part-way through — only through `StreamingTurtleReader::join`. The channel closes either way, so `Ok(None)` means "no more chunks", not "the source was read": a caller that stops there imports a silently truncated graph and reports success. Documented rather than fixed because the fix is a breaking change to a `pub` method — `recv_next` takes `&self` and `join` needs `&mut`, so it cannot join on the caller's behalf without changing its signature — and there are no callers in this workspace to migrate. The streaming import paths drive the reader directly and do join at every completion site. The doc says what a future caller must do and what the safer shape would be.
`the_sampling_error_bound_is_reported_and_grows_with_dispersion` asserts that a corpus whose statements vary by 200x reports a wider bound than a uniform one. Both sides were measured once, and a single preemption in the uniform arm makes a uniform corpus look dispersed — so the test failed on a loaded machine for a reason that had nothing to do with the code under test. Each arm is now the MINIMUM of three trials. Contention only ever ADDS dispersion, since a preempted statement is indistinguishable from a slow one, so the minimum is each arm's least-corrupted estimate — the same reasoning that makes best-of-N the right reduction for a wall-clock measurement.
…inished Supersedes the hazard documented one commit ago. The streaming reader runs on its own thread and reports scan failures — a mid-file `@prefix` redefinition, an I/O error part-way through — only through `StreamingTurtleReader::join`. The channel closes either way, so `Ok(None)` said "no more chunks" when it meant "the reader died at 10%", and a caller draining to end-of-stream imported the truncated half and called it a success. End-of-stream now joins and surfaces what the reader was holding. Joining takes the thread handle, so a second end-of-stream has nothing to report and a caller that keeps polling keeps getting `Ok(None)` rather than a spurious error. That requires `&mut self`, which is a breaking change to a `pub` method with no callers in this workspace and no external publication — the cheapest moment this fix will ever have. Three tests, and the middle one demonstrates the hazard rather than describing it: a clean stream drains to `Ok(None)`; a document with a mid-file redefinition reaches the caller as an error naming a failed READ; polling past the end stays quiet. Removing the join makes the second return `Ok(11)` — eleven chunks imported, the rest of the document silently discarded, reported as success.
`ParallelPlan::decide` consulted the OUTPUT syntax and never the input, and the
workers parsed every chunk with the Turtle parser whatever the input actually
was. Two different bugs from one confusion, and the first is silent.
An N-Triples file above the 4 MiB threshold was read by a parser that accepts
directives, prefixed names, bare numbers and long-quoted strings — precisely
what the strict N-Triples reader exists to reject, and what `riot` and every
other tool in the field reject. So `--parallelism 1` refused those documents
and the default path accepted them: the strict-reader parity that `rdf check`
and `rdf convert` promise silently disappeared for any real-sized `.nt` file.
`write_chunk` now dispatches to the same four readers `rdf::parse_into` uses,
and for the same stated reason.
The second is loud but just as wrong: `.trig` and `.jsonld` inputs were chunked
as though they were Turtle. TriG writes fine as concatenated fragments — which
is what the output check answers — but its statements live inside `GRAPH … { … }`
blocks while the boundary scanner cuts at `.`, so a cut lands mid-block and
neither half parses. JSON-LD has no statement terminators at all. `decide` now
takes both syntaxes, because they answer different questions: `target` asks
whether the OUTPUT survives being written in fragments, `source` asks whether
the INPUT survives being cut. Inputs that cannot be cut convert serially, with
the reason in `--profile`.
Line formats keep their parallelism — they are the common case for large files
and they cut trivially — they just get the right reader now.
Tests fail against the old behaviour: an above-threshold `.nt` under a leading
`@prefix` and one using bare numbers must both exit 1 at `--parallelism 1` AND
8 (they exited 1 and 0), and an above-threshold `.trig` must convert whole with
`threads_used: 1` and a reason naming the cut (it was chunked at 8 and failed).
Same bug as the parallel path, third site. `parse_recovering` called the Turtle parser directly, so recovery on a `.nt` file accepted every Turtle-only construct the strict N-Triples reader exists to reject. Worse here than in the workers, because recovery's whole contract is that it reports what it dropped. A construct that PARSES is never skipped, so nothing was reported: the run exited 1 for the genuinely bad line, printed one diagnostic, and silently wrote a bare integer into an N-Triples file as though it were a term. Recovery now dispatches to the same four readers as `rdf::parse_into` and `write_chunk`. The line formats take neither prefixes nor a base, because they have neither. Verified by reverting the dispatch: the fixture goes from "2 skipped, 2 written" to "1 skipped, 3 written", with `42` in the output. The fixture is worth a note for whoever edits it — every line terminates, including the junk one. Recovery resyncs by scanning to the next terminator, so a junk line without one swallows the statement after it, which is exactly how the first draft of this test hid the line it was written to catch.
… chunks Two independent causes of a 16-worker run sitting near a gigabyte where the serial path holds ~300 MB, both found by W-mem. The channel bounds how many CHUNKS are in flight, which is not a bound on memory: one chunk's N-Triples output is several times its Turtle input, and the reorder buffer is unbounded besides — one early chunk finishing last parks every later chunk in `pending` with nothing capping the pile. `OutputBudget` bounds the BYTES of converted output waiting to be written, at `workers × chunk_bytes × 4` clamped to [32 MiB, 256 MiB]: enough that every worker can hold one chunk's output without the pool serializing, and no more. Workers wait for room BEFORE taking work, never while holding a finished chunk. That ordering is the whole safety argument: the writer never waits on this budget, so it always drains, so the bytes a waiting worker is waiting for are always on their way out. A worker that blocked with output in hand could be holding the very chunk the writer wants — which is the deadlock this pool has already had once, and this must not reintroduce it. The wait is also timed rather than indefinite, so a worker cannot miss the shutdown flag being set between checking it and sleeping on the condvar. Separately, `write_chunk` copied `prefix_block + body` into a fresh String for every chunk. The line formats have no directives, so their prefix block is empty and the copy achieved nothing — ~80 MB of pointless copying on a 4 million triple N-Triples corpus, which is also the case where chunks are biggest. It now borrows the body when there is no prelude to prepend. Peak-RSS A/B is NOT in this commit: the machine spent the measurement window at load 289 from an unrelated filesystem event, and a peak-RSS number taken under memory pressure is understated rather than merely noisy. The numbers follow when the box is real.
…ould hang H1, the fifth dispatch instance. `--nocheck` was honoured by `rdf::parse_into` and hardcoded away everywhere else: `write_chunk`, `parse_recovering` and the dead `parse_chunk` all built `ParserOptions::conformant()` from nothing. Over the 4 MiB threshold, `-p1` exited 0 with validation off and `-p8` exited 1 on the same file. Options now thread through beside `source`, at every site. Both dispatch matches are exhaustive over `RdfSyntax` — no `_` arm — so a syntax added later is a compile error rather than a silent inheritance of Turtle's reader. That earned itself immediately: writing the arm out is what revealed RdfXml, RdfJson and Jelly were already in the enum and would have been swallowed by the catch-all. The condvar in the previous commit was wrong, and its comment was wrong about why it was right. The shutdown flag was an external `AtomicBool` whose setter took no lock and sent no notification, so the 50 ms `wait_timeout` was the only thing preventing a lost wakeup — "notices within one timeout period", not "cannot miss the flag". Both halves of the predicate now live in one `Mutex<BudgetState>`, every writer of either notifies, and the timeout is gone. Removing the timeout exposed a second hang it had been masking. The writer is the only releaser of budget and it has three exits: all chunks written, a write failure, and a parse error that cuts the document short with bytes still charged in `pending`. Only the first two woke anyone. `budget.stop()` is now unconditional after the reassembly loop — the dead-destination deadlock again, reached through the memory bound instead of the channel. Recovery also stopped lying about what it dropped. Resync scans to the next terminator, so a failing statement with none of its own consumes the statement after it — unparsed, undiagnosed, uncounted, with stderr byte-identical to the honest case. The count cannot be repaired (nothing parsed those bytes, so nothing knows how many statements they held) but the span can be shown, on its own `note:` line so the honest case stays byte-stable, and suppressed under `--profile=json` like every other prose line. Guards, all of which were mutable at the previous pin: the cap had no regression test (deleting it left the suite green — adopted review-scan's oversized-header probe, which pins that the in-memory and file paths reach the SAME verdict); three long-string escape fixtures, because no fixture put an escape inside a long string and a missed one ends the literal early and cuts a chunk INSIDE it, on the scanner bulk import runs; and the table contract now covers all seven masks rather than one. The peak-RSS A/B for the previous commit's memory bound is OWED, not delivered: it needs a machine that is not paging, and it is queued for the same lock hold as the G1b-3 sweep. Until it lands, that commit's claim is unevidenced.
L1 from the review. `recv_next`'s "the channel closed" note is pasted twice above the same match arm. No behaviour and no reader benefit — the second copy only makes the arm harder to scan.
L5. A worker parses `prefix block + its chunk`, so the byte offset a failing chunk reports is an offset into that synthesized document. The reporting path dropped it instead of translating it, so one document failed with `file.ttl:90002:13:` at `--parallelism 1` and a bare `file.ttl:` at 8 — a difference in the diagnostic caused by a flag documented never to be a correctness decision. Rendering the offset untranslated would have been worse than dropping it: it names a line inside the chunk, which on the new fixture is line 82 for a failure on line 90,002. So the offset moves into the document at the source, in `write_chunk`, which is the only place the chunk's start is known. The map is exact both sides of the seam — the prelude is a verbatim copy of the document's header, so an offset inside it is already a document offset, and an offset past it is `chunk start + (offset - prelude length)`. Only the parse result goes through it: a failure to build the writer carries a placeholder `0` that is not a position and must not be rendered as one. The dead `parse_chunk` gets the same treatment for the same reason it got the `--nocheck` fix — `check` and `count` inherit it the day they go parallel. Both paths now report through one function rather than two near-copies, since the copy is what drifted. `report_parse_failure` takes a statement count rather than `WriterStats` so the parallel path, which has no single writer to ask, can call it. L3. `Phase::Chunk` documented itself as "it exists only when the run chunks — a serial run reports zero", and a run that attempts to chunk and falls back reports a non-zero chunk lane beside `threads_used: 1`. On the oversized-header fixture that is 166 ms, and on a fallback it is pure overhead: the case where the lane's number is most worth having. The doc said the pairing was impossible, which invites reading it as a bug in the profile.
…he budget Every fix in the previous pin's H1/(b) commit could be reverted with the suite still green. These are the tests that stop that, verified by reverting each hunk and watching them go red. `--nocheck` gets a test per dispatch site rather than per verb. The flag was honoured by `rdf::parse_into` and built from nothing at the other three, so the matrix runs a bad-language-tag corpus past the 4 MiB gate at `--parallelism 1` and 8 and under `--continue-on-error`, with the flag and without it. It also pins that the two widths produce the same BYTES: agreeing on the exit code is not enough when the flag turns off a check rather than a parser, and a parallel path that accepted the document by reading it with different options would pass the weaker assertion. Reverting either `write_chunk` or `parse_recovering` to `conformant()` fails it. The resync swallow-note gets a positive case, a negative control, and a case pinning WHICH bytes the span names — content after the resume point on the same line survives and must not be reported lost. The control is why the note has a line of its own, and it does not go red when the note is removed; that is what a control is for, and saying so here stops a later reader from "fixing" it. The budget's third exit gets the bounded-wait shape the dead-reader matrix uses. Getting a worker into `wait_for_room` at all is the whole difficulty: with a fast destination the writer drains faster than the pool fills, the budget never binds, and a first attempt against `-o FILE` passed with the fix reverted. So the destination is a pipe drained at ~2 MB/s against a pool that manages several times that, and the corpus expands ~25x from Turtle to N-Triples so the few chunks that can be in flight exceed the budget's 32 MiB floor. Removing the unconditional `budget.stop()` hangs it at the 90 s ceiling. Two properties of that corpus are load-bearing and easy to lose in an edit. The bad statement must LEX — the chunker tokenizes the first megabyte looking for the header, so a lexical error there makes the document unchunkable and the whole run falls back to serial, testing nothing. And the test asserts the fallback note is ABSENT, so a fixture that quietly stops being chunked fails instead of passing on the serial path, where there are no workers to deadlock.
R1. A statement may span lines and still carry its own terminator, so the
resync lands on ITS `.` and eats nothing. The check split the error-to-resume
span at the first newline and reported whatever followed, which on such a
document is the statement's own second line: 24 bytes announced as lost while
every statement converted.
No positional rule can fix it. The honest case and the false positive are the
same shape — error on line N, resume at the end of line N+1:
junk with no terminator ex:bad ~~~ "still the same statement"
ex:c ex:p "3" . ex:more "and more" .
Only the left one lost anything, and the only signal separating them by
position is indentation, which Turtle does not have — the splitter carries a
test asserting exactly that. So the question is what the bytes ARE. A
statement the resync consumed is a statement and parses as one against the
prefixes in force; a continuation is a fragment of one and does not.
The obvious second check — a trailing `;` or `,` means the next line is a
continuation — is in the "why this is not here" comment rather than the code,
because it is wrong in the direction that matters. `ex:bad ~~~ ;` followed by
`ex:c ex:p "3" .` has no terminator of its own, so the resync really does eat
`ex:c`; with that check in place the document lost a statement and stderr said
nothing, which is the bug this note exists to end. It buys nothing either: a
`;` continuation is `predicate object` and a `,` continuation is one object,
so neither parses standalone and the parse test already refutes them. The
guard against reinstating it is a test, not a comment.
Re-parsing needs the reader, prefixes, base and options the recovery itself
used, so the dispatch is lifted into one `parse_fragment` rather than copied —
this file has already paid once for a copied dispatch, when `--nocheck` was
honoured at one of five sites.
The prefixes it re-reads are the LIVE ones, not the snapshot taken at the top
of the loop. That snapshot predates the parse that records the directives, so
on the first pass it is empty and a candidate using a prefix declared on line 1
fails to parse — the swallow would go unreported, silently, which is the exact
failure being fixed. Caught by the existing four-line fixture going quiet.
Six prose sites in this file, three different spellings of the same predicate, and one site that omitted a term. The chunk-fallback note was guarded by `!quiet` alone, so `convert big.ttl --profile=json 2> run.json` on a document that turns out not to chunk wrote `note: … converting serially` ahead of the JSON and left the file unparseable — on exactly the large inputs most likely to be profiled. The predicate now lives in one type and the emit goes through it, because the per-call-site version is a rake that has now been stepped on twice: this is the same defect the ✓-line guard was added to prevent, reappearing at a site that did not copy the guard. Two levels, not one, and collapsing them would be a regression. Courtesy lines — the ✓ summary, the fallback note — are what `-q` exists to silence. Diagnostics — every skip, the swallow note, the closing warning — survive `-q` on purpose, because a script that asked for quiet still must not read a partial conversion as a whole one. Both go quiet when stderr is carrying a JSON document. The 2x2 test over (quiet x profile) is what stops a later simplification folding the levels together; giving `diagnostic` the `courtesy` body fails it at `-q true: a dropped statement went unreported`. `report_parse_failure` is the one exception and stays outside, with the reason written down rather than left to be rediscovered. On the serial path it prints before the profile is emitted, so it has the same leak — but `ProfileReport` has no field for a parse failure, so routing it through `diagnostic()` would trade an unparseable stderr for an unexplained exit 1. The real fix is a diagnostic field in the profile schema, which is a schema change and not this commit.
… literals
R2. `every_mask_matches_what_its_state_branches_on` restates the STOP table as
hand-written byte lists, so it re-asserts the table against a copy of itself
and cannot notice `feed` growing a branch the table does not list. The sibling
test directly below it already does the right thing for `STOP_NORMAL` —
`branched_on` is derived from `is_token_delimiter` and `keyword_byte_matches`
rather than restated — so the pattern was there to follow.
The new test puts a scanner in each of the six other table states, feeds one
byte, and asks whether anything but `prev_byte` changed. `prev_byte` is
excluded because it changes on every byte by construction: it records what was
seen, not what was decided.
`STOP_NORMAL` is deliberately not in the list. It is the one state where "a
field changed" is the wrong question, because `advance_state` feeds
`prefix_check` there and `absorb` replicates exactly that for a skipped run —
and it already has a derived test. In the other six neither touches
`prefix_check`, so any change beyond `prev_byte` is a decision the fast path
would have skipped straight past. `InStringEscape` has no mask at all:
`skip_len` returns 0 for it unconditionally.
A fresh scanner is not a simplification, it is the configuration under test.
`skip_len` returns 0 while any lookahead is outstanding, so the masks only ever
describe a state with nothing pending.
The two mutations the review named both bite, and — the reason for the exercise
— both leave the literal test green: an `InIri` that also ends at `\n` fails
with "byte 10 ('\n'): table says stop=false, the scanner branched=true", and an
`InComment` that reacts to `\t` fails the same way at byte 9. Either one forks
the fast and slow paths, and the fast path is what bulk import runs.
The literal test stays. It documents which bytes are *meant* to matter, which
is a different claim from what the code does, and the two disagreeing is the
signal.
…it notifies R4, whose brief described a state this branch has not been in since 4d6c4c3. There is no 50 ms timed wait here: it was removed then, `wait_for_room` is an untimed `room.wait(state)`, and the only `wait_timeout` left in the tree is the doc comment explaining why it went. The delta reviewer should re-derive this from the code rather than reconcile it to the original description. What was actually true: `shutdown` had exactly one store site, the write-failure path, which merely happened to be followed by `budget.stop()`. Nothing tied them together. A worker parked in `wait_for_room` is woken by `release` or `stop` and never by the flag, so pool liveness rested on one call site remembering to do both, and a second setter — a cancellation path, a deadline — would have reintroduced the parse-error hang exactly. `BudgetState.stopped` already means "the run is ending" and lives under the mutex the condvar waits on, so the flag is retired rather than taught to notify. One signal, read in one place, impossible to set without waking everyone. The worker's top-of-loop check goes with it: `wait_for_room` returns false the moment `stopped` is set, whether or not it ever had to block, and the next line took that mutex anyway. The four-way mutation table in the test comment was re-derived rather than carried over, and it no longer holds — which is why re-deriving it was worth the hour. The dead-reader matrix passes with the prompt receiver drop, the write-failure stop AND the unconditional post-loop stop all removed, because `rx` is a local of the scope closure and drops before `thread::scope` joins. The old sentence described a receiver shape this file no longer has. Those mechanisms buy promptness — without them a worker converts most of a 4 GiB file into a closed pipe before parking in `send` — but the matrix does not measure that and never did. The mechanism that IS load-bearing is the unconditional `budget.stop()`, proven by a different test: `a_parse_error_wakes_workers_waiting_on_the_output_budget` hangs at the 90 s ceiling without it, verified again here. It needs a saturated budget, which a dead reader never produces, which is precisely why the matrix cannot see it. The comment now says that, so nobody deletes the one guarantee believing the other test covers it. Also folds in the measured memory bound, since this commit owns that documentation. The budget caps buffered output and nothing else: peak is the budget (256 MB at the ceiling) plus one chunk of overshoot per worker plus the whole input, which `convert` holds in memory. Measured 1620 MiB peak RSS on a 745 MiB input at K=16 defaults. Quoting the budget alone understates a real run by about the size of its input.
… megabyte
(a). `extract_prefix_block_from_bytes` materialized every token in its 1 MB
window before looking at one, then walked them and stopped at the first
non-directive — usually about forty bytes in. So a lex failure ANYWHERE in that
window sank chunking for the whole document, and the run fell back to serial
with "the input could not be split into chunks", which blames the input.
The input is often blameless. `find_safe_header` backs the window up to a
newline, and for any literal long enough to span a megabyte that lands inside
the string:
@Prefix ex: <http://example.org/> .
ex:doc ex:body """
…a megabyte and a half of prose…
""" .
is a valid document whose header region, taken alone, is an unterminated
literal. It converted serially and said the document could not be split.
Pulling tokens one at a time fixes the class rather than the instance. The walk
already stopped at the first non-directive token; now the lexer stops there
too, so the literal is never lexed. A lex error that survives the loop is
therefore a real defect IN THE HEADER — which is what `SplitError::Tokenize`
always claimed to mean — and the reason string becomes true without anyone
writing a heuristic to guess.
It also kills the larger class: a typo 300 KB into a 5 MB file used to move the
whole run to the serial path. Harmless for correctness, since the document
fails either way, but it made "unchunkable" report a property of the document's
shape when the cause was a defect, and those want different fixes from a user.
And it deletes a cost centre. Every parallel run lexed up to a megabyte to find
~35 bytes of prelude, on the single-threaded pass that runs before the first
worker starts — the same shape as the whole-document tokenize this bucket
opened with, on the Amdahl term `Phase::Chunk` exists to expose, now ~30% of
wall at K=16.
`StreamingLexer` is not new: it is `pub`, re-exported at the crate root, and
already what `parser.rs` runs on. The cap and `find_safe_header` are both
deliberately left alone — the cap is what an existing test mutates to prove it
is load-bearing, and changing the truncation in the same commit as the
tokenizer is how this function earned its bugs. `StreamingLexer::new` skips the
`check_input_len` that `Lexer::tokenize` performs, which is harmless under a
megabyte and noted in place for whoever lifts the cap.
The competitor matrix put `fluree rdf convert` at ~1 GB of peak RSS on a 118 MB Turtle corpus next to serdi's 1.5 MB, with no way to say where the bytes were. This is the instrument that answers it: a tracking global allocator whose peak is maintained with fetch_max on every allocation rather than sampled, and the convert pipeline taken apart into the three stages that can each hold memory — the input read, the parse, and the parse into a real writer. Reporting live heap rather than RSS is the point. RSS includes whatever the allocator has not returned and whatever capacity was reserved but never touched, which is exactly the part that cannot be attributed to a line of code; the process high-water mark is printed alongside so the two can be reconciled against an outside measurement. The counting sink doubles as a corpus census: the parser serves repeated terms from its own cache, so every term_* call that reaches a sink is a distinct term, and that count is the multiplier on everything a writer keeps per term.
…ength The caches were pre-reserved at input.len()/60 entries, capped at 2M, into both the IRI map and the prefixed-name map. On a 118 MB document that is ~1.97M entries each, which at hashbrown's load factor is 2 x 4.19M buckets — about 210 MB of empty table allocated before a single token is read. The document it was measured against holds 800,005 distinct terms. The estimate cannot be rescued by tuning the divisor, because a document's LENGTH is not evidence about its distinct-term COUNT: one subject with ten million properties and ten million subjects with one each are the same number of bytes and differ by six orders of magnitude in what they need here. Reserving a small floor and letting the map grow costs a rehash per doubling, ~17 of them on the way to 800K entries and O(n) in total. Measured on the 4M-triple synthetic corpus: parse-only live-heap peak 373.8 -> 223.8 MiB, whole-run peak RSS 521 -> 386 MiB, with parse time unchanged within run-to-run variance.
read_all_guarded wraps the reader in take(LIMIT+1) so an oversized input is refused after reading 4 GiB rather than after allocating whatever was behind it. The cost of that wrapper was invisible: it erases the File specialization read_to_end uses to size the buffer once from metadata, leaving the generic doubling path. On the 280.7 MB N-Triples corpus that is 26 allocations, a 512 MB buffer for 280.7 MB of document, and a 768.3 MiB live-heap peak reached by copying the whole thing one last time. Reserving the real length is two allocations, no copy, and a 281.0 MiB peak. The length is a hint and never a promise — only a regular file offers one, it is clamped at the refusal limit so a wrong or sparse length cannot turn into an allocation failure in place of the error message this function exists to produce, and a file that grows past its own metadata still reads correctly because the Vec simply grows for the tail.
…scoped The TermId contract says ids from term_iri and term_blank live for the whole session, because the Turtle parser caches them across statements. Every sink pays for that, including against producers that do no such thing: the strict line reader mints a fresh id for every term on every line and caches nothing, so a writing sink was holding one term plus one Arc<str> per term OCCURRENCE. Measured on a 4M-statement N-Triples document: 814.2 MiB of writer term table for a document whose widest statement needs three slots. GraphSink::declare_term_scope lets the producer say which it is. The default is the contract as written, so nothing changes for a producer that stays quiet — and a sink may ignore the declaration entirely, but must never honor one that was not made, because recycling a slot a producer still holds an id for is silent data corruption rather than a crash. In the writers the table splits in two: a session region and a region reused from the start at every statement boundary. Labelled blank nodes stay session-scoped under either scope, and not for storage reasons — BlankLabeler mints a fresh output label per call, so this map is the only record that two occurrences of _:x are one node. Everything else recycles. Sound because every writer clones the term out of the table before the slot can be reused, which each emit path already did. Debug builds carry a generation stamp per recycled slot, so a producer that declares statement scope and then caches an id across end_statement panics naming the id instead of silently writing the wrong term. Measured on the 4M-statement N-Triples corpus, serial: writer footprint 814.2 -> 0.0 MiB, whole-run live-heap peak 1144.7 -> 280.7 MiB — the document itself and nothing above it. Both halves are pinned by tests that fail against a mutation of the fix: dropping the slot reuse fails the bound, and letting labelled blanks recycle fails the identity. A differential test converts documents that are valid under both grammars through both scopes and requires the bytes to be identical.
CountingSink holds five counters and no heap, so dropping it releases nothing and only extends a lifetime — clippy says so under --all-targets. The census it produced is already copied out above. Fold into the instrument commit at integration.
The reachable half of a mis-declaration — a producer that declares statement scope and then caches an id anyway — is caught by the writers' debug generation stamp. The other half is invisible from inside a sink: a second, caching producer handed the same sink says nothing, so there is nothing to notice. That one is held structurally (a writer owns one destination and writes one document) and now says so. Fold into the declare_term_scope commit at integration.
A caching parser keeps an Arc<str> per distinct IRI because its own cache needs one as a key. A sink handed only &str has no way to reach that allocation, so a sink that STORES terms had to make its own copy of the very bytes the producer was already holding — on the 4M-statement Turtle corpus, 800,005 allocations and about 37 MB of duplicate strings, with both copies live at once for the length of the parse. GraphSink::term_iri_shared is the same event as term_iri with a default body that forwards to it, so nothing changes for a sink that does not store terms or does not care. A sink that does override it clones the Arc: a refcount bump, and the same lifetime it would have given its own copy, because the bytes now outlive the producer by construction. The parser builds its cache key first and hands over a reference to it, which is why the clone is free rather than a race with a copy — the sink and the producer are the only two owners at receipt, and a test asserts exactly that count. Measured on the 4M-statement Turtle corpus: 35,000,070 -> 34,200,065 allocations (800,005 fewer, one per distinct IRI), 1048.8 -> 1012.2 MiB of churn, and the parse-into-writer live-heap peak 316.4 -> 286.8 MiB. The steady-state figure does not move, and should not: the bytes still exist once, they are simply not duplicated while both sides are alive. Three tests, because none of this is visible in the output bytes: the parser must take the sharing path and never the copying one (with a distinct-IRI count, so a cache regression surfaces here too), the writer's table must store a pointer-equal Arc (a copy compares equal by value, so ptr_eq is the only witness), and TimingSink must forward the shared form AS the shared form while counting it identically — a decorator that forwarded it to term_iri would silently restore the copy, and one that forgot to count it would empty the IRI line in --profile.
…dy set The assertion said a term scope may only be declared before the first term is minted. That is true of a CHANGE and false of a repeat, and the difference is a shape the protocol has to support: --continue-on-error re-enters the reader at every resync point, so a recovering line-format run declares statement scope once per surviving fragment, against a sink already holding terms from the fragments before it. The second declaration panicked the debug build. Re-declaring the scope in force is now a no-op at any point. What stays refused is a change of scope once terms exist — that one invalidates ids already handed out, which is the entire hazard this declaration exists to keep on the producer's side of the line. Found by rebasing onto the scan branch and running the integrated suite: neither branch's tests drove it alone, because the writers had no recovering producer and recovery had no declaring reader until the two met. The regression test is the recovery shape rather than the report of it — re-declare, mint, end statement, five times over — and it panics against the previous assertion.
Sizing the term caches is a choice between two wastes, and which one is cheaper reverses around a million distinct terms. Below it, reserving is the waste: a length-derived estimate put ~210 MB of empty table in front of a document holding 800K distinct terms. Above it, growing is the waste: hashbrown doubles, the grow path holds the old table and the new one at once, and the peak runs ~1.5x the final table. Measured on a 128 MB ingest-shaped corpus, hinting with the old estimate: 1000 distinct no-hint 130.0 MiB hinted 167.2 MiB hint costs +37.2 2M distinct no-hint 682.5 MiB hinted 483.2 MiB hint costs -199.3 So neither default is right for both, and the previous commit's small floor was right for convert and wrong for bulk import — whose memory budget formula (max_inflight x chunk x 2.5) was calibrated with the reservation in place, and which routinely runs above the flip. The estimate moves to whoever has one: ParserOptions gains an optional distinct-terms hint defaulting to None, import passes its chunk-derived estimate at the historical 60-bytes-per-term ratio, and convert passes nothing because a document's LENGTH is not evidence about its distinct-term COUNT. The parser clamps the hint, which is not a second guess at the right size but a bound on how wrong a hint can be. The regression test pins the CONTRACT rather than the byte counts, which are allocator-dependent: the same document must denote the same RDF at both ends of the flip, with no hint, with an absurd hint, and with a hint past the clamp — so a future change chasing bytes cannot quietly change the answer while it does so.
… panics The guard for a producer that declares statement scope and then caches an id stamped the SLOT with the statement that last wrote it. That catches the sparse shape — next statement narrower, cached slot never re-minted, stamp stale. It does not catch the dense shape, where the next statement is at least as wide, the slot has already been re-minted with the current stamp, and the stale id therefore reads as FRESH. The reviewer's probe named the result exactly: SUBJECT-TWO written where the producer meant SUBJECT-ONE, no panic, no error, valid-looking output. The dense shape is the common one. The guard caught the case I imagined and missed the case that happens. The stamp now travels in the id — seven bits at 24-30, beside the session tag at 31, debug builds only — so a stale id carries the statement it was minted in no matter what has become of its slot. Both shapes panic, and the per-slot generation vector is gone since the id carries what it recorded. Seven bits wrap every 128 statements, so an id cached across exactly a multiple of 128 still slips through: this is a debug-build heuristic against an API misuse, not a soundness mechanism, and 24 bits of index for the widest single statement is the more valuable half of the split. Also asserts what the session tag has been assuming. "No table can hold 2^31 entries" is an argument about the parser's 4 GiB input cap, and this table serves producers that are not the parser, so push_session now checks it instead of inheriting it. Both shapes are pinned by tests that fail against a disabled guard, and a third test runs 300 correct statements to prove the guard does not fire on correct use — a guard that rejects valid programs is worse than none.
The three tests that shipped with the handover check it from the sink's side, and a hostile parser satisfies all three while allocating twice: hand the sink a reference, then build a SECOND Arc for the cache key. The sink still receives a shared reference, the owner count at receipt is still two, the writer still stores a pointer-equal clone of what it was handed — and 800K duplicate allocations come back with every test green. The only party that could prove otherwise is the parser's cache, which is dropped before any test can look at it. So this measures from outside, and it measures a SLOPE rather than a count. An absolute allocation count moves with hashmap growth, Vec doubling and anything else that allocates along the way; the marginal cost of one more distinct IRI does not, because everything unrelated appears in both arms and subtracts out. Absolute IRIs, deliberately: a prefixed name costs the expanded-IRI String, the Arc built from it, and a second Arc keyed by the span text for the prefixed-name cache — about four allocations per distinct term, which buries the one allocation this is about. Measured on the isolated path the slope is 1.00; with the second allocation restored it is 2.00 and the test fails naming the cause. A lower bound catches the other failure: a slope near zero would mean the two corpora are not differing in distinct-IRI count, which would make the test vacuous rather than passing. Also corrects the owners-at-receipt comment, which claimed the producer hands the term over before caching it as though that were a careful choice. It is forced — the cache maps the IRI to the TermId, and the TermId does not exist until the call returns. A fact that cannot vary should not be written as a decision.
The take(LIMIT + 1) cap already guarantees the refusal. What it costs is 4 GiB of reading and 4 GiB of buffer to reach a verdict the file's own length already implied. A caller pointing at a 10 GB dump should be told immediately, not after a minute of I/O. The hint is not trusted for correctness — an input whose metadata lies small still hits the cap and is refused there — which is why this is a short-circuit and not the check. Both paths now raise the same error through one constructor, so they cannot drift into explaining one condition two different ways, and the boundary case is pinned: a hint exactly AT the limit reads, matching the cap that backs it. Also takes clippy's then_some, and bounds the memory claim in the PR body. "Input size and nothing above it" is true for the corpora measured and false for a document with millions of distinct LABELLED blank nodes, whose label map is session-scoped under either term scope because it is the only record that two occurrences of _:x are one node. That is a gap in the evidence rather than something the claim covers, and it now says so.
Two `#[cfg]` blocks with early returns tripped needless_return, because in a debug build the block IS the tail expression. Splitting each into a pair of cfg'd helper functions says the same thing without the lint, and reads better: the stamped and unstamped id layouts now sit side by side with a line each, instead of being interleaved inside the code that uses them.
The contract test pins the safety half — same RDF whatever the reservation does — and a null implementation satisfies it completely: plumb the hint through, then reserve the floor anyway. Every assertion passes, the crate stays green, and the fix is silently reverted. The reviewer demonstrated it in three lines. It is the same shape as a sink that receives a shared Arc and copies it regardless, and a test asserting only the second half of its own name is how both got through. The witness measures the reservation from outside, in bytes allocated during a parse of a deliberately tiny document, so the number is essentially the reservation. Exact byte counts are hashbrown's business and are not asserted; the RATIO is, and a two-million-entry table sits about three orders of magnitude from the floor. Against the reviewer's null implementation the two measurements come out identical — 102520 bytes either way — and the test says so by name. A second test pins the clamp, which is dodgeable on its own and whose failure mode is an allocation the machine cannot serve rather than a wrong answer. Also stops a test from reserving 4 GiB to check an inequality. The at-the-limit case went through the real read, which reserves the hint — fine under overcommit, an abort inside a memory-capped CI container. The off-by-one is the whole risk and it now lives in a predicate that can be tested without allocating anything.
…al counter term_cache_efficacy.rs was deterministically red under `cargo test` and green under nextest, and the difference is isolation rather than luck. Both its tests arm the same process-global counters; `cargo test` runs a binary's tests on threads of one process, so each measured the other's allocations. The floor came out at 104,960,128 bytes instead of ~102,520, inflated by the clamp test's two-million-entry table allocating alongside it. Worse than flaky: the assertion that fired blamed the PRODUCT — "the hint is being accepted and ignored" — for a defect in the measurement. That is the most expensive kind of false alarm, because it sends the next reader to audit working code. It stayed hidden because every green in this bucket came from nextest, which gives each test its own process and isolates the statics for free. CI runs nextest, so this gated nothing, while a developer typing `cargo test` hit it immediately. A test that is green only under the runner CI happens to use has accidental isolation, not isolation. The arm/measure/disarm window is now behind a mutex, with poisoning recovered rather than propagated so a genuine assertion failure does not turn every sibling into a confusing poison error instead of its own verdict. term_sharing_witness.rs uses the same pattern and is safe only because it holds exactly one measuring test; both files now say so, since the next person to add a second one will not otherwise find out until it misattributes a failure. Verified under both runners across the five crates: `cargo test --no-fail-fast` 1385 passed / 0 failed / 5 ignored, nextest 1371 — which reconcile, 1371 + 14 executed doctests = 1385.
…ape corpus Harness changes the first real competitor run needed. Our column gains four rows rather than one. It is the only column with a CHECKING dimension — validate (the default) pairs with riot --check=true and nocheck pairs with --check=false, which is the parity H-8 defines — and the only one that parallelizes Turtle, so --parallelism 8 is carried as its own labelled row. It is never merged into the serial rows and never presented as a competitor comparison: no other tool in the matrix parallelizes Turtle, so there is nothing on the other side of that comparison. FLUREE_BIN is now overridable, so a matrix can be pointed at a binary built from a different tree than the harness lives in. Adds a BSBM-shape Turtle corpus emitter. A second SHAPE, not a second size: the synthetic corpus is uniform, subject-major and single-prefix, which flatters a parser, while BSBM's four interlinked entity types with mixed datatypes exercise a different path. An ordering that survives both shapes is worth more than one measured twice. Turtle only, deliberately. The generator has no native N-Triples emitter, and producing that half by converting with our own writer would benchmark every tool on input shaped by the tool under test — the same circularity that removed RiverBench's Turtle column from the corpus set. The manifest carries N/A and the reason.
The lock's disclosed flags carried the Jena 5.x spelling. Under 6.1.0, `--check=false` parses as `--check` plus a POSITIONAL argument `false`, which riot then tries to open as a file: ERROR riot :: Not found: file:///.../benchmarks/conversion/false So all four riot cells failed. That is the harness working: a wrong flag took the column out of the matrix loudly instead of producing riot numbers measured under a checking mode nobody had verified. flags_disclosed exists as a lock field precisely so this is auditable rather than buried in a script. Corrected to `--check` and `--nocheck` in both the lock and the runner, with the 5.x-vs-6.x difference recorded in the lock note. Verified: both modes now convert the smoke corpus to 10000 statements.
…-2 by size Two more things the first real run found, both of the same shape as the riot flag: a lock written against an older CLI. oxigraph 0.5.9 rejects the 0.4.x `--file` spelling outright, so both its cells failed and the gate EXCLUDED them rather than comparing against a tool that had not run. Corrected to --from-file in the lock and the runner, with the version difference recorded in the lock note. Level 2 of the correctness gate does not scale, and pretending otherwise would have been the worse failure. Measured at 4M statements: level 1 normalizes a 281MB cell in 3.5s, while rdflib needs ~40s merely to PARSE the same file, before canonicalization, times every pair of cells. Above FLUREE_BENCH_MAX_ISOMORPHISM_STATEMENTS (default 500k) level 2 is now skipped BY NAME, stating the count, the limit, what level 1 still covered, and how to force it. A run must never look fully verified because its expensive half quietly did not happen.
Several agents share this machine and each other's CPU, and a timed run started while another is compiling does not measure the tool. The damaging part is not that load adds noise — it is that the contamination is ASYMMETRIC. A build beginning midway through a matrix hits whichever cells run last, so one column is measured under load the others never saw. A median does not remove that; it is a systematic bias in favour of whoever ran first. This is not hypothetical. In the 4M matrix, the four competitor tools recorded load/core 0.43-0.62 while our own cells — which the runner schedules last — sat at 1.07-1.65. The comparison understated our own tool by construction, and the two noisiest cells in the run (19.4% and 11.3% REL_MAD) were the two highest-load ones. The per-cell load field added in the previous bucket is what made this diagnosable rather than a mystery. The filesystem is the only channel these agents share, so the lock is a file. Advisory — nothing enforces it but cooperation — so it records holder, pid, start time and load, letting a stale lock be diagnosed instead of guessed at. Acquisition uses noclobber so two agents racing cannot both win. The trap covers PIPE and HUP alongside EXIT/INT/TERM. That is not defensive padding: piping a run into `head` kills the shell before an EXIT trap fires and leaves a stale lock that blocks the other agent, which is what the first version of this did when its own self-test was piped into head.
…not serial `--parallelism` defaults to 0, which the CLI reads as "as many as this host has". The rows named `validate` and `nocheck` passed no parallelism flag, so on this 16-core host they were 16-worker cells — and the matrix presented them opposite serdi, rapper, oxigraph and riot, every one of which is single-threaded here. The par8 rows were the only ones whose worker count was stated, which is how a 16-worker row and an 8-worker row came to look like a serial-vs-parallel comparison. Every row now states its count: serial-* passes --parallelism 1 and is the only kind comparable with the competitors, auto-* is the default a user gets and is ours alone, par8-* stays a fixed count so the row means the same thing on another host. Verified against the binary: `rdf convert` on this host reports "16 workers, 24 chunks" with no flag.
…ecord core count Three one-liners from the harness review, all about the next operator rather than tonight's numbers: - check-correctness's isomorphism-skip message asserted 'this corpus has none' about blank nodes unconditionally. The sentence is now derived from the cells (grep for '_:'), so pointing the gate at a corpus that HAS blank nodes states that their structure is unverified instead of stating a falsehood inside the one message whose job is honesty. - bench_lock_acquire installs an EXIT trap, and bash has one EXIT slot per shell: today's callers have no trap of their own, but a future caller with a no-silent-pass EXIT guard (check-correctness) would have it silently deleted. The comment now says so at the install site. - The run manifest records available_parallelism. An 'auto' row's resolved worker count was otherwise not recoverable from the artifact: the same matrix on an 8-core host produced indistinguishable JSON.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fluree rdf convert: the parallel path scales and the memory is attributableTwo workstreams, one branch: the parallel convert path goes from 1.94× at sixteen workers to 8.22× (the single-threaded chunk scan was the entire cap), and peak RSS on the worst cell drops 1091 → 294 MiB (the footprint is now deterministic run-to-run, not just smaller). Thirty-one commits on
feat/rdf-toolkit-m1(#1569): sixteen on the parallel/scan path, fifteen on memory. Every number below is dev-signal from an M-series laptop (host_class darwin-arm64-translated) — nothing here is a publishable claim until the official-locus run; commands and raw data ship with the branch.Headline measurements
Scaling (G1b-3, 143.5 MiB / 6M-triple Turtle corpus, best-of-3, quiet box, zero loud points):
The chunk scan that was 2.85s and 43% of wall (K-independent, ~52 MB/s) now runs at ~556 MiB/s and holds flat at ~258 ms across every K. The 8.22× landed inside the band pre-registered from the scan-fix model before measurement (7.7–10.1×). The next cap is named: the scan is 30% of wall at K=16; parallelizing it at K offsets is a recorded follow-up, not part of this PR.
Memory (peak RSS, interleaved A/B, 3 reps/arm, 4M-statement corpora, zero paged runs):
The before-column's nt-16 cell ranged 651–894 MiB across three identical runs; every after-cell is reproducible to within ~10 MiB. Output is byte-identical before and after on all cells.
Where we stand against the field (Tier-2 4M-statement matrix, correctness-gated — every cell's output verified equivalent across all tools): single-threaded, we are about half serdi's speed (3.79s vs 1.96s on Turtle) and behind riot (2.56s); with the parallel path at defaults we convert the same corpus in 0.63s. That 6.39M stmt/s figure is our own sixteen-worker ceiling measured beside four single-threaded tools — it is not a like-for-like win over anyone. The honest sentence: about half serdi's speed single-threaded, and we scale where they do not. Validation is measured at ≤1.5% of wall (serial validate 3.788s vs nocheck 3.740s).
The scan/parallel line (16 commits)
The G1b-2 finding was that the worker pool was already at 92.5% of ideal width and the single-threaded pre-pass was the whole ceiling. The fixes, each with a failing-before test:
--continue-on-errorre-parsed as Turtle too;--nocheckwas ignored at three parse sites (now honored, with p1/p8 byte-identity pinned, because agreeing on the exit code is not enough when the flag turns off a check).BudgetState.stopped, and every writer of it notifies by construction — the second-store-site hang is unrepresentable rather than guarded. The liveness proof is a saturated-budget test that fails at 90.03s when the unconditional stop is removed; the dead-reader matrix structurally cannot see this guarantee, and the comment now says so.Phase::Chunklane (the scan was 43% of wall charged to "unattributed"); parse errors on the parallel path now relocate to document coordinates (previously a failure on line 90,002 was confidently reported at line 82); the resync note no longer cries wolf on multi-line statements (a candidate span is re-parsed standalone before being reported as swallowed — a fragment cannot parse, a real statement can); all prose on stderr routes through one gate that knows--profile=jsonmeans stderr is a document (this had regressed at six call sites with three spellings of the same guard).The memory line (15 commits)
Three defects, separately attributed by a tracking-allocator instrument (
mem_attrib.rs, live-heap withfetch_max-maintained peak, committed as an example):take(LIMIT+1)erased theFilesize specialization. Now reserves from metadata (clamped at the refusal limit; a lying length cannot turn into an allocation failure; oversized inputs are refused from metadata before reading 4 GiB).GraphSink::declare_term_scopelets a producer declare statement-scoped ids; sinks recycle a tagged region at statement boundaries. The default is the existing contract, a sink may ignore a declaration, and a sink may never honor one that was not made.The tests here are witnesses, not just contracts: the size hint has an efficacy witness that fails if a null implementation accepts-and-ignores it (allocation-ratio based, with a lower bound so a degenerate corpus fails as vacuous); the Arc handover has a slope witness (1.00 clean, 2.00 with the second allocation restored, and the failure names the cause); the statement-scope guard stamps a generation into the id and panics on the dense re-mint shape that previously wrote one subject's data under another's label. The witnesses measure through a process-global allocator, so they serialize behind a mutex — nextest's process-per-test isolation is not assumed (
cargo testand nextest are both green, and both figures are quoted below because they will always differ by the doctest count).Gates at the assembly pin
Both runners are quoted deliberately: they disagree by a fixed doctest offset that looks like a discrepancy and is not, and
cargo test's thread-per-test model is the only runner that can see cross-test contamination of process-global state (it caught exactly one such bug in this branch's own witness, fixed here).Each line was independently adversarially reviewed before assembly (three passes on the scan line, three on the memory line), with mutation testing on every claimed guard — the review protocol's standing rule, derived the hard way three times in this wave: before citing a test as verifying a property, mutate the property and confirm it goes red. The measurement provenance (quiet-window discipline, per-run load and pageout-rate recording, interleaved A/B arms, pre-registered predictions, correctness gate over every matrix cell) is in
riot-analog-pr-plan.md§1b-3.The harness that produced the matrix rides along
The final six commits are the Tier-2 comparison harness refinements (
benchmarks/conversion/) that generated every matrix number above, so the evidence chain is inspectable in the same diff as the claims: per-tool pinned invocations with the flag-breakage notes that motivated them (riot 6.x bare--check, oxigraph 0.5.x--from-file), the cross-agent bench lock (O_EXCL, pid-matched release,PIPEin the trap because piping a run intoheadstrands the lock — found by testing exactly that), worker counts in our row names so a 16-worker cell can never again sit unlabeled beside single-threaded competitors, the BSBM-shape corpus generator (Turtle-only on purpose: generating the NT half with our own writer would benchmark every tool on input shaped by the tool under test), and the correctness gate's isomorphism-skip message now deriving its blank-node sentence from the cells rather than asserting it. The run manifest recordsavailable_parallelism, so anautorow's resolved worker count is recoverable from the artifact on any host.Named follow-ups (recorded, not in this PR)
fluree.rdf.profile.v1gains an optional parse-failure field, so the last prose site (report_parse_failure, serial invalid-document path) can route through the stderr gate without trading unparseable stderr for an unexplained exit 1. Schema change, own commit, own review.it_property_path_batchedlane assert, FIR6-root reload race,it_values_type_deleted_repro(contention-correlated, structurally unrelated to this branch).