Skip to content

Speed up structural testing: one read per file, no per-field regex or allocation - #75

Open
dionmcm wants to merge 4 commits into
IHTSDO:developfrom
aehrc:upstream/structural-single-pass
Open

Speed up structural testing: one read per file, no per-field regex or allocation#75
dionmcm wants to merge 4 commits into
IHTSDO:developfrom
aehrc:upstream/structural-single-pass

Conversation

@dionmcm

@dionmcm dionmcm commented Aug 31, 2026

Copy link
Copy Markdown

Structural testing takes 110s for SNOMED CT-AU. This optimisation makes it 2.86x faster.

Measured on a SNOMED CT-AU edition, 8 cores:

structural phase
current code 110.3s
+ one pass for the line-terminator checks 86.4s
+ largest-first scheduling, regex fast paths 71.0s
+ deferred allocations, remaining hot-path regexes 38.5s

Same tests and reporting.

1. Three reads per file where one would do

RF2FileStructureTester.runTestForFile made three full passes:

  • readLine loop counting lines
  • Scanner over a CRLF delimiter, counting again
  • re-open, readLine to totalLine - 1, to inspect the last line's terminator

On the 594MB description file (4,174,475 lines): 1.33s + 6.71s + 1.14s = 9.19s, against 3.26s for one pass. Scanner was the most expensive because it tokenises with the regex engine.

RF2FileStructureTesterScanTest runs the previous logic as a reference implementation and asserts agreement over eighteen terminator shapes.

One existing quirk is preserved: the last-line check only fires with at least two lines, because reaching it required reading line totalLine - 1. A single-line file has never had its terminator checked. Changing that would report terminators no release has been failed for, so it belongs in its own change.

2. The regex engine is the per-field cost; split is not

On the 1,039MB relationship file (92,283,780 fields):

read only                     3.88s
+ split("\t", -1)             3.37s   (free, within noise)
+ regex per field            11.33s   (+7.96s)
+ the same checks as chars    3.80s   (+0.43s)

Six of the seven patterns now have hand-written equivalents, with the regex engine as fallback. split is left alone: replacing it needs PatternTest to take a CharSequence with offsets, a signature change across every column test, for no gain (index scanning measured 4.13s against 3.80s).

NOT_BLANK deliberately has no fast path. It needed the most explanation for the least gain: 31,792ms against 32,726ms on the column pass, inside run-to-run variance. In isolation over 2,246,130 real term values it is 1.55x, against the ~8s that removing regex from SCTID, effectiveTime and boolean saves on a single file.

ColumnPatternFastPathTest holds each remaining fast path against its pattern over adversarial values and 200,000 random strings per pattern. It keeps one case as a warning rather than a check: String.isBlank() is not equivalent to NOT_BLANK, because . does not match a line terminator, so the pattern rejects "a\nb".

3. Per-field allocations

validate() opened by allocating a String[] and a String (lineNumber + "") for every field. getErrorArgs() is read at one call site, immediately after validate() returns false. Now built there.

getPatternString() rebuilt a StringBuilder on every call, including on the success path, for a value fixed at construction. Built once.

Three further hot-path regexes removed: isNumericSctId ran SCTID_PATTERN per field on the success path, isBlank ran ^$ where isEmpty() is equivalent, and DateTimeTest matched DATE_PATTERN directly instead of using the shared fast path.

4. Largest-first scheduling

RF2 file sizes are skewed: the largest of 76 files is 20% of all bytes, the top eight are 82%. In directory order the small files finish while the giants are still starting, and the pool drains to two or three busy threads. ResourceProvider gains getFileSize (default -1, so a provider that cannot answer keeps its order) and getFileNamesLargestFirst.

Verification

clean upstream-develop   213 run, 0 failures, 142 errors, 21 skipped
with this PR             235 run, 0 failures, 142 errors, 21 skipped

What remains

This now runs at about 2.2 of 8 cores, bounded by the largest single file rather than CPU. Splitting one file's lines across threads would address that.

We have it working and measured at a further 1.38x, but it costs two thread pools, a semaphore and an ordering constraint on header processing. It is complex and probably not worth it.

RF2FileStructureTester.runTestForFile made three full passes over every file:

  1. a readLine loop counting lines
  2. a Scanner over a CRLF delimiter counting them again
  3. a third open that read every line up to (totalLine - 1), purely to reach
     the last one and look at its terminator

All three want the same facts, and one pass yields all of them: the line count
with readLine's semantics, the CRLF-separated token count Scanner produced, and
the final line's terminator.

