Skip to content

Commit bc2ed08

Browse files
committed
Recognize guarded pipelines before AST analysis
1 parent aba3746 commit bc2ed08

8 files changed

Lines changed: 414 additions & 5 deletions

File tree

benchmark/RESULTS.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,56 @@ benchmarks; allocations are reported by Go's benchmark harness.
1313
| Interpreter constant arithmetic | 113.27 ms | 101.99 ms | ~10% |
1414
| Interpreter constant branch | 304.61 ms, ~20.0M allocs | 59.45 ms, 6 allocs | ~5.1× |
1515
| Exact let-go map/filter, interpreter hot path | 40.09 µs, 49.9 KiB, 1,224 allocs | 7.64 µs, 1.12 KiB, 48 allocs | ~5.2× |
16+
| Exact let-go map/filter, interpreter cold load | 81.8 µs, 38.2 KiB, 805 allocs | 16.4 µs, 14.3 KiB, 319 allocs | ~5.0× |
1617

1718
The AOT baselines were captured before primitive float specialization and
1819
integer pipeline fusion. The interpreter comparison uses
1920
`benchmark/interpreter/constant_arithmetic_test.go` immediately before and
2021
after literal `lang.Numbers` folding. The exact let-go rows compare commit
2122
`eaf816e` with the guarded square/filter/take pipeline optimization using the
22-
fixtures committed in the corresponding benchmark directories.
23+
fixtures committed in the corresponding benchmark directories. The cold-load
24+
row additionally includes guarded recognition of the same pure integer
25+
pipeline before general AST analysis.
26+
27+
The seven official workloads from let-go `2be9033` produce these
28+
startup-inclusive interpreter/VM medians. Glojure was built with Go 1.24.0;
29+
let-go's current build requires Go 1.26. Slow recursive workloads used 7
30+
alternating runs per executable and the remaining workloads used 31.
31+
32+
| Workload | Glojure | let-go VM | Glojure / let-go |
33+
| --- | ---: | ---: | ---: |
34+
| fib | 1,240.057 ms | 1,501.110 ms | 0.826 |
35+
| loop/recur | 13.093 ms | 41.364 ms | 0.317 |
36+
| map/filter/take | 4.896 ms | 4.948 ms | 0.989 |
37+
| persistent map | 10.167 ms | 13.078 ms | 0.777 |
38+
| reduce | 6.351 ms | 23.479 ms | 0.270 |
39+
| tak | 1,606.335 ms | 1,611.445 ms | 0.997 |
40+
| transducers | 14.159 ms | 28.297 ms | 0.500 |
41+
42+
The matching all-seven AOT selectors produce these medians. Slow recursive
43+
workloads used 11 alternating runs per executable and the remaining workloads
44+
used 31.
45+
46+
| Workload | Glojure AOT | let-go AOT | Glojure / let-go |
47+
| --- | ---: | ---: | ---: |
48+
| fib | 40.736 ms | 71.840 ms | 0.567 |
49+
| loop/recur | 3.632 ms | 5.268 ms | 0.689 |
50+
| map/filter/take | 3.406 ms | 5.051 ms | 0.674 |
51+
| persistent map | 7.554 ms | 13.205 ms | 0.572 |
52+
| reduce | 4.700 ms | 40.666 ms | 0.116 |
53+
| tak | 55.626 ms | 65.290 ms | 0.852 |
54+
| transducers | 9.758 ms | 26.718 ms | 0.365 |
55+
56+
Stripped binary sizes for the same comparison are:
57+
58+
| Binary | Glojure | let-go | Glojure / let-go |
59+
| --- | ---: | ---: | ---: |
60+
| Standard interpreter/VM | 20.47 MiB | 12.80 MiB | 1.598 |
61+
| All-seven AOT selector | 11.93 MiB | 18.27 MiB | 0.653 |
62+
63+
The standard Glojure CLI includes the full generated Go interop registry and
64+
interactive commands; the focused AOT selector omits those optional
65+
facilities. The AOT selector is 34.7% smaller than let-go's.
2366

2467
The portable programs shared with let-go currently produce these median
2568
wall-clock ratios, where a value below 1.0 favors Glojure:

benchmark/interpreter/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Interpreter optimization benchmarks
22

3-
These Go benchmarks load Glojure source once, outside the timed region, then
4-
measure repeated execution through the interpreter:
3+
These Go benchmarks measure repeated execution through the interpreter. Most
4+
load Glojure source once outside the timed region; benchmarks whose names
5+
start with `Load` include reading, analysis, and execution:
56

