From b829ed499650f8332264d846761562ef6891e49c Mon Sep 17 00:00:00 2001 From: CharlesKWON Date: Sat, 18 Apr 2026 12:44:27 +0900 Subject: [PATCH] perf(gengo): constant-propagate literal-init LOCALs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan each function body for LOCALs whose sole write is a literal initialiser (never ++/-- / += / @byref / MultiAssign target / FOR var / @GET target / macro). Reads substitute the literal inline at emit time, which cascades into all earlier folds: dead IF branches, AND/OR short-circuit, NOT, string-concat reassoc, and the FOR LocalLessEqualInt fast path (extended to see through a propagated ident limit). Walker is bounded — unrecognised AST nodes abort propagation for the whole function rather than risk missing a hidden write. Harbour compat 56/56, FiveSql2 43/43. Co-Authored-By: Claude Opus 4.7 (1M context) --- compiler/gengo/gengo.go | 308 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 306 insertions(+), 2 deletions(-) diff --git a/compiler/gengo/gengo.go b/compiler/gengo/gengo.go index 86d7ceb..99b6f91 100644 --- a/compiler/gengo/gengo.go +++ b/compiler/gengo/gengo.go @@ -46,6 +46,9 @@ type Generator struct { forLabelSeq int // monotonic counter for FOR..NEXT LOOP labels curForLabel string // current FOR loop's LOOP goto label ("" if not in FOR) blockSeq int // monotonic counter for unique closure capture names + // Per-function constant locals: LOCAL names (uppercase) whose sole + // assignment is a literal initializer. Reads get substituted inline. + constLocals map[string]*ast.LiteralExpr } type symbolEntry struct { @@ -387,6 +390,282 @@ func tryFoldBinary(e *ast.BinaryExpr) (*ast.LiteralExpr, bool) { return nil, false } +// collectConstLocals returns a map of LOCAL names (uppercase) whose +// only assignment is a literal initializer — these can be propagated +// inline. Any reassignment, ++/--, += family, @byref, MultiAssignStmt +// target, FOR/FOREACH loop var, or AtGet target disqualifies the name. +// +// The walker is bounded: if it encounters a macro expansion or any +// AST node it doesn't recognise, it aborts and returns an empty map. +// Correctness trumps coverage — an unrecognised node might hide a +// write, so we refuse to propagate. +func collectConstLocals(fn *ast.FuncDecl) map[string]*ast.LiteralExpr { + v := &constLocalVisitor{ + candidates: map[string]*ast.LiteralExpr{}, + } + // Seed candidates from top-level LOCAL decls with literal init. + for _, d := range fn.Decls { + vd, ok := d.(*ast.VarDecl) + if !ok || vd.Scope != ast.ScopeLocal { + continue + } + for _, vi := range vd.Vars { + if vi.Init == nil { + continue + } + if lit, ok := vi.Init.(*ast.LiteralExpr); ok { + v.candidates[strings.ToUpper(vi.Name)] = lit + } + } + } + if len(v.candidates) == 0 { + return nil + } + // Params are writable even without explicit assignment (by-value + // but reassignable) — disqualify any candidate that shadows a param. + // Params come from a separate slot but guard in case of odd decls. + for _, p := range fn.Params { + delete(v.candidates, strings.ToUpper(p.Name)) + } + for _, st := range fn.Body { + v.stmt(st) + if v.aborted { + return nil + } + } + if len(v.candidates) == 0 { + return nil + } + return v.candidates +} + +type constLocalVisitor struct { + candidates map[string]*ast.LiteralExpr + aborted bool +} + +func (v *constLocalVisitor) abort() { + v.aborted = true + v.candidates = nil +} + +func (v *constLocalVisitor) writeIdent(e ast.Expr) { + if id, ok := e.(*ast.IdentExpr); ok { + delete(v.candidates, strings.ToUpper(id.Name)) + } +} + +func (v *constLocalVisitor) writeName(name string) { + delete(v.candidates, strings.ToUpper(name)) +} + +func (v *constLocalVisitor) exprs(es []ast.Expr) { + for _, e := range es { + v.expr(e) + } +} + +func (v *constLocalVisitor) stmts(ss []ast.Stmt) { + for _, s := range ss { + v.stmt(s) + } +} + +func (v *constLocalVisitor) expr(e ast.Expr) { + if v.aborted || e == nil { + return + } + switch x := e.(type) { + case *ast.LiteralExpr, *ast.IdentExpr, *ast.SelfExpr: + // leaf; reads don't disqualify + case *ast.BinaryExpr: + v.expr(x.Left) + v.expr(x.Right) + case *ast.UnaryExpr: + if x.Op == token.INC || x.Op == token.DEC { + v.writeIdent(x.X) + } + v.expr(x.X) + case *ast.PostfixExpr: + v.writeIdent(x.X) + v.expr(x.X) + case *ast.AssignExpr: + // All assign ops (:= += -= *= /= %= ^=) are writes to Left's + // outer ident. Compound assigns also read, but disqualification + // is based on being written at all. + v.writeIdent(x.Left) + // Still walk Left in case of indexing: arr[i] := v — the ident + // arr is read (and we don't want to accidentally treat it as a + // write since writeIdent only triggers on a bare IdentExpr). + if _, isIdent := x.Left.(*ast.IdentExpr); !isIdent { + v.expr(x.Left) + } + v.expr(x.Right) + case *ast.CallExpr: + v.expr(x.Func) + v.exprs(x.Args) + case *ast.DotExpr: + v.expr(x.X) + case *ast.SendExpr: + v.expr(x.Object) + if x.MacroMethod != nil { + v.expr(x.MacroMethod) + } + v.exprs(x.Args) + case *ast.IndexExpr: + v.expr(x.X) + v.expr(x.Index) + case *ast.AliasExpr: + v.expr(x.Alias) + v.expr(x.Field) + case *ast.MacroExpr: + // Macros can expand to any name including writes. Bail. + v.abort() + case *ast.BlockExpr: + v.expr(x.Body) + case *ast.ArrayLitExpr: + v.exprs(x.Items) + case *ast.HashLitExpr: + v.exprs(x.Keys) + v.exprs(x.Values) + case *ast.IIfExpr: + v.expr(x.Cond) + v.expr(x.True) + v.expr(x.False) + case *ast.RefExpr: + // @ident — passes by reference; callee may mutate. + v.writeIdent(x.X) + v.expr(x.X) + case *ast.SliceExpr: + v.expr(x.X) + v.expr(x.Low) + v.expr(x.High) + case *ast.NilSafeExpr: + v.expr(x.X) + case *ast.InterpolatedString: + v.exprs(x.Parts) + default: + v.abort() + } +} + +func (v *constLocalVisitor) stmt(s ast.Stmt) { + if v.aborted || s == nil { + return + } + switch x := s.(type) { + case *ast.ExprStmt: + v.expr(x.X) + case *ast.ReturnStmt: + v.expr(x.Value) + case *ast.QOutStmt: + v.exprs(x.Exprs) + case *ast.IfStmt: + v.expr(x.Cond) + v.stmts(x.Body) + for _, ei := range x.ElseIfs { + v.expr(ei.Cond) + v.stmts(ei.Body) + } + v.stmts(x.ElseBody) + case *ast.DoWhileStmt: + v.expr(x.Cond) + v.stmts(x.Body) + case *ast.ForStmt: + v.writeName(x.Var) + v.expr(x.Start) + v.expr(x.To) + v.expr(x.Step) + v.stmts(x.Body) + case *ast.ForEachStmt: + v.writeName(x.Var) + v.expr(x.Collection) + v.stmts(x.Body) + case *ast.SwitchStmt: + v.expr(x.Expr) + for _, c := range x.Cases { + v.expr(c.Value) + v.stmts(c.Body) + } + v.stmts(x.Otherwise) + case *ast.SeqStmt: + v.stmts(x.Body) + if x.RecoverVar != "" { + v.writeName(x.RecoverVar) + } + v.stmts(x.RecoverBody) + case *ast.MultiAssignStmt: + for _, t := range x.Targets { + v.writeName(t) + } + v.exprs(x.Values) + case *ast.VarDecl: + // Init exprs are reads. The LOCAL name itself was already + // collected as a candidate by collectConstLocals; we don't + // treat its own init as a reassignment. + for _, vi := range x.Vars { + v.expr(vi.Init) + } + case *ast.DeferStmt: + v.expr(x.Call) + case *ast.ExitStmt, *ast.LoopStmt: + // no expression + case *ast.SkipCmd: + v.expr(x.Count) + case *ast.GoCmd: + v.expr(x.RecNo) + case *ast.SeekCmd: + v.expr(x.Key) + case *ast.UseCmd: + v.expr(x.File) + v.expr(x.AliasExpr) + case *ast.SelectCmd: + v.expr(x.Area) + case *ast.ReplaceCmd: + for _, f := range x.Fields { + v.expr(f.Field) + v.expr(f.Value) + } + case *ast.AppendCmd, *ast.DeleteCmd, *ast.ReadCmd: + // no expressions + case *ast.IndexCmd: + v.expr(x.KeyExpr) + v.expr(x.File) + v.expr(x.ForCond) + case *ast.SetCmd: + v.expr(x.Expr) + case *ast.AtSayCmd: + v.expr(x.Row) + v.expr(x.Col) + v.expr(x.SayExpr) + v.expr(x.Picture) + case *ast.AtGetCmd: + // @ GET var writes to Var at READ time. + v.writeIdent(x.Var) + if x.VarName != "" { + v.writeName(x.VarName) + } + v.expr(x.Row) + v.expr(x.Col) + v.expr(x.Picture) + v.expr(x.Valid) + v.expr(x.When) + case *ast.AtSayGetCmd: + v.writeIdent(x.Var) + if x.VarName != "" { + v.writeName(x.VarName) + } + v.expr(x.Row) + v.expr(x.Col) + v.expr(x.SayExpr) + v.expr(x.Picture) + v.expr(x.Valid) + v.expr(x.When) + default: + v.abort() + } +} + // emitSymCache writes the package-level `var _sym_NAME *hbrt.Symbol` // declarations discovered during body emission. Called after all // function bodies are emitted so every PushSymbol call site has had @@ -656,6 +935,9 @@ func (g *Generator) emitFuncDecl(fn *ast.FuncDecl) { // Build local map FIRST (needed for init expressions that reference params) g.curLocals = g.buildLocalMap(fn) + // Scan for LOCALs that are literal-initialised and never reassigned + // so reads can be constant-propagated at emit time. + g.constLocals = collectConstLocals(fn) // Emit LOCAL initializers localIdx := nParams + 1 // 1-based, params come first @@ -682,6 +964,9 @@ func (g *Generator) emitFuncDecl(fn *ast.FuncDecl) { g.writeln("}") g.writeln("") + // Drop per-function constant map so it doesn't leak to siblings. + g.constLocals = nil + // Remove function-scoped STATIC vars from the map so they don't // leak into other functions that happen to share a variable name. for _, d := range fn.Decls { @@ -1647,8 +1932,18 @@ func (g *Generator) emitFor(s *ast.ForStmt, locals localMap) { g.writeln("for {") g.indent++ - // Comparison: fused opcode when limit is literal int (most common) - if lit, ok := s.To.(*ast.LiteralExpr); ok && lit.Kind == token.INT { + // Comparison: fused opcode when limit is literal int (most common). + // Also see through const-propagated LOCALs: `LOCAL n := 100; FOR i := 1 + // TO n` should hit the same fast path as a bare literal. + toLit, _ := s.To.(*ast.LiteralExpr) + if toLit == nil { + if id, ok := s.To.(*ast.IdentExpr); ok { + if l, ok2 := g.constLocals[strings.ToUpper(id.Name)]; ok2 { + toLit = l + } + } + } + if lit := toLit; lit != nil && lit.Kind == token.INT { if isNegStep { g.writeln(fmt.Sprintf("if !t.LocalGreaterEqualInt(%d, %s) { break }", idx, lit.Value)) } else { @@ -2321,6 +2616,15 @@ func (g *Generator) emitIdent(e *ast.IdentExpr) { return } + // Constant-propagate literal-init LOCALs that collectConstLocals + // proved are never reassigned or passed by reference. Emitting the + // literal inline lets downstream folding (dead IF, AND/OR, FOR + // fused ops) fire on what was a variable reference. + if lit, ok := g.constLocals[upper]; ok { + g.emitLiteral(lit) + return + } + if idx, found := g.curLocals[upper]; found { g.writeln(fmt.Sprintf("t.PushLocalFast(%d)", idx)) } else if goVar, found := g.staticVars[upper]; found {