Measured on the 594MB description file of a real edition, 4,174,475 lines:

  pass 1  readLine count      1.33s
  pass 2  Scanner CRLF        6.71s
  pass 3  last-line reread    1.14s
  ---------------------------------
  old total                   9.19s
  new single scan             3.26s   2.82x

Scanner was the most expensive of the three because it tokenises with the regex
engine - it took 6.71s to recount what readLine had counted in 1.33s.

Why this is worth doing now: with an engine that runs the assertion phase in 26s,
structural testing at 110s is the floor on a validation, so no engine change can
improve end-to-end time by more than about 9.5x while it stands. This removes
roughly a third of that phase.

Nothing about the report changes. The Scanner token count is reproduced exactly
rather than approximated - Scanner splits on each CRLF and returns the trailing
segment only when non-empty, so the count is the number of CRLF occurrences plus
one when anything follows the last of them, and adjacent CRLFs yield the empty
tokens that formula counts. RF2FileStructureTesterScanTest asserts the new scan
agrees with the old three passes, running the previous logic as a reference, over
twelve terminator shapes: all-CRLF, all-LF, mixed, empty, single line with and
without a terminator, an unterminated last line, adjacent terminators, CR-only,
a trailing empty line, and a header-only file.

One existing quirk is preserved deliberately: the last-line check only fires when
there are at least two lines, because reaching it required reading line
(totalLine - 1). A single-line file has never had its terminator checked. Fixing
that would report last-line terminators no release has been failed for before, so
it belongs in its own change if SI wants it.

Suite: 225 tests run, 0 failures, 142 errors, 21 skipped - the same 142 as clean
develop (Testcontainers with no Docker daemon on this host), 12 tests added.
… path

Structural testing is the floor on validation time, and after the previous commit
ColumnPatternTester was 92% of it - 70.3s of a 76.5s phase, measured by
instrumenting each tester. Four changes, none of which alters what is reported.

1. The regex engine is the per-field cost, and split() is not.

On the 1,039MB relationship file of a real edition (92,283,780 fields):

  read only                     3.88s
  + split("\t", -1)             3.37s   (free, within noise)
  + regex per field            11.33s   (+7.96s)  <- the whole cost
  + the same checks as chars    3.80s   (+0.43s)

So the seven patterns in this class now have hand-written equivalents and the
regex engine is the fallback for anything else. split() is left alone: it is not
the problem, and replacing it would mean changing PatternTest to take a
CharSequence with offsets, which is a signature change across every column test
for no measurable gain (index scanning measured 4.13s against 3.80s).

Equivalence is the whole risk here, so ColumnPatternFastPathTest holds each
replacement against its pattern over adversarial values and 200,000 random
strings drawn from an alphabet chosen to hit every boundary - digits, hex, '-',
ASCII spaces, line terminators, NBSP, U+2028. One case is worth naming:
String.isBlank() is NOT equivalent to NOT_BLANK (^(?=\s*\S).*$), because '.' does
not match a line terminator, so "a\nb" is non-blank to isBlank() and rejected by
the pattern. The replacement reproduces the pattern; a test pins that so nobody
simplifies it later.

2. errorArgs was built for every field, and read only on failure.

validate() opened by allocating a String[] and a String (lineNumber + "") for
every field of every line, while getErrorArgs() is read at exactly one call site,
immediately after validate() returns false. Now built on the failure path.

3. getPatternString() rebuilt a StringBuilder on every call, including the
success path, for a value fixed at construction. Built once.

4. Two more hot-path regexes removed: isNumericSctId ran SCTID_PATTERN per field
on the success path, isBlank ran ^$ where isEmpty() is exactly equivalent, and
DateTimeTest matched DATE_PATTERN directly instead of going through the shared
fast path - effectiveTime is one field on every row.

Also: both testers now submit largest-first. RF2 file sizes are extremely skewed
- on this edition the largest of 76 files is 20% of all bytes and the top eight
are 82% - so in directory order the small files finish while the giants are still
starting and the pool drains to two or three busy threads. ResourceProvider gains
getFileSize (default -1, so a provider that cannot answer keeps its old order)
and getFileNamesLargestFirst.

Measured end to end on the same host, same release, structural phase:

  upstream baseline                                    110.3s
  + one pass for the line-terminator checks             86.4s
  + largest-first and the fast paths                    71.0s
  + deferred allocations and the last hot-path regexes  38.5s   2.86x

Suite: 233 tests run, 0 failures, 142 errors, 21 skipped - the same 142 as clean
develop, 20 tests added.