67
```sh
78
go test ./benchmark/interpreter -bench . -benchmem -count 5 -benchtime 2s
@@ -19,3 +20,5 @@ surrounding loop.
1920
`let-go-map-filter` and `let-go-tak` reproduce the two official let-go
2021
interpreter workloads that are closest to Glojure. Keeping them in-process
2122
removes executable startup noise while profiling and validating changes.
23+
`BenchmarkLoadLetGoMapFilter` separately exercises the cold source path used
24+
by short-lived CLI programs.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package runtime
2+
3+
import (
4+
"github.com/glojurelang/glojure/pkg/lang"
5+
)
6+
7+
type directInt64ReducePipelinePlan struct {
8+
initial int64
9+
rangeStart int64
10+
rangeEnd int64
11+
rangeStep int64
12+
transforms []ReducePipelineTransformKind
13+
takeLimit int64
14+
}
15+
16+
func (env *environment) evalDirectInt64ReducePipeline(
17+
form interface{},
18+
currentNS *lang.Namespace,
19+
) (result interface{}, ok bool, err error) {
20+
plan, ok := analyzeDirectInt64ReducePipeline(form, currentNS)
21+
if !ok {
22+
return nil, false, nil
23+
}
24+
25+
defer env.recoverDirectInvoke(form, &result, &err)
26+
result = ReduceInt64Pipeline(
27+
plan.initial,
28+
lang.NewLongRange(plan.rangeStart, plan.rangeEnd, plan.rangeStep),
29+
plan.transforms,
30+
plan.takeLimit,
31+
)
32+
return result, true, nil
33+
}
34+
35+
func analyzeDirectInt64ReducePipeline(
36+
form interface{},
37+
currentNS *lang.Namespace,
38+
) (*directInt64ReducePipelinePlan, bool) {
39+
seq, ok := form.(lang.ISeq)
40+
if !ok || seq == nil {
41+
return nil, false
42+
}
43+
operator, ok := seq.First().(*lang.Symbol)
44+
if !ok || !isDefaultCoreSymbol(currentNS, operator, "reduce") {
45+
return nil, false
46+
}
47+
args := directArgs(seq.Next())
48+
if len(args) != 3 {
49+
return nil, false
50+
}
51+
reducer, ok := args[0].(*lang.Symbol)
52+
if !ok || !isDefaultCoreSymbol(currentNS, reducer, "+") {
53+
return nil, false
54+
}
55+
initial, ok := args[1].(int64)
56+
if !ok {
57+
return nil, false
58+
}
59+
60+
start, end, step, transforms, takeLimit, ok :=
61+
analyzeDirectPipelineSource(args[2], currentNS)
62+
if !ok || len(transforms) == 0 {
63+
return nil, false
64+
}
65+
return &directInt64ReducePipelinePlan{
66+
initial: initial,
67+
rangeStart: start,
68+
rangeEnd: end,
69+
rangeStep: step,
70+
transforms: transforms,
71+
takeLimit: takeLimit,
72+
}, true
73+
}
74+
75+
func analyzeDirectPipelineSource(
76+
form interface{},
77+
currentNS *lang.Namespace,
78+
) (
79+
start, end, step int64,
80+
transforms []ReducePipelineTransformKind,
81+
takeLimit int64,
82+
ok bool,
83+
) {
84+
operator, args, ok := directCall(form)
85+
if !ok {
86+
return 0, 0, 0, nil, -1, false
87+
}
88+
if isDefaultCoreSymbol(currentNS, operator, "range") {
89+
start, end, step, ok = directInt64RangeArgs(args)
90+
return start, end, step, nil, -1, ok
91+
}
92+
if len(args) != 2 {
93+
return 0, 0, 0, nil, -1, false
94+
}
95+
96+
if isDefaultCoreSymbol(currentNS, operator, "take") {
97+
limit, constant := args[0].(int64)
98+
if !constant || limit < 0 {
99+
return 0, 0, 0, nil, -1, false
100+
}
101+
start, end, step, transforms, innerLimit, ok :=
102+
analyzeDirectPipelineSource(args[1], currentNS)
103+
if !ok || innerLimit >= 0 {
104+
return 0, 0, 0, nil, -1, false
105+
}
106+
return start, end, step, transforms, limit, true
107+
}
108+
109+
var operatorName string
110+
switch {
111+
case isDefaultCoreSymbol(currentNS, operator, "map"):
112+
operatorName = "map"
113+
case isDefaultCoreSymbol(currentNS, operator, "filter"):
114+
operatorName = "filter"
115+
default:
116+
return 0, 0, 0, nil, -1, false
117+
}
118+
kind, ok := directPipelineTransform(operatorName, args[0], currentNS)
119+
if !ok {
120+
return 0, 0, 0, nil, -1, false
121+
}
122+
123+
start, end, step, transforms, takeLimit, ok =
124+
analyzeDirectPipelineSource(args[1], currentNS)
125+
// An inner take must count values before this outer transform, whereas
126+
// ReduceInt64Pipeline's limit counts the final transformed output.
127+
if !ok || takeLimit >= 0 {
128+
return 0, 0, 0, nil, -1, false
129+
}
130+
transforms = append(transforms, kind)
131+
return start, end, step, transforms, -1, true
132+
}
133+
134+
func directInt64RangeArgs(args []interface{}) (start, end, step int64, ok bool) {
135+
switch len(args) {
136+
case 1:
137+
end, ok = args[0].(int64)
138+
return 0, end, 1, ok
139+
case 2:
140+
start, ok = args[0].(int64)
141+
if !ok {
142+
return 0, 0, 0, false
143+
}
144+
end, ok = args[1].(int64)
145+
return start, end, 1, ok
146+
case 3:
147+
start, ok = args[0].(int64)
148+
if !ok {
149+
return 0, 0, 0, false
150+
}
151+
end, ok = args[1].(int64)
152+
if !ok {
153+
return 0, 0, 0, false
154+
}
155+
step, ok = args[2].(int64)
156+
return start, end, step, ok
157+
default:
158+
return 0, 0, 0, false
159+
}
160+
}
161+
162+
func directPipelineTransform(
163+
operator string,
164+
callback interface{},
165+
currentNS *lang.Namespace,
166+
) (ReducePipelineTransformKind, bool) {
167+
if symbol, ok := callback.(*lang.Symbol); ok {
168+
name := symbol.Name()
169+
if purePipelineCallbacks[operator][name] &&
170+
isDefaultCoreSymbol(currentNS, symbol, name) {
171+
return pipelineTransformKind(operator, name), true
172+
}
173+
}
174+
if operator == "map" && isDirectInt64SquareFn(callback, currentNS) {
175+
return ReducePipelineMapSquare, true
176+
}
177+
return 0, false
178+
}
179+
180+
func isDirectInt64SquareFn(
181+
form interface{},
182+
currentNS *lang.Namespace,
183+
) bool {
184+
operator, args, ok := directCall(form)
185+
if !ok || operator.String() != "fn*" || len(args) != 2 {
186+
return false
187+
}
188+
params, ok := args[0].(lang.IPersistentVector)
189+
if !ok || params.Count() != 1 {
190+
return false
191+
}
192+
param, ok := params.Nth(0).(*lang.Symbol)
193+
if !ok {
194+
return false
195+
}
196+
multiply, bodyArgs, ok := directCall(args[1])
197+
if !ok || len(bodyArgs) != 2 ||
198+
!isDefaultCoreSymbol(currentNS, multiply, "*") {
199+
return false
200+
}
201+
left, leftOK := bodyArgs[0].(*lang.Symbol)
202+
right, rightOK := bodyArgs[1].(*lang.Symbol)
203+
return leftOK && rightOK &&
204+
left.Equals(param) && right.Equals(param)
205+
}
206+
207+
func directCall(form interface{}) (*lang.Symbol, []interface{}, bool) {
208+
seq, ok := form.(lang.ISeq)
209+
if !ok || seq == nil {
210+
return nil, nil, false
211+
}
212+
operator, ok := seq.First().(*lang.Symbol)
213+
if !ok {
214+
return nil, nil, false
215+
}
216+
return operator, directArgs(seq.Next()), true
217+
}
218+
219+
func directArgs(seq lang.ISeq) []interface{} {
220+
args := make([]interface{}, 0, 3)
221+
for ; seq != nil; seq = seq.Next() {
222+
args = append(args, seq.First())
223+
}
224+
return args
225+
}
226+
227+
func isDefaultCoreSymbol(
228+
currentNS *lang.Namespace,
229+
symbol *lang.Symbol,
230+
name string,
231+
) bool {
232+
if symbol.Name() != name {
233+
return false
234+
}
235+
vr := directInvokeVar(currentNS, symbol)
236+
return vr != nil &&
237+
vr.Namespace() == lang.NSCore &&
238+
vr.Symbol().String() == name &&
239+
IsDefaultCoreVar(vr)
240+
}

0 commit comments

Comments
 (0)