From 56d2f48ca63d414c0ca000fe4e7415a650a978f6 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 11 Sep 2026 21:31:01 +0530 Subject: [PATCH 01/12] feat(compiler): make function outputs write-only and inputs iteration-stable Declared outputs can no longer be read inside their template: values, conditions, call arguments, prints, and formatting markers (including dynamic width and precision) are rejected with `output "y" is read inside its function; outputs are write-only, use a local`. A body transforms inputs into outputs and never observes an output's value, so the incoming destination seed can never leak in; the seed and destination-seeded staging slots stay as an unobservable keep-old carrier and the ABI is unchanged. This supersedes the seed-read effect analysis of the closed #102: the reproducer `y = x > 0 x` then `y = y + 1` is now a template error. Range-bearing variants snapshot every non-iterator input at the start of each scalar iteration. A direct scalar aliased to an output reads the carried output once; an indirect input the body reads after an output write keeps a private copy for the iteration. Before, an aliased input read the output's storage on every read, so `out = current + item` then `seen = current` observed the new value: `value, before = Fold(value, 1:3)` printed `13 13` and now prints `13 11`, and a ranged heap-string accumulator prints `abc ab` instead of `ac`. The promoted-alias path is deleted; nothing reaches it once inputs are snapshotted. Fixtures that read an output are rewritten with locals or single expressions. `Rebuild` changes from reading its freshly written output to reading the previous iteration's result (`[26]` -> `[37]`, `[2]` -> `[6]`), and `shareStaticOutput(0)` no longer blanks its second output. The flow-versus-slot call specialization gap found in review is #103. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- compiler/cfg.go | 69 ++++++++++---- compiler/cfg_test.go | 95 ++++++++++++++++++- compiler/codecompiler.go | 21 ++++- compiler/compiler.go | 115 ++++++++--------------- compiler/compiler_test.go | 15 ++- compiler/solver_test.go | 60 ++---------- docs/Pluto Effects and Follow-up Plan.md | 25 ++++- docs/Pluto IR Plan.md | 12 ++- docs/Pluto Memory Model.md | 28 +++++- tests/alias_input/self_alias.exp | 4 + tests/alias_input/self_alias.pt | 20 ++-- tests/alias_input/self_alias.spt | 13 +++ tests/array/array_func.exp | 4 +- tests/array/array_func.pt | 3 +- tests/array/array_scalar_assign.pt | 3 +- tests/math/math.pt | 5 +- tests/math/range.pt | 10 +- tests/mem/mem_str.exp | 2 +- tests/mem/mem_str.pt | 7 +- 20 files changed, 321 insertions(+), 192 deletions(-) diff --git a/README.md b/README.md index 5c3e2964..7da6ea8b 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ 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. Every function is a transformation. A caller may reuse a variable as both argument and destination, `a = Square(a)`, which is how an old value reaches the function. ### Generics by use diff --git a/compiler/cfg.go b/compiler/cfg.go index 8e8178d7..c64b0881 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,9 @@ 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) + cfg.CodeCompiler.lateInputReads[fn] = body.lateInputReads + readInputs, assignedOutputs := body.readInputs, body.assignedOutputs for _, input := range fn.Parameters { if _, wasRead := readInputs[input.Value]; wasRead { @@ -225,35 +232,51 @@ 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. lateInputReads +// names the parameters read in a statement after the first statement that +// writes an output; reads within that statement precede its writes. +type templateBody struct { + statementReads [][]VarEvent + readInputs map[string]struct{} + assignedOutputs map[string]struct{} + lateInputReads 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)), + lateInputReads: make(map[string]struct{}), + } 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{}{} + if _, isParameter := parameterNames[event.Name]; !isParameter { + continue + } + body.readInputs[event.Name] = struct{}{} + if len(body.assignedOutputs) > 0 { + body.lateInputReads[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 +301,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 @@ -334,9 +356,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 +467,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 transforms inputs into outputs and never observes an +// output's value, so the incoming destination seed can never leak in. +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_test.go b/compiler/cfg_test.go index bafab129..74deae48 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -142,6 +142,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 +258,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 +857,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/codecompiler.go b/compiler/codecompiler.go index cd859dec..2aa7a277 100644 --- a/compiler/codecompiler.go +++ b/compiler/codecompiler.go @@ -13,6 +13,11 @@ type CodeCompiler struct { Code *ast.Code globalBindings map[string]token.Token funcTemplates map[funcKey]*ast.FuncStatement + // lateInputReads records, per template, the parameters read after an + // output has been written. Lowering snapshots those inputs per iteration + // of a range-bearing variant, since an aliased input would otherwise + // observe the output's new value. + lateInputReads map[*ast.FuncStatement]map[string]struct{} } type funcKey struct { @@ -23,12 +28,24 @@ type funcKey struct { func NewCodeCompiler(ctx llvm.Context, modName, relPath string, code *ast.Code) *CodeCompiler { mangledPath := MangleDirPath(modName, relPath) cc := &CodeCompiler{ - Compiler: NewCompiler(ctx, mangledPath, nil), - Code: code, + Compiler: NewCompiler(ctx, mangledPath, nil), + Code: code, + lateInputReads: make(map[*ast.FuncStatement]map[string]struct{}), } return cc } +// lateInputReadsFor returns the parameters a template reads after writing an +// output. AnalyzeFuncs records the fact for every template before any script +// lowers a call, so a missing entry is an ICE. +func (cc *CodeCompiler) lateInputReadsFor(template *ast.FuncStatement) map[string]struct{} { + late, recorded := cc.lateInputReads[template] + if !recorded { + panic(fmt.Sprintf("internal: template %s was lowered before structural analysis", template.Token.Literal)) + } + return late +} + func (cc *CodeCompiler) registerGlobalBinding(name string, tok token.Token) *token.CompileError { previous, exists := cc.globalBindings[name] if !exists { diff --git a/compiler/compiler.go b/compiler/compiler.go index c0d19ebc..f97b9d42 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -69,6 +69,9 @@ type Symbol struct { type FuncArgs struct { Inputs []*Symbol // lowered function inputs (range iterators remain pointer-backed) IterIndices []int // Indices of iterator params + // LateInputs names parameters read after an output write; each scalar + // iteration reads a snapshot taken at its start. + LateInputs map[string]struct{} } type callArg struct { @@ -234,9 +237,9 @@ func identNames(idents []*ast.Identifier) []string { } // 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. +// are resolved from scope when an iteration snapshots the param +// (snapshotIterationInputs). This allows direct outputs to remain values or +// be replaced in scope without invalidating the alias metadata. func (c *Compiler) bindParamAlias(name string, sym *Symbol, aliasIndex llvm.Value, outputNames []string) { c.currentParamAliases()[name] = ¶mAlias{ Base: sym, @@ -245,10 +248,6 @@ func (c *Compiler) bindParamAlias(name string, sym *Symbol, aliasIndex llvm.Valu } } -func (c *Compiler) clearParamAlias(name string) { - delete(c.currentParamAliases(), name) -} - func (c *Compiler) paramAliasFor(name string, sym *Symbol) (*paramAlias, bool) { alias, ok := c.currentParamAliases()[name] if !ok || alias.Base != sym { @@ -369,23 +368,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 { @@ -1635,10 +1617,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 +1628,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 @@ -2757,6 +2698,7 @@ func (c *Compiler) compileFuncIter(template *ast.FuncStatement, inputs []*Symbol fa := &FuncArgs{ Inputs: inputs, IterIndices: iterIndices, + LateInputs: c.CodeCompiler.lateInputReadsFor(template), } return c.funcLoopNest(template, fa, 0, currentOutput) } @@ -2909,11 +2851,18 @@ 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.snapshotIterationInputs(fn, fa) + 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] @@ -2960,14 +2909,32 @@ 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) +// snapshotIterationInputs fixes each non-iterator input for one scalar +// iteration. An input that aliases an output shares its storage, so a read +// after the output's write would observe the new value. A direct scalar reads +// the carried output once here; an indirect input read after an output write +// keeps a private copy of its value for the iteration, freed with the scope. +func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) { + for i, param := range fn.Parameters { + if slices.Contains(fa.IterIndices, i) { + continue + } + + name := param.Value + sym, _ := Get(c.Scopes, name) + if alias, aliased := c.paramAliasFor(name, sym); aliased { + Put(c.Scopes, name, c.directParamValue(name, sym, alias)) + continue + } + if _, late := fa.LateInputs[name]; !late || sym.Type.Kind() != PtrKind { + continue + } - output, _ := c.localValSymbol(fn.Outputs[0].Value, fn.Outputs[0].Value+"_iter_out") - return output + snapshot := c.deepCopyIfNeeded(c.derefIfPointer(sym, name+"_iter_input")) + snapshot.FuncArg = true + snapshot.ReadOnly = true + Put(c.Scopes, name, snapshot) + } } func (c *Compiler) compileFuncBody(fn *ast.FuncStatement) { diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 5127824b..38a9de98 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -435,23 +435,22 @@ out = Echo(value) // 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) { +func TestIterationSnapshotSelectsCompatibleOutput(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, "iteration_snapshot_gap", code, script) - require.Regexp(t, `%a_alias_1 = icmp eq i32 %\d+, 2`, ir, + require.Regexp(t, `%a_alias_match_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.Regexp(t, `%a_alias_value_1 = select i1 %a_alias_match_1, i64 %res_alias_load_1, i64 %\d+`, ir, + "selector 2 must read the caller's res destination once per iteration, falling back to the parameter") + require.NotContains(t, ir, "%a_alias_match_0", + "the mismatched leading output must never be selectable as the parameter's value") } func TestRangeCollectorScalarVariant(t *testing.T) { diff --git a/compiler/solver_test.go b/compiler/solver_test.go index aea3b290..d01dda45 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -1615,9 +1615,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 +1675,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 +2132,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 Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index c4a7070b..1197b7a9 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -19,9 +19,26 @@ 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 (branch +`feat-write-only-outputs`, superseding the closed +[PR #102](https://github.com/thiremani/pluto/pull/102)): declared outputs are +write-only inside their template, so a body can never observe its incoming +seed and the reproducer below is rejected at `y = y + 1`. The hidden seed and +destination-seeded staging slots stay as an unobservable keep-old carrier and +the public ABI is unchanged. Range-bearing variants additionally snapshot each +non-iterator input at the start of every scalar iteration, so an input +aliased to a destination reads the previous iteration's output rather than the +current iteration's write. The canonical description is in +[the memory model](./Pluto%20Memory%20Model.md) under "Parameters and Outputs". + +Still open from the same review: a call argument is specialized on the +binding's flow type at the call, while its storage uses the merged slot type. +`s = "a"` followed by `s, prev = FoldStr(s, "b")`, where `FoldStr` writes +`out = current ⊕ item` and `seen = current`, prints an empty `prev`, and a +static destination used as a ranged accumulator does not feed back across +flavors. Both need the callee specialized on the destination's slot type. + +The original analysis plan is kept below for the record. ### Confirmed failure @@ -198,7 +215,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 on `feat-write-only-outputs`; flow-versus-slot call specialization still open | | `%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..d9f6186e 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -850,10 +850,14 @@ 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 reads nothing. That last case holds by construction: +declared outputs are write-only inside their template (the structural CFG +rejects every read, including formatting markers), so a body can never observe +its incoming seed and the seed stays an unobservable keep-old carrier. 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..62275907 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -257,11 +257,19 @@ 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**: Input values (passed by value for scalars). Inside the + body an input is fixed: a range-bearing variant captures every non-iterator + input at the start of each scalar iteration, so an input that the caller + aliases to a destination never observes that output's write mid-iteration. +- **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 without ever seeing it. 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 @@ -283,6 +291,16 @@ res = sum(res, 5) # - 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 +feeds an old value into a transformation. The template itself sees only its +declared inputs; `res = res + 1` inside `sum` would be rejected. + +With a range, the same reuse is an accumulation: `sum = Acc(sum, 1:5)` runs +the body once per yield, and the input that aliases the destination receives +the previous iteration's output at the start of the next iteration. Within an +iteration that input is stable. An empty range leaves an existing destination +unchanged and a fresh destination at its zero value. + ### Range Parameters ```python diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 71afb345..f3a00101 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -1,3 +1,7 @@ 15 hi!hi [1 2 9 1 2] +15 10 +13 11 +10 0 +abc ab diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 891a8b64..7594e7e6 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -1,11 +1,19 @@ 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 + +# The input is read after the output is written. Without a range, the whole +# call sees the pre-call value; with a range, each iteration sees the value +# it started with, and the next iteration receives the written output. +out, seen = Fold(current, item) + out = current + item + seen = current + +out, seen = FoldStr(current, item) + out = current ⊕ item + seen = current diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index 9c98ec69..d26427e4 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -8,3 +8,16 @@ w v = [1 2] v = Grow(v) v +single = 10 +single, was = Fold(single, 5) +single, was +value = 10 +value, before = Fold(value, 1:3) +value, before +empty = 10 +empty, never = Fold(empty, 0:0) +empty, never +items = ["b" "c"] +text = "a" ⊕ "" +text, last = FoldStr(text, items[0:2]) +text, last 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/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_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) From 7387308a7a7a18add0c5ac836e6a576ac9f3d1d0 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 11 Sep 2026 21:31:22 +0530 Subject: [PATCH 02/12] docs(effects): record PR #104 and issue #103 in the seed disposition Co-Authored-By: Claude Fable 5.1 --- docs/Pluto Effects and Follow-up Plan.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index 1197b7a9..600016e1 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -19,8 +19,8 @@ type, and stored type separately, as the corrected code comment already does. ## 1. Next compiler PR: seed dependency analysis -Resolved by a language rule instead of an analysis (branch -`feat-write-only-outputs`, superseding the closed +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 a body can never observe its incoming seed and the reproducer below is rejected at `y = y + 1`. The hidden seed and @@ -31,8 +31,10 @@ aliased to a destination reads the previous iteration's output rather than the current iteration's write. The canonical description is in [the memory model](./Pluto%20Memory%20Model.md) under "Parameters and Outputs". -Still open from the same review: a call argument is specialized on the -binding's flow type at the call, while its storage uses the merged slot type. +Still open from the same review, filed as +[issue #103](https://github.com/thiremani/pluto/issues/103): a call argument is +specialized on the binding's flow type at the call, while its storage uses the +merged slot type. `s = "a"` followed by `s, prev = FoldStr(s, "b")`, where `FoldStr` writes `out = current ⊕ item` and `seen = current`, prints an empty `prev`, and a static destination used as a ranged accumulator does not feed back across @@ -215,7 +217,7 @@ and [ABI stability plan](./Pluto%20ABI%20Optimization%20Plan.md). | Work | Completion criterion / existing reference | | --- | --- | -| Seed/effect correctness | Section 1; resolved by the write-only-outputs rule on `feat-write-only-outputs`; flow-versus-slot call specialization still open | +| 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 | From 1eac2fea22d8936de12f7b9aac2f6217dfdd9adb Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 11 Sep 2026 22:17:57 +0530 Subject: [PATCH 03/12] perf(compiler): snapshot only inputs an output could alias snapshotIterationInputs copied every indirect input read after an output write, even one no output could share storage with, so `count, value = Read(data, 0:100000)` with integer outputs copied and freed the whole array on every iteration: 2.2s for the probe against milliseconds on master. The caller aliases an input to an output only when the two types lower identically (setCallArgAliasSelectors), so mirror that check before allocating a snapshot. The probe is back under the timer's resolution and its IR carries no arr_i64_copy; a heap-string input read after a heap-string output write still copies per iteration. Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 20 +++++++++++++++++++- compiler/compiler_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index f97b9d42..13939b5a 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -2913,7 +2913,9 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, // iteration. An input that aliases an output shares its storage, so a read // after the output's write would observe the new value. A direct scalar reads // the carried output once here; an indirect input read after an output write -// keeps a private copy of its value for the iteration, freed with the scope. +// keeps a private copy of its value for the iteration, freed with the scope, +// but only when some output could back it: the caller aliases identical +// storage types only, so any other input never shares output storage. func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) { for i, param := range fn.Parameters { if slices.Contains(fa.IterIndices, i) { @@ -2929,6 +2931,9 @@ func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) if _, late := fa.LateInputs[name]; !late || sym.Type.Kind() != PtrKind { continue } + if !c.inputCanAliasOutput(fn, sym.Type.(Ptr).Elem) { + continue + } snapshot := c.deepCopyIfNeeded(c.derefIfPointer(sym, name+"_iter_input")) snapshot.FuncArg = true @@ -2937,6 +2942,19 @@ func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) } } +// inputCanAliasOutput mirrors setCallArgAliasSelectors: a caller passes an +// output's staged storage as an input only when the two types lower +// identically. +func (c *Compiler) inputCanAliasOutput(fn *ast.FuncStatement, paramType Type) bool { + for _, output := range fn.Outputs { + outputSym, _ := Get(c.Scopes, output.Value) + if aliasableOutput(paramType, outputSym.Type) { + return true + } + } + return false +} + func (c *Compiler) compileFuncBody(fn *ast.FuncStatement) { for _, stmt := range fn.Body.Statements { c.compileStatement(stmt) diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 38a9de98..4f295b58 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -453,6 +453,41 @@ h, r` "the mismatched leading output must never be selectable as the parameter's value") } +func TestIterationSnapshotSkipsInputsNoOutputCanAlias(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, "iteration_snapshot_skip", code, script) + + require.NotContains(t, ir, "@arr_i64_copy", + "an input no output can alias must not be copied per iteration") +} + +func TestIterationSnapshotCopiesAliasableInputReadAfterWrite(t *testing.T) { + // The heap-string input can back the heap-string output, and the body + // reads it after writing that output, so each iteration works on a copy. + code := `out, seen = FoldStr(current, item) + out = current ⊕ item + seen = current` + script := `items = ["b" "c"] +text = "a" ⊕ "" +text, last = FoldStr(text, items[0:2]) +text, last` + + ir, _ := compileScriptAndCodeIR(t, "iteration_snapshot_copy", code, script) + + require.Contains(t, ir, "%current_iter_input", "the aliased input is loaded once per iteration") + require.Regexp(t, `%str_copy\d* = call ptr @\w+\(ptr %current_iter_input\)`, ir, + "the snapshot copies the loaded input before the body runs") +} + func TestRangeCollectorScalarVariant(t *testing.T) { code := `res = Scale(x) res = x * 3` From 19dcfe36f08770c3575b1991a9d0b76b08f958e4 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 11 Sep 2026 23:54:51 +0530 Subject: [PATCH 04/12] refactor(compiler): snapshot every aliasable input, drop lateInputReads The structural CFG recorded which inputs a template reads after its first output write so lowering could skip the per-iteration copy for inputs read only before it. Measurement shows ranged string accumulation is quadratic with or without that copy, because today's lowering allocates a fresh string every iteration; the amortized in-place append is planned, not implemented. The fact therefore bought about a 2x constant on an already-quadratic path (0.99s to 1.97s at 160000 appends) while making correctness depend on a read-ordering analysis being complete. Remove the fact and its plumbing from the CFG, CodeCompiler, and FuncArgs. An indirect input that some output could back is now copied at the start of every scalar iteration; direct scalars still re-read the carried output once. Inputs no output can alias are still left in place. Linear accumulation will come from consuming the input on its last use when carried appends land. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg.go | 15 +++------------ compiler/codecompiler.go | 21 ++------------------- compiler/compiler.go | 17 +++++------------ compiler/compiler_test.go | 6 +++--- 4 files changed, 13 insertions(+), 46 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index c64b0881..8ccc62e3 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -213,7 +213,6 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { } body := cfg.validateTemplateBody(fn.Body.Statements, parameterNames, outputNames) - cfg.CodeCompiler.lateInputReads[fn] = body.lateInputReads readInputs, assignedOutputs := body.readInputs, body.assignedOutputs for _, input := range fn.Parameters { @@ -232,14 +231,11 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { } } -// templateBody is the structural summary of one template body. lateInputReads -// names the parameters read in a statement after the first statement that -// writes an output; reads within that statement precede its writes. +// templateBody is the structural summary of one template body. type templateBody struct { statementReads [][]VarEvent readInputs map[string]struct{} assignedOutputs map[string]struct{} - lateInputReads map[string]struct{} } // validateTemplateBody runs structural validation over one template body. A @@ -250,7 +246,6 @@ func (cfg *CFG) validateTemplateBody(statements []ast.Statement, parameterNames, statementReads: make([][]VarEvent, 0, len(statements)), readInputs: make(map[string]struct{}, len(parameterNames)), assignedOutputs: make(map[string]struct{}, len(outputNames)), - lateInputReads: make(map[string]struct{}), } for _, stmt := range statements { reads := cfg.collectStatementReads(stmt) @@ -261,12 +256,8 @@ func (cfg *CFG) validateTemplateBody(statements []ast.Statement, parameterNames, body.statementReads = append(body.statementReads, reads) for _, event := range reads { - if _, isParameter := parameterNames[event.Name]; !isParameter { - continue - } - body.readInputs[event.Name] = struct{}{} - if len(body.assignedOutputs) > 0 { - body.lateInputReads[event.Name] = struct{}{} + if _, isParameter := parameterNames[event.Name]; isParameter { + body.readInputs[event.Name] = struct{}{} } } for _, target := range targets { diff --git a/compiler/codecompiler.go b/compiler/codecompiler.go index 2aa7a277..cd859dec 100644 --- a/compiler/codecompiler.go +++ b/compiler/codecompiler.go @@ -13,11 +13,6 @@ type CodeCompiler struct { Code *ast.Code globalBindings map[string]token.Token funcTemplates map[funcKey]*ast.FuncStatement - // lateInputReads records, per template, the parameters read after an - // output has been written. Lowering snapshots those inputs per iteration - // of a range-bearing variant, since an aliased input would otherwise - // observe the output's new value. - lateInputReads map[*ast.FuncStatement]map[string]struct{} } type funcKey struct { @@ -28,24 +23,12 @@ type funcKey struct { func NewCodeCompiler(ctx llvm.Context, modName, relPath string, code *ast.Code) *CodeCompiler { mangledPath := MangleDirPath(modName, relPath) cc := &CodeCompiler{ - Compiler: NewCompiler(ctx, mangledPath, nil), - Code: code, - lateInputReads: make(map[*ast.FuncStatement]map[string]struct{}), + Compiler: NewCompiler(ctx, mangledPath, nil), + Code: code, } return cc } -// lateInputReadsFor returns the parameters a template reads after writing an -// output. AnalyzeFuncs records the fact for every template before any script -// lowers a call, so a missing entry is an ICE. -func (cc *CodeCompiler) lateInputReadsFor(template *ast.FuncStatement) map[string]struct{} { - late, recorded := cc.lateInputReads[template] - if !recorded { - panic(fmt.Sprintf("internal: template %s was lowered before structural analysis", template.Token.Literal)) - } - return late -} - func (cc *CodeCompiler) registerGlobalBinding(name string, tok token.Token) *token.CompileError { previous, exists := cc.globalBindings[name] if !exists { diff --git a/compiler/compiler.go b/compiler/compiler.go index 13939b5a..96154307 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -69,9 +69,6 @@ type Symbol struct { type FuncArgs struct { Inputs []*Symbol // lowered function inputs (range iterators remain pointer-backed) IterIndices []int // Indices of iterator params - // LateInputs names parameters read after an output write; each scalar - // iteration reads a snapshot taken at its start. - LateInputs map[string]struct{} } type callArg struct { @@ -2698,7 +2695,6 @@ func (c *Compiler) compileFuncIter(template *ast.FuncStatement, inputs []*Symbol fa := &FuncArgs{ Inputs: inputs, IterIndices: iterIndices, - LateInputs: c.CodeCompiler.lateInputReadsFor(template), } return c.funcLoopNest(template, fa, 0, currentOutput) } @@ -2912,10 +2908,10 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, // snapshotIterationInputs fixes each non-iterator input for one scalar // iteration. An input that aliases an output shares its storage, so a read // after the output's write would observe the new value. A direct scalar reads -// the carried output once here; an indirect input read after an output write -// keeps a private copy of its value for the iteration, freed with the scope, -// but only when some output could back it: the caller aliases identical -// storage types only, so any other input never shares output storage. +// the carried output once here; an indirect input that some output could back +// keeps a private copy of its value for the iteration, freed with the scope. +// The caller aliases identical storage types only, so any other input never +// shares output storage and is left in place. func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) { for i, param := range fn.Parameters { if slices.Contains(fa.IterIndices, i) { @@ -2928,10 +2924,7 @@ func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) Put(c.Scopes, name, c.directParamValue(name, sym, alias)) continue } - if _, late := fa.LateInputs[name]; !late || sym.Type.Kind() != PtrKind { - continue - } - if !c.inputCanAliasOutput(fn, sym.Type.(Ptr).Elem) { + if sym.Type.Kind() != PtrKind || !c.inputCanAliasOutput(fn, sym.Type.(Ptr).Elem) { continue } diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 4f295b58..47b43d5b 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -470,9 +470,9 @@ count, value` "an input no output can alias must not be copied per iteration") } -func TestIterationSnapshotCopiesAliasableInputReadAfterWrite(t *testing.T) { - // The heap-string input can back the heap-string output, and the body - // reads it after writing that output, so each iteration works on a copy. +func TestIterationSnapshotCopiesAliasableInput(t *testing.T) { + // The heap-string input can back the heap-string output, so each + // iteration works on a private copy whatever the statement order. code := `out, seen = FoldStr(current, item) out = current ⊕ item seen = current` From 92bddd8c454fe04882581f6b696f4ce413581d8c Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 10:36:02 +0530 Subject: [PATCH 05/12] fix(llvm): preserve loop metadata when adding unroll hints Live input alias selectors change the optimized Fib tail loop so its latch already carries llvm.loop.peeled.count. Preserve existing loop properties when adding scalar unroll hints, while respecting explicit unroll policies. Read metadata operands through a native C pointer buffer rather than relying on the layout of llvm.Value. Existing metadata tests cover preservation, policy handling, and idempotence. --- llvm_metadata_byollvm.go | 53 +++++++++++++++++++++++++++++++++ llvm_pipeline.go | 38 +++++++++++++++++++++--- llvm_pipeline_test.go | 64 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 llvm_metadata_byollvm.go 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 { From 1f1b20822595180f6f525d4e2cb3019c1818354e Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 10:36:13 +0530 Subject: [PATCH 06/12] feat(compiler): preserve live input references across output writes Keep outputs write-only while allowing explicitly aliased inputs to observe earlier output writes in ordinary and ranged calls. Remove iteration snapshots, preserve nested reference identity, and specialize calls on settled binding storage, including compatible wider output slots. Reject formatting %n writes to input and iterator parameters; writable local copies retain their own permissions. Document the live-reference rule and cover both Fold orders and sequential versus simultaneous swaps without adding duplicate test combinations. BREAKING CHANGE: native ABI 2.1 adds an i32 alias selector for every direct scalar parameter in ordinary as well as ranged variants. Native callers must supply zero for inputs that do not alias an output. Fixes #103. --- README.md | 15 +- compiler/abi.go | 22 +-- compiler/cfg.go | 40 +++- compiler/cfg_replay_test.go | 5 +- compiler/cfg_test.go | 27 +++ compiler/compiler.go | 229 ++++++++++++++--------- compiler/compiler_test.go | 55 +++--- compiler/format.go | 6 + compiler/live_alias_format_test.go | 38 ++++ compiler/solver.go | 63 ++++++- compiler/solver_test.go | 78 ++++++++ docs/Pluto ABI Optimization Plan.md | 15 +- docs/Pluto C ABI Spec.md | 48 +++-- docs/Pluto Effects and Follow-up Plan.md | 64 ++++--- docs/Pluto IR Plan.md | 26 ++- docs/Pluto Memory Model.md | 72 +++++-- tests/alias_input/self_alias.exp | 27 ++- tests/alias_input/self_alias.pt | 55 +++++- tests/alias_input/self_alias.spt | 96 ++++++++-- tests/math/acc_fmt.pt | 5 +- tests/mem/mem_alias_refine.exp | 2 +- tests/mem/mem_alias_refine.pt | 5 +- tests/mem/mem_alias_refine.spt | 3 +- 23 files changed, 743 insertions(+), 253 deletions(-) create mode 100644 compiler/live_alias_format_test.go diff --git a/README.md b/README.md index 7da6ea8b..b206bc21 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 write-only inside the template — they flow out; use a local for intermediate values. Every function is a transformation. A caller may reuse a variable as both argument and destination, `a = Square(a)`, which is how an old value reaches the function. +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. ### Generics by use diff --git a/compiler/abi.go b/compiler/abi.go index 48e022f3..98f60d80 100644 --- a/compiler/abi.go +++ b/compiler/abi.go @@ -29,12 +29,11 @@ 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. Direct scalar inputs carry hidden alias state +// so reads can observe writes through an output that shares their binding. type FuncABI struct { - Params []ABIParam - Return ABIReturn - HasRangeParams bool + Params []ABIParam + Return ABIReturn } func isDirectScalarABIType(t Type) bool { @@ -79,13 +78,6 @@ 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{ @@ -97,10 +89,8 @@ func classifyFuncABI(paramTypes []Type, outTypes []Type) FuncABI { if isDirectScalarABIType(paramType) { paramABI.Mode = ABIParamDirect paramABI.Lowered = paramType - if abi.HasRangeParams { - paramABI.AliasSlot = aliasSlot - aliasSlot++ - } + paramABI.AliasSlot = aliasSlot + aliasSlot++ } abi.Params[i] = paramABI } diff --git a/compiler/cfg.go b/compiler/cfg.go index 8ccc62e3..e6249ad3 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -307,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 { @@ -316,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) } } @@ -459,8 +487,8 @@ func (cfg *CFG) backwardPass(live map[string]struct{}) { } // validateStructuralRead enforces that a declared output is write-only inside -// its template: a body transforms inputs into outputs and never observes an -// output's value, so the incoming destination seed can never leak in. +// 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)) 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 74deae48..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{ { diff --git a/compiler/compiler.go b/compiler/compiler.go index 96154307..b960f1bc 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 // @@ -81,16 +81,17 @@ type callArg struct { // 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 + // alias nothing. A nested call may forward a run-time alias relationship. + AliasSelector llvm.Value } type callSignature struct { - FuncName string - Mangled string - ParamTypes []Type - FnInfo *FuncInfo - ABI FuncABI + FuncName string + Mangled string + StorageName string // private lowering variant when output slots have wider storage + ParamTypes []Type + FnInfo *FuncInfo + ABI FuncABI } type preparedCall struct { @@ -138,6 +139,7 @@ type Compiler struct { FuncNameMangled string // current script root or function specialization key Errors []*token.CompileError paramAliasStack []map[string]*paramAlias + outputSlotTypes map[string]Type stmtCtxStack []stmtCtx } @@ -202,6 +204,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 { @@ -233,10 +238,9 @@ func identNames(idents []*ast.Identifier) []string { return names } -// bindParamAlias records the output names eagerly, but the outputs themselves -// are resolved from scope when an iteration snapshots the param -// (snapshotIterationInputs). This allows direct outputs to remain values or -// be replaced in scope without invalidating the alias metadata. +// bindParamAlias records the output names eagerly, but resolves their current +// values on every input read. Outputs may remain values or be replaced in scope +// without invalidating the input's reference to them. func (c *Compiler) bindParamAlias(name string, sym *Symbol, aliasIndex llvm.Value, outputNames []string) { c.currentParamAliases()[name] = ¶mAlias{ Base: sym, @@ -316,15 +320,16 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, } // setCallArgAliasSelectors records on each argument which caller destination it -// aliases for range-bearing variants. Direct scalar params encode the selected +// aliases. 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 { + if dest == nil { return } for paramIndex, arg := range args { + args[paramIndex].AliasSelector = llvm.ConstInt(c.Context.Int32Type(), 0, false) if arg.Name == "" { continue } @@ -333,24 +338,48 @@ func (c *Compiler) setCallArgAliasSelectors(sig *callSignature, args []callArg, if outputIndex >= len(sig.ABI.Return.OutTypes) { break } - if output.Value != arg.Name { - 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. + // 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 } - args[paramIndex].AliasSelector = outputIndex + 1 - break + selected := llvm.ConstInt(c.Context.Int32Type(), uint64(outputIndex+1), false) + if output.Value == arg.Name { + args[paramIndex].AliasSelector = selected + break + } + + if same := c.inputAliasesBinding(arg.Name, output.Value); !same.IsNil() { + args[paramIndex].AliasSelector = c.builder.CreateSelect(same, selected, args[paramIndex].AliasSelector, arg.Name+"_call_alias") + } } } } +// inputAliasesBinding preserves reference identity across nested calls, where +// the input and output may have different source names but share storage. +func (c *Compiler) inputAliasesBinding(input, output string) llvm.Value { + sym, ok := Get(c.Scopes, input) + if !ok { + return llvm.Value{} + } + if alias, ok := c.paramAliasFor(input, sym); ok { + for i, name := range alias.OutputNames { + if name == output { + return c.builder.CreateICmp(llvm.IntEQ, alias.AliasIndex, + llvm.ConstInt(c.Context.Int32Type(), uint64(i+1), false), input+"_forwards_alias") + } + } + } + + dest, ok := Get(c.Scopes, output) + if ok && sym.Type.Kind() == PtrKind && dest.Type.Kind() == PtrKind && sym.FuncArg && sym.ReadOnly { + return c.builder.CreateICmp(llvm.IntEQ, sym.Val, dest.Val, input+"_shares_output") + } + return llvm.Value{} +} + // directReturnSeedForCall captures the caller's current destination value for a // direct scalar return. The callee receives it through a hidden ABI parameter // so a skipped output write preserves the destination even though the LLVM @@ -1019,7 +1048,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 } @@ -2343,10 +2377,11 @@ 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. Input references may +// share it at run time and follow the staged slot through a pointer select. +// 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 @@ -2361,11 +2396,19 @@ func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symb 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 sym.FuncArg && sym.ReadOnly && TypeEqual(sym.Type, current.Type) && TypeEqual(sym.Type, outputs[i].Type) { + shared := c.builder.CreateICmp(llvm.IntEQ, sym.Val, current.Val, name+"_range_alias") + reference := GetCopy(sym) + reference.Val = c.builder.CreateSelect(shared, outputs[i].Val, sym.Val, name+"_range_ref") + Put(c.Scopes, name, reference) + continue + } if sym.Type.Kind() == PtrKind && sym.Val == current.Val { names = append(names, name) } @@ -2541,7 +2584,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.StorageName != "" { + function.SetLinkage(llvm.InternalLinkage) + } if sig.ABI.UsesIndirectReturn() { sretAttr := c.Context.CreateTypeAttribute(llvm.AttributeKindID("sret"), retStruct) @@ -2575,9 +2621,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 { @@ -2709,7 +2761,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) } @@ -2852,7 +2904,6 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, if currentOutput != nil { Put(c.Scopes, fn.Outputs[0].Value, currentOutput) } - c.snapshotIterationInputs(fn, fa) c.compileFuncBody(fn) if currentOutput == nil { return nil @@ -2871,7 +2922,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) @@ -2905,49 +2956,6 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, return result } -// snapshotIterationInputs fixes each non-iterator input for one scalar -// iteration. An input that aliases an output shares its storage, so a read -// after the output's write would observe the new value. A direct scalar reads -// the carried output once here; an indirect input that some output could back -// keeps a private copy of its value for the iteration, freed with the scope. -// The caller aliases identical storage types only, so any other input never -// shares output storage and is left in place. -func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) { - for i, param := range fn.Parameters { - if slices.Contains(fa.IterIndices, i) { - continue - } - - name := param.Value - sym, _ := Get(c.Scopes, name) - if alias, aliased := c.paramAliasFor(name, sym); aliased { - Put(c.Scopes, name, c.directParamValue(name, sym, alias)) - continue - } - if sym.Type.Kind() != PtrKind || !c.inputCanAliasOutput(fn, sym.Type.(Ptr).Elem) { - continue - } - - snapshot := c.deepCopyIfNeeded(c.derefIfPointer(sym, name+"_iter_input")) - snapshot.FuncArg = true - snapshot.ReadOnly = true - Put(c.Scopes, name, snapshot) - } -} - -// inputCanAliasOutput mirrors setCallArgAliasSelectors: a caller passes an -// output's staged storage as an input only when the two types lower -// identically. -func (c *Compiler) inputCanAliasOutput(fn *ast.FuncStatement, paramType Type) bool { - for _, output := range fn.Outputs { - outputSym, _ := Get(c.Scopes, output.Value) - if aliasableOutput(paramType, outputSym.Type) { - return true - } - } - return false -} - func (c *Compiler) compileFuncBody(fn *ast.FuncStatement) { for _, stmt := range fn.Body.Statements { c.compileStatement(stmt) @@ -3205,16 +3213,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 } @@ -3270,6 +3278,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( @@ -3282,6 +3291,42 @@ func (c *Compiler) compileIndirectCallIntoStagedOutputs( ) } +func (sig *callSignature) loweredName() string { + if sig.StorageName != "" { + return sig.StorageName + } + return sig.Mangled +} + +// 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 { + return + } + + sig.StorageName = sig.Mangled + "$outputs" + for _, output := range sig.ABI.Return.OutTypes { + sig.StorageName += "$" + output.Mangle() + } +} + func (c *Compiler) makeCallOutputWriteFlags(count int) []llvm.Value { flags := make([]llvm.Value, count) for i := range flags { @@ -3341,12 +3386,21 @@ 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 + hasAlias := !arg.AliasSelector.IsNil() && + (!arg.AliasSelector.IsConstant() || arg.AliasSelector.ZExtValue() != 0) + if sig.ABI.Params[i].Mode == ABIParamIndirect && hasAlias { + for j, output := range outputs { + if !aliasableOutput(sig.ParamTypes[i], sig.ABI.Return.OutTypes[j]) { + continue + } + match := c.builder.CreateICmp(llvm.IntEQ, arg.AliasSelector, + llvm.ConstInt(c.Context.Int32Type(), uint64(j+1), false), arg.Name+"_arg_alias") + argVal = c.builder.CreateSelect(match, output.Val, argVal, arg.Name+"_arg_ref") + } } llvmArgs = append(llvmArgs, argVal) } - aliasIndices := make([]int, sig.ABI.NumAliasSlots()) + aliasIndices := make([]llvm.Value, sig.ABI.NumAliasSlots()) for i, arg := range call.Args { slot := sig.ABI.Params[i].AliasSlot if slot < 0 { @@ -3355,7 +3409,10 @@ func (c *Compiler) callArgs( aliasIndices[slot] = arg.AliasSelector } for _, aliasIndex := range aliasIndices { - llvmArgs = append(llvmArgs, llvm.ConstInt(c.Context.Int32Type(), uint64(aliasIndex), false)) + if aliasIndex.IsNil() { + aliasIndex = llvm.ConstInt(c.Context.Int32Type(), 0, false) + } + llvmArgs = append(llvmArgs, aliasIndex) } if sig.ABI.Return.Mode == ABIReturnDirect { seed := c.coerceSymbolForType(directSeed, sig.ABI.Return.DirectType, sig.FuncName+"_seed") diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 47b43d5b..9305bd09 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -111,8 +111,8 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "Add", []Type{I64, I64}) - require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(i64 noundef %0, i64 noundef %1, i64 noundef %2)", "expected direct scalar signature with a hidden destination seed") - require.Contains(t, scriptIR, "call i64 @"+mangled+"(i64 2, i64 3, i64 0)", "expected direct scalar call with a fresh-destination seed") + require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(i64 noundef %0, i64 noundef %1, i32 noundef %2, i32 noundef %3, i64 noundef %4)", "expected direct scalar signature with alias selectors and a hidden destination seed") + require.Contains(t, scriptIR, "call i64 @"+mangled+"(i64 2, i64 3, i32 0, i32 0, i64 0)", "expected direct scalar call with no aliases and a fresh-destination seed") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar return should not use sret struct") } @@ -152,8 +152,8 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "AddF", []Type{F64, F64}) - require.Contains(t, scriptIR, "define noundef double @"+mangled+"(double noundef %0, double noundef %1, double noundef %2)", "expected direct float signature with a hidden destination seed") - require.Contains(t, scriptIR, "call double @"+mangled+"(double 2.500000e+00, double 3.500000e+00, double 0.000000e+00)", "expected direct float call with a fresh-destination seed") + require.Contains(t, scriptIR, "define noundef double @"+mangled+"(double noundef %0, double noundef %1, i32 noundef %2, i32 noundef %3, double noundef %4)", "expected direct float signature with alias selectors and a hidden destination seed") + require.Contains(t, scriptIR, "call double @"+mangled+"(double 2.500000e+00, double 3.500000e+00, i32 0, i32 0, double 0.000000e+00)", "expected direct float call with no aliases and a fresh-destination seed") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar float return should not use sret struct") } @@ -431,11 +431,9 @@ 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 TestIterationSnapshotSelectsCompatibleOutput(t *testing.T) { +// Alias selectors retain the declared output positions even when an earlier +// output has a type that cannot back the input. +func TestInputAliasSelectsCompatibleOutput(t *testing.T) { code := `half, res = Rev(a, x) half = x * 0.5 res = a + x` @@ -443,17 +441,17 @@ func TestIterationSnapshotSelectsCompatibleOutput(t *testing.T) { h, r = Rev(r, 1:4) h, r` - ir, _ := compileScriptAndCodeIR(t, "iteration_snapshot_gap", code, script) + ir, _ := compileScriptAndCodeIR(t, "input_alias_gap", code, script) require.Regexp(t, `%a_alias_match_1 = icmp eq i32 %\d+, 2`, ir, "the compatible output is the second one, so its ABI selector value must be 2") require.Regexp(t, `%a_alias_value_1 = select i1 %a_alias_match_1, i64 %res_alias_load_1, i64 %\d+`, ir, - "selector 2 must read the caller's res destination once per iteration, falling back to the parameter") + "selector 2 must read the current res output, falling back to the parameter") require.NotContains(t, ir, "%a_alias_match_0", "the mismatched leading output must never be selectable as the parameter's value") } -func TestIterationSnapshotSkipsInputsNoOutputCanAlias(t *testing.T) { +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. @@ -464,28 +462,27 @@ func TestIterationSnapshotSkipsInputsNoOutputCanAlias(t *testing.T) { count, value = Read(data, 0:8) count, value` - ir, _ := compileScriptAndCodeIR(t, "iteration_snapshot_skip", code, script) + 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 TestIterationSnapshotCopiesAliasableInput(t *testing.T) { - // The heap-string input can back the heap-string output, so each - // iteration works on a private copy whatever the statement order. - code := `out, seen = FoldStr(current, item) - out = current ⊕ item - seen = current` - script := `items = ["b" "c"] -text = "a" ⊕ "" -text, last = FoldStr(text, items[0:2]) -text, last` +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, "iteration_snapshot_copy", code, script) + ir, _ := compileScriptAndCodeIR(t, "matching_array_input", code, script) - require.Contains(t, ir, "%current_iter_input", "the aliased input is loaded once per iteration") - require.Regexp(t, `%str_copy\d* = call ptr @\w+\(ptr %current_iter_input\)`, ir, - "the snapshot copies the loaded input before the body runs") + require.NotContains(t, ir, "@arr_i64_copy", + "a matching output type must not introduce an input copy on every iteration") + require.NotContains(t, ir, "%data_arg_ref", + "an input with a known-zero alias selector must use its original pointer directly") } func TestRangeCollectorScalarVariant(t *testing.T) { @@ -629,11 +626,11 @@ res` Range{Iter: I64}, }) - require.Contains(t, scriptIR, "define noundef i64 @"+scalarMangled+"(i64 noundef %0, i64 noundef %1, i64 noundef %2)", + require.Contains(t, scriptIR, "define noundef i64 @"+scalarMangled+"(i64 noundef %0, i64 noundef %1, i32 noundef %2, i32 noundef %3, i64 noundef %4)", "a shared driver must select the ordinary scalar specialization") require.GreaterOrEqual(t, strings.Count(scriptIR, "call i64 @"+scalarMangled+"("), 1, "the shared caller-side loop should invoke the scalar specialization") - require.Contains(t, scriptIR, "call i64 @"+scalarMangled+"(i64 %get, i64 %iter, i64 %call_seed)", + require.Contains(t, scriptIR, "call i64 @"+scalarMangled+"(i64 %get, i64 %iter, i32 0, i32 0, i64 %call_seed)", "the array access and scalar argument should use the same caller-loop iterator") require.NotContains(t, scriptIR, arrayRangeMangled, "arr[i] and i must not become independent callee iterators") 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/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 d01dda45..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{}}, diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index 0fb2d699..12e732a6 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,9 @@ 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 + with one hidden alias selector for every direct scalar input; reads use the + selected output's current value, including writes in the same iteration `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..09bab589 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 @@ -331,8 +331,10 @@ Module: `github.com/user/math`, RelPath: `stats/integral` The native calling convention is selected from the solved parameter and output types: -- `I64` and `F64` parameters are passed directly. Ranges, internal - `ArrayRange` descriptors, and other values are passed indirectly. +- `I64` and `F64` parameters are passed directly, with a hidden `i32` alias + selector for each direct parameter after all source parameters, in source + order. Ranges, internal `ArrayRange` descriptors, and other values are + passed indirectly. - A function with exactly one `I64` or `F64` output returns that scalar directly and receives one hidden seed value. The seed preserves the caller's staged value when the callee does not write its output, including a failed @@ -346,17 +348,20 @@ 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 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_Square_I64(int64_t x, int32_t x_output_alias, int64_t seed); +int64_t Pt_ConditionalSquare_I64(int64_t x, int32_t x_output_alias, int64_t seed); int64_t Pt_Acc_I64_Range( int64_t a, const PtRangeI64 *range, @@ -385,17 +390,28 @@ struct Results { bool *wrote1; }; -void Pt_example(Results *results, I64 direct_arg, Other *indirect_arg); +void Pt_example( + Results *results, + I64 direct_arg, + Other *indirect_arg, + int32_t direct_arg_output_alias +); ``` -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. +Every ordinary or range-bearing variant receives one hidden alias selector +for each direct scalar parameter. Zero selects the explicit argument value; +a positive value `k` selects output slot `k - 1`, whose type must match the +parameter. Each input read observes the selected output's current value, so a +write through an output is visible to a later read through an aliased input. 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. +staged output pointer itself, without another hidden parameter. Both forms +also carry output values into subsequent range iterations. The caller's real +destinations remain unchanged until the surrounding assignment commits. + +Version 2.1 adds these selectors to ordinary variants as well as ranged ones; +C callers must supply zero for inputs that do not alias an output. This changes +the native C signature. Hidden ABI fields and parameters are not part of name +mangling. An eligible immediate bare `array[range]` call argument may therefore select an `ArrayRange` specialization and run its loop inside the callee. This diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index 600016e1..6dbe396c 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -22,23 +22,26 @@ type, and stored type separately, as the corrected code comment already does. 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 a body can never observe its incoming -seed and the reproducer below is rejected at `y = y + 1`. The hidden seed and -destination-seeded staging slots stay as an unobservable keep-old carrier and -the public ABI is unchanged. Range-bearing variants additionally snapshot each -non-iterator input at the start of every scalar iteration, so an input -aliased to a destination reads the previous iteration's output rather than the -current iteration's write. The canonical description is in +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. Direct scalar inputs use hidden alias selectors for ordinary as well +as ranged variants, which changes the native calling convention while keeping +its classification independent of body effects. The canonical description is in [the memory model](./Pluto%20Memory%20Model.md) under "Parameters and Outputs". -Still open from the same review, filed as -[issue #103](https://github.com/thiremani/pluto/issues/103): a call argument is -specialized on the binding's flow type at the call, while its storage uses the -merged slot type. -`s = "a"` followed by `s, prev = FoldStr(s, "b")`, where `FoldStr` writes -`out = current ⊕ item` and `seen = current`, prints an empty `prev`, and a -static destination used as a ranged accumulator does not feed back across -flavors. Both need the callee specialized on the destination's slot type. +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. @@ -140,18 +143,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-selector +coverage remains in `TestInputAliasSelectsCompatibleOutput`. 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 @@ -168,8 +177,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. diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index d9f6186e..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,12 @@ 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. That last case holds by construction: -declared outputs are write-only inside their template (the structural CFG -rejects every read, including formatting markers), so a body can never observe -its incoming seed and the seed stays an unobservable keep-old carrier. Step 2A +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 diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 62275907..cfcaafb8 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -257,10 +257,12 @@ res = sum(a, b) res = a + b ``` -- **Parameters**: Input values (passed by value for scalars). Inside the - body an input is fixed: a range-bearing variant captures every non-iterator - input at the start of each scalar iteration, so an input that the caller - aliases to a destination never observes that output's write mid-iteration. +- **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 @@ -268,23 +270,26 @@ res = sum(a, b) 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 without ever seeing it. The real destinations are committed only + 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 @@ -292,14 +297,43 @@ res = sum(res, 5) ``` Reusing a variable as both an argument and a destination is how a caller -feeds an old value into a transformation. The template itself sees only its -declared inputs; `res = res + 1` inside `sum` would be rejected. +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 still precede its writes. + +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 the input that aliases the destination receives -the previous iteration's output at the start of the next iteration. Within an -iteration that input is stable. An empty range leaves an existing destination -unchanged and a fresh destination at its zero value. +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 diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index f3a00101..791d5280 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -1,7 +1,26 @@ 15 hi!hi [1 2 9 1 2] -15 10 -13 11 -10 0 -abc ab +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 +StagedString: helloabc helloabc hello +ResetArray: [ +] [1 2] [ +] diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 7594e7e6..029c5dbc 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -7,13 +7,56 @@ s = Shout(t) r = Grow(q) r = q ⊕ [9] ⊕ q -# The input is read after the output is written. Without a range, the whole -# call sees the pre-call value; with a range, each iteration sees the value -# it started with, and the next iteration receives the written output. -out, seen = Fold(current, item) +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 - seen = current -out, seen = FoldStr(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) + +out, seen = NestedArrayRange(current) + out, seen = ArrayAfter(current, (1:3) + 0) + +out, seen = ConditionalFold(current, item) + out = item > 0 current + item seen = current + +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 d26427e4..aa395078 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,16 +8,88 @@ w v = [1 2] v = Grow(v) v -single = 10 -single, was = Fold(single, 5) -single, was -value = 10 -value, before = Fold(value, 1:3) -value, before + +# 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 = Fold(empty, 0:0) -empty, never +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"] -text = "a" ⊕ "" -text, last = FoldStr(text, items[0:2]) -text, last +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 + +# 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/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/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 From 31d99772c0f7e4bc4f4d273873333bc04dc97416 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 18:32:40 +0530 Subject: [PATCH 07/12] docs(memory): state the in-body read order and explicit-save rule Reads precede writes in every simultaneous assignment, inside a body as at the call site, so a shared input still yields its prior value within the statement that writes the output. Keeping an old value across a write is an explicit assignment; that is where any copy is paid. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- docs/Pluto Memory Model.md | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b206bc21..b2d901cf 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ 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. +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/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index cfcaafb8..e649fa0e 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -313,8 +313,15 @@ out, after = FoldAfter(current, item) 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 still precede its writes. +`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 From 38c4eea35bb2b6d80d277370642286b1d3d2218f Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 18:45:21 +0530 Subject: [PATCH 08/12] refactor(compiler): decide input/output aliasing at compile time Whether a call's argument shares a binding with one of its destinations is known from the call's names, so it no longer travels as a hidden i32 alias selector on every direct scalar parameter. A call whose argument names its own destination lowers to a private variant of the specialization, `$alias$` with internal linkage, in which a direct scalar input reads the output's current value and a compatible indirect input receives the staged output pointer. Nested calls and caller-driven ranges forward the sharing by name through the same alias bindings, replacing the run-time selects and pointer comparisons. The exported prototypes return to ABI 2.0, `(params..., seed)`, and the plain variant's IR matches master's, which recovers the 10% loss the selectors had caused on fib_tail: master-relative timings are now fib 0.97x, fib_tail 1.01x, harmonic 1.01x. Behavior is unchanged; every alias fixture prints the same output. Co-Authored-By: Claude Fable 5.1 --- compiler/abi.go | 48 ++--- compiler/compiler.go | 215 ++++++++++------------- compiler/compiler_test.go | 41 +++-- docs/Pluto ABI Optimization Plan.md | 5 +- docs/Pluto C ABI Spec.md | 51 +++--- docs/Pluto Effects and Follow-up Plan.md | 10 +- 6 files changed, 154 insertions(+), 216 deletions(-) diff --git a/compiler/abi.go b/compiler/abi.go index 98f60d80..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,8 +28,9 @@ 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. Direct scalar inputs carry hidden alias state -// so reads can observe writes through an output that shares their binding. +// 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 @@ -48,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. @@ -78,19 +78,15 @@ func classifyFuncABI(paramTypes []Type, outTypes []Type) FuncABI { }, } - 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 - paramABI.AliasSlot = aliasSlot - aliasSlot++ } abi.Params[i] = paramABI } @@ -110,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 @@ -131,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/compiler.go b/compiler/compiler.go index b960f1bc..68be9fae 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -76,22 +76,28 @@ 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. A nested call may forward a run-time alias relationship. - AliasSelector llvm.Value -} - + // 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 StorageName string // private lowering variant when output slots have wider storage - ParamTypes []Type - FnInfo *FuncInfo - ABI FuncABI + // 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 { @@ -101,13 +107,12 @@ 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. type paramAlias struct { - Base *Symbol - AliasIndex llvm.Value - OutputNames []string + Base *Symbol + Output string } type symbolSource int @@ -238,15 +243,11 @@ func identNames(idents []*ast.Identifier) []string { return names } -// bindParamAlias records the output names eagerly, but resolves their current -// values on every input read. Outputs may remain values or be replaced in scope -// without invalidating the input's reference to them. -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) { + c.currentParamAliases()[name] = ¶mAlias{Base: sym, Output: output} } func (c *Compiler) paramAliasFor(name string, sym *Symbol) (*paramAlias, bool) { @@ -319,17 +320,19 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, }, true } -// setCallArgAliasSelectors records on each argument which caller destination it -// aliases. 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) { +// 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 { - args[paramIndex].AliasSelector = llvm.ConstInt(c.Context.Int32Type(), 0, false) if arg.Name == "" { continue } @@ -344,40 +347,31 @@ func (c *Compiler) setCallArgAliasSelectors(sig *callSignature, args []callArg, if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { continue } - selected := llvm.ConstInt(c.Context.Int32Type(), uint64(outputIndex+1), false) - if output.Value == arg.Name { - args[paramIndex].AliasSelector = selected - break + if output.Value != arg.Name && !c.inputAliasesOutput(arg.Name, output.Value) { + continue } - - if same := c.inputAliasesBinding(arg.Name, output.Value); !same.IsNil() { - args[paramIndex].AliasSelector = c.builder.CreateSelect(same, selected, args[paramIndex].AliasSelector, arg.Name+"_call_alias") + if pattern == nil { + pattern = make([]int, len(args)) } + args[paramIndex].AliasOutput = outputIndex + 1 + pattern[paramIndex] = outputIndex + 1 + break } } + + sig.AliasPattern = pattern } -// inputAliasesBinding preserves reference identity across nested calls, where -// the input and output may have different source names but share storage. -func (c *Compiler) inputAliasesBinding(input, output string) llvm.Value { +// 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 llvm.Value{} - } - if alias, ok := c.paramAliasFor(input, sym); ok { - for i, name := range alias.OutputNames { - if name == output { - return c.builder.CreateICmp(llvm.IntEQ, alias.AliasIndex, - llvm.ConstInt(c.Context.Int32Type(), uint64(i+1), false), input+"_forwards_alias") - } - } - } - - dest, ok := Get(c.Scopes, output) - if ok && sym.Type.Kind() == PtrKind && dest.Type.Kind() == PtrKind && sym.FuncArg && sym.ReadOnly { - return c.builder.CreateICmp(llvm.IntEQ, sym.Val, dest.Val, input+"_shares_output") + return false } - return llvm.Value{} + alias, ok := c.paramAliasFor(input, sym) + return ok && alias.Output == output } // directReturnSeedForCall captures the caller's current destination value for a @@ -433,37 +427,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) @@ -2402,11 +2383,8 @@ func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symb continue } seen[name] = struct{}{} - if sym.FuncArg && sym.ReadOnly && TypeEqual(sym.Type, current.Type) && TypeEqual(sym.Type, outputs[i].Type) { - shared := c.builder.CreateICmp(llvm.IntEQ, sym.Val, current.Val, name+"_range_alias") - reference := GetCopy(sym) - reference.Val = c.builder.CreateSelect(shared, outputs[i].Val, sym.Val, name+"_range_ref") - Put(c.Scopes, name, reference) + if alias, aliased := c.paramAliasFor(name, sym); aliased && alias.Output == dest[i].Value { + names = append(names, name) continue } if sym.Type.Kind() == PtrKind && sym.Val == current.Val { @@ -2553,9 +2531,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)) } @@ -2585,7 +2560,7 @@ 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.loweredName(), funcType) - if sig.StorageName != "" { + if sig.isVariant() { function.SetLinkage(llvm.InternalLinkage) } @@ -2606,9 +2581,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) } @@ -2720,9 +2692,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, @@ -2732,6 +2701,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) @@ -3088,7 +3063,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{ @@ -3291,11 +3266,26 @@ 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 { + name := sig.Mangled if sig.StorageName != "" { - return sig.StorageName + name = sig.StorageName + } + if sig.AliasPattern == nil { + return name } - return sig.Mangled + + name += "$alias" + for _, output := range sig.AliasPattern { + name += fmt.Sprintf("$%d", output) + } + return name +} + +func (sig *callSignature) isVariant() bool { + return sig.StorageName != "" || sig.AliasPattern != nil } // specializeOutputStorage keeps a writable output and a compatible input on @@ -3386,34 +3376,11 @@ func (c *Compiler) callArgs( } for i, arg := range call.Args { argVal := arg.Lowered.Val - hasAlias := !arg.AliasSelector.IsNil() && - (!arg.AliasSelector.IsConstant() || arg.AliasSelector.ZExtValue() != 0) - if sig.ABI.Params[i].Mode == ABIParamIndirect && hasAlias { - for j, output := range outputs { - if !aliasableOutput(sig.ParamTypes[i], sig.ABI.Return.OutTypes[j]) { - continue - } - match := c.builder.CreateICmp(llvm.IntEQ, arg.AliasSelector, - llvm.ConstInt(c.Context.Int32Type(), uint64(j+1), false), arg.Name+"_arg_alias") - argVal = c.builder.CreateSelect(match, output.Val, argVal, arg.Name+"_arg_ref") - } + 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([]llvm.Value, 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 { - if aliasIndex.IsNil() { - aliasIndex = llvm.ConstInt(c.Context.Int32Type(), 0, false) - } - llvmArgs = append(llvmArgs, aliasIndex) - } 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 9305bd09..d5431776 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -111,8 +111,8 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "Add", []Type{I64, I64}) - require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(i64 noundef %0, i64 noundef %1, i32 noundef %2, i32 noundef %3, i64 noundef %4)", "expected direct scalar signature with alias selectors and a hidden destination seed") - require.Contains(t, scriptIR, "call i64 @"+mangled+"(i64 2, i64 3, i32 0, i32 0, i64 0)", "expected direct scalar call with no aliases and a fresh-destination seed") + require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(i64 noundef %0, i64 noundef %1, i64 noundef %2)", "expected direct scalar signature with a hidden destination seed") + require.Contains(t, scriptIR, "call i64 @"+mangled+"(i64 2, i64 3, i64 0)", "expected direct scalar call with a fresh-destination seed") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar return should not use sret struct") } @@ -152,8 +152,8 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "AddF", []Type{F64, F64}) - require.Contains(t, scriptIR, "define noundef double @"+mangled+"(double noundef %0, double noundef %1, i32 noundef %2, i32 noundef %3, double noundef %4)", "expected direct float signature with alias selectors and a hidden destination seed") - require.Contains(t, scriptIR, "call double @"+mangled+"(double 2.500000e+00, double 3.500000e+00, i32 0, i32 0, double 0.000000e+00)", "expected direct float call with no aliases and a fresh-destination seed") + require.Contains(t, scriptIR, "define noundef double @"+mangled+"(double noundef %0, double noundef %1, double noundef %2)", "expected direct float signature with a hidden destination seed") + require.Contains(t, scriptIR, "call double @"+mangled+"(double 2.500000e+00, double 3.500000e+00, double 0.000000e+00)", "expected direct float call with a fresh-destination seed") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar float return should not use sret struct") } @@ -431,9 +431,11 @@ out = Echo(value) } } -// Alias selectors retain the declared output positions even when an earlier -// output has a type that cannot back the input. -func TestInputAliasSelectsCompatibleOutput(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) half = x * 0.5 res = a + x` @@ -441,14 +443,15 @@ func TestInputAliasSelectsCompatibleOutput(t *testing.T) { h, r = Rev(r, 1:4) h, r` - ir, _ := compileScriptAndCodeIR(t, "input_alias_gap", code, script) + ir, _ := compileScriptAndCodeIR(t, "input_alias_variant", code, script) + mangled := Mangle(MangleDirPath("input_alias_variant", ""), "Rev", []Type{I64, Range{Iter: I64}}) - require.Regexp(t, `%a_alias_match_1 = icmp eq i32 %\d+, 2`, ir, - "the compatible output is the second one, so its ABI selector value must be 2") - require.Regexp(t, `%a_alias_value_1 = select i1 %a_alias_match_1, i64 %res_alias_load_1, i64 %\d+`, ir, - "selector 2 must read the current res output, falling back to the parameter") - require.NotContains(t, ir, "%a_alias_match_0", - "the mismatched leading output must never be selectable as the parameter's value") + require.Contains(t, ir, `define internal void @"`+mangled+`$alias$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) { @@ -545,9 +548,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+`$alias$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+`$alias$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") } @@ -626,11 +629,11 @@ res` Range{Iter: I64}, }) - require.Contains(t, scriptIR, "define noundef i64 @"+scalarMangled+"(i64 noundef %0, i64 noundef %1, i32 noundef %2, i32 noundef %3, i64 noundef %4)", + require.Contains(t, scriptIR, "define noundef i64 @"+scalarMangled+"(i64 noundef %0, i64 noundef %1, i64 noundef %2)", "a shared driver must select the ordinary scalar specialization") require.GreaterOrEqual(t, strings.Count(scriptIR, "call i64 @"+scalarMangled+"("), 1, "the shared caller-side loop should invoke the scalar specialization") - require.Contains(t, scriptIR, "call i64 @"+scalarMangled+"(i64 %get, i64 %iter, i32 0, i32 0, i64 %call_seed)", + require.Contains(t, scriptIR, "call i64 @"+scalarMangled+"(i64 %get, i64 %iter, i64 %call_seed)", "the array access and scalar argument should use the same caller-loop iterator") require.NotContains(t, scriptIR, arrayRangeMangled, "arr[i] and i must not become independent callee iterators") diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index 12e732a6..fec17df5 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -95,8 +95,9 @@ Direct lowering for scalar numeric inputs and single scalar outputs. skipped conditional writes and empty-range behavior without making the physical signature depend on the function body - preserve live input/output sharing in both ordinary and range-bearing calls - with one hidden alias selector for every direct scalar input; reads use the - selected output's current value, including writes in the same iteration + 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; the exported signature is unchanged `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 09bab589..f71ffe70 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.1 | **Status:** Draft | **Target:** C11 / C++17 +**Version:** 2.0 | **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. --- @@ -331,10 +330,8 @@ Module: `github.com/user/math`, RelPath: `stats/integral` The native calling convention is selected from the solved parameter and output types: -- `I64` and `F64` parameters are passed directly, with a hidden `i32` alias - selector for each direct parameter after all source parameters, in source - order. Ranges, internal `ArrayRange` descriptors, and other values are - passed indirectly. +- `I64` and `F64` parameters are passed directly. Ranges, internal + `ArrayRange` descriptors, and other values are passed indirectly. - A function with exactly one `I64` or `F64` output returns that scalar directly and receives one hidden seed value. The seed preserves the caller's staged value when the callee does not write its output, including a failed @@ -360,8 +357,8 @@ unconditionally overwrites its output. Schematically, with mangled names abbreviated: ```c -int64_t Pt_Square_I64(int64_t x, int32_t x_output_alias, int64_t seed); -int64_t Pt_ConditionalSquare_I64(int64_t x, int32_t x_output_alias, int64_t seed); +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, @@ -390,28 +387,24 @@ struct Results { bool *wrote1; }; -void Pt_example( - Results *results, - I64 direct_arg, - Other *indirect_arg, - int32_t direct_arg_output_alias -); +void Pt_example(Results *results, I64 direct_arg, Other *indirect_arg); ``` -Every ordinary or range-bearing variant receives one hidden alias selector -for each direct scalar parameter. Zero selects the explicit argument value; -a positive value `k` selects output slot `k - 1`, whose type must match the -parameter. Each input read observes the selected output's current value, so a -write through an output is visible to a later read through an aliased input. -For compatible indirect parameters, the caller instead passes the matching -staged output pointer itself, without another hidden parameter. Both forms -also carry output values into subsequent range iterations. The caller's real -destinations remain unchanged until the surrounding assignment commits. - -Version 2.1 adds these selectors to ordinary variants as well as ranged ones; -C callers must supply zero for inputs that do not alias an output. This changes -the native C signature. 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 variant of the specialization, an internal symbol named +`$alias$$...` with one entry per parameter: `0` for an +unshared input, `k` for an input sharing output slot `k - 1`, whose type must +match the parameter. 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 naturally, and a register scalar is always a plain value. Hidden ABI +fields and private variants are not part of name mangling. An eligible immediate bare `array[range]` call argument may therefore select an `ArrayRange` specialization and run its loop inside the callee. This diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index 6dbe396c..f185c782 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -28,9 +28,9 @@ 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. Direct scalar inputs use hidden alias selectors for ordinary as well -as ranged variants, which changes the native calling convention while keeping -its classification independent of body effects. The canonical description is in +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 +is unchanged and stays independent of body effects. The canonical description is in [the memory model](./Pluto%20Memory%20Model.md) under "Parameters and Outputs". The storage mismatch filed as @@ -151,8 +151,8 @@ does not reject read-only parameters. 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-selector -coverage remains in `TestInputAliasSelectsCompatibleOutput`. The `acc_fmt` +`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. From 8c862a22b30eb2971ed922a6c164002b0f2f3804 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 18:54:29 +0530 Subject: [PATCH 09/12] refactor(compiler): mangle lowering variants with marker suffixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The private alias and output-storage variants were named with `$`, which LLVM accepts but which is not a C identifier character and did not follow the mangling scheme. Name them with the scheme's lowercase-marker-plus- count form, alongside `_fN`, `_tN`, and the reserved `_cN`: `_oN_` lists every output slot's storage type for a widened-storage variant and `_aN_...` carries the per-parameter alias pattern, `_oN` before `_aN` when both apply. MangleVariant builds the suffixes, Demangle parses them back into OutputStorage and AliasPattern and renders them as `-> (StrH, StrH)` and `[in1->out1]`, and the C ABI spec gains §5.2 plus grammar rules for VariantSym. Emitted symbols change; behavior does not. Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 33 ++++---------- compiler/compiler_test.go | 6 +-- compiler/mangle.go | 94 ++++++++++++++++++++++++++++++++++++++- compiler/mangle_test.go | 35 +++++++++++++++ docs/Pluto C ABI Spec.md | 56 ++++++++++++++++++----- 5 files changed, 183 insertions(+), 41 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 68be9fae..037113e6 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -89,9 +89,11 @@ type callArg struct { // 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 - StorageName string // private lowering variant when output slots have wider storage + 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 @@ -3269,23 +3271,11 @@ 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 { - name := sig.Mangled - if sig.StorageName != "" { - name = sig.StorageName - } - if sig.AliasPattern == nil { - return name - } - - name += "$alias" - for _, output := range sig.AliasPattern { - name += fmt.Sprintf("$%d", output) - } - return name + return MangleVariant(sig.Mangled, sig.OutputStorage, sig.AliasPattern) } func (sig *callSignature) isVariant() bool { - return sig.StorageName != "" || sig.AliasPattern != nil + return sig.OutputStorage != nil || sig.AliasPattern != nil } // specializeOutputStorage keeps a writable output and a compatible input on @@ -3307,13 +3297,8 @@ func (c *Compiler) specializeOutputStorage(sig *callSignature, outputs []*Symbol sig.ABI.Return.OutTypes[i] = storage changed = true } - if !changed { - return - } - - sig.StorageName = sig.Mangled + "$outputs" - for _, output := range sig.ABI.Return.OutTypes { - sig.StorageName += "$" + output.Mangle() + if changed { + sig.OutputStorage = slices.Clone(sig.ABI.Return.OutTypes) } } diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index d5431776..90fe1e8d 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -446,7 +446,7 @@ h, r` 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+`$alias$2$0"(`, + 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") @@ -548,9 +548,9 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "Acc", []Type{I64, Range{Iter: I64}}) - require.Contains(t, scriptIR, `define internal noundef i64 @"`+mangled+`$alias$1$0"(`, "the self-aliased range-bearing call lowers to a private variant that keeps the direct scalar return") + 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+`$alias$1$0"(`, "expected direct scalar call/return for ranged accumulator case") + 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/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/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index f71ffe70..6e24b209 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -393,18 +393,15 @@ void Pt_example(Results *results, I64 direct_arg, Other *indirect_arg); 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 variant of the specialization, an internal symbol named -`$alias$$...` with one entry per parameter: `0` for an -unshared input, `k` for an input sharing output slot `k - 1`, whose type must -match the parameter. 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 naturally, and a register scalar is always a plain value. Hidden ABI -fields and private variants are not part of name mangling. +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 naturally, and a register scalar is +always a plain value. An eligible immediate bare `array[range]` call argument may therefore select an `ArrayRange` specialization and run its loop inside the callee. This @@ -433,6 +430,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 @@ -440,6 +468,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 @@ -496,4 +527,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` From ceb8a891cd3d2cea6c3e64a3ea03a68fe6dd2546 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 19:15:30 +0530 Subject: [PATCH 10/12] fix(compiler): keep alias identity through ranged staging and conditionals Two lowerings replaced the name a shared input was recorded under, so a nested call inside them selected the unshared variant. Ranged staging rebinds an aliased input to the staged output slot, which no longer matched paramAlias.Base; `out, seen = Fold(current, (1:3) + 0)` in a wrapper called with a shared input gave `13 11` instead of `13 13`, and a second ranged call in the same body gave `20 16` instead of `20 20`. Conditional lowering targets synthetic `$c_cond_` destinations that never equal the recorded output name; `item > 0 Fold(current, item)` gave `15 10` instead of `15 15`. A parameter may now carry several alias bases, and ranged staging registers the staged slot as one. Conditional temps record the source destination they commit into, and call-site aliasing resolves through that map before comparing names. Fixtures cover both ranged shapes, arrays, and the conditional call taken and skipped. The C ABI spec is versioned to 2.1: master's range-bearing variants carried hidden alias selectors before the seed, and removing them moves the seed, so those prototypes change. Native pointer sharing is scoped to the called body's own statements; a nested Pluto call stages its outputs and does not extend it. Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 55 ++++++++++++++++++++++++-------- compiler/cond.go | 2 ++ docs/Pluto C ABI Spec.md | 29 ++++++++++------- tests/alias_input/self_alias.exp | 5 +++ tests/alias_input/self_alias.pt | 19 +++++++++++ tests/alias_input/self_alias.spt | 15 +++++++++ 6 files changed, 101 insertions(+), 24 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 037113e6..3c4edcec 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -111,7 +111,9 @@ type preparedCall struct { // 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. +// 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 Output string @@ -145,9 +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 + paramAliasStack []map[string][]*paramAlias outputSlotTypes map[string]Type - stmtCtxStack []stmtCtx + // 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 { @@ -190,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{}, } } @@ -222,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 } @@ -230,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() { @@ -249,15 +255,29 @@ func identNames(idents []*ast.Identifier) []string { // 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) { - c.currentParamAliases()[name] = ¶mAlias{Base: sym, Output: output} + aliases := c.currentParamAliases() + aliases[name] = append(aliases[name], ¶mAlias{Base: sym, Output: output}) } func (c *Compiler) paramAliasFor(name string, sym *Symbol) (*paramAlias, bool) { - alias, ok := c.currentParamAliases()[name] - if !ok || alias.Base != sym { - return nil, false + for _, alias := range c.currentParamAliases()[name] { + if alias.Base == sym { + return alias, true + } + } + 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 { @@ -349,7 +369,8 @@ func (c *Compiler) setCallArgAliases(sig *callSignature, args []callArg, dest [] if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { continue } - if output.Value != arg.Name && !c.inputAliasesOutput(arg.Name, output.Value) { + base := c.destinationBase(output.Value) + if base != arg.Name && !c.inputAliasesOutput(arg.Name, base) { continue } if pattern == nil { @@ -2375,7 +2396,9 @@ 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] @@ -2385,8 +2408,9 @@ func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symb continue } seen[name] = struct{}{} - if alias, aliased := c.paramAliasFor(name, sym); aliased && alias.Output == dest[i].Value { + 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 { @@ -2400,6 +2424,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) + } } } } 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/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 6e24b209..2f922849 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 @@ -359,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 @@ -398,10 +393,22 @@ 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 naturally, and a register scalar is -always a plain value. +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 before the seed. Version 2.1 removes those selectors: every variant's +native signature is the source parameters followed by the seed, and aliasing +is lowered as private variants instead. The prototype of a range-bearing +function such as `Acc` therefore changes, and 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 diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 791d5280..544f7b59 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -20,6 +20,11 @@ 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 029c5dbc..8a3f5fa5 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -52,6 +52,25 @@ 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 diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index aa395078..eebfd63e 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -80,6 +80,21 @@ skipped, skippedSeen = ConditionalFold(skipped, -1) 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. From 03d9970d01f38714745c16886a31cd55a8837969 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 19:36:40 +0530 Subject: [PATCH 11/12] docs(abi): state the 2.1 prototype change precisely across the plans Only direct-return functions end in a seed; indirect returns keep their leading result carrier and have none. The effects plan and the ABI optimization plan no longer claim the calling convention is unchanged: range-bearing prototypes change in 2.1, and the generic pointer entry for native callers with unknown sharing is recorded as outstanding. The alias fixture gains the two-level array wrapper from review. Co-Authored-By: Claude Fable 5.1 --- docs/Pluto ABI Optimization Plan.md | 4 +++- docs/Pluto C ABI Spec.md | 13 ++++++++----- docs/Pluto Effects and Follow-up Plan.md | 11 ++++++++++- tests/alias_input/self_alias.exp | 1 + tests/alias_input/self_alias.pt | 5 +++++ tests/alias_input/self_alias.spt | 3 +++ 6 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index fec17df5..e986f414 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -97,7 +97,9 @@ Direct lowering for scalar numeric inputs and single scalar outputs. - 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; the exported signature is unchanged + 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 2f922849..62592cea 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -404,11 +404,14 @@ 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 before the seed. Version 2.1 removes those selectors: every variant's -native signature is the source parameters followed by the seed, and aliasing -is lowered as private variants instead. The prototype of a range-bearing -function such as `Acc` therefore changes, and its seed moves one position -earlier. Functions without a `Range` or `ArrayRange` parameter are unchanged. +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 diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index f185c782..232c222d 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -30,7 +30,16 @@ 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 -is unchanged and stays independent of body effects. The canonical description is in +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 diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 544f7b59..0159b7d7 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -23,6 +23,7 @@ RepeatedWrites: 16 NestedRange: 13 13 NestedRangeTwice: 20 20 NestedArrayTwice: [10 1 2 3 4] [10 1 2 3 4] +NestedArrayDeep: [10 1 2] [10 1 2] ConditionalNested: 15 15 ConditionalNestedSkipped: 10 0 StagedString: helloabc helloabc hello diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 8a3f5fa5..28fb3188 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -66,6 +66,11 @@ out, seen = NestedArrayTwice(current) out, seen = ArrayAfter(current, (1:3) + 0) out, seen = ArrayAfter(current, (3:5) + 0) +# One more wrapper layer: the alias must survive two levels of forwarding +# before the ranged call rebinds it. +out, seen = NestedArrayDeep(current) + out, seen = NestedArrayRange(current) + # 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) diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index eebfd63e..89ceb3f2 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -89,6 +89,9 @@ nestedTwice, nestedTwiceSeen = NestedRangeTwice(nestedTwice) nestedArrayTwice = [10] nestedArrayTwice, nestedArrayTwiceSeen = NestedArrayTwice(nestedArrayTwice) "NestedArrayTwice:", nestedArrayTwice, nestedArrayTwiceSeen +nestedArrayDeep = [10] +nestedArrayDeep, nestedArrayDeepSeen = NestedArrayDeep(nestedArrayDeep) +"NestedArrayDeep:", nestedArrayDeep, nestedArrayDeepSeen condNested = 10 condNested, condNestedSeen = ConditionalNested(condNested, 5) "ConditionalNested:", condNested, condNestedSeen From 3819f656d3de308c60d51c23b1db6110ce4cfd51 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 20:19:28 +0530 Subject: [PATCH 12/12] test(compiler): make the array alias regression bite and retire selector wording NestedArrayRange now stages the ranged call and makes its nested call inside that loop, which is the shape that lost the alias before ceb8a89: it prints `[10 1 2] [10 1]` on 8c862a2 and the expected `[10 1 2] [10 1 2]` on the fix. The previous extra wrapper forwarded the alias before staging and passed on both. The mismatched-sibling IR test now shares the input with its second output so the variant has to skip the incompatible first one, and the unrelated-array test asserts the public specialization is called rather than a retired selector name. A stale comment describing a run-time pointer select is corrected. Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 5 +++-- compiler/compiler_test.go | 20 +++++++++++--------- tests/alias_input/self_alias.exp | 1 - tests/alias_input/self_alias.pt | 12 ++++++------ tests/alias_input/self_alias.spt | 3 --- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 3c4edcec..9e740499 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -2381,8 +2381,9 @@ 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. Input references may -// share it at run time and follow the staged slot through a pointer select. +// 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. diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 90fe1e8d..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) { @@ -481,11 +482,12 @@ 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.NotContains(t, ir, "@arr_i64_copy", "a matching output type must not introduce an input copy on every iteration") - require.NotContains(t, ir, "%data_arg_ref", - "an input with a known-zero alias selector must use its original pointer directly") + 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) { diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 0159b7d7..544f7b59 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -23,7 +23,6 @@ RepeatedWrites: 16 NestedRange: 13 13 NestedRangeTwice: 20 20 NestedArrayTwice: [10 1 2 3 4] [10 1 2 3 4] -NestedArrayDeep: [10 1 2] [10 1 2] ConditionalNested: 15 15 ConditionalNestedSkipped: 10 0 StagedString: helloabc helloabc hello diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 28fb3188..0f2b241a 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -45,8 +45,13 @@ out, seen = NestedFold(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 = ArrayAfter(current, (1:3) + 0) + 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 @@ -66,11 +71,6 @@ out, seen = NestedArrayTwice(current) out, seen = ArrayAfter(current, (1:3) + 0) out, seen = ArrayAfter(current, (3:5) + 0) -# One more wrapper layer: the alias must survive two levels of forwarding -# before the ranged call rebinds it. -out, seen = NestedArrayDeep(current) - out, seen = NestedArrayRange(current) - # 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) diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index 89ceb3f2..eebfd63e 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -89,9 +89,6 @@ nestedTwice, nestedTwiceSeen = NestedRangeTwice(nestedTwice) nestedArrayTwice = [10] nestedArrayTwice, nestedArrayTwiceSeen = NestedArrayTwice(nestedArrayTwice) "NestedArrayTwice:", nestedArrayTwice, nestedArrayTwiceSeen -nestedArrayDeep = [10] -nestedArrayDeep, nestedArrayDeepSeen = NestedArrayDeep(nestedArrayDeep) -"NestedArrayDeep:", nestedArrayDeep, nestedArrayDeepSeen condNested = 10 condNested, condNestedSeen = ConditionalNested(condNested, 5) "ConditionalNested:", condNested, condNestedSeen