perf(gengo): constant-propagate literal-init LOCALs

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 12:44:27 +09:00
parent 7e4079f845
commit b829ed4996

View File

@@ -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 {