What is left, for the record: the phase now runs at about 2.2 of 8 cores, so it is
bounded by the largest single file rather than by CPU. Splitting a file's lines
across threads would address that and is worth roughly another 2-3x, but it needs
per-range readers and merged reporting, which is a larger change than anything
here.
Review feedback, and correct. Of the seven patterns this class uses, NOT_BLANK
(^(?=\s*\S).*$) needed by far the most explanation for by far the least gain, so
it now falls through to the regex engine like anything else without an equivalent.

Measured, same branch and host, on the structural phase's column pass:

  with the NOT_BLANK fast path     31,792 ms
  without it                       32,726 ms

Under a second on a 32-second pass, which is inside run-to-run variance. In
isolation over 2,246,130 real term values the hand-written version is 1.55x
(0.439s to 0.284s) - real, but nothing beside the 8s that removing regex from the
SCTID, effectiveTime and boolean columns saves on a single file, because those run
on every field of every row while the lookahead fails fast on ordinary content.

What goes with it: isNotBlankPattern, isAsciiSpace and isLineTerminator, and the
agreement test for them. Three methods and a page of reasoning about why
String.isBlank() is not equivalent - '.' rejects five line-terminator characters,
so the pattern refuses a value isBlank() calls non-blank - and about which of
those five can reach a term. None occurs anywhere in the 73 files of a real
edition that were scanned for them.

ColumnPatternFastPathTest keeps the isBlank case, rewritten as a warning rather
than a check: anyone adding a fast path here has to reproduce that behaviour, and
the measurement says not to bother.
Found by a reviewer asking what "adjacent CRLFs yield the empty tokens that
formula counts" meant. It was badly worded because it was wrong, and the wording
was hiding a real defect.

The claim was that Scanner's token count is the number of CRLF occurrences plus
one when anything follows the last of them. Checked against a real Scanner, that
is right for trailing and interior delimiters and wrong for leading ones:

    input             Scanner   old formula
    "\r\n"                  0             1     <- would lose an error
    "\r\n\r\n"              1             2     <- would lose an error
    "\r\na"                 1             2     <- would lose an error
    "a\r\nb\r\n"            2             2
    "a\r\n\r\nb\r\n"        3             3

The actual rule: splitting on every CRLF gives crlfCount + 1 segments, and
Scanner returns all of them EXCEPT an empty first segment and an empty last one.
Interior empty segments are kept, which is the only part the old wording got
right.

Consequence, had this shipped: a file whose first line is empty would report one
token against one line, so `tokens < totalLine` would be false and the
"total line is terminated with CR+LF" error would silently stop being raised. No
real release begins with a terminator, which is why the corpus never caught it -
and why the tests should have, but had no case starting with a delimiter.

Now derived from the segment rule, verified against a real Scanner over fourteen
shapes, and pinned by six new test cases covering leading, only, and
leading-then-content terminators. Reverting to the old formula fails three of them
with the counts above, so they discriminate.

Suite: 235 tests run, 0 failures, 142 errors, 21 skipped - the same 142 as clean
develop.
dionmcm added a commit to aehrc/release-validation-framework that referenced this pull request Sep 1, 2026
The plan had the materialised-release cache next. The phase split of a real
two-edition nightly says otherwise, and the number I guessed was wrong by
5x - worth recording rather than quietly correcting.

Measured on the AU edition with a previous release supplied, 339s total:

    received -> start structural            44s
    start structural -> prospective unpacked  198s   <- 58%
    previous unpacked -> prospective mat.    23s
    prospective mat. -> previous mat.        25s     <- what the cache saves
    previous mat. -> archive written         45s

So caching the previous release saves 25s, or 7% - not the ~40% I
estimated from the difference between a one-edition and a two-edition run.
That difference is mostly structural testing and unpacking a second
edition, not materialisation.

Meanwhile the branch did not carry the structural work already written,
measured and raised as IHTSDO#75: four commits taking each RF2 file from
three full reads to one, the regex engine and per-field allocations off the
hot path, and a corrected Scanner token rule. Cherry-picked here because
this is what a nightly actually spends its time on.

Same release, same request, same 8 cores:

    phase                          before   after   delta
    start structural -> unpacked     198s    127s    -71s
    TOTAL                            339s    257s    -82s   (1.32x)

And identical findings, which is the part that matters: 149 tests run both
times, 13 failures, 0 warnings, 1 incomplete, and per-assertion comparison
shows 0 assertions appearing in one report and not the other, 0 with a
different bucket or count.

The cache is still worth building - 21s of a 257s nightly, and it removes
the last per-run rework - but it is an 8% optimisation, not the headline,
and it now sits behind this in the plan.
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