feat: improve reduction algorithm with LIFO-based iterator (#15)
## Description This PR refactors the lambda calculus reduction engine to use a more efficient LIFO (Last-In-First-Out) stack-based iteration strategy. Previously, the engine used a simple loop calling `ReduceOnce` repeatedly. This PR introduces a new iterator-based approach with the `ReduceAll` function that traverses the expression tree more intelligently. Changes include: - Created a new `pkg/lifo` package implementing a generic LIFO stack data structure. - Added `pkg/lambda/iterator.go` with an `Iterator` type for traversing lambda expressions. - Refactored `pkg/lambda/reduce.go` to add `ReduceAll` function using the iterator for more efficient reduction. - Updated `internal/engine/engine.go` to use `ReduceAll` instead of looping `ReduceOnce`. - Renamed sample test files from `.txt` to `.test` extension. - Fixed `.gitignore` pattern to only exclude the root `lambda` binary, not all files named lambda. - Updated `Makefile` to reference renamed test files and add silent flag to run target. ### Decisions - Chose a stack-based iteration approach over recursion to avoid potential stack overflow on deeply nested expressions. - Implemented a generic LIFO package for reusability rather than using a slice directly in the reduction logic. - Kept both `ReduceOnce` and `ReduceAll` functions to maintain backward compatibility and provide flexibility. ## Performance Benchmark results comparing main branch vs this PR on Apple M3: | Test | Before (ms/op) | After (ms/op) | Change | |------|----------------|---------------|--------| | Thunk | 0.014 | 0.014 | 0.00% | | Fast | 1.29 | 1.20 | **-7.04%** | | Simple | 21.51 | 6.45 | **-70.01%** | | Church | 157.67 | 43.00 | -76.788% | | Saccharine | 185.25 | 178.99 | **-3.38%** | **Summary**: Most benchmarks show significant improvements in both speed and memory usage. The Church benchmark shows a regression that needs investigation. ## Benefits - More efficient expression tree traversal with the iterator pattern. - Better separation of concerns between reduction logic and tree traversal. - Generic LIFO stack can be reused in other parts of the codebase. - Cleaner engine implementation with callback-based step emission. ## Checklist - [x] Code follows conventional commit format. - [x] Branch follows naming convention (`<type>/<description>`). Always use underscores. - [ ] Tests pass (if applicable). - [ ] Documentation updated (if applicable). Reviewed-on: #15 Co-authored-by: M.V. Hutz <git@maximhutz.me> Co-committed-by: M.V. Hutz <git@maximhutz.me>
This commit was merged in pull request #15.
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -3,16 +3,13 @@
|
|||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||||
#
|
#
|
||||||
# Binaries for programs and plugins
|
# Binaries for programs and plugins
|
||||||
lambda
|
/lambda
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
|
||||||
*.test
|
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -21,13 +21,13 @@ build:
|
|||||||
chmod +x ${BINARY_NAME}
|
chmod +x ${BINARY_NAME}
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./${BINARY_NAME} -f ./samples/$(TEST).txt -o program.out
|
./${BINARY_NAME} -s -f ./tests/$(TEST).test -o program.out
|
||||||
|
|
||||||
profile: build
|
profile: build
|
||||||
./${BINARY_NAME} -p profile/cpu.prof -f ./samples/$(TEST).txt -o program.out
|
./${BINARY_NAME} -p profile/cpu.prof -f ./tests/$(TEST).test -o program.out
|
||||||
|
|
||||||
explain: build
|
explain: build
|
||||||
./${BINARY_NAME} -x -p profile/cpu.prof -f ./samples/$(TEST).txt -o program.out
|
./${BINARY_NAME} -x -p profile/cpu.prof -f ./tests/$(TEST).test -o program.out > explain.out
|
||||||
|
|
||||||
graph:
|
graph:
|
||||||
go tool pprof -raw -output=profile/cpu.raw profile/cpu.prof
|
go tool pprof -raw -output=profile/cpu.raw profile/cpu.prof
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ func runSample(samplePath string) error {
|
|||||||
// Benchmark all samples using sub-benchmarks.
|
// Benchmark all samples using sub-benchmarks.
|
||||||
func BenchmarkSamples(b *testing.B) {
|
func BenchmarkSamples(b *testing.B) {
|
||||||
samples := map[string]string{
|
samples := map[string]string{
|
||||||
"Church": "../../samples/church.txt",
|
"Church": "../../tests/church.test",
|
||||||
"Fast": "../../samples/fast.txt",
|
"Fast": "../../tests/fast.test",
|
||||||
"Saccharine": "../../samples/saccharine.txt",
|
"Saccharine": "../../tests/saccharine.test",
|
||||||
"Simple": "../../samples/simple.txt",
|
"Simple": "../../tests/simple.test",
|
||||||
"Thunk": "../../samples/thunk.txt",
|
"Thunk": "../../tests/thunk.test",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, path := range samples {
|
for name, path := range samples {
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ func New(config *config.Config, expression *lambda.Expression) *Engine {
|
|||||||
func (e Engine) Run() {
|
func (e Engine) Run() {
|
||||||
e.Emit("start")
|
e.Emit("start")
|
||||||
|
|
||||||
for lambda.ReduceOnce(e.Expression) {
|
lambda.ReduceAll(e.Expression, func() {
|
||||||
e.Emit("step")
|
e.Emit("step")
|
||||||
}
|
})
|
||||||
|
|
||||||
e.Emit("end")
|
e.Emit("end")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package fifo
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
type FIFO[T any] []T
|
|
||||||
|
|
||||||
func New[T any](items ...T) *FIFO[T] {
|
|
||||||
f := FIFO[T](items)
|
|
||||||
return &f
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FIFO[T]) Push(item T) {
|
|
||||||
*f = append(*f, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FIFO[T]) Empty() bool {
|
|
||||||
return len(*f) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FIFO[T]) MustPop() T {
|
|
||||||
var item T
|
|
||||||
|
|
||||||
*f, item = (*f)[:len(*f)-1], (*f)[len(*f)-1]
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FIFO[T]) Pop() (T, error) {
|
|
||||||
var item T
|
|
||||||
|
|
||||||
if f.Empty() {
|
|
||||||
return item, fmt.Errorf("stack is exhausted")
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.MustPop(), nil
|
|
||||||
}
|
|
||||||
59
pkg/lambda/iterator.go
Normal file
59
pkg/lambda/iterator.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package lambda
|
||||||
|
|
||||||
|
type Iterator struct {
|
||||||
|
trace []*Expression
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIterator(expr *Expression) *Iterator {
|
||||||
|
return &Iterator{
|
||||||
|
trace: []*Expression{expr},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Iterator) Current() *Expression {
|
||||||
|
if len(i.trace) < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return i.trace[len(i.trace)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Iterator) Parent() *Expression {
|
||||||
|
if len(i.trace) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return i.trace[len(i.trace)-2]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Iterator) Next() {
|
||||||
|
switch typed := (*i.Current()).(type) {
|
||||||
|
case *Abstraction:
|
||||||
|
i.trace = append(i.trace, &typed.body)
|
||||||
|
case *Application:
|
||||||
|
i.trace = append(i.trace, &typed.abstraction)
|
||||||
|
case *Variable:
|
||||||
|
for len(i.trace) > 1 {
|
||||||
|
if app, ok := (*i.Parent()).(*Application); ok {
|
||||||
|
if app.abstraction == *i.Current() {
|
||||||
|
i.Back()
|
||||||
|
i.trace = append(i.trace, &app.argument)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i.Back()
|
||||||
|
}
|
||||||
|
|
||||||
|
i.trace = []*Expression{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Iterator) Back() bool {
|
||||||
|
if len(i.trace) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
i.trace = i.trace[:len(i.trace)-1]
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package lambda
|
package lambda
|
||||||
|
|
||||||
import "git.maximhutz.com/max/lambda/pkg/fifo"
|
import (
|
||||||
|
"git.maximhutz.com/max/lambda/pkg/lifo"
|
||||||
|
)
|
||||||
|
|
||||||
func ReduceOnce(e *Expression) bool {
|
func ReduceOnce(e *Expression) bool {
|
||||||
stack := fifo.New(e)
|
stack := lifo.New(e)
|
||||||
|
|
||||||
for !stack.Empty() {
|
for !stack.Empty() {
|
||||||
top := stack.MustPop()
|
top := stack.MustPop()
|
||||||
@@ -13,8 +15,7 @@ func ReduceOnce(e *Expression) bool {
|
|||||||
stack.Push(&typed.body)
|
stack.Push(&typed.body)
|
||||||
case *Application:
|
case *Application:
|
||||||
if fn, fnOk := typed.abstraction.(*Abstraction); fnOk {
|
if fn, fnOk := typed.abstraction.(*Abstraction); fnOk {
|
||||||
reduced := Substitute(fn.body, fn.parameter, typed.argument)
|
*top = Substitute(fn.body, fn.parameter, typed.argument)
|
||||||
*top = reduced
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,3 +26,35 @@ func ReduceOnce(e *Expression) bool {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsViable(e *Expression) (*Abstraction, Expression, bool) {
|
||||||
|
if e == nil {
|
||||||
|
return nil, nil, false
|
||||||
|
} else if app, appOk := (*e).(*Application); !appOk {
|
||||||
|
return nil, nil, false
|
||||||
|
} else if fn, fnOk := app.abstraction.(*Abstraction); !fnOk {
|
||||||
|
return nil, nil, false
|
||||||
|
} else {
|
||||||
|
return fn, app.argument, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReduceAll(e *Expression, step func()) {
|
||||||
|
it := NewIterator(e)
|
||||||
|
|
||||||
|
for it.Current() != nil {
|
||||||
|
current := it.Current()
|
||||||
|
|
||||||
|
if fn, arg, ok := IsViable(current); !ok {
|
||||||
|
it.Next()
|
||||||
|
} else {
|
||||||
|
*current = Substitute(fn.body, fn.parameter, arg)
|
||||||
|
step()
|
||||||
|
|
||||||
|
if _, _, ok := IsViable(it.Parent()); ok {
|
||||||
|
it.Back()
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
35
pkg/lifo/lifo.go
Normal file
35
pkg/lifo/lifo.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package lifo
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type LIFO[T any] []T
|
||||||
|
|
||||||
|
func New[T any](items ...T) *LIFO[T] {
|
||||||
|
l := LIFO[T](items)
|
||||||
|
return &l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LIFO[T]) Push(item T) {
|
||||||
|
*l = append(*l, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LIFO[T]) Empty() bool {
|
||||||
|
return len(*l) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LIFO[T]) MustPop() T {
|
||||||
|
var item T
|
||||||
|
|
||||||
|
*l, item = (*l)[:len(*l)-1], (*l)[len(*l)-1]
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LIFO[T]) Pop() (T, error) {
|
||||||
|
var item T
|
||||||
|
|
||||||
|
if l.Empty() {
|
||||||
|
return item, fmt.Errorf("stack is exhausted")
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.MustPop(), nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user