diff --git a/README.md b/README.md index 5c3e2964..b2d901cf 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Compile and run: Templates are defined once with a clear input/output contract. The first line declares the output and input — the indented body describes the transformation. -Think of a template as a **black box**: data flows in through inputs, gets transformed, and flows out through outputs. Outputs work **by reference** — calling a template directly modifies the output variable in the caller's scope. +Think of a template as a **black box**: data flows in through inputs, gets transformed, and flows out through outputs. Outputs work **by reference**. A caller may connect an input and an output to the same variable; inside the call, later input reads observe writes through that output. The caller's variable receives the result after every right-hand side of the assignment has been evaluated. `math.pt` ```python @@ -134,7 +134,18 @@ y = Square(x) y = x * x ``` -Inputs are read-only — they flow in. Outputs are writable — they flow out. Every function is a transformation. +Inputs are read-only — they flow in. Outputs are write-only inside the template — they flow out; use a local for intermediate values. Read-only means the template cannot assign through the input name; it does not freeze a value shared with an output. A caller may reuse a variable as both argument and destination, `a = Square(a)`. + +```python +out, seen = Fold(current, item) + out = current + item + seen = current + +value = 10 +value, seen = Fold(value, 5) # value = 15, seen = 15 +``` + +Moving `seen = current` before `out = current + item` instead makes `seen` equal 10. The same order applies to each iteration of a ranged call. To keep an old value across a write, save it first with an explicit assignment; that is where any copy happens. ### Generics by use diff --git a/compiler/abi.go b/compiler/abi.go index 48e022f3..b6c55a3c 100644 --- a/compiler/abi.go +++ b/compiler/abi.go @@ -15,10 +15,9 @@ const ( ) type ABIParam struct { - Source Type - Lowered Type - Mode ABIParamMode - AliasSlot int + Source Type + Lowered Type + Mode ABIParamMode } type ABIReturn struct { @@ -29,12 +28,12 @@ type ABIReturn struct { // FuncABI captures the lowered function boundary for one mangled variant. // Direct scalar returns carry a hidden destination seed so a skipped write -// preserves the caller's value. Range-bearing variants may additionally need -// hidden alias state for loop-carried accumulation. +// preserves the caller's value. Whether an input shares a caller binding with +// an output is a compile-time property of each call site, lowered as a private +// variant of the function; it never appears in the native signature. type FuncABI struct { - Params []ABIParam - Return ABIReturn - HasRangeParams bool + Params []ABIParam + Return ABIReturn } func isDirectScalarABIType(t Type) bool { @@ -49,7 +48,7 @@ func isDirectScalarABIType(t Type) bool { } // aliasableOutput reports whether an output can back a parameter's alias slot. -// The hidden selector picks an output by position and the callee then reads that +// The alias pattern names an output by position and the callee then reads that // storage as the parameter's own type, so the two must lower identically. There // is no numeric conversion anywhere on this path, and a pointer selected across // mismatched types would be loaded as the wrong type. @@ -79,28 +78,15 @@ func classifyFuncABI(paramTypes []Type, outTypes []Type) FuncABI { }, } - for _, paramType := range paramTypes { - if isRangeDriverType(paramType) { - abi.HasRangeParams = true - break - } - } - - aliasSlot := 0 for i, paramType := range paramTypes { paramABI := ABIParam{ - Source: paramType, - Lowered: Ptr{Elem: paramType}, - Mode: ABIParamIndirect, - AliasSlot: -1, + Source: paramType, + Lowered: Ptr{Elem: paramType}, + Mode: ABIParamIndirect, } if isDirectScalarABIType(paramType) { paramABI.Mode = ABIParamDirect paramABI.Lowered = paramType - if abi.HasRangeParams { - paramABI.AliasSlot = aliasSlot - aliasSlot++ - } } abi.Params[i] = paramABI } @@ -120,16 +106,6 @@ func (abi FuncABI) UsesIndirectReturn() bool { return abi.Return.Mode == ABIReturnIndirect } -func (abi FuncABI) NumAliasSlots() int { - count := 0 - for _, param := range abi.Params { - if param.AliasSlot >= 0 { - count++ - } - } - return count -} - func (abi FuncABI) sourceParamBaseIndex() int { if abi.UsesIndirectReturn() { return 1 @@ -141,21 +117,9 @@ func (abi FuncABI) SourceFunctionParamIndex(paramIndex int) int { return abi.sourceParamBaseIndex() + paramIndex } -func (abi FuncABI) AliasParamBaseIndex() int { - return abi.sourceParamBaseIndex() + len(abi.Params) -} - -func (abi FuncABI) AliasFunctionParamIndex(paramIndex int) int { - slot := abi.Params[paramIndex].AliasSlot - if slot < 0 { - return -1 - } - return abi.AliasParamBaseIndex() + slot -} - func (abi FuncABI) DirectReturnSeedParamIndex() int { if abi.Return.Mode != ABIReturnDirect { return -1 } - return abi.AliasParamBaseIndex() + abi.NumAliasSlots() + return abi.sourceParamBaseIndex() + len(abi.Params) } diff --git a/compiler/cfg.go b/compiler/cfg.go index 8e8178d7..e6249ad3 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -193,9 +193,14 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { PushScope(&cfg.Scopes, FuncScope) defer PopScope(&cfg.Scopes) + // Outputs are published up front so that a formatting marker naming one + // resolves as a read and is rejected, instead of passing as literal text. for _, param := range fn.Parameters { cfg.publishTarget(param) } + for _, output := range fn.Outputs { + cfg.publishTarget(output) + } parameterNames := make(map[string]struct{}, len(fn.Parameters)) for _, parameter := range fn.Parameters { @@ -207,7 +212,8 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { outputNames[output.Value] = struct{}{} } - _, readInputs, assignedOutputs := cfg.validateTemplateBody(fn.Body.Statements, parameterNames, outputNames) + body := cfg.validateTemplateBody(fn.Body.Statements, parameterNames, outputNames) + readInputs, assignedOutputs := body.readInputs, body.assignedOutputs for _, input := range fn.Parameters { if _, wasRead := readInputs[input.Value]; wasRead { @@ -225,35 +231,43 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { } } -// validateTemplateBody runs structural validation over one template body and -// returns each statement's reads plus the parameter and output names the body -// read and assigned. A script is a zero-input, zero-output template: it passes -// nil name sets and consumes only the reads. -func (cfg *CFG) validateTemplateBody(statements []ast.Statement, parameterNames, outputNames map[string]struct{}) ([][]VarEvent, map[string]struct{}, map[string]struct{}) { - statementReads := make([][]VarEvent, 0, len(statements)) - readInputs := make(map[string]struct{}, len(parameterNames)) - assignedOutputs := make(map[string]struct{}, len(outputNames)) +// templateBody is the structural summary of one template body. +type templateBody struct { + statementReads [][]VarEvent + readInputs map[string]struct{} + assignedOutputs map[string]struct{} +} + +// validateTemplateBody runs structural validation over one template body. A +// script is a zero-input, zero-output template: it passes nil name sets and +// consumes only the reads. +func (cfg *CFG) validateTemplateBody(statements []ast.Statement, parameterNames, outputNames map[string]struct{}) templateBody { + body := templateBody{ + statementReads: make([][]VarEvent, 0, len(statements)), + readInputs: make(map[string]struct{}, len(parameterNames)), + assignedOutputs: make(map[string]struct{}, len(outputNames)), + } for _, stmt := range statements { reads := cfg.collectStatementReads(stmt) - targets := cfg.validateStatementStructure(stmt, reads, parameterNames) + targets := cfg.validateStatementStructure(stmt, reads, parameterNames, outputNames) if let, ok := stmt.(*ast.LetStatement); ok { cfg.publishTargets(let.Name) } - statementReads = append(statementReads, reads) + body.statementReads = append(body.statementReads, reads) for _, event := range reads { if _, isParameter := parameterNames[event.Name]; isParameter { - readInputs[event.Name] = struct{}{} + body.readInputs[event.Name] = struct{}{} } } for _, target := range targets { if _, isOutput := outputNames[target.Value]; isOutput { - assignedOutputs[target.Value] = struct{}{} + body.assignedOutputs[target.Value] = struct{}{} } } } - return statementReads, readInputs, assignedOutputs + return body } // AnalyzeScript treats the script as a zero-input, zero-output template before @@ -278,8 +292,7 @@ func (cfg *CFG) validateScriptTemplate(statements []ast.Statement) [][]VarEvent PushScope(&cfg.Scopes, BlockScope) defer PopScope(&cfg.Scopes) - statementReads, _, _ := cfg.validateTemplateBody(statements, nil, nil) - return statementReads + return cfg.validateTemplateBody(statements, nil, nil).statementReads } // AnalyzeSpecialization runs only typed dataflow. Structural diagnostics were @@ -294,7 +307,7 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf cfg.publishTarget(param) } - cfg.typedForwardPass(template.Body.Statements, info.StatementEffects) + cfg.typedForwardPass(template, info) live := make(map[string]struct{}, len(template.Outputs)) for _, output := range template.Outputs { @@ -303,11 +316,39 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf cfg.backwardPass(live) } -func (cfg *CFG) typedForwardPass(statements []ast.Statement, effects map[*ast.LetStatement]StatementEffect) { +// inputOutputAliases lists outputs that a caller could share with each input. +// Specializations are reused across calls, so liveness must conservatively +// retain writes observable through any compatible input reference. These are +// scalar body types, so this also conservatively includes iterator inputs. +func inputOutputAliases(template *ast.FuncStatement, info *FuncInfo) map[string][]*ast.Identifier { + aliases := make(map[string][]*ast.Identifier, len(template.Parameters)) + for i, paramType := range info.Sig.Params { + for j, outputType := range info.Sig.OutTypes { + if !bindingSlotCompatible(paramType, outputType) { + continue + } + name := template.Parameters[i].Value + aliases[name] = append(aliases[name], template.Outputs[j]) + } + } + + return aliases +} + +func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo) { + aliases := inputOutputAliases(template, info) lastWrites := make(map[string]VarEvent) - for _, stmt := range statements { + for _, stmt := range template.Body.Statements { reads := cfg.collectStatementReads(stmt) - cfg.processTypedStatement(stmt, reads, effects, lastWrites) + for _, read := range reads { + for _, output := range aliases[read.Name] { + if !cfg.isDefined(output.Value) { + continue + } + reads = append(reads, VarEvent{Name: output.Value, Kind: Read, Token: read.Token}) + } + } + cfg.processTypedStatement(stmt, reads, info.StatementEffects, lastWrites) } } @@ -334,9 +375,9 @@ func (cfg *CFG) processTypedStatement(stmt ast.Statement, reads []VarEvent, effe // validateStatementStructure reports template-stable read and write errors and // returns named targets for caller-specific bookkeeping. The caller publishes // them only after all statement reads have been checked. -func (cfg *CFG) validateStatementStructure(stmt ast.Statement, reads []VarEvent, parameters map[string]struct{}) []*ast.Identifier { +func (cfg *CFG) validateStatementStructure(stmt ast.Statement, reads []VarEvent, parameters, outputs map[string]struct{}) []*ast.Identifier { for _, event := range reads { - cfg.validateStructuralRead(event) + cfg.validateStructuralRead(event, outputs) } let, ok := stmt.(*ast.LetStatement) @@ -445,7 +486,14 @@ func (cfg *CFG) backwardPass(live map[string]struct{}) { } } -func (cfg *CFG) validateStructuralRead(event VarEvent) { +// validateStructuralRead enforces that a declared output is write-only inside +// its template. A body may observe output writes through an explicitly passed +// input that shares the output's binding, but never through the output name. +func (cfg *CFG) validateStructuralRead(event VarEvent, outputs map[string]struct{}) { + if _, isOutput := outputs[event.Name]; isOutput { + cfg.addError(event.Token, fmt.Sprintf("output %q is read inside its function; outputs are write-only, use a local", event.Name)) + return + } if !cfg.isDefined(event.Name) { cfg.addError(event.Token, fmt.Sprintf("variable %q has not been defined", event.Name)) } diff --git a/compiler/cfg_replay_test.go b/compiler/cfg_replay_test.go index e0ab280e..eace49a9 100644 --- a/compiler/cfg_replay_test.go +++ b/compiler/cfg_replay_test.go @@ -285,8 +285,9 @@ result = Diamond(x) func TestCFGResultsAreIndependentPerType(t *testing.T) { code := mustParseCode(t, `result = MaskOrKeep(x) - result = x - result = x > 0 + local = x + result = local + result = local > 0 `) ctx := llvm.NewContext() diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index bafab129..a6b326f4 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -73,6 +73,33 @@ func TestFunctionDataflowWaitsForSpecialization(t *testing.T) { require.Equal(t, 2, deadStores) } +func TestInputAliasOutputWriteLiveness(t *testing.T) { + tests := []cfgTestCase{ + { + name: "Repeated Output Write", + code: `out = BumpTwice(current, item) + out = current + item + out = current + item`, + input: "value = 10\nvalue = BumpTwice(value, 5)\nvalue", + }, + { + name: "Incompatible Input Output Storage", + code: `out = Replaced(current) + out = "first" + current + out = "second"`, + input: "value = Replaced(1)\nvalue", + errorContains: `unconditional assignment to "out" overwrites a previous value that was never used`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runCFGTest(t, tt, tt.errorContains != "") + }) + } +} + func getValidTestCases() []cfgTestCase { return []cfgTestCase{ { @@ -142,6 +169,28 @@ func getValidTestCases() []cfgTestCase { name: "Failable Value Protects Only Its Own Destination", input: "x = 7\na = 10\na, b = x < 5, 30\na, b", }, + { + // Writing an output twice never reads it, and a call may target it. + name: "Output Rewritten And Targeted By Nested Call", + code: `res = maybe(x) + res = x > 0 x + +res = refine(x) + res = x + res = x > 5 x * x + res = maybe(x)`, + input: "x = refine(3)\nx", + }, + { + // Intermediate values live in locals; the caller may still reuse a + // variable as both argument and destination. + name: "Local Accumulator Feeds Output", + code: `res = accumulate(a, x) + total = a + x + total = total * 2 + res = total`, + input: "x = 7\nx = accumulate(x, 3)\nx", + }, } } @@ -236,6 +285,77 @@ func getErrorTestCases() []cfgTestCase { input: `"x is", x`, errorContains: `undefined identifier: x`, }, + { + // The seed-dependent body from the effects plan is rejected at the + // read, not silently resolved at the caller. + name: "Output Read After Conditional Write", + code: `res = maybeIncrement(x) + res = x > 0 x + res = res + 1`, + input: "x = maybeIncrement(-1)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + name: "Output Read After Definite Write", + code: `res = overwrite(x) + res = x + res = res + 1`, + input: "x = overwrite(3)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + name: "Output Read In Condition", + code: `res = gated(x) + res = x + res = res > 5 x * x`, + input: "x = gated(3)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + name: "Output Read As Call Argument", + code: `res = id(x) + res = x + +res = forwarded(x) + res = x + res = id(res)`, + input: "x = forwarded(3)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + name: "Output Read By Print", + code: `res = printed(x) + res = x + res`, + input: "x = printed(3)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + // A marker naming an output is a read even before any assignment, + // where it would otherwise pass as literal text. + name: "Output Read By Format Marker", + code: `res = marked(x) + "seed -res" + res = x`, + input: "x = marked(3)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + name: "Output Read By Dynamic Width", + code: `res = widened(x) + res = x + "-x%(-res)d"`, + input: "x = widened(3)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + name: "Sibling Output Read", + code: `a, b = cross(x) + a = x + b = a + 1`, + input: "p, q = cross(3)\np, q", + errorContains: `output "a" is read inside its function; outputs are write-only, use a local`, + }, { name: "Unresolved Dynamic Specifier", input: `x = 42 @@ -764,7 +884,7 @@ res = readFirst(x) res = x * 2 `, wantMsgs: []string{ - `variable "res" has not been defined`, // or your specific "use before definition" text + `output "res" is read inside its function; outputs are write-only, use a local`, }, }, { diff --git a/compiler/compiler.go b/compiler/compiler.go index c0d19ebc..9e740499 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -33,7 +33,7 @@ type Symbol struct { // // Assignment semantics: when assigning a borrowed symbol to a local variable, the value // is COPIED, just like `x = s` copies in regular scope. This ensures: -// - No aliasing between caller's input and output variables +// - Input/output references may alias; ordinary local assignments still copy // - Local variables get independent copies (with Borrowed=false) // - Consistent semantics: x = identity(s) behaves like x = s // @@ -76,21 +76,30 @@ type callArg struct { Name string Symbol *Symbol Lowered *Symbol - // AliasSelector is the one-based selector for the caller destination this - // argument aliases: 0 means none, N means output N-1. Direct scalar params - // transmit it as a hidden ABI argument; indirect params consume it - // caller-side to pass that output's staged pointer in place of the lowered - // argument. One-based keeps the zero value correct for arguments that - // alias nothing. - AliasSelector int -} - + // AliasOutput is the one-based caller destination this argument shares a + // binding with: 0 means none, N means output N-1. It is decided at compile + // time from the call's names, so it selects a lowering variant rather than + // travelling as an argument. One-based keeps the zero value correct for + // arguments that alias nothing. + AliasOutput int +} + +// callSignature is one call site's view of a specialization. Mangled is the +// solver's key; the lowered symbol additionally encodes call-site facts that +// change the emitted body but not its types: wider output storage and which +// inputs share a binding with which outputs. type callSignature struct { - FuncName string - Mangled string - ParamTypes []Type - FnInfo *FuncInfo - ABI FuncABI + FuncName string + Mangled string + // OutputStorage lists every output slot's storage type when a caller + // destination is wider than the declared output; nil otherwise. + OutputStorage []Type + // AliasPattern holds, per parameter, the one-based output it shares a + // binding with at this call site, or 0. Nil means no parameter aliases. + AliasPattern []int + ParamTypes []Type + FnInfo *FuncInfo + ABI FuncABI } type preparedCall struct { @@ -100,13 +109,14 @@ type preparedCall struct { RetStruct llvm.Type } -// paramAlias tracks an aliased direct scalar param binding for the active -// function body. The Base check prevents alias behavior from leaking onto a -// same-name binding introduced later in the scope tree. +// paramAlias records that a parameter of the active function body shares its +// caller binding with the named output. The Base check prevents alias behavior +// from leaking onto a same-name binding introduced later in the scope tree; a +// lowering that rebinds the parameter on purpose, such as ranged staging, +// registers the new symbol as a further base. type paramAlias struct { - Base *Symbol - AliasIndex llvm.Value - OutputNames []string + Base *Symbol + Output string } type symbolSource int @@ -137,8 +147,12 @@ type Compiler struct { ExprCache map[ExprKey]*ExprInfo FuncNameMangled string // current script root or function specialization key Errors []*token.CompileError - paramAliasStack []map[string]*paramAlias - stmtCtxStack []stmtCtx + paramAliasStack []map[string][]*paramAlias + outputSlotTypes map[string]Type + // condTempDest maps a synthetic conditional destination to the source + // destination it stands in for, so call-site aliasing sees through it. + condTempDest map[string]string + stmtCtxStack []stmtCtx } type stmtCtx struct { @@ -181,7 +195,8 @@ func NewCompiler(ctx llvm.Context, mangledPath string, cc *CodeCompiler) *Compil ExprCache: exprCache, FuncNameMangled: "", Errors: []*token.CompileError{}, - paramAliasStack: []map[string]*paramAlias{}, + paramAliasStack: []map[string][]*paramAlias{}, + condTempDest: make(map[string]string), stmtCtxStack: []stmtCtx{}, } } @@ -202,6 +217,9 @@ func freshCompilerIdentifier(prefix identifierPrefix, role string, counter *int) } func (c *Compiler) bindingSlotType(name string, fallback Type) Type { + if typ, exists := c.outputSlotTypes[name]; exists { + return typ + } f := c.FuncCache[c.FuncNameMangled] typ, ok := f.Vars[name] if !ok { @@ -210,7 +228,7 @@ func (c *Compiler) bindingSlotType(name string, fallback Type) Type { return typ } -func (c *Compiler) currentParamAliases() map[string]*paramAlias { +func (c *Compiler) currentParamAliases() map[string][]*paramAlias { if len(c.paramAliasStack) == 0 { return nil } @@ -218,7 +236,7 @@ func (c *Compiler) currentParamAliases() map[string]*paramAlias { } func (c *Compiler) pushParamAliases() { - c.paramAliasStack = append(c.paramAliasStack, make(map[string]*paramAlias)) + c.paramAliasStack = append(c.paramAliasStack, make(map[string][]*paramAlias)) } func (c *Compiler) popParamAliases() { @@ -233,28 +251,33 @@ func identNames(idents []*ast.Identifier) []string { return names } -// bindParamAlias records the output names eagerly, but the outputs themselves -// are resolved lazily from scope when the param is later read or promoted. -// This allows direct outputs to remain values, be replaced in scope, or be -// promoted to slots without invalidating the alias metadata. -func (c *Compiler) bindParamAlias(name string, sym *Symbol, aliasIndex llvm.Value, outputNames []string) { - c.currentParamAliases()[name] = ¶mAlias{ - Base: sym, - AliasIndex: aliasIndex, - OutputNames: append([]string(nil), outputNames...), - } +// bindParamAlias records the shared output by name and resolves its current +// value on every input read. The output may remain a value or be replaced in +// scope without invalidating the input's reference to it. +func (c *Compiler) bindParamAlias(name string, sym *Symbol, output string) { + aliases := c.currentParamAliases() + aliases[name] = append(aliases[name], ¶mAlias{Base: sym, Output: output}) } -func (c *Compiler) clearParamAlias(name string) { - delete(c.currentParamAliases(), name) +func (c *Compiler) paramAliasFor(name string, sym *Symbol) (*paramAlias, bool) { + for _, alias := range c.currentParamAliases()[name] { + if alias.Base == sym { + return alias, true + } + } + return nil, false } -func (c *Compiler) paramAliasFor(name string, sym *Symbol) (*paramAlias, bool) { - alias, ok := c.currentParamAliases()[name] - if !ok || alias.Base != sym { - return nil, false +// destinationBase resolves a synthetic conditional destination to the source +// destination it commits into, following stage temps through commit temps. +func (c *Compiler) destinationBase(name string) string { + for { + base, synthetic := c.condTempDest[name] + if !synthetic { + return name + } + name = base } - return alias, true } func (c *Compiler) resolvedDestTypes(dest []*ast.Identifier, outTypes []Type) []Type { @@ -319,15 +342,18 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, }, true } -// setCallArgAliasSelectors records on each argument which caller destination it -// aliases for range-bearing variants. Direct scalar params encode the selected -// output through a hidden ABI index; indirect params receive that output's -// staged pointer directly. Arguments that alias nothing keep selector 0. -func (c *Compiler) setCallArgAliasSelectors(sig *callSignature, args []callArg, dest []*ast.Identifier) { - if !sig.ABI.HasRangeParams || dest == nil { +// setCallArgAliases records on each argument which caller destination it +// shares a binding with, and derives the call's alias pattern from them. A +// direct scalar param then reads the output's current value inside the +// variant; an indirect param receives that output's staged pointer instead of +// its own. Everything is decided from names, so a nested call inside a variant +// forwards its enclosing input's alias without any run-time state. +func (c *Compiler) setCallArgAliases(sig *callSignature, args []callArg, dest []*ast.Identifier) { + if dest == nil { return } + var pattern []int for paramIndex, arg := range args { if arg.Name == "" { continue @@ -337,22 +363,38 @@ func (c *Compiler) setCallArgAliasSelectors(sig *callSignature, args []callArg, if outputIndex >= len(sig.ABI.Return.OutTypes) { break } - if output.Value != arg.Name { + // Output storage variants preserve compatible ownership widening. + // A remaining type mismatch cannot share the input's representation + // and must not select that output as its storage. + if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { continue } - // An indirect parameter and a same-named output can legitimately - // differ in ownership flavor, such as a StrH binding receiving a - // StrG output. Redirecting the input to that output's adapter would - // make a sibling output that reads the input see the adapter's - // value instead. Direct scalars cannot reach this: the solver - // rejects a name that would need two numeric types. - if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { + base := c.destinationBase(output.Value) + if base != arg.Name && !c.inputAliasesOutput(arg.Name, base) { continue } - args[paramIndex].AliasSelector = outputIndex + 1 + if pattern == nil { + pattern = make([]int, len(args)) + } + args[paramIndex].AliasOutput = outputIndex + 1 + pattern[paramIndex] = outputIndex + 1 break } } + + sig.AliasPattern = pattern +} + +// inputAliasesOutput reports whether a name read inside a variant is an input +// that already shares the given output's binding, so a nested call targeting +// that output with this input keeps the same storage. +func (c *Compiler) inputAliasesOutput(input, output string) bool { + sym, ok := Get(c.Scopes, input) + if !ok { + return false + } + alias, ok := c.paramAliasFor(input, sym) + return ok && alias.Output == output } // directReturnSeedForCall captures the caller's current destination value for a @@ -369,23 +411,6 @@ func (c *Compiler) directReturnSeedForCall(outType Type, dest *ast.Identifier, o return c.makeZeroValue(outType) } -func (c *Compiler) selectAliasedParamPtr(name string, spill llvm.Value, aliasIndex llvm.Value, outputs []*Symbol) llvm.Value { - slotPtr := spill - for i, output := range outputs { - if output == nil { - continue - } - match := c.builder.CreateICmp( - llvm.IntEQ, - aliasIndex, - llvm.ConstInt(c.Context.Int32Type(), uint64(i+1), false), - fmt.Sprintf("%s_alias_%d", name, i), - ) - slotPtr = c.builder.CreateSelect(match, output.Val, slotPtr, fmt.Sprintf("%s_slot_%d", name, i)) - } - return slotPtr -} - func (c *Compiler) localValSymbol(name string, loadName string) (*Symbol, bool) { s, ok := Get(c.Scopes, name) if !ok { @@ -425,37 +450,24 @@ func (c *Compiler) putGlobal(name, mangledName string, sym *Symbol) { c.MangledNames[name] = mangledName } +// directParamValue reads a direct scalar input that shares its binding with +// an output: the output's current value is the input's value. func (c *Compiler) directParamValue(name string, sym *Symbol, alias *paramAlias) *Symbol { - if alias == nil || len(alias.OutputNames) == 0 { - return sym - } - - value := sym.Val - for i, outputName := range alias.OutputNames { - // Skip rather than filter: the selector names an output by position, so - // index i must keep meaning the i-th output for the remaining slots. - outputSym, ok := Get(c.Scopes, outputName) - if !ok || !aliasableOutput(sym.Type, outputSym.Type) { - continue - } - match := c.builder.CreateICmp( - llvm.IntEQ, - alias.AliasIndex, - llvm.ConstInt(c.Context.Int32Type(), uint64(i+1), false), - fmt.Sprintf("%s_alias_match_%d", name, i), - ) - output, _ := c.localValSymbol(outputName, fmt.Sprintf("%s_alias_load_%d", outputName, i)) - aliasVal := c.coerceSymbolForType(output, sym.Type, fmt.Sprintf("%s_alias_value_%d", outputName, i)) - value = c.builder.CreateSelect(match, aliasVal.Val, value, fmt.Sprintf("%s_alias_value_%d", name, i)) + output, ok := c.localValSymbol(alias.Output, name+"_alias_load") + if !ok { + panic(fmt.Sprintf("internal: input %s aliases unbound output %s", name, alias.Output)) } resolved := GetCopy(sym) - resolved.Val = value + resolved.Val = c.coerceSymbolForType(output, sym.Type, name+"_alias_value").Val return resolved } +// valueSymbol reads a binding. An aliased direct scalar input reads its +// output's current value; an aliased indirect input already points at that +// output's storage, so it reads through its own pointer like any other. func (c *Compiler) valueSymbol(name string, sym *Symbol, loadName string) *Symbol { - if alias, ok := c.paramAliasFor(name, sym); ok { + if alias, ok := c.paramAliasFor(name, sym); ok && sym.Type.Kind() != PtrKind { return c.directParamValue(name, sym, alias) } return c.derefIfPointer(sym, loadName) @@ -1040,7 +1052,12 @@ func (c *Compiler) storeValue(name string, rhsSym *Symbol, shouldCopy bool) { if !exists || oldSym.Type.Kind() != PtrKind { targetType := c.bindingSlotType(name, valueToStore.Type) valueToStore = c.coerceSymbolForType(valueToStore, targetType, name+"_rhs_load") - Put(c.Scopes, name, valueToStore) + + // Parameter permissions belong to the binding, not a copied value. + stored := GetCopy(valueToStore) + stored.FuncArg = exists && oldSym.FuncArg + stored.ReadOnly = exists && oldSym.ReadOnly + Put(c.Scopes, name, stored) return } @@ -1635,10 +1652,6 @@ func (c *Compiler) promoteToMemory(name string) *Symbol { panic("Compiler error: trying to promote to memory an undefined variable: " + name) } - if alias, ok := c.paramAliasFor(name, sym); ok { - return c.promoteAlias(name, sym, alias) - } - ptr, alreadyPtr := c.makePtr(name, sym) if alreadyPtr { return ptr @@ -1650,43 +1663,6 @@ func (c *Compiler) promoteToMemory(name string) *Symbol { return ptr } -func (c *Compiler) promoteAlias(name string, sym *Symbol, alias *paramAlias) *Symbol { - paramPtr := c.createEntryBlockAlloca(c.mapToLLVMType(sym.Type), name) - c.createStore(sym.Val, paramPtr, sym.Type) - - slotPtr := paramPtr - if len(alias.OutputNames) > 0 { - outputPtrs := make([]*Symbol, len(alias.OutputNames)) - for i, outputName := range alias.OutputNames { - outputSym, _ := Get(c.Scopes, outputName) - // Left nil when the output cannot back this slot, so the selector - // keeps its positional meaning but never picks a mistyped pointer. - if !aliasableOutput(sym.Type, outputSym.Type) { - continue - } - if outputSym.Type.Kind() != PtrKind { - // Only params carry alias bindings, so promoting an output here - // cannot recurse through another param-alias entry. - outputSym = c.promoteToMemory(outputName) - } - outputPtrs[i] = outputSym - } - slotPtr = c.selectAliasedParamPtr(name, paramPtr, alias.AliasIndex, outputPtrs) - } - - ptr := &Symbol{ - Val: slotPtr, - Type: Ptr{Elem: sym.Type}, - FuncArg: sym.FuncArg, - Borrowed: sym.Borrowed, - ReadOnly: sym.ReadOnly, - WriteFlag: sym.WriteFlag, - } - Put(c.Scopes, name, ptr) - c.clearParamAlias(name) - return ptr -} - // createStore is a simple helper that creates an LLVM store instruction and sets its alignment. // It has NO side effects on the Go compiler state or symbols. // the val is the value to be stored and the ptr is the memory location it is to be stored to @@ -2405,10 +2381,12 @@ func (c *Compiler) cleanupSkippedCallOutputAdapters(adapters []callOutputAdapter // bindRangedTempOutputs makes each destination name resolve to its staged slot // while that one ranged expression is compiled. Conditional lowering can make // the real destination and a synthetic conditional write name alias the same -// slot, so bind every visible name for that slot as well. This preserves -// loop-carried self-reference (res = res + i) without exposing the staged value -// to sibling right-hand sides in a simultaneous assignment; the caller's -// BlockScope is popped before the next expression is compiled. +// slot, so bind every visible name for that slot as well. An input that +// shares the destination is rebound to the staged slot too and keeps its +// alias, so a nested call inside the loop still selects the sharing variant. +// This preserves loop-carried self-reference without exposing staged values to +// sibling right-hand sides in a simultaneous assignment. The caller pops its +// BlockScope before compiling the next expression. func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symbol) { for i := 0; i < len(dest) && i < len(outputs); i++ { // A blank binds nothing and nothing can read it back, so it has no @@ -2419,15 +2397,23 @@ func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symb } names := []string{dest[i].Value} + aliased := make(map[string]string) if current, ok := Get(c.Scopes, dest[i].Value); ok && current.Type.Kind() == PtrKind { + base := c.destinationBase(dest[i].Value) seen := make(map[string]struct{}) for scopeIdx := len(c.Scopes) - 1; scopeIdx >= 0; scopeIdx-- { scope := c.Scopes[scopeIdx] - for name, sym := range scope.Elems { + for _, name := range scope.BindingOrder { + sym := scope.Elems[name] if _, visited := seen[name]; visited { continue } seen[name] = struct{}{} + if alias, ok := c.paramAliasFor(name, sym); ok && alias.Output == base { + names = append(names, name) + aliased[name] = alias.Output + continue + } if sym.Type.Kind() == PtrKind && sym.Val == current.Val { names = append(names, name) } @@ -2439,6 +2425,11 @@ func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symb } for _, name := range names { Put(c.Scopes, name, outputs[i]) + // The rebound input keeps its alias, so a nested call inside the + // loop still selects the sharing variant. + if output, ok := aliased[name]; ok { + c.bindParamAlias(name, outputs[i], output) + } } } } @@ -2572,9 +2563,6 @@ func (c *Compiler) getFuncType(mangled string, abi FuncABI) (llvm.Type, llvm.Typ for _, param := range abi.Params { llvmParams = append(llvmParams, c.mapToLLVMType(param.Lowered)) } - for i := 0; i < abi.NumAliasSlots(); i++ { - llvmParams = append(llvmParams, c.Context.Int32Type()) - } if abi.Return.Mode == ABIReturnDirect { llvmParams = append(llvmParams, c.mapToLLVMType(abi.Return.DirectType)) } @@ -2603,7 +2591,10 @@ func (c *Compiler) addPointerParamAttributes(function llvm.Value, index int) { } func (c *Compiler) compileFunc(template *ast.FuncStatement, sig *callSignature, funcType llvm.Type, retStruct llvm.Type) llvm.Value { - function := llvm.AddFunction(c.Module, sig.Mangled, funcType) + function := llvm.AddFunction(c.Module, sig.loweredName(), funcType) + if sig.isVariant() { + function.SetLinkage(llvm.InternalLinkage) + } if sig.ABI.UsesIndirectReturn() { sretAttr := c.Context.CreateTypeAttribute(llvm.AttributeKindID("sret"), retStruct) @@ -2622,9 +2613,6 @@ func (c *Compiler) compileFunc(template *ast.FuncStatement, sig *callSignature, c.addPointerParamAttributes(function, paramIndex) } - for i := 0; i < sig.ABI.NumAliasSlots(); i++ { - c.addNoundefAttribute(function, sig.ABI.AliasParamBaseIndex()+i+1) - } if seedParamIndex := sig.ABI.DirectReturnSeedParamIndex(); seedParamIndex >= 0 { c.addNoundefAttribute(function, seedParamIndex+1) } @@ -2637,9 +2625,15 @@ func (c *Compiler) compileFunc(template *ast.FuncStatement, sig *callSignature, // Set FuncNameMangled so ExprCache entries are keyed to this function savedFuncNameMangled := c.FuncNameMangled c.FuncNameMangled = sig.Mangled + savedOutputSlots := c.outputSlotTypes + c.outputSlotTypes = make(map[string]Type, len(template.Outputs)) + for i, output := range template.Outputs { + c.outputSlotTypes[output.Value] = sig.ABI.Return.OutTypes[i] + } c.pushParamAliases() retVal, hasDirectRet := c.compileFuncBlock(template, sig, retStruct, function) c.popParamAliases() + c.outputSlotTypes = savedOutputSlots c.FuncNameMangled = savedFuncNameMangled if hasDirectRet { @@ -2730,9 +2724,6 @@ func (c *Compiler) processParams(template *ast.FuncStatement, sig *callSignature FuncArg: true, ReadOnly: true, } - if aliasParamIndex := sig.ABI.AliasFunctionParamIndex(i); aliasParamIndex >= 0 { - c.bindParamAlias(name, inputs[i], function.Param(aliasParamIndex), outputNames) - } } else { inputs[i] = &Symbol{ Val: paramVal, @@ -2742,6 +2733,12 @@ func (c *Compiler) processParams(template *ast.FuncStatement, sig *callSignature ReadOnly: true, } } + // An aliased indirect param already points at the output's staged + // storage; the binding lets nested calls and caller-side ranges + // forward that sharing by name. + if i < len(sig.AliasPattern) && sig.AliasPattern[i] > 0 { + c.bindParamAlias(name, inputs[i], outputNames[sig.AliasPattern[i]-1]) + } if isRangeDriverType(elemType) { iterIndices = append(iterIndices, i) @@ -2771,7 +2768,7 @@ func (c *Compiler) compileFuncBlock(template *ast.FuncStatement, sig *callSignat var outputs []*Symbol if sig.ABI.UsesIndirectReturn() { sretPtr := function.Param(0) - outputs = c.processIndirectOutputs(template, retStruct, sretPtr, sig.FnInfo.Sig.OutTypes) + outputs = c.processIndirectOutputs(template, retStruct, sretPtr, sig.ABI.Return.OutTypes) } else { outputs = c.processDirectOutputValues(template, sig, function) } @@ -2909,11 +2906,17 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, if level == len(fa.IterIndices) { PushScope(&c.Scopes, BlockScope) defer c.popScope() + // Direct-return ABI is single-output today, so the loop body only + // needs the current scalar output binding for fn.Outputs[0]. + if currentOutput != nil { + Put(c.Scopes, fn.Outputs[0].Value, currentOutput) + } + c.compileFuncBody(fn) if currentOutput == nil { - c.compileFuncBody(fn) return nil } - return c.compileDirectOutputIterBody(fn, currentOutput) + output, _ := c.localValSymbol(fn.Outputs[0].Value, fn.Outputs[0].Value+"_iter_out") + return output } paramIdx := fa.IterIndices[level] @@ -2926,7 +2929,7 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, Type: iterType, FuncArg: true, Borrowed: true, - ReadOnly: false, + ReadOnly: true, } PushScope(&c.Scopes, BlockScope) Put(c.Scopes, name, iterSym) @@ -2960,16 +2963,6 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, return result } -func (c *Compiler) compileDirectOutputIterBody(fn *ast.FuncStatement, currentOutput *Symbol) *Symbol { - // Direct-return ABI is single-output today, so the loop body only needs the - // current scalar output binding for fn.Outputs[0]. - Put(c.Scopes, fn.Outputs[0].Value, currentOutput) - c.compileFuncBody(fn) - - output, _ := c.localValSymbol(fn.Outputs[0].Value, fn.Outputs[0].Value+"_iter_out") - return output -} - func (c *Compiler) compileFuncBody(fn *ast.FuncStatement) { for _, stmt := range fn.Body.Statements { c.compileStatement(stmt) @@ -3102,7 +3095,7 @@ func (c *Compiler) freeCallArgTemps(callArgs []callArg) { func (c *Compiler) prepareCall(sig *callSignature, ce *ast.CallExpression, dest []*ast.Identifier) preparedCall { callArgs := c.compileCallArgs(sig, ce) - c.setCallArgAliasSelectors(sig, callArgs, dest) + c.setCallArgAliases(sig, callArgs, dest) c.lowerCallArgs(sig.FuncName, callArgs, sig) fn, funcType, retStruct := c.getOrCompileCallFunction(sig) return preparedCall{ @@ -3227,16 +3220,16 @@ func (c *Compiler) compileCallExpression(ce *ast.CallExpression, dest []*ast.Ide // them at independent, destination-seeded slots so a call in one RHS cannot // mutate a real destination before sibling RHS expressions have read the // statement-start values. The outer assignment owns the eventual commit and - // cleanup. ABI-flavor adapters handle established slots such as StrH when a - // callee declares StrG. + // cleanup. Private output-storage variants preserve compatible widening, + // such as an established StrH slot receiving a declared StrG output. outputs := c.makeSeededTempOutputs(dest, info.OutTypes) c.compileIndirectCallIntoStagedOutputs(sig, ce, dest, outputs) return c.loadOutputValues(outputs, "call_final") } func (c *Compiler) getOrCompileCallFunction(sig *callSignature) (llvm.Value, llvm.Type, llvm.Type) { - funcType, retStruct := c.getFuncType(sig.Mangled, sig.ABI) - fn := c.Module.NamedFunction(sig.Mangled) + funcType, retStruct := c.getFuncType(sig.loweredName(), sig.ABI) + fn := c.Module.NamedFunction(sig.loweredName()) if !fn.IsNil() { return fn, funcType, retStruct } @@ -3292,6 +3285,7 @@ func (c *Compiler) compileIndirectCallIntoStagedOutputs( dest []*ast.Identifier, staged []*Symbol, ) { + c.specializeOutputStorage(sig, staged) adapters := c.makeCallOutputAdapters(staged, sig.ABI.Return.OutTypes) callOutputs := callAdapterOutputs(adapters) c.compileIndirectCallIntoOutputs( @@ -3304,6 +3298,40 @@ func (c *Compiler) compileIndirectCallIntoStagedOutputs( ) } +// loweredName is the symbol of the private variant this call site lowers to, +// or the public specialization when no call-site fact changes the body. +func (sig *callSignature) loweredName() string { + return MangleVariant(sig.Mangled, sig.OutputStorage, sig.AliasPattern) +} + +func (sig *callSignature) isVariant() bool { + return sig.OutputStorage != nil || sig.AliasPattern != nil +} + +// specializeOutputStorage keeps a writable output and a compatible input on +// the same representation. In particular an untyped empty array result must +// reset the actual array slot, rather than a separate zero-seeded adapter that +// its input cannot observe. Solver facts remain keyed by the source signature; +// only the private function's output storage and ownership change. +func (c *Compiler) specializeOutputStorage(sig *callSignature, outputs []*Symbol) { + changed := false + for i, output := range outputs { + storage := output.Type.(Ptr).Elem + declared := sig.ABI.Return.OutTypes[i] + if TypeEqual(storage, declared) || !bindingSlotCompatible(storage, declared) { + continue + } + if !TypeEqual(mergeBindingSlotType(storage, declared), storage) { + continue + } + sig.ABI.Return.OutTypes[i] = storage + changed = true + } + if changed { + sig.OutputStorage = slices.Clone(sig.ABI.Return.OutTypes) + } +} + func (c *Compiler) makeCallOutputWriteFlags(count int) []llvm.Value { flags := make([]llvm.Value, count) for i := range flags { @@ -3363,22 +3391,11 @@ func (c *Compiler) callArgs( } for i, arg := range call.Args { argVal := arg.Lowered.Val - if sig.ABI.Params[i].Mode == ABIParamIndirect && arg.AliasSelector > 0 && arg.AliasSelector <= len(outputs) { - argVal = outputs[arg.AliasSelector-1].Val + if sig.ABI.Params[i].Mode == ABIParamIndirect && arg.AliasOutput > 0 && arg.AliasOutput <= len(outputs) { + argVal = outputs[arg.AliasOutput-1].Val } llvmArgs = append(llvmArgs, argVal) } - aliasIndices := make([]int, sig.ABI.NumAliasSlots()) - for i, arg := range call.Args { - slot := sig.ABI.Params[i].AliasSlot - if slot < 0 { - continue - } - aliasIndices[slot] = arg.AliasSelector - } - for _, aliasIndex := range aliasIndices { - llvmArgs = append(llvmArgs, llvm.ConstInt(c.Context.Int32Type(), uint64(aliasIndex), false)) - } if sig.ABI.Return.Mode == ABIReturnDirect { seed := c.coerceSymbolForType(directSeed, sig.ABI.Return.DirectType, sig.FuncName+"_seed") llvmArgs = append(llvmArgs, seed.Val) diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 5127824b..788d630b 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -201,15 +201,16 @@ func verifyCompiledModules(t *testing.T, moduleName, codeSrc, scriptSrc string) compileScriptAndCodeIR(t, moduleName, codeSrc, scriptSrc) } -// The alias selector picks an output by position, so a mistyped output reaching -// it can produce invalid IR or silently select the wrong slot. -func TestAliasSelectorTypeGaps(t *testing.T) { - const accFirst = "s = 1\nq, r = Mixed(s, 0:4)\nq, r" +// A shared input may only alias an output of its own type. With an +// incompatible output declared first, the variant must pass over it and bind +// the input to the compatible sibling, producing valid IR for each kind. +func TestAliasVariantSkipsIncompatibleOutputs(t *testing.T) { + const sharedSecond = "s = 1\nr, s = Mixed(s, 0:4)\nr, s" cases := []struct{ name, code, script string }{ - {"float sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = x * 0.5", accFirst}, - {"string sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = \"n\"", accFirst}, - {"array sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = [x x]", accFirst}, + {"float first", "other, sum = Mixed(a, x)\n other = x * 0.5\n sum = a + x", sharedSecond}, + {"string first", "other, sum = Mixed(a, x)\n other = \"n\"\n sum = a + x", sharedSecond}, + {"array first", "other, sum = Mixed(a, x)\n other = [x x]\n sum = a + x", sharedSecond}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -431,27 +432,62 @@ out = Echo(value) } } -// Writing a parameter through %n promotes it to memory, which picks the aliased -// slot by pointer. Opaque pointers make a mistyped pointer select valid IR and -// the selector never matches the skipped index at runtime, so only the emitted -// slot selects distinguish this path. -func TestPromotedAliasTypeGap(t *testing.T) { +// A call whose argument names one of its own destinations lowers to a private +// variant in which that input reads the output's storage. The pattern names +// outputs by declared position, so a leading output whose type cannot back the +// input keeps its slot in the name. +func TestAliasedInputReadsOutputInVariant(t *testing.T) { code := `half, res = Rev(a, x) - "count-a%n chars" half = x * 0.5 res = a + x` script := `r = 10 h, r = Rev(r, 1:4) h, r` - ir, _ := compileScriptAndCodeIR(t, "pointer_promotion_gap", code, script) + ir, _ := compileScriptAndCodeIR(t, "input_alias_variant", code, script) + mangled := Mangle(MangleDirPath("input_alias_variant", ""), "Rev", []Type{I64, Range{Iter: I64}}) + + require.Contains(t, ir, "define internal void @"+mangled+"_a2_2_0(", + "the aliased call must lower to a private variant naming the second output for the first input") + require.Contains(t, ir, "%a_alias_load = load i64, ptr %res_dest", + "inside the variant the input reads the res output's storage directly") + require.NotContains(t, ir, "alias_match", "no run-time selection remains") + require.NotContains(t, ir, "define void @"+mangled+"(", "the unaliased specialization is not emitted when only the variant is called") +} + +func TestRangedCallDoesNotCopyUnrelatedArrayInput(t *testing.T) { + // Both outputs are integers, so writing them can never change the array + // input even though it is read after the first output write. Copying it + // per iteration would make the call quadratic. + code := `count, value = Read(data, index) + count = index + value = data[index]` + script := `data = [0:8] +count, value = Read(data, 0:8) +count, value` + + ir, _ := compileScriptAndCodeIR(t, "unrelated_array_input", code, script) + + require.NotContains(t, ir, "@arr_i64_copy", + "an input no output can alias must not be copied per iteration") +} + +func TestRangedCallDoesNotCopyArrayInputWithMatchingOutputType(t *testing.T) { + // The output has the same type as the input, but each iteration selects + // only one element. Copying the input would turn this linear call quadratic. + code := `out = Pick(data, index) + out = [data[index]]` + script := `data = [0:8] +result = Pick(data, 0:8) +result` + + ir, _ := compileScriptAndCodeIR(t, "matching_array_input", code, script) + mangled := Mangle(MangleDirPath("matching_array_input", ""), "Pick", []Type{Array{ElemType: I64, Rank: 1}, Range{Iter: I64}}) - require.Regexp(t, `%a_alias_1 = icmp eq i32 %\d+, 2`, ir, - "the compatible output is the second one, so its ABI selector value must be 2") - require.Contains(t, ir, "%a_slot_1 = select i1 %a_alias_1, ptr %res_dest, ptr %a", - "selector 2 must choose the caller's res destination, falling back to the parameter spill") - require.NotContains(t, ir, "%a_slot_0 = select", - "the mismatched leading output must never be selectable as the parameter's slot") + require.NotContains(t, ir, "@arr_i64_copy", + "a matching output type must not introduce an input copy on every iteration") + require.NotContains(t, ir, mangled+"_a", + "an input that shares no destination calls the public specialization, not an alias variant") } func TestRangeCollectorScalarVariant(t *testing.T) { @@ -514,9 +550,9 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "Acc", []Type{I64, Range{Iter: I64}}) - require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(", "range-bearing variant should keep the direct scalar return") - require.Contains(t, scriptIR, "i64 noundef %0, ptr noundef nonnull \"captures\"=\"none\" %1, i32 noundef %2, i64 noundef %3", "range-bearing variant should keep the range indirect but lower scalar input/output directly with param attrs") - require.Contains(t, scriptIR, "call i64 @"+mangled+"(", "expected direct scalar call/return for ranged accumulator case") + require.Contains(t, scriptIR, "define internal noundef i64 @"+mangled+"_a2_1_0(", "the self-aliased range-bearing call lowers to a private variant that keeps the direct scalar return") + require.Contains(t, scriptIR, "i64 noundef %0, ptr noundef nonnull \"captures\"=\"none\" %1, i64 noundef %2", "range-bearing variant should keep the range indirect but lower scalar input/output directly with param attrs") + require.Contains(t, scriptIR, "call i64 @"+mangled+"_a2_1_0(", "expected direct scalar call/return for ranged accumulator case") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar range variant should not use sret struct") } diff --git a/compiler/cond.go b/compiler/cond.go index 6e9381e1..d4746439 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -222,6 +222,7 @@ func (c *Compiler) createConditionalTempOutputsFor(dest []*ast.Identifier, outTy // Temporary conditional outputs are borrowed so scope cleanup does not free // values that are transferred to real destinations in the merge block. Put(c.Scopes, tempName, tempSym) + c.condTempDest[tempName] = ident.Value slots[i] = OutputSlot{dest: ident, temp: tempIdent, outType: outTypes[i]} } return slots @@ -362,6 +363,7 @@ func (c *Compiler) createStageTempOutputsFor(commit []OutputSlot) []OutputSlot { stageTempSym.WriteFlag = commitSym.WriteFlag } Put(c.Scopes, tempName, stageTempSym) + c.condTempDest[tempName] = cs.dest.Value stage[i] = OutputSlot{dest: cs.dest, temp: tempIdent, outType: outType} } return stage diff --git a/compiler/format.go b/compiler/format.go index 39e52331..cf61b3d4 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -682,6 +682,12 @@ func (c *Compiler) formatSpecialValue(tok token.Token, mainID string, mainSym *S Msg: fmt.Sprintf("cannot write to constant %q", mainID), } } + if mainSym.ReadOnly { + return true, &token.CompileError{ + Token: tok, + Msg: fmt.Sprintf("cannot write to input parameter %q", mainID), + } + } s := c.promoteToMemory(mainID) result.args = append(result.args, s.Val) return true, nil diff --git a/compiler/live_alias_format_test.go b/compiler/live_alias_format_test.go new file mode 100644 index 00000000..b5b4e297 --- /dev/null +++ b/compiler/live_alias_format_test.go @@ -0,0 +1,38 @@ +package compiler + +import ( + "testing" + + "github.com/stretchr/testify/require" + "tinygo.org/x/go-llvm" +) + +func TestFormatCountRejectsInputParameter(t *testing.T) { + tests := []struct { + name string + script string + }{ + {name: "plain", script: "value = 10\nvalue = Count(value)\nvalue"}, + {name: "range", script: "value = Count(1:3)\nvalue"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + code := mustParseCode(t, `out = Count(current) + "count-current%n" + out = current`) + cc := NewCodeCompiler(ctx, "format_input_parameter", "", code) + require.Empty(t, cc.Compile()) + + sc := NewScriptCompiler(ctx, t.Name(), mustParseScript(t, tt.script), cc) + linkCodeModuleForTest(t, ctx, sc.Compiler.Module, cc.Compiler.Module) + errs := sc.Compile() + + require.Len(t, errs, 1) + require.Equal(t, `cannot write to input parameter "current"`, errs[0].Msg) + }) + } +} diff --git a/compiler/mangle.go b/compiler/mangle.go index 7bf59ae6..108d6c4a 100644 --- a/compiler/mangle.go +++ b/compiler/mangle.go @@ -14,6 +14,8 @@ const ( R = "r" // Relpath end marker (for constants with relpath) F = "f" // Function arity marker T = "t" // Generic type params marker + A = "a" // Alias variant marker: per-parameter output slot pattern + O = "o" // Output storage variant marker: widened output slot types M = "m" // Method separator OP = "op" // Operator prefix N = "n" // Numeric segment prefix @@ -58,6 +60,13 @@ type Demangled struct { Kind SymbolKind // Type of symbol Arity int // Number of arguments (for functions) ArgTypes []string // Argument type names (for functions) + // OutputStorage lists every output slot's storage type for a private + // output-storage variant; nil for the public specialization. + OutputStorage []string + // AliasPattern holds, per parameter, the one-based output slot the + // parameter shares at the call site, 0 for none; nil when no parameter + // aliases. Present only on private alias variants. + AliasPattern []int } // FullPath returns the complete path (ModPath + RelPath). @@ -89,9 +98,32 @@ func (d *Demangled) String() string { result.WriteString(strings.Join(d.ArgTypes, ", ")) result.WriteString(")") } + if d.OutputStorage != nil { + result.WriteString(" -> (") + result.WriteString(strings.Join(d.OutputStorage, ", ")) + result.WriteString(")") + } + if aliases := d.aliasDisplay(); aliases != "" { + result.WriteString(" [") + result.WriteString(aliases) + result.WriteString("]") + } return result.String() } +// aliasDisplay renders the non-zero alias pattern entries as in->out, +// both one-based, in parameter order. +func (d *Demangled) aliasDisplay() string { + var parts []string + for i, slot := range d.AliasPattern { + if slot == 0 { + continue + } + parts = append(parts, fmt.Sprintf("in%d->out%d", i+1, slot)) + } + return strings.Join(parts, ", ") +} + // Mangle generates C ABI-compliant function name per Pluto C ABI Spec. // Format: [MangledPath]_[Name]_f[N]_[Types...] // mangledPath is pre-computed via MangleDirPath. @@ -103,6 +135,29 @@ func Mangle(mangledPath, funcName string, args []Type) string { return strings.Join(parts, SEP) } +// MangleVariant names a private lowering variant of a function specialization +// per Pluto C ABI Spec §5.2. An output-storage suffix _oN_ lists +// every output slot's storage type; an alias suffix _aN_... carries one +// entry per parameter, 0 for a parameter sharing no output and k for one +// sharing output k-1. A nil slice omits its suffix, so two nils return the +// public specialization symbol unchanged. +func MangleVariant(mangled string, outputStorage []Type, aliasPattern []int) string { + parts := []string{mangled} + if outputStorage != nil { + parts = append(parts, O+strconv.Itoa(len(outputStorage))) + for _, storage := range outputStorage { + parts = append(parts, storage.Mangle()) + } + } + if aliasPattern != nil { + parts = append(parts, A+strconv.Itoa(len(aliasPattern))) + for _, slot := range aliasPattern { + parts = append(parts, strconv.Itoa(slot)) + } + } + return strings.Join(parts, SEP) +} + // ManglePath converts a logical path to its mangled form per Pluto C ABI Spec. // Separators: . -> d, / -> s, - -> h // Identifiers are length-prefixed. @@ -390,14 +445,49 @@ func demangleFunc(result *Demangled, rest string) { // Parse argument types for strings.HasPrefix(rest, SEP) { - rest = rest[len(SEP):] - typeName, remaining := demangleType(rest) + typeName, remaining := demangleType(rest[len(SEP):]) if typeName == "" { break } result.ArgTypes = append(result.ArgTypes, typeName) rest = remaining } + + demangleVariant(result, rest) +} + +// demangleVariant parses the optional private-variant suffixes that follow a +// function's argument types: _oN and N storage types, then _aN and N slots. +func demangleVariant(result *Demangled, rest string) { + if after, ok := strings.CutPrefix(rest, SEP+O); ok && startsWithDigit(after) { + count, remaining := parseArity(after) + result.OutputStorage = []string{} + for i := 0; i < count && strings.HasPrefix(remaining, SEP); i++ { + typeName, next := demangleType(remaining[len(SEP):]) + if typeName == "" { + break + } + result.OutputStorage = append(result.OutputStorage, typeName) + remaining = next + } + rest = remaining + } + + after, ok := strings.CutPrefix(rest, SEP+A) + if !ok || !startsWithDigit(after) { + return + } + count, remaining := parseArity(after) + result.AliasPattern = []int{} + for i := 0; i < count && strings.HasPrefix(remaining, SEP) && startsWithDigit(remaining[len(SEP):]); i++ { + slot, next := parseArity(remaining[len(SEP):]) + result.AliasPattern = append(result.AliasPattern, slot) + remaining = next + } +} + +func startsWithDigit(s string) bool { + return len(s) > 0 && s[0] >= '0' && s[0] <= '9' } // parseArity parses arity digits from s. diff --git a/compiler/mangle_test.go b/compiler/mangle_test.go index b04e0611..6103892a 100644 --- a/compiler/mangle_test.go +++ b/compiler/mangle_test.go @@ -821,3 +821,38 @@ func TestMangleScriptUsesPathEncoding(t *testing.T) { assert.Equal(t, "Pt_7example_d_3com_s_4math_s_2v1_d_n2_d_n3_p_7reports_s_5daily_r_n1_d_n2_h_7summary_e", mangled) } + +func TestMangleVariantRoundTrip(t *testing.T) { + base := Mangle(MangleDirPath("math", ""), "Fold", []Type{I64, StrH{}}) + tests := []struct { + name string + storage []Type + pattern []int + mangled string + expected string + }{ + {name: "public specialization", mangled: base, expected: "math.Fold(I64, StrH)"}, + {name: "alias variant", pattern: []int{1, 0}, mangled: base + "_a2_1_0", expected: "math.Fold(I64, StrH) [in1->out1]"}, + {name: "storage variant", storage: []Type{StrH{}, StrH{}}, mangled: base + "_o2_StrH_StrH", expected: "math.Fold(I64, StrH) -> (StrH, StrH)"}, + {name: "storage and alias variant", storage: []Type{StrH{}, I64}, pattern: []int{2, 1}, mangled: base + "_o2_StrH_I64_a2_2_1", expected: "math.Fold(I64, StrH) -> (StrH, I64) [in1->out2, in2->out1]"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mangled := MangleVariant(base, tt.storage, tt.pattern) + assert.Equal(t, tt.mangled, mangled) + assert.Equal(t, tt.expected, Demangle(mangled)) + + parsed, err := DemangleParsed(mangled) + assert.NoError(t, err) + assert.Equal(t, SymbolFunc, parsed.Kind) + assert.Equal(t, []string{"I64", "StrH"}, parsed.ArgTypes) + assert.Equal(t, tt.pattern, parsed.AliasPattern) + if tt.storage == nil { + assert.Nil(t, parsed.OutputStorage) + return + } + assert.Len(t, parsed.OutputStorage, len(tt.storage)) + }) + } +} diff --git a/compiler/solver.go b/compiler/solver.go index 10176250..29cd39ab 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2,6 +2,7 @@ package compiler import ( "fmt" + "maps" "slices" "github.com/thiremani/pluto/ast" @@ -145,6 +146,8 @@ type TypeSolver struct { PendingAssignments map[pendingAssignment]struct{} walkedFuncs map[string]walkedSpecialization // specializations walked in the current pass firstUnresolved *ast.FuncStatement + storageRevision uint64 // increments when a previously observed binding slot widens + previousSlotTypes map[string]Type // prior walk's slots for the body being inferred recLimit recursionLimit } @@ -172,6 +175,20 @@ func (ts *TypeSolver) recordBindingSlotType(name string, typ Type) { if f == nil { panic(fmt.Sprintf("internal: missing cached body %s while recording variable %s", ts.FuncNameMangled, name)) } + previous, exists := f.Vars[name] + if !exists { + previous, exists = ts.previousSlotTypes[name] + } + if exists { + // Rewalks retain storage learned from later statements. Publishing it + // only after the declaration keeps name resolution in source order. + if bindingSlotCompatible(typ, previous) { + typ = mergeBindingSlotType(typ, previous) + } + if !TypeEqual(previous, typ) { + ts.storageRevision++ + } + } f.Vars[name] = typ } @@ -714,10 +731,26 @@ func (ts *TypeSolver) TypeStatement(stmt ast.Statement) { func (ts *TypeSolver) Solve() { program := ts.ScriptCompiler.Program oldErrs := len(ts.Errors) - for _, stmt := range program.Statements { - ts.TypeStatement(stmt) - if len(ts.Errors) > oldErrs { - return + initialScope := ts.Scopes[0] + + // A later assignment can widen the storage read by an earlier call. + // Rebuild source-order facts until those call signatures match the slots. + for { + ts.Scopes[0] = Scope[Type]{ + Elems: maps.Clone(initialScope.Elems), + BindingOrder: slices.Clone(initialScope.BindingOrder), + ScopeKind: initialScope.ScopeKind, + } + revision := ts.storageRevision + + for _, stmt := range program.Statements { + ts.TypeStatement(stmt) + if len(ts.Errors) > oldErrs { + return + } + } + if revision == ts.storageRevision { + break } } @@ -2387,10 +2420,21 @@ func (ts *TypeSolver) callScopedArrayRangeType(expr ast.Expression) (ArrayRange, // Uses the shared TypeExprsForIter for the core logic. func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args []Type, innerArgs []Type, loopInside bool) { outerTypesPerArg, loopInside, _ := ts.TypeExprsForIter(ce.Arguments, isRoot) + _, builtin := Builtins[ce.Function.Value] // Build args and innerArgs from outer types // If loopInside=false, ALL range args become their inner type (loop outside) for argIndex, outerTypes := range outerTypesPerArg { + if ident, ok := ce.Arguments[argIndex].(*ast.Identifier); ok && !builtin { + // Calls receive the binding's actual slot, including ownership + // widening learned from later writes. Other expressions retain + // their flow type (an empty value can still reset another array). + body := ts.ScriptCompiler.Compiler.FuncCache[ts.FuncNameMangled] + if slotType, exists := body.Vars[ident.Value]; exists { + outerTypes = []Type{slotType} + } + } + if loopInside { if arrayRangeType, yieldedType, ok := ts.callScopedArrayRangeType(ce.Arguments[argIndex]); ok { args = append(args, arrayRangeType) @@ -2610,7 +2654,12 @@ func (ts *TypeSolver) TypeFunc(mangled string, template *ast.FuncStatement) bool info: f, template: template, } - clear(f.Vars) + revision := ts.storageRevision + previousSlots := ts.previousSlotTypes + ts.previousSlotTypes = f.Vars + f.Vars = make(map[string]Type) + defer func() { ts.previousSlotTypes = previousSlots }() + previousCycleStart := ts.recLimit.push(specializationFrame{ mangled: mangled, template: template, @@ -2623,6 +2672,10 @@ func (ts *TypeSolver) TypeFunc(mangled string, template *ast.FuncStatement) bool defer func() { ts.FuncNameMangled = savedFuncNameMangled }() ts.TypeBlock(template, f) + if revision != ts.storageRevision { + ts.Converging = true + } + return f.OutputTypesInferred() } diff --git a/compiler/solver_test.go b/compiler/solver_test.go index aea3b290..e068ac65 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -643,6 +643,84 @@ a = a ⊕ "d"` require.True(t, IsStrH(secondInfo.OutTypes[0]), "concat expression should remain StrH") } +func TestCallArgumentsUseSettledBindingSlotTypes(t *testing.T) { + for _, tt := range []struct { + name string + seed string + append string + item string + want Type + }{ + {"string scalar", `"hello"`, "item", `"abc"`, StrH{}}, + {"array range", "[]", "[item]", "1:3", Array{ElemType: I64, Rank: 1}}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + code := mustParseCode(t, fmt.Sprintf(`out, before = Fold(current, item) + out = current ⊕ %s + before = current +`, tt.append)) + cc := NewCodeCompiler(ctx, t.Name(), "", code) + require.Empty(t, cc.Compile()) + source := fmt.Sprintf("value = %s\nvalue, before = Fold(value, %s)\nvalue, before", tt.seed, tt.item) + + for _, run := range []string{"Cold", "Warm"} { + ts := solveScriptTypes(t, ctx, cc, t.Name()+run, source) + stmt := ts.ScriptCompiler.Program.Statements[1].(*ast.LetStatement) + call := stmt.Value[0].(*ast.CallExpression) + info := ts.ExprCache[key(ts.FuncNameMangled, call)] + root := ts.ScriptCompiler.Script.Root + require.True(t, TypeEqual(tt.want, root.Vars["value"])) + require.True(t, TypeEqual(tt.want, info.CallParamTypes[0]), "call must specialize on the storage used by lowering") + require.True(t, TypeEqual(tt.want, info.ScalarCallParamTypes[0])) + require.True(t, TypeEqual(tt.want, info.OutTypes[1]), "copying the input must retain its ownership type") + argInfo := ts.ExprCache[key(ts.FuncNameMangled, call.Arguments[0])] + seed := ts.ScriptCompiler.Program.Statements[0].(*ast.LetStatement).Value[0] + seedInfo := ts.ExprCache[key(ts.FuncNameMangled, seed)] + require.True(t, TypeEqual(seedInfo.OutTypes[0], argInfo.OutTypes[0]), "argument expressions retain their flow type") + callee := cc.Compiler.FuncCache[Mangle(cc.Compiler.MangledPath, "Fold", info.CallParamTypes)] + require.NotNil(t, callee) + require.True(t, callee.Settled) + } + }) + } +} + +func TestLocalSlotRefinementRemanglesNestedCalls(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + code := mustParseCode(t, `out, before = Wrapper(item) + current = "hello" + current, previous = Fold(current, item) + out = current + before = previous + +out, before = Fold(current, item) + out = current ⊕ "-item" + before = current +`) + cc := NewCodeCompiler(ctx, t.Name(), "", code) + require.Empty(t, cc.Compile()) + wrapperKey := Mangle(cc.Compiler.MangledPath, "Wrapper", []Type{I64}) + foldKey := Mangle(cc.Compiler.MangledPath, "Fold", []Type{StrH{}, I64}) + wrapperTemplate := code.Statements[0].(*ast.FuncStatement) + call := wrapperTemplate.Body.Statements[1].(*ast.LetStatement).Value[0].(*ast.CallExpression) + + for _, run := range []string{"Cold", "Warm"} { + ts := solveScriptTypes(t, ctx, cc, t.Name()+run, "value, before = Wrapper(2)\nvalue, before") + wrapper := cc.Compiler.FuncCache[wrapperKey] + require.NotNil(t, wrapper) + require.True(t, wrapper.Settled) + require.True(t, IsStrH(wrapper.Vars["current"])) + require.True(t, IsStrH(wrapper.Sig.OutTypes[1])) + require.Equal(t, []string{foldKey}, wrapper.CFGResult.DirectCallees) + info := ts.ExprCache[key(wrapperKey, call)] + require.True(t, IsStrH(info.CallParamTypes[0])) + require.True(t, IsStrH(info.OutTypes[1])) + } +} + func TestMergeBindingSlotTypeIsMonotonic(t *testing.T) { headerOnly := Table{Columns: []TableColumn{ {Name: "Name", ElemType: Empty{}}, @@ -1615,9 +1693,10 @@ func TestSpecializationTraceCapsIndividualFrames(t *testing.T) { const fixedRankRecursionSource = `res = FixedRank(x) "-x" - res = 0 + total = 0 nested = FixedRank([[1]]) - res = res + nested + total = total + nested + res = total ` func TestRecursiveGrowthReachesFixedClosure(t *testing.T) { @@ -1674,14 +1753,16 @@ func TestRecursiveLimitCountsColdDiscovery(t *testing.T) { func TestFinitePolymorphicRecursionIsAccepted(t *testing.T) { code := mustParseCode(t, `res = Outer(x) - res = 0 + total = 0 inner = Inner([x]) - res = res + inner + total = total + inner + res = total res = Inner(xs) - res = 0 + total = 0 outer = Outer(xs[0]) - res = res + outer + total = total + outer + res = total `) ctx := llvm.NewContext() @@ -2129,51 +2210,6 @@ res = Relay(k) } } -// Consume precedes Root's StrH refinement and must be remangled on the stable sweep. -func TestRefinedOutputSeedsStableBody(t *testing.T) { - code := mustParseCode(t, `res = Root(k) - res = "lit" - tmp = Consume(res) - "-tmp" - res = k > 0 Relay(k) - -res = Relay(k) - res = Root(k - 1) ⊕ "x" - -res = Consume(x) - res = x -`) - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "seedRefinedOutput", "", code) - require.Empty(t, cc.Compile()) - - sl := lexer.New("TestSeedRefinedOutputScript", "v = Root(3)\nv") - sp := parser.NewScriptParser(sl) - program := sp.Parse() - require.Empty(t, sp.Errors()) - - sc := NewScriptCompiler(ctx, t.Name(), program, cc) - ts := NewTypeSolver(sc) - ts.Solve() - - require.Empty(t, ts.Errors) - heapConsumer := cc.Compiler.FuncCache[Mangle(cc.Compiler.MangledPath, "Consume", []Type{StrH{}})] - require.NotNil(t, heapConsumer, "the stable body sweep must remangle Consume with Root's StrH output slot") - require.True(t, heapConsumer.AllTypesInferred()) - - root := code.Statements[0].(*ast.FuncStatement) - consumeStmt := root.Body.Statements[1].(*ast.LetStatement) - consumeCall := consumeStmt.Value[0].(*ast.CallExpression) - rootMangled := Mangle(cc.Compiler.MangledPath, "Root", []Type{I64}) - callInfo := ts.ExprCache[key(rootMangled, consumeCall)] - require.NotNil(t, callInfo) - require.Len(t, callInfo.CallParamTypes, 1) - require.Len(t, callInfo.ScalarCallParamTypes, 1) - require.True(t, TypeEqual(StrH{}, callInfo.CallParamTypes[0]), "final call metadata must use the output slot's StrH storage type") - require.True(t, TypeEqual(StrH{}, callInfo.ScalarCallParamTypes[0]), "final scalar-call metadata must use the output slot's StrH storage type") -} - func TestFunctionOutputTableJoinMatchesStorage(t *testing.T) { code := mustParseCode(t, `res = RefineTable(k) "-k" diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index 0fb2d699..e986f414 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -28,10 +28,14 @@ After Phase 1, `fib_tail` is no longer a strong argument for a Pluto-level tail- Pluto's source-level semantics stay unchanged: - assignments copy -- inputs are logically read-only -- outputs are logically writable results flowing back to the caller +- input names are read-only, but can observe writes through a shared output +- output names are write-only, and results reach the caller at assignment commit -These are **language semantics**. How values physically move across a call boundary is the **lowered calling convention** — a separate concern. A read-only `I64` input can be passed by value without changing Pluto semantics. A single `I64` output can be returned in a register while still behaving like a Pluto output. +These are **language semantics**. How values physically move across a call +boundary is the **lowered calling convention** — a separate concern. An `I64` +input can be passed by value provided alias metadata redirects each read to +its shared output when required. A single `I64` output can be returned in a +register while still behaving like a Pluto output. ## 3. Architecture @@ -90,8 +94,12 @@ Direct lowering for scalar numeric inputs and single scalar outputs. - give every direct scalar return a final hidden destination seed, preserving skipped conditional writes and empty-range behavior without making the physical signature depend on the function body -- preserve range-bearing accumulator behavior with additional hidden alias - selectors where needed +- preserve live input/output sharing in both ordinary and range-bearing calls + by lowering a call whose argument names its own destination to a private + alias variant, in which reads use that output's current value, including + writes in the same iteration; exported prototypes no longer carry alias + selectors, which changes range-bearing prototypes (ABI 2.1) and leaves + every other function's signature as it was `MustWrite`/`MayWrite` has limited utility at the public boundary and must not decide whether the seed parameter exists. Adding one conditional output write diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 0bc933e0..62592cea 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -1,6 +1,6 @@ # Pluto C ABI & Name Mangling Specification -**Version:** 2.0 | **Status:** Draft | **Target:** C11 / C++17 +**Version:** 2.1 | **Status:** Draft | **Target:** C11 / C++17 ## 1. Overview @@ -284,8 +284,7 @@ typedef struct { The descriptor occupies the ordinary source-parameter position. An indirect result carrier, when present, comes first; all source parameters follow in -source order; hidden alias selectors follow them; and a hidden direct-return -seed is last. +source order; and a hidden direct-return seed is last. --- @@ -346,9 +345,12 @@ types: - Output expressions are staged independently at the call site, so one output cannot mutate a destination before a sibling right-hand side reads its statement-start value. -- When a compatible caller destination has a different ownership or shape - representation from the declared output, the ABI slot starts at the declared - type's zero value. The caller commits it only if its write marker is set. +- When a caller destination has a compatible wider ownership or shape + representation than the declared output, a private lowering variant uses + that output storage so an aliased input can observe its writes. It has a + distinct internal symbol; the source specialization and its effect facts + remain unchanged. Other representation changes use a separate ABI output + adapter initialized to zero and committed only if its write marker is set. The direct-return seed is always present, even when the function body unconditionally overwrites its output. Schematically, with mangled names @@ -357,12 +359,7 @@ abbreviated: ```c int64_t Pt_Square_I64(int64_t x, int64_t seed); int64_t Pt_ConditionalSquare_I64(int64_t x, int64_t seed); -int64_t Pt_Acc_I64_Range( - int64_t a, - const PtRangeI64 *range, - int32_t a_output_alias, - int64_t seed -); +int64_t Pt_Acc_I64_Range(int64_t a, const PtRangeI64 *range, int64_t seed); ``` A C caller passes the destination's current value to request Pluto's keep-old @@ -388,14 +385,33 @@ struct Results { void Pt_example(Results *results, I64 direct_arg, Other *indirect_arg); ``` -Range-bearing variants may also receive hidden alias selectors for direct -scalar parameters that refer to an output destination. These preserve -loop-carried accumulation without changing the source signature or mangled -specialization identity. They do change the native C signature. -For compatible indirect parameters, the caller instead passes the matching -staged output pointer itself, so the callee observes the same loop-carried -value without another hidden parameter. -Hidden ABI fields and parameters are not part of name mangling. +A call whose argument and destination are the same binding shares the input +with that output. This is a compile-time fact of the call site, so it never +appears in the exported signature. The compiler lowers such a call to a +private alias variant of the specialization (§5.2). Inside the variant a +direct scalar input reads the output's current value, and for a compatible +indirect input the caller passes the matching staged output pointer itself. +Each read therefore observes the selected output's current value, and both +forms carry output values into subsequent range iterations. The caller's real +destinations remain unchanged until the surrounding assignment commits. + +A native caller cannot request a variant. Passing the same address for a +pointer input and an output shares them only within the called body's own +statements: a nested Pluto call inside that body stages its outputs and +commits them afterwards, so it does not extend the sharing. A register scalar +is always a plain value. Sharing across nested calls is guaranteed for Pluto +callers, whose call sites select the variants statically. + +**Changes in 2.1.** Version 2.0 gave range-bearing variants a hidden `i32` +alias selector per direct scalar parameter, placed after the source parameters +and, for direct returns, before the seed. Version 2.1 removes those selectors, +and aliasing is lowered as private variants instead. A direct-return +function's native signature is therefore its source parameters followed by +the seed; an indirect-return function keeps its leading result carrier +followed by the source parameters, with no seed. The prototype of a +range-bearing function such as `Acc` changes, and for a direct return its +seed moves one position earlier. Functions without a `Range` or `ArrayRange` +parameter are unchanged. An eligible immediate bare `array[range]` call argument may therefore select an `ArrayRange` specialization and run its loop inside the callee. This @@ -424,6 +440,37 @@ collector for an item type `T` will be passed as `PtArrayT *` in the final native parameter position; this statement reserves the position but does not make it part of the current calling convention. +### 5.2 Private Lowering Variants + +Two facts of a call site change the emitted body of a specialization without +changing its types. Each lowers to a private variant: an internal symbol that +appends a suffix to the ordinary function mangle and is never exported. The +suffixes use the same lowercase-marker-plus-count form as `_fN`, `_tN`, and +the reserved `_cN`, so they parse unambiguously after the argument types. + +``` +_oN_ +_aN__... +``` + +`_oN` is the output-storage variant. It lists the storage type of every +output slot, in declaration order, when a caller destination holds a +compatible wider representation than the declared output (an owned `StrH` +slot receiving a `StrG` output, or a concrete-rank array slot receiving `[]`). +`_aN` is the alias variant. It carries one entry per parameter, in source +order: `0` for a parameter that shares no output, `k` for one that shares +output slot `k - 1`, whose type must match the parameter. When both apply, +`_oN` precedes `_aN`. + +Examples: `Pt_4math_p_4Fold_f2_I64_StrH_a2_1_0` is `Fold(I64, StrH)` with its +first parameter sharing its first output; `..._o2_StrH_StrH` is the same +function writing both outputs into owned string slots. `Demangle` renders +these as `math.Fold(I64, StrH) [in1->out1]` and +`math.Fold(I64, StrH) -> (StrH, StrH)`. + +The public specialization symbol is unchanged by either variant. C callers +never see a variant and cannot request one. + --- ## 6. Grammar @@ -431,6 +478,9 @@ make it part of the current calling convention. ```ebnf FunctionSym := 'Pt' ModPath '_p_' Ident '_f' Arity Types | 'Pt' ModPath '_p_' RelPath '_r_' Ident '_f' Arity Types +VariantSym := FunctionSym OutputStorage? AliasPattern? (* internal linkage only *) +OutputStorage := '_o' Num Types +AliasPattern := '_a' Num ('_' Num)* MethodSym := 'Pt' ModPath '_p_' Ident '_m_' Ident '_f' Arity Types | 'Pt' ModPath '_p_' RelPath '_r_' Ident '_m_' Ident '_f' Arity Types OperatorSym := 'Pt' ModPath '_p_' Ident '_m_op_' Opcode '_' Fixity Types @@ -487,4 +537,5 @@ Generic := (Qualified | Ident) '_t' Num Types * Numeric path segments preserve source digits; `Num` keeps arities, counts, and length prefixes canonical * Operators: Fixity implies arity (in=2, pre/suf=1, cirN=N); Types listed left-to-right * Generics (`_tN`) only in type arguments, not as top-level linkable symbols +* Variant suffixes (`_oN`, `_aN`) name private lowering variants (§5.2); they follow the argument types and never appear on exported symbols * All symbols always have `_p_` after ModPath; symbols with relpath use `_r_`, and script roots end with `_e` diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index c4a7070b..232c222d 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -19,9 +19,40 @@ type, and stored type separately, as the corrected code comment already does. ## 1. Next compiler PR: seed dependency analysis -Preserve the existing seeded-output semantics and public ABI. Correct the -analysis before deciding whether a later language version should change those -semantics. +Resolved by a language rule instead of an analysis +([PR #104](https://github.com/thiremani/pluto/pull/104), superseding the closed +[PR #102](https://github.com/thiremani/pluto/pull/102)): declared outputs are +write-only inside their template, so the reproducer below is rejected at +`y = y + 1`. The hidden seed and destination-seeded staging slots continue to +preserve outputs that are not written. A caller can explicitly connect an +input to an output by reusing the same binding: later statements then observe +writes through that output, in ordinary and ranged calls alike. Inputs are +read-only bindings, not frozen values. No per-iteration input snapshot is +needed. Sharing is a compile-time fact of each call site and lowers to a +private alias variant of the specialization, so the native calling convention +stays independent of body effects. It is not unchanged: range-bearing +variants on master carried hidden alias selectors, and removing them changes +those prototypes, recorded as ABI 2.1 in +[the C ABI specification](./Pluto%20C%20ABI%20Spec.md). Still outstanding on +that boundary: a native caller that passes one address as both an input and an +output shares them only within the called body, because a nested Pluto call +stages its outputs. A generic pointer entry that resolves unknown sharing at +run time, alongside the private variants, would close that gap; nested +staging would still need alias handling inside it. The canonical description +of the language rule is in +[the memory model](./Pluto%20Memory%20Model.md) under "Parameters and Outputs". + +The storage mismatch filed as +[issue #103](https://github.com/thiremani/pluto/issues/103) is addressed by +specializing binding arguments on their merged storage type and revisiting +calls when a later assignment widens that storage. Under live-reference +semantics, `s = "a"` followed by `s, prev = FoldStr(s, "b")`, where the body +writes `out = current ⊕ item` before `seen = current`, must produce `ab ab`. +Compatible wider output storage is handled by a private lowering variant, +preserving sharing without changing unrelated input types. These cases are +covered by `tests/alias_input`. + +The original analysis plan is kept below for the record. ### Confirmed failure @@ -121,18 +152,24 @@ Retaining `%n` with a real write contract is a viable proposed direction. Its destination is an effectful operand even though it appears inside formatting syntax. This plan does not choose new source syntax or silently remove `%n`. -The baseline accepts a function that receives `x = 99`, evaluates -`"hello-x%n"`, and then returns `x`; it prints `hello` and returns 5. -`formatSpecialValue` in `compiler/format.go` checks the type and code globals, -but does not reject read-only parameters. CFG marker handling records reads. -`TestPromotedAliasTypeGap` deliberately uses this path, so its coverage needs a -replacement when the read-only rule is enforced. +The recorded baseline `840b147` accepts a function that receives `x = 99`, +evaluates `"hello-x%n"`, and then returns `x`; it prints `hello` and returns 5. +At that baseline, `formatSpecialValue` checks the type and code globals but +does not reject read-only parameters. -Required work if `%n` is retained: +The live-reference update now rejects `%n` writes to input and iterator +parameters through `Symbol.ReadOnly`, with ordinary and ranged rejection +covered by `TestFormatCountRejectsInputParameter`. The former +`TestPromotedAliasTypeGap` no longer mutates an input; its output-position +coverage remains in `TestAliasedInputReadsOutputInVariant`. The `acc_fmt` +fixture now writes a local count. CFG marker handling still records reads, +so the formatting write effects below remain unimplemented. -- Resolve and validate the destination as a writable location. Reject input - parameters, constants, and unsupported targets through the normal rules. - Identify inputs structurally; `Symbol.FuncArg` also covers writable outputs. +Remaining work if `%n` is retained: + +- Resolve and validate the destination as a writable location through the + normal rules, including unsupported targets. Retain the implemented input + and constant rejection; `Symbol.FuncArg` alone also covers writable outputs. - Record its write separately from reads of other markers and dynamic widths or precisions. `%n` does not inherently read the destination's previous value. - Describe whether execution reaches the write and whether it initializes the @@ -149,8 +186,9 @@ Required work if `%n` is retained: `vsnprintf` twice, so sizing and output passes need an explicit effect contract. - Do not let an unmodeled formatting write enter an ordinary PIR `eval` as if it were effect-free. Keep unsupported cases legacy or reject them explicitly. -- Test read-only rejection, writable locals/outputs, old-value liveness, - repeated markers, sequencing, skipped execution, aliases, and failure paths. +- Extend the existing rejection tests with writable locals/outputs, old-value + liveness, repeated markers, sequencing, skipped execution, aliases, and + failure paths. An explicit formatter/count output is another possible surface design. Choose that separately if it makes programs clearer; correctness does not require it. @@ -198,7 +236,7 @@ and [ABI stability plan](./Pluto%20ABI%20Optimization%20Plan.md). | Work | Completion criterion / existing reference | | --- | --- | -| Seed/effect correctness | Section 1; next compiler PR before broadening call routing | +| Seed/effect correctness | Section 1; resolved by the write-only-outputs rule in [PR #104](https://github.com/thiremani/pluto/pull/104); flow-versus-slot call specialization is [#103](https://github.com/thiremani/pluto/issues/103) | | `%n` effect contract | Section 2; separate bounded change with formatting semantics updated | | Output path protection | [Issue #80](https://github.com/thiremani/pluto/issues/80): compilation cannot overwrite source/configuration through name collisions or unsafe path resolution | | Numeric edge behavior | Define and guard integer divide/remainder faults and invalid shift counts; audit range/count/allocation arithmetic | diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index c5f35aff..45b1b3a2 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -285,12 +285,16 @@ For owned heap values this may lower to an ownership swap without deep copies. If one owned source feeds multiple targets, at most one consumer takes it; the others require a derived copy. -The same snapshot rule holds across a call boundary: in `a = F(a)` the callee -reads the pre-call value through its read-only input for the whole call, -while its output writes land in the destination-seeded staging slot and reach -`a` only at commit. `tests/alias_input` pins this for direct scalars, heap -strings, and arrays (`y = x * 2` then `y = y + x` yields 15 for `a = 5`, not -20); Step 4's call lowering must preserve it. +At a call boundary, `a = F(a)` connects the callee input and output to the +same destination-seeded staging slot. The input name is read-only, but each +read observes earlier output writes to that slot. Reads within one assignment +still precede its writes. The real `a` changes only at the outer assignment's +commit, so sibling RHS expressions continue to read the pre-commit binding. +`tests/alias_input` pins both statement orders for ordinary and ranged calls, +with direct scalars, static and heap strings, and arrays: starting at 10, +`out = current + item` before `seen = current` yields `15 15` for item 5; +reversing those body statements yields `15 10`. Step 4's call lowering must +preserve this distinction between internal sharing and external commit. ## 7. Loop-Carried State @@ -850,10 +854,16 @@ Boundary resolution implies an **implicit read of the destination seed**, and only where the dependency is real: after a successful invocation, at an *existing* target whose direct callee output is `MayWrite`, resolved at `=`. A fresh destination, a discard, a nested or targetless call, or an -all-`MustWrite` callee reads nothing. Step 2A records this as a `ReadsSeed` -fact on the call site — the CFG is untouched in 2A — and Step 2B converts the -fact into an ordinary CFG read event, so a `MustWrite` classification cannot -let backward liveness kill the prior value. +all-`MustWrite` callee introduces no implicit seed read. Declared outputs are +write-only inside their template (the structural CFG rejects every read, +including formatting markers), so the body cannot read the hidden seed through +an output name. An input explicitly shared with an output can observe the +staged value and later writes; that dependency is already an explicit argument +read at the call site. Step 2A +records boundary resolution as a `ReadsSeed` fact on the call site — the CFG +is untouched in 2A — and Step 2B converts the fact into an ordinary CFG read +event, so a `MustWrite` classification cannot let backward liveness kill the +prior value. The validity-carrying result comes from a **private direct-call variant** behind the stable seeded entry point (§1). The clone **keeps the seed diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 4c267e46..e649fa0e 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -257,32 +257,91 @@ res = sum(a, b) res = a + b ``` -- **Parameters**: Input values (passed by value for scalars) -- **Outputs**: Independently staged result slots. An existing destination - supplies the initial value, while a fresh destination starts at its type's - zero value. The real destinations are committed only after every sibling - right-hand side has been evaluated. +- **Parameters**: Read-only bindings. A template cannot assign through an + input name, but an input may share a result slot with an output when the + caller uses the same binding as argument and destination. Each input read + observes that slot's current value, including writes from earlier statements + in the body. This rule applies to both ordinary and ranged calls and is + independent of whether the implementation passes the value or a pointer. +- **Outputs**: Write-only inside their template. A body may assign an output + any number of times, conditionally or not, and a nested call may target it, + but reading it anywhere — a value, a condition, a call argument, a print, or + a formatting marker — is a compile error. Intermediate values live in + locals. Outputs are independently staged result slots: an existing + destination supplies the initial value and a fresh destination starts at + its type's zero value, so a body that writes nothing preserves the caller's + value. The body may observe that value through an explicitly aliased input; + it cannot read the output name itself. The real destinations are committed only + after every sibling right-hand side has been evaluated. - **No name overlap**: Parameters and outputs must have distinct names -When a caller destination and a function's declared output use different -representations of a compatible value (for example, owned versus static -strings, or an empty array type versus a concrete-rank array), the callee sees -the zero value of its declared representation. A per-output write marker tells -the caller whether to commit that adapted value. If the function does not -write the output, the caller's staged value is preserved. This avoids treating -one ownership or shape representation as if it were another. +Calls specialize binding arguments on their actual storage type. When a +caller's destination has a compatible wider representation than the declared +output (for example, an owned string slot receiving a static string, or a +concrete-rank array slot receiving `[]`), a private lowering variant uses that +wider output storage. An aliased input and output therefore continue to share +one slot: assigning `[]` makes a later input read observe the empty array. +An unrelated input keeps its own type and value. Other representation changes +use a separate output adapter with a per-output write marker; the caller only +commits its value when the callee actually writes the output. ### Call Site ```python res = sum(res, 5) -# - Parameter 'a' receives value of 'res' +# - Parameter 'a' shares the call's staged result slot for 'res' # - Parameter 'b' receives 5 # - Staged output 'res' starts with the caller destination's existing value # - Body executes: res = a + b # - Result commits back to the caller's res after sibling RHS evaluation ``` +Reusing a variable as both an argument and a destination is how a caller +connects an input to a call's staged output. The template itself reads through +its declared inputs; `res = res + 1` inside `sum` would be rejected. + +```python +out, before = FoldBefore(current, item) + before = current + out = current + item + +out, after = FoldAfter(current, item) + out = current + item + after = current +``` + +Starting with `value = 10`, `value, seen = FoldBefore(value, 5)` produces +`15 10`, while `value, seen = FoldAfter(value, 5)` produces `15 15`. Assigning +the first output to a different binding leaves `current` unchanged, so +`other, seen = FoldAfter(value, 5)` instead produces `15 10`. + +Reads within one assignment precede its writes, inside a body as much as at +the call site. `out, before = current + item, current` therefore gives +`before` the value from before that statement even when `current` shares +`out`; the sharing becomes visible only to later statements. Keeping an old +value across a write is an explicit assignment that creates an independent +value, such as `saved = current` before `out = current + item`; the copy it +may cost sits at that assignment, not inside the call. + +Use a simultaneous assignment when swapping through shared inputs. In +`a, b = Swap(x, y)`, the body `a = y` followed by `b = x` makes +`p, q = Swap(p, q)` produce `2 2` from `p, q = 1, 2`: the second statement +reads the value just written through `a`. The body `a, b = y, x` instead +produces `2 1`, because both reads happen before either write. + +The sharing is internal to each call. For +`value, seen, old = FoldAfter(value, 5), value`, the result is `15 15 10`: +`seen` observes the call's updated slot, while the sibling right-hand side +reads the caller's binding before the assignment commits. + +With a range, the same reuse is an accumulation: `sum = Acc(sum, 1:5)` runs +the body once per yield, and each iteration continues from the previous +iteration's output. The body's statement order still applies within each +iteration. Starting from 10, `FoldBefore(value, 1:3)` produces `13 11` and +`FoldAfter(value, 1:3)` produces `13 13` when their first output targets +`value`. An empty range leaves an existing destination unchanged and a fresh +destination at its zero value. + ### Range Parameters ```python diff --git a/llvm_metadata_byollvm.go b/llvm_metadata_byollvm.go new file mode 100644 index 00000000..e5e09c78 --- /dev/null +++ b/llvm_metadata_byollvm.go @@ -0,0 +1,53 @@ +//go:build byollvm + +package main + +/* +#include "llvm-c/Core.h" +*/ +import "C" + +import ( + "unsafe" + + "tinygo.org/x/go-llvm" +) + +// go-LLVM exposes metadata handles but not these operand inspection APIs. +// The returned nodes are borrowed from the module's LLVM context. +func llvmMetadataOperands(node llvm.Value) []llvm.Value { + if node.IsNil() { + return nil + } + count := int(C.LLVMGetMDNodeNumOperands(C.LLVMValueRef(unsafe.Pointer(node.C)))) + if count == 0 { + return nil + } + // Use a C pointer buffer without depending on llvm.Value's struct layout. + refs := make([]C.LLVMValueRef, count) + C.LLVMGetMDNodeOperands(C.LLVMValueRef(unsafe.Pointer(node.C)), &refs[0]) + + operands := make([]llvm.Value, count) + for i, ref := range refs { + *(*unsafe.Pointer)(unsafe.Pointer(&operands[i].C)) = unsafe.Pointer(ref) + } + + return operands +} + +func llvmMetadataString(value llvm.Value) string { + if value.IsNil() { + return "" + } + var length C.unsigned + str := C.LLVMGetMDString(C.LLVMValueRef(unsafe.Pointer(value.C)), &length) + + return C.GoStringN(str, C.int(length)) +} + +func llvmValueAsMetadata(value llvm.Value) llvm.Metadata { + var metadata llvm.Metadata + *(*unsafe.Pointer)(unsafe.Pointer(&metadata.C)) = unsafe.Pointer(C.LLVMValueAsMetadata(C.LLVMValueRef(unsafe.Pointer(value.C)))) + + return metadata +} diff --git a/llvm_pipeline.go b/llvm_pipeline.go index f5f0f6af..200baf3d 100644 --- a/llvm_pipeline.go +++ b/llvm_pipeline.go @@ -152,10 +152,11 @@ func annotateScalarUnrollLoops(module llvm.Module) int { for fn := module.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { loops := scalarUnrollCandidates(fn) for _, loop := range loops { - if !loop.term.Metadata(loopMDKind).IsNil() { + metadata := loop.term.Metadata(loopMDKind) + if llvmLoopHasUnrollDirective(metadata) { continue } - loop.term.SetMetadata(loopMDKind, llvmUnrollCountMetadata(ctx, llvmScalarUnrollCount)) + loop.term.SetMetadata(loopMDKind, llvmUnrollCountMetadata(ctx, metadata, llvmScalarUnrollCount)) annotated++ } } @@ -450,14 +451,43 @@ func valueUsesVectorType(v llvm.Value) bool { return false } -func llvmUnrollCountMetadata(ctx llvm.Context, count int) llvm.Metadata { +func llvmLoopHasUnrollDirective(loopID llvm.Value) bool { + for i, property := range llvmMetadataOperands(loopID) { + if i == 0 { + continue + } + operands := llvmMetadataOperands(property) + if len(operands) == 0 { + continue + } + name := llvmMetadataString(operands[0]) + if strings.HasPrefix(name, "llvm.loop.unroll.") || + strings.HasPrefix(name, "llvm.loop.unroll_and_jam.") || + name == "llvm.loop.disable_nonforced" { + return true + } + } + + return false +} + +func llvmUnrollCountMetadata(ctx llvm.Context, previous llvm.Value, count int) llvm.Metadata { temp := ctx.TemporaryMDNode(nil) + properties := []llvm.Metadata{temp} + for i, property := range llvmMetadataOperands(previous) { + if i > 0 { + properties = append(properties, llvmValueAsMetadata(property)) + } + } + countMD := llvm.ConstInt(ctx.Int32Type(), uint64(count), false).ConstantAsMetadata() countNode := ctx.MDNode([]llvm.Metadata{ ctx.MDString("llvm.loop.unroll.count"), countMD, }) - loopID := ctx.MDNode([]llvm.Metadata{temp, countNode}) + loopID := ctx.MDNode(append(properties, countNode)) + // The C API replacement also disposes the temporary node. temp.ReplaceAllUsesWith(loopID) + return loopID } diff --git a/llvm_pipeline_test.go b/llvm_pipeline_test.go index 029a3519..654fe81a 100644 --- a/llvm_pipeline_test.go +++ b/llvm_pipeline_test.go @@ -95,6 +95,62 @@ exit: } } +func TestAnnotateScalarUnrollLoopsPreservesMetadata(t *testing.T) { + for _, tt := range []struct { + name string + property string + want int + }{ + {"peeled", `!{!"llvm.loop.peeled.count", i32 1}`, 1}, + {"unroll disabled", `!{!"llvm.loop.unroll.disable"}`, 0}, + {"unroll and jam", `!{!"llvm.loop.unroll_and_jam.disable"}`, 0}, + {"nonforced disabled", `!{!"llvm.loop.disable_nonforced"}`, 0}, + } { + t.Run(tt.name, func(t *testing.T) { + mod := parseTestIR(t, ` +define i64 @fib_like(i64 %n) { +entry: + br label %loop + +loop: + %a = phi i64 [ 0, %entry ], [ %b, %loop ] + %b = phi i64 [ 1, %entry ], [ %sum, %loop ] + %i = phi i64 [ %n, %entry ], [ %dec, %loop ] + %dec = add i64 %i, -1 + %sum = add i64 %a, %b + %done = icmp eq i64 %dec, 0 + br i1 %done, label %exit, label %loop, !llvm.loop !0 + +exit: + ret i64 %b +} + +!0 = distinct !{!0, !1} +!1 = `+tt.property) + before := mod.String() + if got := annotateScalarUnrollLoops(mod); got != tt.want { + t.Fatalf("annotateScalarUnrollLoops() = %d, want %d", got, tt.want) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("invalid loop metadata after annotation: %v", err) + } + after := mod.String() + if !strings.Contains(after, tt.property) { + t.Fatalf("existing metadata was lost:\n%s", after) + } + if tt.want == 0 && after != before { + t.Fatalf("existing unroll policy was changed:\n%s", after) + } + if tt.want == 1 && !strings.Contains(after, `!{!"llvm.loop.unroll.count", i32 4}`) { + t.Fatalf("peeled loop did not receive an unroll count:\n%s", after) + } + if got := annotateScalarUnrollLoops(mod); got != 0 { + t.Fatalf("second annotation added %d duplicate hints", got) + } + }) + } +} + func TestAnnotateScalarUnrollLoopsSkipsCallHeavyLoops(t *testing.T) { mod := parseTestIR(t, ` declare void @side_effect() @@ -337,7 +393,7 @@ res t.Fatalf("run O3 pipeline: %v", err) } loopMDKind := scriptModule.Context().MDKindID("llvm.loop") - // Loops the O3 run already marked must contribute no add chains; that + // Loops the O3 run already marked for unrolling contribute no add chains; that // makes any post-unroll chain growth attributable to the loops annotated // below rather than to pre-existing loop metadata. if got := maxChainedAddsInMarkedLatch(scriptModule); got != 0 { @@ -349,7 +405,7 @@ res preUnrollChain := 0 for fn := scriptModule.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { for _, loop := range scalarUnrollCandidates(fn) { - if !loop.term.Metadata(loopMDKind).IsNil() { + if llvmLoopHasUnrollDirective(loop.term.Metadata(loopMDKind)) { continue } candidates++ @@ -386,7 +442,7 @@ res } // maxChainedAddsInMarkedLatch returns, across all loop latches that carry -// llvm.loop metadata, the largest number of add instructions that consume +// unroll metadata, the largest number of add instructions that consume // another add from the same block. An unrolled scalar recurrence leaves its // replicated adds chained together inside the marked latch, so this grows // when the annotated loop is actually unrolled. @@ -396,7 +452,7 @@ func maxChainedAddsInMarkedLatch(module llvm.Module) int { for fn := module.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { for bb := fn.FirstBasicBlock(); !bb.IsNil(); bb = llvm.NextBasicBlock(bb) { term := bb.LastInstruction() - if term.IsNil() || term.Metadata(loopMDKind).IsNil() { + if term.IsNil() || !llvmLoopHasUnrollDirective(term.Metadata(loopMDKind)) { continue } if chained := chainedAddsInBlock(bb); chained > best { diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 71afb345..544f7b59 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -1,3 +1,31 @@ 15 hi!hi [1 2 9 1 2] +SequentialSwap: 2 2 +SimultaneousSwap: 2 1 +IntPlainBefore: 15 10 +IntPlainAfter: 15 15 +IntRangeBefore: 13 11 +IntRangeAfter: 13 13 +Empty: 10 0 +Separate: 10 15 10 +HeapRangeBefore: abc ab +HeapRangeAfter: abc abc +StaticPlainAfter: helloabc helloabc +ArrayRangeBefore: [10 1 2] [10 1] +ArrayRangeAfter: [10 1 2] [10 1 2] +NestedNumber: 15 15 +NestedString: helloabc helloabc +NestedArrayRange: [10 1 2] [10 1 2] +ConditionalTaken: 15 15 +ConditionalSkipped: 10 10 +RepeatedWrites: 16 +NestedRange: 13 13 +NestedRangeTwice: 20 20 +NestedArrayTwice: [10 1 2 3 4] [10 1 2 3 4] +ConditionalNested: 15 15 +ConditionalNestedSkipped: 10 0 +StagedString: helloabc helloabc hello +ResetArray: [ +] [1 2] [ +] diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 891a8b64..0f2b241a 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -1,11 +1,86 @@ y = Twice(x) - y = x * 2 - y = y + x + y = x * 2 + x s = Shout(t) - s = t ⊕ "!" - s = s ⊕ t + s = t ⊕ "!" ⊕ t r = Grow(q) - r = q ⊕ [9] - r = r ⊕ q + r = q ⊕ [9] ⊕ q + +a, b = SequentialSwap(x, y) + a = y + b = x + +a, b = SimultaneousSwap(x, y) + a, b = y, x + +# An input shared with an output observes writes from earlier statements. +out, before = FoldBefore(current, item) + before = current + out = current + item + +out, after = FoldAfter(current, item) + out = current + item + after = current + +out, before = ConcatBefore(current, item) + before = current + out = current ⊕ item + +out, after = ConcatAfter(current, item) + out = current ⊕ item + after = current + +out, before = ArrayBefore(current, item) + before = current + out = current ⊕ [item] + +out, after = ArrayAfter(current, item) + out = current ⊕ [item] + after = current + +out, seen = NestedFold(current, item) + out, seen = FoldAfter(current, item) + +out, seen = NestedConcat(current, item) + out, seen = ConcatAfter(current, item) + +# The ranged call rebinds the shared input to its staged slot; the nested +# call made inside that loop must still select the sharing variant. +out, seen = NestedArrayRange(current) + out, seen = NestedArrayDeep(current, (1:3) + 0) + +out, seen = NestedArrayDeep(current, item) + out, seen = ArrayAfter(current, item) + +out, seen = ConditionalFold(current, item) + out = item > 0 current + item + seen = current + +# Caller-driven ranges rebind the shared input to the staged output while the +# loop runs; the nested call must still select the sharing variant, on the +# first ranged call and on a later one. +out, seen = NestedRange(current) + out, seen = FoldAfter(current, (1:3) + 0) + +out, seen = NestedRangeTwice(current) + out, seen = FoldAfter(current, (1:3) + 0) + out, seen = FoldAfter(current, (3:5) + 0) + +out, seen = NestedArrayTwice(current) + out, seen = ArrayAfter(current, (1:3) + 0) + out, seen = ArrayAfter(current, (3:5) + 0) + +# A conditional call writes through synthetic destinations that stand in for +# the outputs; sharing follows them to the real output. +out, seen = ConditionalNested(current, item) + out, seen = item > 0 FoldAfter(current, item) + +out = BumpTwice(current, item) + out = current + item + out = current + item + +out, left, right = ResetPair(first, second) + out = [] + left = first + right = second diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index 9c98ec69..eebfd63e 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -1,4 +1,4 @@ -# An input that aliases the output reads the pre-call value for the whole call. +# Reads in a single assignment precede its output write. a = 5 a = Twice(a) a @@ -8,3 +8,103 @@ w v = [1 2] v = Grow(v) v + +# Cross-aliases make statement order significant: only the simultaneous form swaps. +sequentialLeft, sequentialRight = 1, 2 +sequentialLeft, sequentialRight = SequentialSwap(sequentialLeft, sequentialRight) +"SequentialSwap:", sequentialLeft, sequentialRight +simultaneousLeft, simultaneousRight = 1, 2 +simultaneousLeft, simultaneousRight = SimultaneousSwap(simultaneousLeft, simultaneousRight) +"SimultaneousSwap:", simultaneousLeft, simultaneousRight + +# Both statement orders, in ordinary and ranged calls. +plainBefore = 10 +plainBefore, seenBefore = FoldBefore(plainBefore, 5) +"IntPlainBefore:", plainBefore, seenBefore +plainAfter = 10 +plainAfter, seenAfter = FoldAfter(plainAfter, 5) +"IntPlainAfter:", plainAfter, seenAfter +rangeBefore = 10 +rangeBefore, rangeSeenBefore = FoldBefore(rangeBefore, 1:3) +"IntRangeBefore:", rangeBefore, rangeSeenBefore +rangeAfter = 10 +rangeAfter, rangeSeenAfter = FoldAfter(rangeAfter, 1:3) +"IntRangeAfter:", rangeAfter, rangeSeenAfter +empty = 10 +empty, never = FoldAfter(empty, 0:0) +"Empty:", empty, never +separate = 10 +separateOut, separateSeen = FoldAfter(separate, 5) +"Separate:", separate, separateOut, separateSeen + +# Heap ownership and the static-to-heap specialization regression. +items = ["b" "c"] +heapBefore = "a" ⊕ "" +heapBefore, heapSeenBefore = ConcatBefore(heapBefore, items[0:2]) +"HeapRangeBefore:", heapBefore, heapSeenBefore +heapAfter = "a" ⊕ "" +heapAfter, heapSeenAfter = ConcatAfter(heapAfter, items[0:2]) +"HeapRangeAfter:", heapAfter, heapSeenAfter +staticText = "hello" +staticText, staticSeen = ConcatAfter(staticText, "abc") +"StaticPlainAfter:", staticText, staticSeen + +# Array copies must preserve a value saved before a later append. +arrayBefore = [10] +arrayBefore, arraySeenBefore = ArrayBefore(arrayBefore, 1:3) +"ArrayRangeBefore:", arrayBefore, arraySeenBefore +arrayAfter = [10] +arrayAfter, arraySeenAfter = ArrayAfter(arrayAfter, 1:3) +"ArrayRangeAfter:", arrayAfter, arraySeenAfter + +# Nested calls forward sharing even though their argument and target use +# different local names in the outer template. +nested = 10 +nested, nestedSeen = NestedFold(nested, 5) +"NestedNumber:", nested, nestedSeen +nestedText = "hello" ⊕ "" +nestedText, nestedTextSeen = NestedConcat(nestedText, "abc") +"NestedString:", nestedText, nestedTextSeen +nestedArray = [10] +nestedArray, nestedArraySeen = NestedArrayRange(nestedArray) +"NestedArrayRange:", nestedArray, nestedArraySeen + +# A skipped write leaves the shared value unchanged; multiple writes each +# read the latest value through the input. +taken = 10 +taken, takenSeen = ConditionalFold(taken, 5) +"ConditionalTaken:", taken, takenSeen +skipped = 10 +skipped, skippedSeen = ConditionalFold(skipped, -1) +"ConditionalSkipped:", skipped, skippedSeen +repeated = 10 +repeated = BumpTwice(repeated, 1:3) +"RepeatedWrites:", repeated +nestedRange = 10 +nestedRange, nestedRangeSeen = NestedRange(nestedRange) +"NestedRange:", nestedRange, nestedRangeSeen +nestedTwice = 10 +nestedTwice, nestedTwiceSeen = NestedRangeTwice(nestedTwice) +"NestedRangeTwice:", nestedTwice, nestedTwiceSeen +nestedArrayTwice = [10] +nestedArrayTwice, nestedArrayTwiceSeen = NestedArrayTwice(nestedArrayTwice) +"NestedArrayTwice:", nestedArrayTwice, nestedArrayTwiceSeen +condNested = 10 +condNested, condNestedSeen = ConditionalNested(condNested, 5) +"ConditionalNested:", condNested, condNestedSeen +condNestedSkipped = 10 +condNestedSkipped, condNestedSkippedSeen = ConditionalNested(condNestedSkipped, -1) +"ConditionalNestedSkipped:", condNestedSkipped, condNestedSkippedSeen + +# Sharing is internal to the call: a sibling RHS still reads the caller's +# pre-assignment binding until every RHS finishes. +staged = "hello" ⊕ "" +staged, stagedSeen, old = ConcatAfter(staged, "abc"), staged +"StagedString:", staged, stagedSeen, old + +# An untyped empty output resets the actual shared slot. The output's storage +# must follow its aliased second input, without changing the unrelated rank. +flat = [1 2] +matrix = [[3 4]] +matrix, flatSeen, matrixSeen = ResetPair(flat, matrix) +"ResetArray:", matrix, flatSeen, matrixSeen diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index 9e6b2292..f8d2f09f 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -82,9 +82,9 @@ SquareVecRange: [0 1 4 9 16] SquareInline: [0 1 4 9 16] SquareSameDriver: [4 5 8 13 20] NestedCallCollector: [5 6 9 14 21] -RebuildAssign: [26] +RebuildAssign: [37] RebuildNoAlias: [14] -RebuildAssign2: [2] +RebuildAssign2: [6] RebuildNoAlias2: [6] PairSumRange: [3 4 4 5] ArrayRangeLastNamedRow: [3 4] diff --git a/tests/array/array_func.pt b/tests/array/array_func.pt index 1b075a48..c968d9af 100644 --- a/tests/array/array_func.pt +++ b/tests/array/array_func.pt @@ -10,8 +10,7 @@ res = BiasAndScale(x, bias, scale) res = tmp * scale res = Rebuild(vec, i) - res = [i + 1] - res = res + vec + res = [i + 1] + vec res = PairSum(i, j) res = i + j diff --git a/tests/array/array_scalar_assign.pt b/tests/array/array_scalar_assign.pt index 38a3f494..aa1e95ce 100644 --- a/tests/array/array_scalar_assign.pt +++ b/tests/array/array_scalar_assign.pt @@ -5,5 +5,4 @@ res = ArrayScalarAdd(i) res = [0:6] + i res = ArraySetAdd(i) - res = [0:i] - res = res + 4 + res = [0:i] + 4 diff --git a/tests/math/acc_fmt.pt b/tests/math/acc_fmt.pt index 8949a6e4..38810a3a 100644 --- a/tests/math/acc_fmt.pt +++ b/tests/math/acc_fmt.pt @@ -1,3 +1,4 @@ res = AccFmt(a, x) - "count-a%n chars" - res = a + x + count = a + "count-count%n chars" + res = count + x diff --git a/tests/math/math.pt b/tests/math/math.pt index f9166b25..fcbff42e 100644 --- a/tests/math/math.pt +++ b/tests/math/math.pt @@ -21,8 +21,9 @@ quo, rem = Div(dividend, divisor) rem = dividend % divisor mod, res = IsDiv(x, y) - mod, res = x % y, "no" - res = mod == 0 "yes" + remainder = x % y + mod, res = remainder, "no" + res = remainder == 0 "yes" x, y = F(i) x, y = 2 + i, 3 + i diff --git a/tests/math/range.pt b/tests/math/range.pt index 9c06c576..0c986055 100644 --- a/tests/math/range.pt +++ b/tests/math/range.pt @@ -1,10 +1,12 @@ y = Triple(x) - y = 3x - y + tripled = 3x + tripled + y = tripled res = Sum(curr, x) - res = curr + x - res + total = curr + x + total + res = total yes = Divides(in, y, x) yes = in * (y % x) diff --git a/tests/mem/mem_alias_refine.exp b/tests/mem/mem_alias_refine.exp index 8b32da5b..39b29f1e 100644 --- a/tests/mem/mem_alias_refine.exp +++ b/tests/mem/mem_alias_refine.exp @@ -1 +1 @@ -Refined: static Sibling: hello! +Refined: static Sibling: static! diff --git a/tests/mem/mem_alias_refine.pt b/tests/mem/mem_alias_refine.pt index 26a0ff20..f13d3726 100644 --- a/tests/mem/mem_alias_refine.pt +++ b/tests/mem/mem_alias_refine.pt @@ -1,7 +1,6 @@ # A heap-string argument whose same-named destination receives a static output. -# The ownership flavors differ (StrH in, StrG out), so the input must keep its -# own pointer: redirecting it to the output's adapter would make this sibling -# output, which reads the input, see the adapter's value instead. +# The output uses the destination's heap storage, so the later input read +# observes the replacement even though its expression produces a static string. out, echo = RefineEcho(s, x) out = "static" echo = x > -1 s ⊕ "!" diff --git a/tests/mem/mem_alias_refine.spt b/tests/mem/mem_alias_refine.spt index c32420dc..f6b37709 100644 --- a/tests/mem/mem_alias_refine.spt +++ b/tests/mem/mem_alias_refine.spt @@ -1,5 +1,4 @@ -# The range driver is what makes the callee alias-bearing, so this only covers -# the intended path while the call carries a range. +# Each iteration observes the output's earlier write through its shared input. text = "he" ⊕ "llo" sibling = "z" i = 0:3 diff --git a/tests/mem/mem_str.exp b/tests/mem/mem_str.exp index 1627afa8..4095782c 100644 --- a/tests/mem/mem_str.exp +++ b/tests/mem/mem_str.exp @@ -28,7 +28,7 @@ keep_heap CallStaticSeedKeep: keep_heap CallStaticSeedWrite: static changed CallStaticShareBefore: < shared_left > < shared_right > -CallStaticShareSkipped: < shared_left > < > +CallStaticShareSkipped: < shared_left > < shared_right > CallStaticShareWritten: < shared static > < shared static > slot plain diff --git a/tests/mem/mem_str.pt b/tests/mem/mem_str.pt index 59d20312..934b7eb2 100644 --- a/tests/mem/mem_str.pt +++ b/tests/mem/mem_str.pt @@ -15,10 +15,11 @@ s = getStaticAt(x) s = maybeStatic(flag) s = flag > 0 "static changed" -# Propagate one conditionally written static output into another output. +# Write one conditionally chosen static value into two outputs. s, t = shareStaticOutput(flag) - s = flag > 0 "shared static" - t = s + shared = flag > 0 "shared static" + s = flag > 0 shared + t = flag > 0 shared # Mixed indirect outputs exercise one widened string slot and one exact scalar slot. s, n = getStaticPair(x)