Transitioning the Go SIMD generators to internal/spec
This plan lays out how to transition the myriad generators and hand-written sources of the SIMD API from disjoint, manually-enforced consistency, to a single underlying executable API specification that provides all method signatures, documentation, and checkable semantics.
See README.md for background on the SIMD packages, the “one name, one meaning” rule, the generators, how simdgen works, what spec and specgen are, and how to run things.
Table of contents
- Part 1: Decisions and rationale
- Part 2: Task graph
- Part 3: Task details
- Part 4: Risks and unknowns
- Appendix A: Regenerating the two generated files
The plan in brief
internal/spec — generic Go functions describing each operation — becomes the
single hand-written description of the SIMD API. Today the same operation is
described in six places, and they have already drifted from each other (§1.5).
Three things have to happen, and they are largely independent:
- Build the
specfillchecker. One AST pass that injects spec’s docs into generated and hand-written source and verifies signatures against spec. Types can be enforced almost at once — SPEC-STATS.txt §2 shows zero type-level mismatches between spec and the shipped API. - Write the spec functions (
spec-commonandspec-all). Coverage is in SPEC-STATS.txt §1. This is the bottleneck the other tracks draw on. - Retire the old inputs: the other doc sources, then
categories.yaml, at which point the unifier no longer defines the API — it only expands one arch entry into the machine instructions matching it.
Behavioral conformance against internal/simdref (track C) runs in parallel.
Beyond its own ci-audit, it depends only on spec coverage and on the width
and impl-defined decisions.
Monitoring plan progress
Progress on this transition is tracked in two ways:
Task completion is tracked directly in this document via the [ ]
checkboxes on each task and risk. The task table in part 2 summarizes all task
completion in one place.
SPEC-TASKS.dot shows a visual representation of the task graph
and completed tasks. It is generated directly from this document by running go run ./cmd/spectasks -w from _gen. This generator also checks the consistency
of this doc, and the test in ./cmd/spectasks enforces doc consistency in CI.
Rerun the generator after modifying any task metadata.
Measurements live in SPEC-STATS.txt, which tracks spec
coverage of the API, signature and name agreement, documentation sources, and
retirement of the old inputs. It is generated by running go run ./cmd/specstats -w from _gen. Every figure in that file is a fraction heading for 100% or a
count heading for zero, tagged with the tasks that move it. Indented, untagged
numbers are context breaking down the figure above them. Part 1 of this doc
quotes measurements that motivated specific decisions, stamped with the CL at
which they were measured; those are frozen and do not need refreshing.
How to implement a task
Part 2 tells you which task to pick up: the next one whose prerequisites are all ticked. SPEC-TASKS.dot draws the same thing if a picture helps. The commit sequences in Part 3 are guesses made before the work started; deviate when the code tells you to, but say so in the CL description.
Tasks and risks carry [ ] checkboxes. When you resolve one, tick the box,
update the task text to reflect the reality of the implementation so it remains
useful to reference when performing follow-on tasks (you can drop or summarize
the exact commit sequence), and regenerate SPEC-TASKS.dot.
Commit this as its own CL on top of the CLs that implement the task, and include
justifications and reasoning for any changes to the task text in the commit
message.
Key concepts and terms
specfill is the tool this plan builds first and then leans on. It is one
AST pass over Go source that injects spec-derived doc comments and parameter
and result names, and verifies signatures against spec. It exists as both a
package that can be integrated into generators and a standalone tool:
specdoc.Fillis the library entry point — Go source in, Go source out:func Fill(src []byte, idx *specgen.Index, opts Options) ([]byte, error). If verification checks fail, it returns a*Reportas an error. Generators call it on theirgentoolsbuffer before writing.cmd/specfillis thegentools-based command wrapping it, for running over hand-written files. Where this document writesspecfill -w, that is the command; where it writesspecfill, that is the library function. CI enforcement is ago testin this package.
These package and command names are suggestions; the split between library and command is not.
Target means one of amd64, arm64 (NEON), arm64 SVE, or wasm. See README.md.
Spec operation means one function in package spec. Each expands across
element types, widths, and templated names into many API names and many
(receiver, name) pairs, so it is the grain spec is authored at — and the
only grain at which “implemented nowhere” means anything (SPEC-STATS.txt §5).
Operation (unqualified) means the API-level operation a name denotes,
whichever of spec, categories.yaml, or generated source describes it. A
categories.yaml entry is called an entry; one operation name can have several.
Granularity. Spec coverage is measured at three granularities:
(target, receiver, name) — every exported declaration, counted once per build
configuration; (receiver, name) — collapsed across targets, the primary
denominator since spec describes an operation once; and name — collapsed across
receivers too, coarser and misleading when a name exists on different receiver
sets in spec and the API (SPEC-STATS.txt §5). SPEC-STATS.txt §1 reports all
three. String and Len are excluded throughout (§1.7).
Part 1 — Decisions and rationale
These are the load-bearing decisions. Each was contested during design. The reasoning is recorded so it need not be re-litigated, while also making it easier to revisit decisions if new evidence arises.
Measurements in this section were taken at CL 831485 unless otherwise noted. They should NOT be updated unless decisions need revisiting.
1.1 We are not rewriting simdgen
The temptation is real: simdgen has 28 stub templates whose names encode
arity, operand permutation, and immediate placement (op3_231Type1, op4_31,
op2Imm8_II); 42 fields across RawOperation (23) and Operand (19); and 27
bail-outs whose messages begin “simdgen only supports” or “simdgen does not”.
But the layer accounting says the mess is small and the value is large (at
CL 831485; Go counts include _test.go; “signature derivation + emission” is
simdgen/gen_*.go, godefs.go, arch.go, and types/operation.go):
| Layer | Lines | Affected by “was unification wrong?” |
|---|---|---|
| Platform loaders | 5,100 | No — irreplaceable |
| Unifier engine | 3,169 | Partly |
| Signature derivation + emission | 4,715 | The ~500 messiest lines, yes |
categories.yaml |
1,284 | Deleted by this plan anyway |
go_GOARCH.yaml |
4,475 | No |
The part that would be thrown away if unification were the wrong call is the ~500 messiest lines of signature derivation, against irreplaceable platform loaders several times that size.
A few thousand lines of hand-written YAML drive tens of thousands of lines of
generated API and compiler backend across four targets. That ratio is good.
What is not earning its keep is unification’s role as API definer — the
categories.yaml ↔ go_GOARCH.yaml join — and this transition removes that role
by construction. The unifier’s remaining job, expanding one arch entry into every
matching concrete instruction variant against the ISA database, it does well.
1.2 Spec is not a unifier input
Feeding spec into unification via DefBuilder is easy and wrong. Spec’s
structure would be flattened into the unstructured value language the moment it
crossed the boundary, recreating the exact failure mode we are escaping. Instead,
simdgen consumes []*specgen.Func as a typed Go table.
Furthermore, spec is inherently only aware of the structure of the Go SIMD API, but the inputs to the unifier are in platform terms. Much of the output of the unifier is information on how to map the platform structure into the Go SIMD API, so we can’t bring spec in until after that mapping has been processed.
1.3 What belongs in spec
The test is: is it a checkable claim about the spec’s own semantics?
commutative— yes.Add(x,y) == Add(y,x)is testable against the executable body, and spec is the only place that can check it.noGenericOps,noTypes— no. Claims about generator output.constImm— no. A claim about x86 encoding.regexpTag,signed— no. Claims about the unifier.
The test is self-policing: anything that passes it falls out of the conformance testing we want anyway. Note it admits facts the compiler consumes — the wrong line to draw is “API vs compiler”; the right one is “intrinsic to the operation vs incidental to the mechanism or platform.”
For commutative, no name in categories.yaml currently needs
platform-specific commutativity, so a function-level directive in spec is
sufficient today. However, there may be corner cases we haven’t uncovered;
notably, we have evidence that Min/Max on floats may not be commutative on
all platforms. It may suffice to simply not mark these operations as
commutative, and to document when an operation is not commutative for surprising
reasons.
Applying the test to categories.yaml’s fields (at CL 831485; 217 entries, 195
unique operation names, 163 exported):
| Field | Count | Transition plan |
|---|---|---|
go |
217 | → spec function name |
commutative |
217 | → //specgen:commutative directive; categories.yaml copy removed by sig-from-spec |
documentation |
211 | → spec doc comment; removed by template-docs |
regexpTag |
25 | unification join key; role unverified (see risk-cat-unknowns) |
constImm |
22 | → sideways into go_amd64.yaml (x86 encodings) |
out |
14 | join key used only for selecting a doc variant; → multiple spec funcs sharing one //specgen:name |
elemBits |
14 | same |
bits |
4 | same |
signed |
12 | same |
noGenericOps 13 / noTypes 6 |
13 entries | open question — compiler-only ops |
Task cat-rehome tackles most of the “transition plan” column.
compiler-ops-impl handles the noGenericOps / noTypes entries.
1.4 specfill parses; it does not type-check
ops_amd64.go and types_amd64.go have minimal imports, and no exported
signature in either package uses a qualified type, so go/parser is sufficient —
no go/types, no packages.Load, no GOEXPERIMENT wrangling, no cross-module
loading of GOROOT std packages.
This is better than a workaround. Type-checking sees one build configuration at a
time and would make the cross-architecture comparison harder. Parsing all files
regardless of build tag yields (Int32x4) Add from ops_amd64.go,
ops_arm64.go, and ops_wasm.go in a single pass, which is precisely the
comparison we want. apisum already does this, so it is proven on this API.
cmd/specstats (Appendix A) takes a different route to the same end: it loads
syntax through go/packages once per build configuration, because it reports
per-target figures. It still does not type-check, and specfill does not need
per-configuration loading.
1.5 Two-pass doc injection, and why the “disadvantage” is the point
The motivation is doc divergence. At CL 831485 there were six independent
documentation sources — categories.yaml, wasmgen inline strings, tmplgen
inline strings, midway/comments.yaml, hand-written simd_emulated.go, and
hand-written archsimd files — and none agreed with any other. All 90
(receiver, name) pairs documented in both simd and archsimd had different
doc text, and within the portable package, simd_stubs.go (midway-generated) and
simd_emulated.go (hand-written) had drifted on 40 of 336 mirrored methods.
Add shipped with three independent wordings:
| Where | Text | Source |
|---|---|---|
ops_amd64.go, ops_sve.go |
Add adds corresponding elements of two vectors. | categories.yaml |
ops_wasm.go |
Add returns the result of adding x and y, elementwise. | wasmgen, inline |
simd_stubs.go, simd_emulated.go |
Add returns the element-wise sum of x and y. | midway/comments.yaml |
124 method names are produced by more than one generator — the easiest place for disagreement to creep in. simdgen accounts for the majority of declarations, but tmplgen, wasmgen, midway, and hand-written code own the rest, and those sets overlap — any instrument that only sees simdgen misses a substantial fraction of the problem (SPEC-STATS.txt §1 has the per-generator counts).
Generators pass their generated code to specfill, which checks signatures and
injects docs before writing to disk; in the end state, generators emit
declarations and implementation notes (§1.6) but no API prose of their own. The
alternative — having specgen emit the whole declaration, as refgen does — was
rejected because it would make agreement with spec true by construction and
leave nothing to verify. A generator whose internal model of the API had drifted
would be silently papered over. Because the template still produces the
signature, specfill sees two independent witnesses to the same declaration and
can compare them: docs in, signatures verified, coverage reported, one AST walk.
Consequences:
specfillis an override, not a replacement. Generators keep emitting their current docs;specfillreplaces the spec-owned paragraphs only when spec knows the operation. Fallback is therefore automatic and no flag day is required. Prose-less emission is the end state reached when a generator’s doc source empties, not a precondition.- wasmgen and tmplgen need only a call before flush, plus making the notes they emit follow §1.6’s prefixes.
- Do not hook this into
gentools.Files.Flush().gentoolsis a general utility; coupling it to specgen would make every consumer depend ongolang.org/x/tools/go/packagesand a loadable spec tree. A narrow hook mechanism in gentools plus a few lines per generator keeps the boundary.
1.6 The doc-ownership convention
specfill rewrites part of an existing doc comment, so it needs a rule for which
part it may touch, and running it twice must be idempotent. The obvious rule,
“everything above the first bare //”, doesn’t work. At CL 831485 only 2,759 of
3,920 documented declarations have that separator. And spec’s own docs contain
blank comment lines: Add’s is a summary, a //, then z[i] = x[i] + y[i].
Hence, using a bare // would not be idempotent.
The rule. A doc comment is spec-owned paragraphs, followed by
“implementation notes”, followed by directive comments (//go:fix inline and
similar). Spec-owned paragraphs describe the behavior of an operation.
Implementation notes are details of how one target implements an operation. The
sections must appear strictly in this order, and the tools will validate this.
An implementation note is a paragraph that starts with one of a fixed set of
prefixes: Asm:, CPU Feature:, Emulated[,\n], Deprecated:,
Performance:, or Note:. The match is exact and case-sensitive. Only the
first line of a paragraph is checked, so a note can combine prefixes (Asm: VPADDD, CPU Feature: AVX) and wrap freely.
Checks. Strict ordering is necessary for idempotence, and the tools perform
the following checks to ensure we maintain it: (a) In an existing comment,
spec-owned paragraphs (those without an implementation note prefix) must appear
strictly before implementation notes (those with an implementation note prefix),
which must appear strictly before directive comments. (b) Spec’s docs may not
contain a line starting with a note prefix, and spec stores them in canonical
go/doc/comment form.
Rewrite. specfill rewrites a comment only when spec has an entry for the
declaration and check (a) passes. The new comment is the spec doc, then a blank
line if there are implementation notes, then those notes unchanged, then the
directives unchanged. In every other case the comment is left alone. This is
idempotent: by (b) the injected spec doc never looks like a note, so the next run
splits the comment at the same place and produces the same bytes. Idempotence
can’t catch text lost on the first run, though, so as we transition to spec,
generated docs for newly covered operations must be reviewed carefully.
Why prefixes, not markers. The alternative was bracketing specfill’s region
with directive comments (//spec:doc … //spec:end). Those are invisible in
godoc and safe by default — no marker, nothing touched — but they add lines to
every declaration and say nothing about why text is outside spec. Prefixes
reuse the shape the notes already have, work the same in generated and
hand-written files, and force each piece of non-spec text to say what kind of
note it is. The cost is that unmarked text defaults to spec-owned, which is why
every rewrite is reviewed as a diff, and why generators, once they emit no API
prose of their own, may not emit spec-owned text at all (see specfill).
1.7 Errors versus tolerated gaps
At CL 831485, of the 782 (receiver, name) pairs declared for more than one
target, not one had divergent parameter or result types — but 72 disagreed on
parameter names and 16 on result names. So spec is preventive, not remedial:
there is no backlog of type-level fixes, and incremental migration is not racing
decay.
- Signature mismatch is an error. For an operation spec knows about, a disagreement is a bug in spec or in a generator.
- A missing spec function is tolerated until the transition completes.
- Parameter and result names are checked where the declaration names them (unnamed ones are filled from spec; §1.8), for two reasons: they appear in documentation and so must be consistent with the documentation’s source, and several past bugs have come from confused or swapped argument names.
specfillconsiders exported declarations only. This is what makes the tolerated-miss list converge: all threeops_internal_*.gofiles (amd64, arm64, sve) contain zero exported declarations, so every compiler-only operation falls outsidespecfill’s view without needing a category. The permanent residue is small and enumerable: theString,Len,ToArchmethods andxFromArchfunctions.
1.8 Named results
Spec names its results and its docs reference those names (z[i] = x[i] + y[i]). Almost nothing in the shipped API names its results (SPEC-STATS.txt §2).
When spec’s doc becomes archsimd’s doc, the references to z in the docs won’t
make sense unless we carry over spec’s result names as well.
Decision: where spec covers a declaration, its parameter and result names
come from spec, so they agree with the doc that also comes from spec. Naming
every result is not a goal: a name is worth having where a doc or a body uses
it, and naming the rest blindly serves no one. Declarations spec does not cover
keep whatever names they have until spec-all covers them.
specfill enforces this without touching bodies. It rewrites unnamed
arguments/results in the API (including _) to match spec, and reports a
mismatch error if both sides name an argument/result and the names disagree. For
a bodyless declaration, all that matters is that the names agree with the doc. A
body can refer to named arguments but not unnamed ones, so filling unnamed ones
is safe, while named ones must be checked. SPEC-STATS.txt §2 counts the two
cases separately, the same way.
1.9 Behavioral conformance is a parallel track
Behavioral conformance has no hard dependency on the API documentation
track. Both consume the same input — spec coverage — independently, and they
catch disjoint bug classes: a swapped argument in a new architecture’s Sub has
an identical signature and identical docs, and only conformance sees it.
The order for bootstrapping conformance matters. Validate spec against amd64 first — it is the most mature target, and every failure is a spec bug unless proven otherwise. There may be bugs or hardware-specific corner cases in the amd64 implementation, but bugs in spec are far more likely. Once spec and amd64 agree, then flip: spec becomes the oracle and gates arm64, wasm, SVE, and any architecture added later. Getting this backwards means conformance failures against correct implementations, and the obvious move is then to “fix” the implementation.
Harness requirements implied by the bug classes we actually see: per-type edge-case inputs (0, ±1, MinInt, MaxInt, ±0, ±Inf, NaN, saturation boundaries) crossed with random vectors — uniform random will not reliably hit saturation or NaN corners; and explicit mask patterns, since an inverted mask sense passes random testing about half the time.
This is a path we have already gone down successfully.
simd/internal/test_helpers carries per-type input vectors — float32s through
uint8s, plus bools for masks — built by cycling a hand-picked list of
exactly those corners (±0, ±Inf, quiet and signalling NaN, MaxFloat, and the
8- and 16-bit saturation edges) up to a fixed length. In
archsimd/internal/simd_test/helpers_test.go, the
forSlice/forSlicePair/forSliceTriple/forSlicePairMasked helpers walk
windows over them for the cross-product, map1/map2/map3/mapCompare build
the expected result elementwise — the same shape a spec body has — and
grouped2 already handles the 128-bit grouping that gives doc-explore
trouble. checkSlicesLogInput even takes a tolerance, which is a first cut at
the vocabulary impl-defined has to settle.
conform-fixed need not reuse that code literally, but it should not start over
either: what it adds is generating the cases from spec rather than hand-writing
them per operation.
1.10 Progress is measured by name, not by normalization
apisum needs normalization because its job is to test whether names mean one
thing. Once we enforce that they do, the method name is the equivalence class,
exactly and for free: coverage is a set intersection, and cmd/specstats already
computes it. No name normalization, and no complex pattern matching rules to
maintain or throw away.
Caveat, worth knowing rather than fixing: templated-name families like
LoadInt8x16, LoadInt8x32, etc, that all come from one spec function inflate
the denominator of any coverage metrics. That is arguably correct for measuring
API surface. Reporting both numbers — “spec has N functions covering M names”
— is more informative than either alone.
Part 2 — Task graph
The 27 tasks fall into six tracks. Tracks are a way to divide the work among people and to make Part 3 navigable. They are not phases and don’t imply any ordering on tasks. Tasks in each track generally cluster in the task dependency graph, but there are inter-track dependencies.
| Track | What it is | Tasks | |
|---|---|---|---|
| A | Decisions | Human judgment calls; no prerequisites, all block downstream work | width, compiler-ops, convert-lo, mask-bits, impl-defined |
| B | Instrumentation and enforcement | Build specfill, then make it authoritative |
specfill, fill-gen, named-results, gen-docs, fill-enforce |
| C | Conformance | Behavioral testing against simdref |
ci-audit, conform-fixed, validate-amd64, conform-scalable, conform-flip |
| D | Spec content | Writing spec functions, docs, and bodies — the shared bottleneck | doc-explore, spec-common, spec-all |
| E | Retiring the old inputs | The five non-spec doc sources, then categories.yaml |
comments-yaml, doc-triage, handwritten-fill, template-docs, cat-rehome, compiler-ops-impl, cat-delete |
| F | simdgen internals | The signature refactor, then sourcing signatures from spec | sig-refactor, sig-from-spec |
Dependencies
A task is startable when every prerequisite is ticked. Hard dependencies are
listed as Needs; soft dependencies — work is possible but wasteful or
lower-value without them — as Prefers.
Tasks are named, not numbered, so that a task’s identity survives inserting
another one next to it, and so that a CL saying “implements cat-rehome” still
means something a year later. Ordering lives in the row order instead: the table
is topologically sorted (both Needs and Prefers count as edges), with ties
broken by track, so reading it top to bottom is a legal order to do the work in,
and cmd/spectasks fails if an edit breaks that. Add a task by putting its row
wherever its prerequisites and track allow.
| ✓ | T | Task | What it is | Needs | Prefers |
|---|---|---|---|---|---|
| [x] | A | width |
Resolve scalable width | — | — |
| [ ] | A | compiler-ops |
Compiler-only ops decision | — | — |
| [ ] | A | convert-lo |
ExtendLo*/ConvertLo* shape and naming |
— | width |
| [ ] | A | mask-bits |
Mask bitmap load/store shape and naming | — | width |
| [ ] | A | impl-defined |
Implementation-defined behavior policy | — | — |
| [x] | B | specfill |
API walker + report mode | — | — |
| [x] | B | fill-gen |
Wire specfill into the generators |
specfill |
— |
| [x] | B | named-results |
Reconcile parameter and result names with spec | fill-gen |
— |
| [x] | B | gen-docs |
Inject spec docs into generated output | fill-gen |
named-results |
| [x] | B | fill-enforce |
Signature + name mismatch becomes an error | named-results |
— |
| [x] | C | ci-audit |
CI/hardware audit | — | — |
| [ ] | C | conform-fixed |
Conformance harness, fixed-width types | ci-audit |
specfill |
| [ ] | C | conform-scalable |
Conformance harness, scalable types | width, conform-fixed |
— |
| [x] | D | doc-explore |
Doc-mechanism exploration on a hard family | — | — |
| [x] | D | spec-common |
Spec the common operations | width, doc-explore |
specfill |
| [ ] | C | validate-amd64 |
Validate spec bodies against amd64 | conform-fixed, spec-common, impl-defined |
— |
| [ ] | C | conform-flip |
Flip conformance direction | validate-amd64, conform-scalable |
— |
| [ ] | D | spec-all |
Bulk spec migration | spec-common, convert-lo, mask-bits |
specfill, fill-enforce |
| [ ] | E | doc-triage |
Hand-written doc conflict triage | specfill |
spec-common |
| [x] | E | comments-yaml |
Drop spec-covered docs from midway/comments.yaml |
gen-docs, spec-common |
fill-enforce |
| [ ] | E | handwritten-fill |
Enable -w on hand-written files |
gen-docs, doc-triage |
— |
| [ ] | E | template-docs |
Retire generator-supplied API docs | gen-docs, spec-all |
— |
| [ ] | E | cat-rehome |
Re-home categories.yaml non-doc fields |
template-docs |
— |
| [ ] | E | compiler-ops-impl |
Implement compiler-only ops representation | compiler-ops, cat-rehome |
— |
| [ ] | F | sig-refactor |
simdgen signature refactor | — | — |
| [ ] | F | sig-from-spec |
simdgen sources signatures from spec | sig-refactor, cat-rehome, compiler-ops-impl |
— |
| [ ] | E | cat-delete |
Delete categories.yaml |
sig-from-spec |
— |
Critical path. SPEC-TASKS.dot draws it in red: every task
on some longest chain of Needs edges. Slipping any of them slips the whole
project. cmd/spectasks derives it from the table, so it is not restated here.
Track B has no prerequisites, so it can start immediately and run to completion
at its own pace — though gen-docs gates three of track E’s tasks.
sig-refactor is independent of everything until sig-from-spec.
Milestones
Milestones carry no work of their own; they are the points worth announcing. A milestone is reached when its tasks are ticked, so there is no separate state to maintain and nothing here to keep in sync.
| Milestone | Reached after | What it means |
|---|---|---|
| M1 — Enforced | fill-enforce |
Spec is authoritative for what it covers: a name or signature that drifts from it fails the build, in both packages, on all four targets. |
| M2 — Documented | comments-yaml, handwritten-fill, spec-all, template-docs |
Every operation’s doc comment in both public packages derives from spec — SPEC-STATS.txt’s “docs sourced from spec” reads 100%. |
| M3 — Gated | conform-flip |
Conformance runs spec-first, so an architecture added later must match spec before it can land. |
| M4 — Complete | cat-delete |
categories.yaml is gone and spec is the only hand-written description of the API; see the section below. |
The graph
SPEC-TASKS.dot draws the two tables above; the rendered graph carries its own key. Track is node colour rather than a cluster, because a cluster has to be laid out contiguously and that fights the left-to-right dependency order the graph exists to show.
dot -Tsvg SPEC-TASKS.dot -o SPEC-TASKS.svg
It is generated, and its generator is also a checker. This document states its
dependencies three times — Part 2’s table, Part 3’s Needs:/Prefers: lines,
and Part 3’s Blocks: lines, which are the same edges reversed — so
go test ./cmd/spectasks fails if they disagree, or if the graph has not been
regenerated since. It also derives the critical path.
Start here
The startable tasks are the ones with no unticked Needs in the dependency
table. For a team of one to three, this is a reasonable opening allocation,
covering every task with no Needs at all:
- Strongest on spec’s type model:
width, thenconvert-loandmask-bits. These root the longest chain and cost the most if deferred. - Owns API semantics:
impl-definedandcompiler-ops— pure decisions, no code, and everything downstream waits on them. - Likes tooling:
specfill, thenfill-genandnamed-results. - Working alone on something large:
sig-refactor. No prerequisites, verifiable byte-for-byte at every step. - Cheap and worth doing early regardless:
doc-exploreandci-audit.
What “done” looks like
After cat-delete, simd/internal/spec is the only hand-written description of
the API: every operation’s name, signature, and documentation derives from it,
in both public packages and on all four targets, and specfill fails the build
if any of them drift. categories.yaml is gone. midway/comments.yaml
documents only what spec does not describe: the vector types, VectorSize,
String and Len. wasmgen and tmplgen no longer carry doc strings.
Hand-written implementations remain hand-written, but their operation doc
comments are generated.
simdgen still exists and is still unification-based — §1.1 explains why. What
changes is its job: the unifier no longer defines the API, it only expands one
go_GOARCH.yaml entry into all the concrete machine instructions matching it.
go_GOARCH.yaml stays, and so do its quirk fields, because instruction
irregularity is real and has to live somewhere. The compiler backend generation
is untouched throughout.
Part 3 — Task details
Many completion criteria are “generated output is byte-identical”, checked with
go run . -diff from _gen. That runs every generator, including simdgen for
all three of its targets, so it needs the XED and ARM ISA data first — see
fetch-xed.sh and fetch-arm64.sh. There is no -arch flag on the driver;
-arch belongs to simdgen when run directly.
Track A — Decisions
Human judgment calls. None has prerequisites, and each blocks downstream work — which is why they lead. Each is small in code and large in consequence.
width — Resolve scalable width
Done: [x] · Needs: — · Blocks: spec-common, conform-scalable · Risk: risk-concrete-width
simd/internal/spec/types.go hardcodes scalableWidth = 4096, and simdref is
built at that width, while archsimd’s SVE uses the runtime vl(). A
conformance test of scalable types therefore compares vectors of different
lengths. Spec’s own TODO flags this and it is unresolved.
4096 bits in particular causes trouble for operations that index into vectors, such as shuffles, which is why every platform other than RISC-V RVV is 2048 bits or less. Resolving this task may require deciding whether we’ll ever support vectors more than 2048 bits wide.
Note what is not wrong: spec’s type system was designed for scalable widths and handles them. The gap appears only when spec is used as a concrete conformance oracle, and the open question is narrow — whether spec can execute at a caller-chosen width without disturbing the generic type machinery.
This is first because the fix may change spec’s type model — making width a runtime parameter rather than a compile-time constant — which reaches every spec function body. Resolving it after a hundred bodies exist is far worse than resolving it now, while there are few (SPEC-STATS.txt §1).
Commits. (1) Write up the options with their consequences for Vec,
makeVec, lanes, refgen, and the conformance harness; seen so far:
parameterize width at spec execution, make simdref width-configurable at
construction, or scope conformance to fixed widths initially and defer. (2)
Implement the chosen approach in internal/spec. (3) Regenerate
internal/simdref and confirm cmd/specls output is unchanged, or explain what
changed.
Done when a scalable-typed operation can be executed through simdref at a
width matching a chosen target, and the choice is documented in
internal/spec/doc.go.
compiler-ops — Compiler-only ops decision
Done: [ ] · Needs: — · Blocks: compiler-ops-impl
Decide how the compiler-only entries — those that produce no exported
declaration (SPEC-STATS.txt §4) — and the noTypes / noGenericOps switches
are represented once categories.yaml is gone. This is the only hard blocker in
the chain without an obvious answer, so make it early.
Note §1.7: because specfill is scoped to exported declarations, this does
not block the checker or conformance tracks.
The design note must also say where the following go once categories.yaml is
gone: the compiler-only entries’ documentation:, which feeds the doc comments
of unexported methods in archsimd/ops_internal_*.go; commutative, signed
and out on those entries; and the go_arm64.yaml use of noGenericOps. This
documentation is strictly internal-facing, so it is held to a lower standard
than user-facing API docs. It need not come from spec, go through specfill, or
satisfy M2. Wherever it lands is acceptable as long as it is kept next to the
compiler-only op’s other description.
Commits. One design note in this document or in internal/spec/doc.go,
listing all compiler-only operations from _gen/simdgen (grep -h "go: [a-z]" ops/*/categories.yaml | sort -u; also the capitalized noTypes entries) and
stating for each family where its description, documentation, and remaining
fields live once categories.yaml is gone. Candidates: a //specgen:unexported
directive; a fourth simdgen input holding compiler-only ops; or leaving them in
go_GOARCH.yaml on the grounds that they are per-architecture lowering details
rather than API.
Done when the decision is written down, including documentation and all
remaining fields, and compiler-ops-impl could be implemented from it without
further design work.
convert-lo — ExtendLo* / ConvertLo* shape and naming
Done: [ ] · Needs: — · Prefers: width · Blocks: spec-all · Risk: risk-spec-defects
Settle how the low-elements widening family is shaped and named, across both fixed-width and scalable vectors. An API design decision, not a mechanical fix. Three entangled problems:
- Shape.
ExtendLoLToZdeclaresx Vec[E,W]andz Vec[zE,W]— the same total width. The shipped API widens instead:Int8x16.ExtendLo8ToInt64()takes 128 bits and returns 512. Spec therefore misses 8 real declarations and invents width-preserving ones no platform has. - Naming.
//specgen:name ExtendLo{zL}To{zE}embeds the result lane count, so the same conceptual operation gets a different name at each width. The API has settled on 128-bit receivers only, which hides this. - Scalable. The
converts.goTODO already notes the literalzLcannot work for scalable vectors, and suggests dropping the number entirely since it is implied by the receiver and target types — e.g.Uint8x16.ExtendLo2ToUint64is really the low eighth, not the low half, so the number is arguably misleading even where it works.
Any renaming here is API-visible and should be decided before spec-all
migrates the Converts family, which is where the bulk of categories.yaml’s
Converts documentation lands.
Commits. (1) Write up the options and their consequences for fixed-width and
scalable vectors, including whether the lane count stays in the name. (2)
Re-shape ExtendLoLToZ, and re-check ConvertLoLToZ, whose zL<xL constraint
currently admits conversions no hardware offers. (3) If the API name changes,
land the rename through the generators as its own CL, separate from the spec
change.
Done when cmd/specls produces exactly the ExtendLo* declarations the API
has, plus whatever maximalist extras are deliberate, and the scalable naming
question is answered in internal/spec/doc.go.
mask-bits — Mask bitmap load/store shape and naming
Done: [ ] · Needs: — · Prefers: width · Blocks: spec-all · Risk: risk-spec-defects
Settle the scalable counterpart of fixed-width ToBits and MaskNxMFromBits
(e.g. Mask8x16FromBits), which today are Mask*s.Store and LoadMask*s. Also
settle what Store and ToBits mean on portable simd.Mask*s, whose midway
docs currently describe element semantics.
The API declares Store(bits []uint16) only on SVE’s scalable masks
(archsimd/types_sve.go), paired with LoadMask8s(bits []uint16) etc. It
stores SVE predicate bits at byte granularity. No fixed-width mask has Store;
fixed-width masks have ToBits() UintN instead (spec: MaskToBits,
internal/spec/masks.go, constrained to FixedWidth). So Mask*s.Store has a
different meaning from every other Store — a candidate violation of the “one
name, one meaning” rule. Its bit layout is SVE’s representation, not a per-lane
one, which arguably fails §1.3’s “claim about the operation’s own semantics”
test.
Commits. (1) Write up the options and their consequences for fixed-width and scalable masks, and for the portable package. (2) Change spec to match. (3) If the API name changes, land the rename through the generators as its own CL.
Done when SPEC-STATS.txt §5 lists no mask Store defect, and the choice is
recorded in internal/spec/doc.go.
impl-defined — Implementation-defined behavior policy
Done: [ ] · Needs: — · Blocks: validate-amd64 · Risk: risk-impl-defined-behavior
Decide what counts as implementation-defined behavior in the SIMD API, and how
spec expresses it so conformance testing stays meaningful. Architectures
legitimately differ on corners — the last bits of a float result, out-of-range
shuffle indices, saturation edges, NaN propagation and signed zero. Spec’s
math.go already carries a TODO on DotProductPairs overflow that names the
problem and floats an “architecture dependent” designated panic as one possible
answer.
Two halves, and both need answering:
- What is implementation-defined? A per-operation API-semantics judgment. Costly in both directions: declare too much and conformance stops catching real bugs; declare too little and correct implementations fail against spec.
- How is it written down? There are at least two major categories of
implementation-defined behavior: exceptional-value cases (e.g., overflow
behavior is fully undefined) and precision (all architectures roughly agree,
but maybe not to the last digit). Spec bodies return exact values today, and
there is no vocabulary for “any value here”, “within N ulp”, or “unspecified
but deterministic per platform”. Whatever is chosen must be understood by
both
refgenand the conformance harness, and it changes what a spec body looks like — which is why this wants settling before the bulk of them are written. The existing suite’scheckSlicesLogInputalready takes a per-call tolerance (§1.9), which is prior art for the numeric half; it says nothing about the “unspecified but deterministic per platform” half.
Commits. (1) Survey the known cases — DotProductPairs overflow, float
conversion rounding, shuffle index out of range, saturation edges, NaN/±0
handling — classifying each as specified, implementation-defined, or undefined.
(2) Choose and document the spec vocabulary in internal/spec/doc.go. (3)
Implement it far enough that one operation with a genuinely
implementation-defined corner round-trips through refgen and the harness.
Done when an operation with an architecture-dependent corner can be written
in spec, generated into simdref, and conformance-tested without a false failure
on any supported target.
Track B — Instrumentation and enforcement
Build specfill, then make it authoritative. Independent of tracks C, D and F;
gen-docs gates three of track E.
specfill — API walker + report mode
Done: [x] · Needs: — · Blocks: fill-gen, doc-triage
The instrument the rest of the plan reads from.
specfill builds the whole of specdoc.Fill and cmd/specfill — the walk, the
spec index, the comparison, and the rewrite of doc comments and parameter/result
names. fill-gen wires it into generators with rewriting off; named-results
and gen-docs then turn on name filling and doc injection there, and
handwritten-fill turns -w on for hand-written files.
Interfaces. specdoc.Fill is the library entry point — Go source in, Go
source out: func Fill(src []byte, idx *specgen.Index, opts Options) ([]byte, error).
Generators call it on their buffer before writing (§1.5). When verification
checks fail, Fill returns a *Report as an error containing the findings
enumerated below. Callers can recognize this error via errors.As if they
choose to inspect or combine findings across files. Option fields:
- RejectUnknown controls whether an exported declaration with no spec entry
is an error (recorded in
Report.UnknownDecls). Off during the transition; generators flip it on afterspec-all, so that adding an operation without writing its spec function fails at generation time rather than silently escaping coverage. - AllowDocRewrite controls whether a declaration spec covers may already
carry spec-owned paragraphs (§1.6). When false (default), each one that does
is an error (recorded in
Report.UnexpectedDocs); implementation notes and directives are always allowed. Once generators emit no API prose of their own (template-docs), this is their guard against silent doc loss (risk-silent-doc-loss): a generator paragraph missing its note prefix is an error rather than somethingFillquietly replaces. Until then generators set it to true. Hand-written files carry prose by design, socmd/specfillalways sets it to true and relies on-diffand its-wguards instead. - AllowNameMismatches controls whether name mismatches are tolerated. When
false (default), they are errors. Generators can set it to true during the
transition, until
named-resultsreconciles them.
Two more fields select what Fill rewrites, for callers that want only part of
it: NoFillDoc leaves doc comments alone, and NoFillNames leaves unnamed
arguments unnamed (and so reports them as name mismatches). By default Fill
does both. Finally, the Filename field labels positions in findings.
Names. Fill fills and checks names as §1.8 describes: an argument the
declaration leaves unnamed (or names _) takes spec’s name, and a named
argument whose name differs from spec’s is a mismatch. It rewrites a
declaration’s names only when that declaration has no type or name mismatch, so
a mismatch has to be fixed before the rest of its declaration’s names are
filled.
cmd/specfill is a gentools-based command that applies specdoc to
hand-written files: it walks the non-test .go files in simd and archsimd,
skips generated ones (their generators call Fill themselves), calls Fill on
each with AllowDocRewrite, and prints the combined report. -diff shows what
a rewrite would change. With -w, rewrite in place; -w aborts without
rewriting when the report has any findings, or if there are uncommitted changes,
so its git diff is the complete story of what it did. The -no-fill-doc and
-no-fill-names flags set the corresponding options. CI enforcement is a go test in this package — fill-enforce wires it — not a command invocation.
Report. The report is the permanent enforcement layer — every
item in it remains useful after the transition as the API continues to evolve,
and is what fill-enforce runs in CI. Coverage and name relations are
transition metrics that converge to their targets; they stay in
cmd/specstats / SPEC-STATS.txt and are not part of this report.
The report holds only errors; it does not preview rewrites. Every consumer of
specdoc is a gentools program, cmd/specfill included, so -diff already
shows exactly what a rewrite would change against the files on disk, including
any text it would drop. That diff is how rewrites are reviewed, and a report that
recomputed it would only be a second copy to keep in sync.
The possible errors are:
- Type signature mismatches — parameter or result types, or their counts, disagree between spec and the declaration the walker found.
- Name signature mismatches — a named parameter or result whose name
differs from spec’s. Unnamed ones are filled rather than reported, unless
NoFillNamesis set. Omitted whenAllowNameMismatchesis set. - Check (a) violations — §1.6 ordering violations in declarations spec
covers.
Fillleaves such a comment as it is. Declarations spec does not cover are not checked: the ordering rule exists so rewrites are idempotent, andFillnever rewrites them. Hand-written files in particular may hold declarations spec will never cover. Each declaration is checked from the moment spec covers it. - Unknown declarations — exported declarations with no spec entry, when
RejectUnknownis set. - Unexpected doc comments — declarations spec covers that carry
spec-owned paragraphs, when
AllowDocRewriteis false.
Commits. (1) specgen: add an index over Load()’s result keyed by (recv, name); add a function that classifies comment paragraphs as spec-owned or
implementation note per §1.6; reject spec docs that break check (b). (2) New
package _gen/specdoc and new command _gen/cmd/specfill: AST declaration
walker, signature and comment ordering comparison, report findings implementing
the error interface, and AllowDocRewrite / AllowNameMismatches options.
Signature comparison translates the parsed *ast.FuncDecl into spec’s own
representation — its types into specexpr types, which suffices because no
qualified types exist (§1.4) — and compares that structurally against the
specgen.Func. (3) Rewrite engine in specdoc.Fill (both doc comments and
signature names) and in-place rewriting (-w, -no-fill-doc, -no-fill-names)
in cmd/specfill.
Done when specfill, run on the current tree, writes nothing (without -w)
and prints all report findings in position order.
SPEC-STATS.txt is the sanity check for signatures: cmd/specstats computes
the same type and name mismatches, counting names the way Fill does (§1.8), so
specfill’s report should agree with it line for line on those. Treat any
disagreement as something to explain before shipping. Coverage and name
relations stay in specstats as transition metrics; once they reach their
targets, specstats can be retired.
fill-gen — Wire specfill into the generators
Done: [x] · Needs: specfill · Blocks: named-results, gen-docs
Each generator passes its buffer through specfill before writing, with all
rewriting off for now: NoFillNames (named-results turns name filling on) and
NoFillDoc (gen-docs turns doc injection on). Generators also set
AllowNameMismatches, because names still disagree with spec until
named-results, and AllowDocRewrite, because they still emit API prose of
their own until template-docs.
So this task only wires the generators up, and generated output does not
change. What does change is that Fill’s remaining checks now run at
generation time: a type mismatch or a check (a) violation in generated output
fails generation. Neither can be tolerated by an option, and as of specfill’s
completion there are none in generated output.
Commits. (1) Add a narrow post-processing hook to gentools that does not
make gentools depend on specgen (see §1.5). (2) Wire simdgen. (3) Wire tmplgen,
wasmgen, midway.
Done when all four generators route through specfill, and generated
output is byte-identical to the previous tree.
named-results — Reconcile parameter and result names with spec
Done: [x] · Needs: fill-gen · Blocks: fill-enforce
Every declaration spec covers carries spec’s parameter and result names. See
§1.8. Declarations spec does not cover are out of scope; they gain names when
spec-all covers them.
SPEC-STATS.txt §2 has the counts, split the way specfill treats them:
- Named arguments whose names disagree with spec, in generated or
hand-written code. Each is fixed in whichever of spec or the API has the worse
name — spec, a generator’s template or input, or a hand-written file; either
way the diff touches only names, and doc text that mentions them. These come
first:
Fillfills no names in a declaration with a mismatch. - Unnamed arguments, which
Fillfills from spec. In generated code, that means turning name filling on in the generators. In hand-written files,specfill -w -no-fill-docfills them;-wrefuses while the report has findings, so this also needs any check (a) violations in hand-written files cleared (they are alsodoc-triage’s).
Cross-target parameter-name divergence (also SPEC-STATS.txt §2) is only fixed
here for pairs spec covers. The rest reach zero as spec-all covers them, when
each new spec function has to agree with every target at once.
Commits. (1) Reconcile name mismatches, grouped by generator or operation
family, each commit with its regenerated output. (2) Stop setting NoFillNames
and AllowNameMismatches in the generators, with the regenerated output: name
filling is on, and a name mismatch in generated output fails generation. (3)
Fill unnamed arguments in hand-written files with specfill -w -no-fill-doc.
Done when SPEC-STATS.txt §2’s name mismatches and unnamed counts are zero,
and the generators run without NoFillNames or AllowNameMismatches.
gen-docs — Inject spec docs into generated output
Done: [x] · Needs: fill-gen · Prefers: named-results · Blocks: comments-yaml, handwritten-fill, template-docs · Risk: risk-silent-doc-loss
Turn on doc injection in the generators by dropping NoFillDoc. Generators keep
setting AllowDocRewrite until template-docs removes their own prose.
Prefer landing this after named-results: spec’s docs refer to argument names
(§1.8), so injecting them before the generators fill those names leaves docs
that mention names the signature does not bind.
Expect a real diff, not a no-op: spec already overlaps several hundred
(receiver, name) pairs (SPEC-STATS.txt §1), and almost none of the existing
doc text matches spec’s (SPEC-STATS.txt §3). So this rewrites that many doc
comments. Use go run . -diff first to size the change exactly.
Before any generator’s docs are injected, its implementation notes must follow
§1.6, or the first rewrite silently drops them. Known gaps: some notes say CPU Features: or omit a colon; simdgen’s performance caveats (“A non-constant value
of shift may result in significantly worse performance”, and the SetElem
jump-table line) have no prefix, and the SetElem one sits directly above its
Asm: line with no blank line between them. specfill’s check (a) violations
and a -diff of the output with injection on are the list to work from.
Commits. (1) Fix note prefixes in simdgen, tmplgen, wasmgen and midway
templates — Performance: for the caveats, a blank line before every note —
with the regenerated output; the diff should touch only note lines. (2) Turn on
doc injection in simdgen. (3) Turn it on in tmplgen, wasmgen, midway. Commits
(2) and (3) include their regenerated output; verify that output changes only
for operations spec covers, and review every changed doc comment to make sure
nothing was unintentionally dropped. (4) If fill-enforce is already done, drop
NoFillDoc: true from cmd/specfill.TestFill for generated files to start
enforcing documentation matches.
Done when generated output has no check (a) violations, all four generators inject docs, nothing was dropped without review, and the only differences against the previous tree are doc comments for operations spec covers.
fill-enforce — Signature + name mismatch becomes an error
Done: [x] · Needs: named-results
For operations spec covers, disagreement fails. Missing spec functions remain
tolerated. Note this does not depend on sig-refactor: specfill compares
emitted source, not simdgen’s internal model.
Generated output is already checked as it is generated: type mismatches fail
generation from fill-gen on, and name mismatches from named-results on.
That misses hand-written files, and generated files edited by hand without
rerunning their generator. This task closes both with a go test in
cmd/specfill that runs Fill over every non-test .go file in simd and
archsimd, generated or not, and fails on any finding. It sets
AllowDocRewrite, since those files carry spec-owned docs by design, whether
injected or hand-written.
Commits. One, adding the test.
Done when a deliberately introduced signature or argument-name change in any generated or hand-written exported declaration fails the build.
Track C — Conformance
Behavioral testing against simdref. Parallel to everything else; shares only
the spec-coverage bottleneck.
ci-audit — CI/hardware audit
Done: [x] · Needs: — · Blocks: conform-fixed · Risk: risk-conformance-env
Determine what can actually execute tests for amd64, arm64, wasm, and SVE — natively or emulated. The conformance suite’s value for other architectures depends on it.
“Execute tests” means: build simd and archsimd for the target and run
go test against real or emulated hardware, at a speed that tolerates a few
thousand generated conformance cases. SVE needs SVE-capable hardware or QEMU, and
wasm needs a runtime.
Done when one document or issue records, per target, whether it can run
today, on what, how fast, and what it would take if not — marking each runnable,
runnable-with-work (with the work named), or not runnable, so conform-fixed
knows which targets it is writing tests for.
Results
| amd64 | gotip-linux-amd64 | gotip-linux-amd64_avx512 |
|---|---|---|
| AVX | ✔ | ✔ |
| AVX2 | ✔ | ✔ |
| AVX512 | ✘ | ✔ |
| AVX512BITALG | ✘ | ✔ |
| AVX512GFNI | ✘ | ✔ |
| AVX512VAES | ✘ | ✔ |
| AVX512VBMI | ✘ | ✔ |
| AVX512VBMI2 | ✘ | ✔ |
| AVX512VNNI | ✘ | ✔ |
| AVX512VPCLMULQDQ | ✘ | ✔ |
| AVX512VPOPCNTDQ | ✘ | ✔ |
| AVXAES | ✔ | ✔ |
| AVXPCLMULQDQ | ✔ | ✔ |
| AVXVNNI | ✘ | ✔ |
| FMA | ✔ | ✔ |
| SHA | ✘ | ✔ |
| VAES | ✘ | ✔ |
| VPCLMULQDQ | ✘ | ✔ |
For amd64, use the gotip-linux-amd64_avx512 gomote for testing.
| arm64 | gotip-linux-arm64 | qemu-aarch64 -cpu neoverse-n2 | qemu-aarch64 -cpu neoverse-v1 |
|---|---|---|---|
| PMULL | ✔ | ✔ | ✔ |
| SVE | ✘ | ✔ | ✔ |
| SVE2 | ✘ | ✔ | ✔ |
| SVE vector size | n/a | 128 | 256 |
qemu-aarch64 -cpu neoverse-n1 (which matches the capabilities of
gotip-linux-arm64) is roughly 7x slower than gotip-linux-arm64, so well within
the range of practical conformance testing.
For NEON, use the gotip-linux-arm64 gomote for testing. For SVE, use
qemu-aarch64 -cpu neoverse-v1.
| wasm | js-node | wasip1-wazero |
|---|---|---|
| SIMD128 | ✔ | ✔ |
node is about 3x faster than wazero.
For wasm, use node for testing.
conform-fixed — Conformance harness, fixed-width types
Done: [ ] · Needs: ci-audit · Blocks: validate-amd64, conform-scalable · Prefers: specfill
Generated tests comparing archsimd against internal/simdref. The generator’s
input is spec (hand-written source), so this is not a generator reading another
generator’s output.
Commits. (1) Input generation: start from
archsimd/internal/simd_test/helpers_test.go, which already has the per-type
edge-case vectors, the mask patterns and the window-walking helpers (§1.9);
decide whether to lift them into a package the generated tests can import. (2)
Test generator producing one test per (recv, name) spec covers. (3) Wire into
the SIMD test suite, skipping operations whose spec body panics.
Done when every operation spec covers, on every target ci-audit marked
runnable, is exercised against simdref with both edge-case and random inputs,
and failures name the operation, the input, and both results.
conform-scalable — Conformance harness, scalable types
Done: [ ] · Needs: width, conform-fixed · Blocks: conform-flip
Extend conform-fixed’s harness to the scalable types (Int8s … Float64s,
Mask8s … Mask64s) on SVE. Separate from conform-fixed because it cannot be
written until width settles how spec executes at a concrete width: the harness
must construct simdref values at the width the hardware reports through vl()
rather than at spec’s fixed scalableWidth.
Commits. (1) Teach input generation to size vectors from the runtime vector
length. (2) Extend the test generator to scalable receivers. (3) Wire into the
suite, gated on SVE availability per ci-audit.
Done when scalable operations spec covers are conformance-tested on SVE at the hardware’s actual vector length.
validate-amd64 — Validate spec bodies against amd64
Done: [ ] · Needs: conform-fixed, spec-common, impl-defined · Blocks: conform-flip
Run conformance with amd64 as the oracle. Every failure is a spec bug until proven otherwise. See §1.9.
Commits. One per batch of spec fixes; keep spec changes separate from harness changes so the reason for each is legible.
Done when the harness passes on amd64 for every operation spec covers, with
every remaining difference either fixed in spec or recorded as
implementation-defined under impl-defined’s policy.
conform-flip — Flip conformance direction
Done: [ ] · Needs: validate-amd64, conform-scalable
Spec becomes the oracle for every target, not just amd64. The second half is the
contributor-facing note — likely in internal/spec/doc.go and the SIMD README —
describing the workflow: add the operation to spec if it is not there, implement
it, run conformance.
Done when conformance runs and fails the build on all targets ci-audit
marked runnable, and the workflow is written down where someone adding an
architecture will find it.
Track D — Spec content
Writing spec functions, docs, and bodies. The shared bottleneck the other tracks draw on.
doc-explore — Doc-mechanism exploration on a hard family
Done: [x] · Needs: — · Blocks: spec-common · Risk: risk-doc-template-ceiling
As of CL 831485, roughly one doc line in eight in categories.yaml carries
shape-specific literals — [x0, x1, x2, x3, ...], 128-bit, 0b_11_01_00_10 —
concentrated in AddSub’s Concat*Pairs and Moves'
concatSelectedConstant*. Those docs cannot be produced by substituting {vL}
into one template; each shape needs its own text.
The expected mechanism is multiple spec functions sharing one //specgen:name,
which specgen supports because funcKey is (recv, name) and the constraint
sets are disjoint. Verify that before bulk migration, not after: discovering
it does not scale once 150 easy docs are written means retrofitting all of them.
Commits. (1) Migrate ConcatAddPairs / ConcatSubPairs into spec, docs
included; their text differs between 16/32-bit and 64-bit elements. Verify via
cmd/specls. (2) Migrate the Grouped variants, whose docs vary by element size
and by the 128-bit grouping — two axes, and the real test of the mechanism. (3)
ShiftSaturated, ShiftAllRight, ShiftRight, whose docs differ between signed
and unsigned elements; categories.yaml selects the variant with a signed:
join key, which this retires. (4) If the mechanism proves insufficient, extend it
(conditional doc text is the likely shape) and document the extension in
internal/spec/doc.go.
concatSelectedConstant* is harder still — four spec functions for one API name
— but it is unexported, so it sits outside specfill’s scope per §1.7 and is
undecided until compiler-ops. Reach for it only if compiler-ops has landed
and you want a sterner test.
Done when cmd/specls reproduces the existing doc text for all three
families, or the doc mechanism has been extended so that it can.
spec-common — Spec the common operations
Done: [x] · Needs: width, doc-explore · Blocks: validate-amd64, comments-yaml, spec-all · Prefers: specfill · Risk: risk-ref-impl-effort
The method set midway/comments.yaml documents: its .common_methods block
less String and Len — Add, Sub, Min, Max, And, Or, MulAdd, the
compares, and the rest of the portable core. SPEC-STATS.txt tracks it as
“portable core”, which is this task’s completion bar. Highest leverage in the
plan — these are the easiest reference implementations to write correctly, they
unlock comments-yaml (the worst doc divergence, in the widest-audience
package), and they are exactly what a new architecture implements first.
The file’s keys are not all operations: besides the method docs, it documents
vector types, functions and templates, and carries receiver names keyed to the
shared block. Only the methods/functions are in scope here, and this excludes
non-spec residue (String, etc, §1.7).
Commits. Start by sampling ~10 operations at random and timing how long it
takes to implement them, to calibrate risk-ref-impl-effort before committing
to the rest. Then one commit per family (arithmetic, bitwise, min/max, compares,
multiply-accumulate), each adding the spec functions with docs and bodies and
confirming cmd/specls output.
Done when every method name in midway/comments.yaml other than non-spec
residue resolves to a spec function, with a real body, unblocking
comments-yaml.
spec-all — Bulk spec migration
Done: [ ] · Needs: spec-common, convert-lo, mask-bits · Prefers: specfill, fill-enforce · Blocks: template-docs · Risk: risk-ref-impl-effort
The remaining exported operations, prioritized by the names produced by more
than one generator (SPEC-STATS.txt §1) — highest drift risk, and each migration
fixes several files at once. Best-effort bodies are acceptable; panic("not implemented") is safe because conformance skips it. A wrong body is worse
than no body.
Two denominators, both useful. Against the API: the (receiver, name) pairs
SPEC-STATS.txt §1 shows uncovered, which is what the scoreboard tracks. Against
the input being retired: categories.yaml’s exported operation names, less the
ones spec-common handles. They are different counts of different things — one
categories.yaml name can expand to many API names — so quote whichever the
audience needs and say which.
Why convert-lo and mask-bits gate this. Only the Converts and LoadStore
families actually depend on them — convert-lo may rename ExtendLo* /
ConvertLo*, and mask-bits may rename the scalable mask bitmap pair, and
renaming after the docs are migrated means doing them twice. If either is still
open when this task starts, the other families are unaffected and are the place
to begin.
Commits. One per operation family, mirroring the ops/*/ grouping. Each
spec function gains //specgen:commutative where the operation is commutative;
the directive already exists and is parsed by specgen. Drop any
comments.yaml entries spec gains as they are covered. Include all regenerated
outputs in each commit. Once gen-docs has landed, each family’s regeneration
rewrites that family’s docs, so review the diff to ensure all doc comment
content is equivalent: any dropped text is either something spec should say or
an implementation note missing its prefix.
Done when specfill reports no remaining API declarations uncovered by spec,
other than the permanent residue named in §1.7 (String, Len).
Track E — Retiring the old inputs
Retiring the other documentation sources, then categories.yaml itself.
doc-triage — Hand-written doc conflict triage
Done: [ ] · Needs: specfill · Prefers: spec-common · Blocks: handwritten-fill · Risk: risk-silent-doc-loss
Make it safe for handwritten-fill to rewrite hand-written files, by emptying
the conflict set first.
A conflict is a declaration where spec has a doc and the hand-written file has
different spec-owned paragraphs (§1.6). Those are the only sites -w would
overwrite: where spec has no entry, the declaration is left alone. Every
conflict therefore resolves one of two ways — the prose says something spec
should say (move it into spec), or it is about how a target implements the
operation (make it an implementation note by giving it one of §1.6’s prefixes,
and specfill never touches it). Neither outcome loses text, which is what makes
-w safe afterwards.
Run specfill -diff over the hand-written files — every doc comment it would
rewrite is a conflict — and sort each conflict into one of those two.
SPEC-STATS.txt §3 breaks the conflict count down by file, with
simd_emulated.go dominating and a long tail of archsimd/slicepart_*.go,
shuffles_amd64.go, and extra_amd64.go. Some implementation text there has no
prefix today — extra_amd64.go’s “This method compiles to VPTEST x, x” is a
Performance: note — and the same pass fixes check (a) violations and note
spellings in these files.
The slicepart_*.go files matter most: they declare the Load*Part family spec
already covers via LoadZPart, so their prose conflicts on day one — and one
of them documents LoadUint32x4Part as loading “a Int32x4”.
Done when specfill -no-fill-names -diff is empty and specfill reports
zero check (a) violations.
comments-yaml — Drop spec-covered docs from midway/comments.yaml
Done: [x] · Needs: gen-docs, spec-common · Prefers: fill-enforce
Every method or function-template entry spec covers is dropped from
comments.yaml. At the time of this task (after spec-common) that is the
portable core less non-spec residue (String, etc, §1.7), plus the
Load/LoadPart/Broadcast templates. Entries spec gains afterwards (in spec-all)
are dropped when spec gains them.
Commits. (1) Confirm spec covers the portable core; fill gaps. (2) Delete
the covered entries from comments.yaml. (3) Run specfill -w over
simd/simd_emulated.go — this overlaps handwritten-fill; sequence whichever
lands first.
Done when comments.yaml has no entry for any operation spec covers, midway
sources those docs through specfill, and simd_stubs.go and
simd_emulated.go carry identical spec-derived docs for them.
handwritten-fill — Enable -w on hand-written files
Done: [ ] · Needs: gen-docs, doc-triage · Risk: risk-silent-doc-loss
Turn on doc rewriting for hand-written files, once doc-triage has emptied the
conflict set — at which point -w rewrites only docs that already match what it
would write.
Two guards, both on cmd/specfill only. This does not change gentools'
shared -w, which every generator uses:
specfill -wrefuses to run when the report has any findings, or if there are uncommitted changes, so its diff is the complete story of what it did.specfill -wnever runs in CI; CI runs report mode (fill-enforce).
Done when specfill -w over the hand-written files is a no-op on a clean
tree.
template-docs — Retire generator-supplied API docs
Done: [ ] · Needs: gen-docs, spec-all · Blocks: cat-rehome
Delete the doc strings embedded in wasmgen’s and tmplgen’s templates, and the
documentation: fields on exported categories.yaml entries, leaving
specfill to supply them. Mechanical once spec-all has covered the operations
those generators emit — but not automatic, because their templates interleave
prose with the implementation notes that stay with the generator (§1.6).
Commits. One per generator, stripping the prose and keeping the notes; one
for the documentation: fields on exported categories.yaml entries. Each
includes its regenerated output. Then stop setting AllowDocRewrite in the
generators, so a generator that emits API prose again fails generation.
Done when no generator’s source contains API prose, no exported
categories.yaml entry has documentation:, regeneration is byte-identical
to the previous tree, and the generators run without AllowDocRewrite.
cat-rehome — Re-home categories.yaml non-doc fields
Done: [ ] · Needs: template-docs · Blocks: compiler-ops-impl, sig-from-spec · Risk: risk-cat-unknowns
Empty the exported entries of categories.yaml of everything but go: and
commutative:, by sending each remaining field where §1.3’s inventory says it
belongs. template-docs has already removed documentation:; this is the rest.
Compiler-only entries are untouched, for compiler-ops-impl.
Expect surprises. §1.3’s audit was static, and the unifier’s behavior on field
combinations was not exhaustively traced — regexpTag’s role is still
unverified (§1.3). Mitigating factor: go run . -diff makes every step
verifiable byte-for-byte against checked-in generated output.
Commits. Follow §1.3’s disposition column, one field per commit, confirming
generated output is unchanged at each step: (1) delete regexpTag and signed
from categories.yaml and from go_*.yaml, expecting most go_*.yaml uses to
be inert already; (2) move constImm into go_amd64.yaml; (3) convert the
out/elemBits/bits entries into spec function splits, which spec-all
should already have done — verify.
Done when every exported entry in categories.yaml has only go: and
commutative: keys; compiler-only entries are untouched, for
compiler-ops-impl. Generated output is byte-identical throughout.
compiler-ops-impl — Implement compiler-only ops representation
Done: [ ] · Needs: compiler-ops, cat-rehome · Blocks: sig-from-spec
Implement whatever compiler-ops decided, for the compiler-only entries —
those that produce no exported declaration — and the noTypes / noGenericOps
switches. No design work should remain at this point; if it does, compiler-ops
was not finished.
These operations have no API surface — they exist only as SSA ops and lowering
rules — so nothing here touches specfill, spec, or the public packages. It is
entirely about giving simdgen somewhere other than categories.yaml to read them
from.
Done when no compiler-only entries remain in categories.yaml
(SPEC-STATS.txt §4) and generated output is byte-identical.
cat-delete — Delete categories.yaml
Done: [ ] · Needs: sig-from-spec
Delete ops/*/categories.yaml, the !import stub that loads it, and its
handling in simdgen/main.go. By this point every field it carried has been
re-homed (cat-rehome), its compiler-only entries have moved
(compiler-ops-impl), and its signatures come from spec (sig-from-spec), so
this should be pure deletion.
Done when categories.yaml no longer exists, simdgen runs without it, and
generated output is byte-identical.
Track F — simdgen internals
The signature refactor and, eventually, sourcing signatures from spec.
sig-refactor — simdgen signature refactor
Done: [ ] · Needs: — · Blocks: sig-from-spec · Risk: risk-sig-refactor-convergence
Make the Go signature a first-class value on Operation instead of an emergent
property of shape() → classifyOp() → a template name. Ideally shaped so it is
directly comparable to specgen.Func’s Recv/In/Out.
This collapses the stub templates (§1.1 counts them) to roughly three, because
operand permutation and parameter naming become data rather than template
identity; the _21, _231Type1, _31 suffixes become an explicit permutation.
It is also the empirical test of whether the unification approach can be kept.
The refactor assumes the signature is expressible as data without a new pile of
special cases; the quirk fields and simdgen only supports… panics catalogued
in §1.1 are mostly upstream of the signature (XED inconsistency) or
downstream (register shapes, rules), so the seam looks thin — but that is a
prior, not a measurement. If it does not converge, that is the evidence for
revisiting §1.1, earned cheaply on the few hundred lines §1.1’s layer table
isolates.
Commits. (1) Introduce the signature type and a GoSignature() method
covering the simplest classes (op1, op2, op3). (2) Extend to immediate
variants (op*Imm*), which may be fiddly. (3) Extend to permuted and
VecAsScalar variants. (4) Rewrite the stub templates to consume the signature
value; delete the redundant templates.
Done when go run . -diff shows byte-identical output at every commit.
sig-from-spec — simdgen sources signatures from spec
Done: [ ] · Needs: sig-refactor, cat-rehome, compiler-ops-impl · Blocks: cat-delete
simdgen looks up the spec function by (recv, name) and asserts signature
equality, reporting a structured diff on mismatch. Key the lookup on
(recv, name) only — spec’s own uniqueness key — and assert the signature
separately. Keying on the signature makes a divergence indistinguishable from
“not migrated yet”, which is the failure this whole effort exists to catch.
This is where sig-refactor pays off: the lookup compares against the signature
value that refactor introduced, rather than against a rendered template.
Commits. (1) Look up the spec function and assert equality, failing the
generator run on mismatch. (2) Delete whatever signature information in
go_GOARCH.yaml is now redundant. (3) Switch simdgen to reading commutativity
from spec’s //specgen:commutative directive and delete commutative: from
exported categories.yaml entries.
Done when simdgen derives every exported signature it emits from spec, commutativity comes from spec, and generated output is byte-identical.
Part 4 — Risks and unknowns
Every risk is owned by a task, and its substance is stated there. This is the
index. risk-concrete-width, risk-impl-defined-behavior and
risk-ref-impl-effort are the expensive ones: each reaches every spec body, so
each is far cheaper to settle before the bodies are written than after.
| Risk | What it is | Owner | |
|---|---|---|---|
| [ ] | risk-spec-defects |
Known spec defects | convert-lo, mask-bits |
| [x] | risk-concrete-width |
Executing spec at a concrete width | width |
| [ ] | risk-impl-defined-behavior |
Implementation-defined behavior | impl-defined |
| [x] | risk-doc-template-ceiling |
Doc-template ceiling — the fallback is a mechanism change affecting every doc written so far | doc-explore |
| [ ] | risk-ref-impl-effort |
Reference-implementation effort — every migrated operation needs a body that is correct, because conformance tests against it; the distribution of difficulty is unknown | spec-common, spec-all |
| [x] | risk-conformance-env |
Conformance execution environment — can CI run arm64/SVE/wasm, and at what cost? | ci-audit |
| [ ] | risk-sig-refactor-convergence |
sig-refactor may not converge — if it does not, §1.1 needs revisiting |
sig-refactor |
| [ ] | risk-silent-doc-loss |
Silent doc loss — under §1.6, text without a note prefix is spec-owned, so implementation text nobody prefixed is dropped on the first rewrite; the guards are review of every rewrite’s -diff and, once generators emit no prose of their own, AllowDocRewrite |
gen-docs, doc-triage, handwritten-fill |
| [ ] | risk-cat-unknowns |
Unknown unknowns in categories.yaml — the field audit was static |
cat-rehome |
Appendix A — Regenerating the two generated files
SPEC-STATS.txt
cmd/specstats generates SPEC-STATS.txt, the transition
scoreboard:
cd $GOROOT/src/simd/archsimd/_gen
go run . -w # (if necessary) ensure generator outputs are up-to-date
go run ./cmd/specstats # print the report
go run ./cmd/specstats -diff # see what's changed
go run ./cmd/specstats -w # rewrite SPEC-STATS.txt in place
It loads simd and archsimd through go/packages for each build
configuration, taking syntax only — no type-checking, per §1.4 — and reads
simd/internal/spec through specgen.Load. It needs a recent Go toolchain and
up-to-date output from the SIMD generators, but does not itself require any ISA
data. Commit the regenerated file alongside whatever change moved the numbers;
the diff is the record of what moved.
It reports only figures that measure how far along the transition is: every one is a fraction heading for 100% or a count heading for zero, tagged with the tasks that move it; indented, untagged numbers are context breaking down the figure above them. If you add a figure, give it a denominator and a direction, or leave it out.
One thing it deliberately does not do: it classifies divergence (§5) but fails
on nothing; enforcement is specfill’s job. specfill should absorb the
comparison half of this program rather than leave two implementations to drift.
For one-off questions the report does not answer, cmd/specls prints the API
spec currently expands to:
go run ./cmd/specls # the whole expansion
go run ./cmd/specls -f Add # one spec function
go run ./cmd/specls -f Add -trace # why it expands the way it does
SPEC-TASKS.dot
The task graph is drawn from Part 2’s two tables by cmd/spectasks:
go run ./cmd/spectasks # print the graph
go run ./cmd/spectasks -w # rewrite SPEC-TASKS.dot in place
dot -Tsvg SPEC-TASKS.dot -o /tmp/tasks.svg # render to SVG
go run ./cmd/spectasks | grep -v ^-- | xdot - # view the current graph
It reads nothing but this file. Two things it does beyond drawing:
It checks, from go test ./cmd/spectasks. Dependencies are stated three
times here — Part 2’s table, Part 3’s Needs:/Prefers: lines, and Part 4’s
Blocks: lines, which are the same edges reversed — and the test fails if the
three disagree, if the table is not topologically sorted, if a task is in no
track or in two, or if an edge points at a task that does not exist. A second
test fails if SPEC-TASKS.dot no longer matches the document, so an edit that
skips the regeneration step is caught too. The command itself refuses to write a
graph from an inconsistent document, but the test is where the problems are
reported.
It derives the critical path rather than restating one: a task is on it when its longest chain in plus its longest chain out spans the whole graph. That keeps every longest chain, not one picked by hand, so parallel roots of equal depth are all marked.