Skip to content

perf!: allocate nothing per sound value in irverify and pass - #1147

Merged
OmarAlJarrah merged 33 commits into
mainfrom
perf/irverify-clean-values
Oct 29, 2026
Merged

OmarAlJarrah merged 33 commits into
mainfrom
perf/irverify-clean-values

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Summary

irverify and pass.Validate built a string or a collection for nearly every value they held to a rule, whether or not the value broke it: a path or a location to place a finding that never came, a prefix to compare against, a key to look a fingerprint up by, a rendering to compare with, a map or a sorted key list per collection. ir did the same for them, spelling the path of every declaration and of every reference met before its target. On the golden spec's compiled IR, which both find clean, those were nearly all of Verify's and Validate's allocations. This change makes a value with nothing wrong cost a check nothing, keeps a path a check may report unspelled until it reports it, and holds every check to that.

On the golden spec (Cloudflare openapi.json @1a8429dc, the revision #1139 measured), one run of each over the compiled IR:

main @acf5e3ba this branch
irverify.Verify allocations 326,146 to 326,264 3,326 to 3,336
irverify.Verify bytes allocated 142.5 to 142.7 MB 116.4 to 116.7 MB
pass.Validate allocations 85,697 to 85,716 2,349 to 2,366
pass.Validate bytes allocated 68.2 to 68.6 MB 61.6 to 62.0 MB
Instructions retired per Verify 19.44 to 19.70 G 18.97 to 19.15 G
Instructions retired per Validate 4.41 to 4.53 G 4.35 to 4.66 G

The ranges are three runs of each binary. Instructions are each binary run with 21 and with 1 call, the difference over 20, three alternating rounds; Validate's two ranges overlap. Wall time swung by more than the difference under the machine's load, so it is not quoted.

What remains is made once per run: the maps the walk and each check keep and the logs that keep paths, all of which grow in steps, about 94 allocations the JSON decoder makes for the 75 names of the one large object payload the spec holds, to refuse a duplicate, and, for Validate, the facts index it asks, which allocates per type it indexes (#1150).

Each site

irverify:

Site Was, per value Now
checkDuplicateIDs, fingerprintOf the property's path, to join its fingerprint to its declaration, and the three parts joined into a string a fingerprint struct of the property's own strings, kept per PropID for the first property the walk meets, and how many it has met there; a later one is compared as it is reached, and only one that differs is marked, by its PropID and occurrence
checkPrimIDs, checkUnions, checkRequires, appendSpaceViolations "types[" + id + "]" and its kin spelled in the branch that reports, through one typePath helper for the type registry
checkDocument the path of every source; #1139 counted it free, as it stays on the stack, but strconv.Itoa builds the string for an index of 100 or more spelled in the branch that reports
memberScopeViolation enumPath + "/", to compare a prefix beneath, comparing the two in place
appendBigVal ir.NewBigVal's rendering, built through a strings.Builder, plus a join of the digit runs to read the range ir.NewBigVal returns a literal already in JSON form as it is, and reads the range without joining the digits
checkNaming a path for every Primitive, recording its exemption the exemption is asked of the walk's own steps, through the new ir.WalkPath.Field, at a Naming that names nothing
checkRawPayloads a decoder borrowed from jsontext.Value.IsValid's sync.Pool per payload, which a race build drops from, so there a payload cost an allocation at random one jsontext.Decoder and bytes.Reader, reset for each payload, held to IsValid's verdict
checkRequires, checkOAuthScopes, checkStringLists, alias checking a map per model, flow, list or Naming, which allocates past eight entries one reusedMap per check, emptied for each collection

pass.Validate:

Site Was, per value Now
forEachPayload, forEachErrorPayload a location formatted for every request, response, error case and default payload the location's parts, spelled by payloadSite.where in the branch that reports
appendUnknownPartDiags, checkMapping a sorted key slice per encoding map and per mapping, and a formatted position per mapping entry the keys sorted in one list each check keeps, the position spelled only for a diagnostic
appendSuccessStatusDiags, checkResponseIndices the binding's location and its sorted SuccessStatus keys only the keys out of range collected and sorted, and the location spelled for them
checkHTTPParamBinding, checkParamReferences the binding's location, three maps per binding, a map and a list of formatted references per operation a reusedMap each and one list, the location spelled only for a diagnostic
checkDuplicateWireNames, ambiguousAliases, checkDuplicateMemberIDs, checkUnionDiscriminator, appendUncarriedMessageDiags a map per model, enum, union or channel, and the binding's location a reusedMap each, the location spelled only for a diagnostic
propIDSites the path of each PropID kept until the walk had read its property the path kept in an ir.PathLog, spelled only for a PropID that dangles

ir:

Site Was, per value Now
IDDeclarations the path of every declaration, 140,062 on the golden spec kept in a PathLog and yielded unspelled by Paths; checkDuplicateIDs and checkIDSpaces spell only the declarations they report
IDReferences the path of every reference met before its target kept in a PathLog; Unresolved spells only those still unresolved

Design choices

  • NewBigVal at its owner, not a new predicate. irverify: checks build strings for values they find nothing wrong with #1139 proposed comparing a literal with its canonical form as it is read, as ir.HasCanonicalWords does for a name. Returning the literal itself when the JSON spelling would not change it gives every caller the saving, the compilers' calls for every numeric literal a spec writes included, and adds no API. The scanner now records whether a point was written, the one affix it left no trace of.
  • WalkPath.Field instead of "the next Naming belongs to the primitive". That proposal depends on the walk visiting a struct before its fields, cannot tell when a walk cut short between the two hands the exemption to an unrelated Naming, and breaks a visit repeated with the same arguments, which irtest.AllocatingVisits relies on. Field reports the struct field a path ends in as String spells it, a field reached through an embedded struct on the outermost struct embedding it, reads the walk's own steps and allocates nothing. The exemption is now exact as well: a struct embedding a Primitive beside a Name of its own shares that Name's path, and the old path record exempted it too.
  • The fingerprint stays irverify's. Recording it where ir.IDDeclarations records the declaration would put a check's policy into ir. The check keeps the first fingerprint per PropID and counts the properties it meets there, so the n-th property met on a PropID is the n-th declaration of it, and TestPropIDs_AreDeclaredByPropertiesAlone pins that ir.Property is the only node declaring a PropID, since another would repeat one unfingerprinted.
  • A path kept, not spelled. ir.PathLog stores each path a walk shows as the steps it shares with the path recorded before it and the steps after those, so recording one copies a step or two. It grows by five halves at a time: append grows a large slice by a quarter, which copied the log several times over and cost about 50 MB more on the golden spec. slices.Grow grows about as far, but a race build makes two allocations for each of its growths, which the gate's cost tests count. Walking the document again to spell a reported path was the alternative; it adds a second walk to every run that reports one, and pass's walk asks its context, which a second walk would not.
  • One map per check, within a bound. A map a check reuses for every collection is emptied while the last collection left it at most 1,024 entries, and replaced past that. Go never shrinks a map, so emptying one that once held a million entries costs about 5 µs against 35 ns for a small one, at every collection after it: one model listing 1,000,000 Requires names beside 200,000 listing two took checkRequires 490 ms with an unbounded map, 166 ms with a map per model, and 127 ms with the bound. Both packages keep the same small reusedMap type, and each holds every clear in it to the type.
  • Keys still asked in sorted order. pass could have collected only the failing keys of a mapping or an encoding map and sorted those, but the rules ask the shared facts index of each key, and the index's cost turns on the order it is asked in. Sorting each map's keys in a list the check keeps leaves that order as it was and allocates nothing per map.

What holds it

  • Each irverify site has a test in the style of TestCheckDeclaredIDs_VisitingCostsNoAllocation, through irtest.AllocatingVisits, and ir has TestNewBigVal_AllocatesOnlyToRewrite, TestIDDeclarations_AllocatesNothingPerDeclaration, TestIDReferences_KeepingCostsNoAllocation and TestPathLog_RecordingCostsNoAllocation. TestPayloadReader_ReadsAsIsValidDoes holds the payload reader to IsValid's verdict over every string of up to five bytes drawn from those JSON's grammar turns on, and either side of the nesting limit.
  • TestRegistryChecks_ACleanEntryCostsNothing holds every check that ranges a registry to a cost that does not grow with its entries, measured at 100 entries against 200 so an index's string crosses into the ones strconv builds, and TestRegistryChecks_ListsEveryRegistryCheck derives every function shaped like one from the package's sources.
  • TestWalkChecks_ACleanValueCostsNothing holds everything Verify's walk carries, the declarations and every walking check, to no allocating visit on the document compiled from extensions-x.yaml. TestVerify_ACleanComponentCostsNothing holds what a visit test cannot see, a check's first meeting with a value and its report once the walk is over, by measuring all of Verify: a spec of 200 components costs less than one allocation more per component than one of 100.
  • TestValidate_ASoundValueCostsNothing holds pass's walk and each rule family, the facts index built, to the same, over a document of 50 and of 100 entities that each reach every family with collections wider than a map holds unallocated.
  • TestValidate_MappingDiagnosticsAreDeterministic holds the sorted order checkMapping keeps a mapping's keys in, which this change rewrote and pass: pin Validate's diagnostic order wherever it ranges a map #821 lists as unpinned; the response-index and encoding-key sorts it also rewrote were pinned already, and still redden when removed.
  • TestReusedMap_EmptiesInPlaceUpToTheLimit pins the bound in each package, and TestReusedMaps_AreEmptiedThroughReusedMapAlone refuses a bare clear anywhere else in it. Each map a check reuses has a case whose first collection would change the verdict on the second; the ones pass lacked are TestValidate_UnionMappingIsHeldToItsOwnVariants, TestValidate_ParamBinding_EachBindingIsJudgedAlone and a case of TestValidate_UnambiguousAliasIsClean. TestPathLog_KeepsASharedStepOnce, TestPathLog_KeepsTheTypeEachFieldIsSpelledOn and TestPathLog_GrowsGeometrically pin the log's sharing, the type each step is spelled on and its growth, and TestIDDeclarations_GrowsTheListGeometrically the declaration list's growth.
  • A location spelled only for a report is pinned past the first of its kind: the second HTTP binding in TestValidate_ParamBinding_EachBindingIsJudgedAlone and TestValidate_ResponseIndexOutOfRangeIsPlacedAtItsBinding, the second content, response and error case in TestValidate_EncodingKeyIsPlacedWhereItSits, the second listed message in TestValidate_MessageNotCarriedIsPlacedAtItsPosition, and the second of two IDs outside the declaration in TestVerify_EachIDOutsideTheDeclarationIsReportedAtItsOwnNode.

That first-meeting test is what found irtest.AllocatingVisits measuring nothing. It walked with one visitor standing for all of them, so what any one pruned was pruned for all, and checkIDSpaces, which visits nothing, prunes at the root. Handed every walking check, it reported a document clean with a path planted on every property. It now wraps each visitor and walks it as ir.WalkValues does, so each prunes for itself alone, and a visit allocating nothing is measured once rather than twenty times, which is what makes measuring every check affordable. Under -race the same test then flagged checkRawPayloads.

Breaking

  • ir.IDDeclaration has no Path. A caller spells a declaration's path from IDDeclarations.Paths, which yields each with its position in List, as a WalkPath valid until the next.
  • irtest.AllocatingVisits shows each visitor the walk it would be shown alone rather than pruning for all what one prunes, and lists a value once for each visitor allocating there. A visitor gathering cycles is now told of them, which it never was, and a nil visitor is skipped, where the old walk called it and panicked; past 64 visitors the list runs walk by walk, as ir.WalkValues traverses. The one caller that passed several visitors before this change, in pass, found the same paths under the new walk, and now finds none, since pass's walk keeps no path spelled.

ir.PathLog, WalkPath.Field, WalkPath.IsMapKey and IDDeclarations.Paths are new.

Out of scope

Test plan

  • make gate on the final tree: both CI jobs pass on it, all 16,199 statements covered. TestMain_AStopSignalAfterTheRunComesTooLate in cmd/morphic, which this change does not touch, failed one earlier run under the coverage build; it fails 5 of 10 runs on main the same way, and test: a covered child's metadata rename fails under parallel signal subtests #1151 tracks it.
  • Every commit builds and passes go vet ./... and go test ./ir/... ./pass ./internal/archtest on its own.
  • No output changes. morphic compile --verify gives byte-identical stdout, stderr and exit status on main and this branch for the golden spec, the 340 YAML and JSON files under testdata and 1,840 real-world OpenAPI specs.
  • Reports on defective documents are identical. Every document the corpus compiles to was rewritten with random defects aimed at the changed irverify sites (1,260 documents, 3,676 violations), and again, with three documents built to reach every rule family, with defects aimed at every changed pass rule and at duplicate and misplaced declarations (1,440 documents, 1,864 diagnostics under 16 codes, every code the changed sites report among them, and 45,106 violations). Verify's and Validate's output is byte-identical on main @acf5e3ba and the branch.
  • Allocating visits across the corpus, every walking check measured on its own by the corrected AllocatingVisits over all 210 clean documents the corpus compiles to: 802 on main (479 in checkDuplicateIDs, 249 in checkNaming, 65 in checkBigVals, 9 in checkReferentialIntegrity), and none on this branch, the declarations' collector included.
  • Mutation, on the final tree: each eager render, per-value map, sort and path restored at its site in both packages, and each clause of the new code broken, from PathLog's sharing, replay, roots and growth to reusedMap's bound, the duplicate check's occurrence count and IsMapKey. 103 defects, every one killed by the test written for it; four no-op controls survive. A later round planted every arm and guard the change adds, each reused map left unemptied between collections, each report's position and each growth policy: 18 survived every test, and each now has a test that reddens on it. pathsAt's check for an empty set, which both callers make first, is gone. Three survivors change no output: spelling every declaration's path once a document has a violation, which is a defective document's cost; appendGrowing's max, which matters only while a log is shorter than one path; and embedded's kind test, since only a field step has an owner.
  • Allocation tests under -race -covermode=atomic, five times each, as make coverage runs them.

Closes #1139.

NewBigVal built a new string for every literal, through a strings.Builder,
even when the result spelled the literal it was given, and read the
literal's range through a Decimal whose digits joined the runs either side
of the point. A literal in JSON form already now comes back as it is: the
scanner records whether a point was written, and the three affixes the
JSON spelling rewrites are checked where they lie. The range is read by
counting digits rather than joining them, so only ParseDecimal, which
keeps the digits, pays for the join.

A canonical literal now costs NewBigVal nothing, and a rewrite the one
string it builds. irverify asks NewBigVal of every numeric literal a
document holds, and the compilers of every literal a spec writes.
irverify exempted a primitive's empty name by rendering the path of every
primitive it met, with ".Name" appended, and looking each Naming that names
nothing up among them. The record cost a string per primitive, leaned on
the walk visiting a struct before its fields, and matched by spelling: a
struct embedding a primitive beside a Name of its own shares that path,
so its own missing name was exempted too.

WalkPath.Field reports the struct field a path ends in, as String spells
it: the field's name and the struct it is spelled on, which for a field
promoted through embedded structs is the outermost embedding one. It reads
the walk's own steps and allocates nothing. checkNaming now asks it of a
Naming that names nothing, keeps no state for the exemption, and exempts
exactly the Name of a node nameOptional lists.

Alias lists are read entry by entry rather than copied into a slice, and
held through one map the check keeps and empties for each list, so a list
longer than a map holds unallocated no longer costs a map of its own.
checkDuplicateIDs rendered the path of every property the walk reached and
joined each property's three fingerprint parts into a string, so that the
report could look up a declaration's fingerprint by its path. Both strings
were thrown away for every property a document holds once, and for every
copy a reused component makes that matches the first.

A fingerprint is now a struct of the property's own strings, kept per
PropID for the first property the walk meets on it. A later property is
compared with it as the walk reaches it, and only one that differs, which
is the defect, has its path rendered. The walk meets properties in the
order the declarations list them, so the first property is the first
declaration; a test pins that ir.Property is the only node declaring a
PropID, since another would repeat one unfingerprinted.
The checks that range over the type registry, the auth schemes and the
namespace declaration built a path for every entry before knowing there
was anything wrong with it: checkPrimIDs and checkUnions for every type
or union, checkRequires for every entry, and appendSpaceViolations for
every namespace. Each now spells it in the branch that reports, through
one typePath helper for the type registry's spelling.

memberScopeViolation joined an enum's path and the separator to compare
each member against; beneath compares the two in place. checkMemberIDs
also sorted every type ID to visit enums in ID order, but each violation's
path names its enum and member, so Verify's own sort already orders them.

checkRequires made two maps per model and checkOAuthScopes one per flow,
which allocate once a model holds more than eight properties or a flow
more than eight scopes. Each check now holds one map of each and empties
it for the next model or flow.

A test holds every function shaped like a registry check to a cost that
does not grow with the entries it ranges over, and a second one holds the
list it measures to every such function the package declares.
checkStringLists made a map for each []string field it holds to a rule,
which allocates once a list holds more than eight entries. The check now
keeps one map and empties it for each list, as checkNaming does for alias
lists.

A test also holds checkBigVals to costing nothing for a literal in
canonical form, which ir.NewBigVal now hands back as it is, through both
carriers: a Value's Num and a bound held by pointer.
AllocatingVisits walked a document with one visitor standing for all of
them, so what any one pruned was pruned for every one. A visitor that
prunes at the root, as one visiting nothing does, then hid the whole
document from the others: handed every irverify walking check, the
measurement reached no value below the root and reported a document
clean with a path rendered for every property.

Each visitor is now wrapped on its own and walked as ir.WalkValues walks
it, so it prunes for itself alone, and a visitor gathering cycles is told
of them. A value is listed once for each visitor allocating there. A
visit allocating nothing is measured once rather than twenty times, as no
later measurement can find fewer, which keeps a walk with many visitors
affordable.
Each check that fixed a per-value allocation pins its own cost, but a
check with no such test, or one written later, could still build a
string for every value it reaches. One test now walks a document the
OpenAPI compiler produces from extensions-x.yaml, which writes an
extension on every object OpenAPI admits one on, and holds the walking
checks together to no allocating visit.

checkReferentialIntegrity is left out of it: ir.IDReferences keeps a
reference whose target the walk has yet to declare with its path, until
the walk is over, which is that type's design and pinned in ir. Verify's
doc now states what a value costs a check and that exception.
checkInferred, which main added beside this branch's changes, asks
ir.Inferred.IsCanonical of every marker list, and that reads a list
through a map of its own: free for the lists a compiler writes, one
allocation for a list of more than eight names, as its doc chooses.
Verify's doc said a value with nothing wrong costs a check nothing
beyond the paths ir reads, which that list contradicts; it now names
the set as the other exception.
checkRawPayloads asked jsontext.Value.IsValid of each Unmodeled and
RawConfig payload, which borrows a decoder from a sync.Pool and hands it
back. A race build drops a share of what a pool is handed, so there a
payload cost an allocation at random. The walking checks' cost test
flagged an Unmodeled map of many entries for it under make coverage's
-race build, while every other build measured nothing.

The check now reads payloads through one jsontext.Decoder and one
bytes.Reader it resets for each, which costs nothing once the decoder's
buffer holds the largest payload, in every build. A test holds it to
IsValid's verdict over every string of up to five bytes JSON's grammar
turns on, and the shapes no short string reaches; a fuzz comparison of
the two found no disagreement in five million inputs.

A payload object with many members still costs the decoder an
allocation per name it keeps to refuse a duplicate, as IsValid did, and
Verify's doc names it beside ir's paths and the inferred-marker set.
checkDocument built the path of every source in the table before holding
it to either rule. The issue counted it free, since the string never
escapes and stays short enough to build on the stack, but strconv.Itoa
builds the string for an index of 100 or more, so a document read from
more than 100 sources paid an allocation for each source past the
hundredth. The path is now spelled in the branch that reports, as every
other registry check spells its own.

The registry checks' cost test measured 10 entries against 20, below the
index at which that starts, so it could not see this; it now measures 100
against 200.
TestVerify_OptionalOwnerExemptsOnlyItsOwnName said the descendant axis,
a nameless node inside an exempt one, had no pair to measure with while
ir.Primitive is the only exemption. nameExempt takes its table, so
TestNameExempt_IsTheOptionalNodesOwnNameAlone now holds a node of its
own inside the listed one to the rule, and the comment points there.
visitMeasurements' comment held the chance of a pool drop in every
measurement negligible for the handful of pools a visit reaches, which
leaves out a visit handing back dozens: checkRawPayloads' Unmodeled maps
were one until it stopped pooling. It now says so, and two comments
describing that cost read in the present.
AllocatingVisits said it lists paths in walk order, which holds for up to
64 visitors: past that ir.WalkValues walks again, so the list runs walk
by walk. A walk-path test said a primitive's Name is promoted through two
embedded structs where its own table counts three. The walking checks'
cost test leaned on extensions-x.yaml holding none of the costs Verify's
doc excepts, the one it leaves out aside, without saying so; a later
edit to the spec adding a large extension object would redden it for a
documented cost.
AllocatingVisits measures a visit only once the walk has made it, so a
cost a check pays on its first meeting with a value goes unmeasured. That
is meant for a memo kept per type, but it hides state kept per value too:
checkDuplicateIDs rendering and keeping a path for each PropID it meets,
the cost this branch removed, passed every test in the package.

TestWalkChecks_AFirstMeetingCostsNothing compiles a spec of 100 and of
200 components, each an operation and the schema it returns, and requires
the walking checks to allocate less than one more per component added,
with the walk's own allocations taken out. The checks add 2 in the plain
build and in the race and coverage build; the planted path adds about 400.

AllocatingVisits' doc now says what a first meeting hides. Verify's no
longer says a sound value costs a check nothing while checkDuplicateIDs
keeps a fingerprint per PropID: what it may cost is the step a map the
check keeps grows by.
TestCheckNaming_ExemptsAPrimitivesOwnNameWhereverItSits pins that a
primitive embedded in a struct beside a Name of its own keeps no
exemption, since the walk spells the primitive's promoted Name on the
embedding struct, so its name claimed an exemption its second case shows
does not hold. It is renamed for the two positions it holds, and its doc
now says both Names at the shared path are reported.
WalkPath.Field said a field Go promotes through an embedded struct is
spelled on the embedding struct. The walk also reaches a field Go
shadows, and Field reports that one on the outermost struct too, as
TestWalkPath_FieldIsTheFieldThePathEndsIn pins. Say so there, and in
the naming test that measures the case, which called the shadowed Name
promoted.

AllocatingVisits said the zero WalkPath renders empty without saying why
that matters: it renders without allocating, so a visit measured with it
would hide every render.
TestPayloadReader_ReadsAsIsValidDoes reached the decoder's nesting limit
only from far past it, at 20,000 levels. The reader skips a value token
by token, where jsontext.Value.IsValid reads it whole, and the library
bounds the two paths separately, so the rows that would show them
parting are the ones either side of the limit: an array and an object
nested 10,000 deep, which both accept, and 10,001, which both refuse.
checkRequires, checkOAuthScopes, checkStringLists and the alias check
each read every collection through one map they emptied with clear, so a
collection cost no map of its own. Go never shrinks a map: clear returns
at once on an empty one, and otherwise walks every table it grew to. One
huge collection therefore taxed every later one. A model listing
1,000,000 Requires names beside 200,000 models listing two each took
checkRequires 490 ms, against 166 ms making a map per model as before.

Each site now reads through a reusedMap, which empties its map in place
while the last collection left it at most 1,024 entries and replaces it
past that. The same input takes 127 ms, and a collection within the
bound still costs no allocation. A test refuses a bare clear anywhere
else in the package, so a later site goes through the bound too.
ir.IDDeclarations spelled the path of every declaration, and
ir.IDReferences that of every reference met before its target, though a
check reports few of them, and none on a sound document. On the golden
spec's IR they were 140,062 of the 143,400 allocations irverify.Verify
made once its checks built nothing else.

PathLog keeps a walk's paths without spelling any. Each is stored as the
steps it shares with the path recorded before it and the steps after
those, so recording one copies a step or two and allocates nothing but
the log's growth, by five halves at a time. A path is spelled only when
a replay of the log is asked for its String. IDDeclarations keeps its
paths there and yields them through Paths, and IDReferences keeps its
references' there and spells, in Unresolved, only those still
unresolved. irverify's duplicate and namespace checks spell only the
declarations they report.
WalkPath.IsMapKey tells a map key from its value without spelling the
path, for a caller that keeps one and not the other.

BREAKING CHANGE: ir.IDDeclaration has no Path. A caller spells a
declaration's path from IDDeclarations.Paths, which yields each with its
position in List.
pass.Validate built for nearly every value it held to a rule: a location
formatted for every payload, error case, HTTP binding and pagination
input, a map per model, enum, union, binding and channel, the sorted
keys of every mapping, encoding map and SuccessStatus, and the path of
each PropID the walk kept until its property was read. On the golden
spec's IR, which it finds clean, that was most of its 85,700
allocations.

Each rule now keeps a location as its parts and spells it in the branch
that reports, reads its collections through a reusedMap or a key list it
keeps for the run, and keeps a PropID's path in an ir.PathLog, spelled
only if the PropID dangles. A mapping's and an encoding map's keys are
still asked in sorted order, so the questions the shared facts index is
asked do not follow map iteration. Every output is as it was.

TestValidate_ASoundValueCostsNothing holds the walk and every rule
family, the index built, to costing less than one allocation more per
entity when the document doubles. The index itself still allocates per
type it indexes and per question it walks (#1150), as the doc on the
rule families says.
The rule on measuring a render through a real walk told a reader to give
each visitor a call of its own to irtest.AllocatingVisits, so that no
other visitor's pruning hid a visit. AllocatingVisits now walks each
visitor as it would walk alone, so that reason no longer holds, and the
rule said nothing of what the helper cannot see: it measures only a
repeat of each visit, so a path kept on every first meeting passed it.
The rule now says both, and names the doubling test that holds a first
meeting.
@OmarAlJarrah OmarAlJarrah changed the title perf(ir)!: allocate nothing per sound value in irverify perf!: allocate nothing per sound value in irverify and pass Oct 29, 2026
WalkPath's doc said a path is valid until the call it was passed to
returns and that a visitor keeping one keeps its String. A PathLog now
replays paths too, each valid until the next is yielded, and is the way
to keep one without spelling it.
checkMapping asks a mapping's keys in sorted order, so its reports
within one discriminator do not follow Go's map iteration, and this
change rewrote that sort to run in a list the check keeps. No test held
it: iterating unsorted left the package green, as #821 records for this
site. TestValidate_MappingDiagnosticsAreDeterministic maps five keys out
of order onto no variant and requires their reports in key order across
nine runs, which the messages show since every one is placed at the
union.
TestValidate_ASoundValueCostsNothing said each entity reaches every rule
family, but its document named no server, so the server-index check
ran over nothing, and kept no PropID for checkPropIDRefs to settle. A
location spelled for every channel's servers passed it. Each channel
and the service now name a server, and each operation paginates to an
item property the walk meets before its model, which the test requires
is kept. Both plants now redden it.
The cost test for a first meeting measured the walk alone. What a check
does once the walk is over went unmeasured: checkDuplicateIDs and
checkIDSpaces spell a declaration's path only for one they report, and
spelling every path again took irverify.Verify on the golden spec's IR
from 3.3k allocations to 144k with every test green.

The test now measures Verify as a whole, and is renamed
TestVerify_ACleanComponentCostsNothing for what it holds, so the reports
and the registry checks answer to it beside the walk. Restoring either
report's eager paths reddens it, as a path kept on each first meeting
still does.
checkIDSpaces places each ID outside the declaration by its position
among the declarations, and no case checked where two such IDs were
reported, so placing all of them at the first one's node passed. A case
with two, a declared ID between them, now pins each to its own node.

beneath compares a member's path with its enum's in two steps, and
dropping the first, that the path opens with the enum's, passed too: no
case's member path opened with an empty segment. A member whose path
opens with one before the enum's now pins it.
Both callers of declarations.pathsAt return before asking for no
position, so its own test for an empty set decided nothing: removing it
changed no result and reddened no test. The zero-value guard beside it
stays.
Validate reads each union's variants, each binding's bound parameters
and locations, and each model's claimed names through one map per check,
emptied for each collection. A site that stopped emptying it would carry
one collection into the next, and nothing in pass noticed for four of
them: a mapping onto the previous union's variant passed, a parameter
the first binding placed was never unbound in a second, one placed once
by each binding was bound twice, and a name another model claimed made
an alias ambiguous.

Each now has a case whose first collection would change the verdict on
the second. The bindings case also pins that each report names its own
binding, the second, which no case with one binding could.
Validate spells a report's location from its parts only once there is a
report, and no response-index, encoding-key or message-binding case
reported anything past the first binding, content, response, error case
or listed message, so a location spelling index 0 for a later one
passed.

A second binding with a response index out of range, a second content,
response and error case each carrying an encoding key no property
answers to, and a second listed message its channel does not carry now
pin each report to its own position.
PathLog shares a step with the path recorded before it when the two are
equal, owner included. A walk shows one value at one path, so within a
walk the owner never decides; it does once a log is fed walks of two
types, and comparing steps without it passed every test. A log fed a
Document's IRVersion and then a Channel's ID, each its struct's first
field, now pins it.

IDDeclarations grows its list five halves at a time, as the log grows
its own, and restoring append there passed too. A test beside the log's
now holds the list to fewer growths than doubling would take.
The doc on Validate's rule families, and the sound-value cost test's,
said a family sorts a collection only for a diagnostic. checkMapping and
appendUnknownPartDiags sort every mapping's and encoding map's keys, so
the facts index is asked in a fixed order; they sort into a list kept
for the run, which costs nothing per value. Both now say what holds: a
family allocates only for a diagnostic.
@OmarAlJarrah
OmarAlJarrah merged commit 82908fb into main Oct 29, 2026
2 checks passed
@OmarAlJarrah
OmarAlJarrah deleted the perf/irverify-clean-values branch October 29, 2026 21:43
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.

irverify: checks build strings for values they find nothing wrong with

1 participant