Skip to content

Commit fc843fb

Browse files
committed
more improvements
1 parent 1d8e0e2 commit fc843fb

6 files changed

Lines changed: 184 additions & 69 deletions

File tree

benchmark/baseline.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

docs/pages.jl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
pages = ["Home" => "index.md", "API" => "API.md"]
1+
pages = [
2+
"Home" => "index.md",
3+
"API" => "API.md",
4+
"Developer" => ["Symbolic Rewriting" => "dev/symbolic_rewriting.md"],
5+
]

docs/src/dev/symbolic_rewriting.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Symbolic Rewriting: Design Decisions
2+
3+
This document records what was tried when evaluating SymbolicUtils.jl's `@rule`-based
4+
rewriting system as a replacement for QuestBase's manual tree traversal.
5+
6+
## What We Tried
7+
8+
We attempted to replace the manual `isadd`/`ismul`/`isdiv` dispatch in four functions
9+
with `@rule` / `@acrule` + `Postwalk`:
10+
11+
### `expand_exp_power`
12+
13+
```julia
14+
# Before: manual recursion (14 μs)
15+
function expand_exp_power(expr::BasicSymbolic)
16+
if isadd(expr)
17+
sum(expand_exp_power(arg) for arg in arguments(expr))
18+
elseif ismul(expr)
19+
prod(expand_exp_power(arg) for arg in arguments(expr))
20+
elseif ispow(expr) && isexp(arguments(expr)[1])
21+
exp(arguments(arguments(expr)[1])[1] * arguments(expr)[2])
22+
else
23+
expr
24+
end
25+
end
26+
27+
# After: single rule + Postwalk (28 μs, 1.9x slower)
28+
const _expand_exp_power_rw = Postwalk(PassThrough(@rule exp(~x)^~n => exp(~x * ~n)))
29+
expand_exp_power(expr::BasicSymbolic) = _expand_exp_power_rw(expr)
30+
```
31+
32+
### `simplify_exp_products`
33+
34+
```julia
35+
# Before: manual findall + index manipulation (10 μs)
36+
# (see src/Symbolics/exponentials.jl)
37+
38+
# After: @acrule + Fixpoint + Postwalk (71 μs, 6.8x slower)
39+
const _simplify_exp_products_rw = Fixpoint(
40+
Postwalk(
41+
PassThrough(
42+
Chain((
43+
@acrule(exp(~a) * exp(~b) => exp(~a + ~b)),
44+
@rule(exp(~x)^~n => exp(~x * ~n)),
45+
@rule(exp(~x::is_literal_number) => begin
46+
iszero(unwrap_const(~x)) ? 1 : exp(~x)
47+
end),
48+
)),
49+
),
50+
),
51+
)
52+
```
53+
54+
The `Fixpoint` is needed because combining two exps can create a new `exp(x)^2` (via
55+
hash-consing auto-simplification), which then needs the power rule, which may enable
56+
further combining.
57+
58+
### `expand_fraction`
59+
60+
```julia
61+
# Before: manual _apply_termwise dispatch (25 μs)
62+
63+
# After: single rule + Postwalk (37 μs, 1.5x slower)
64+
const _expand_fraction_rw = Postwalk(
65+
PassThrough(@rule (+(~~xs)) / ~c => sum(x / ~c for x in ~~xs))
66+
)
67+
```
68+
69+
### `exp_to_trig`
70+
71+
```julia
72+
# Before: manual _apply_termwise + inline conversion (0.01 μs on no-op input)
73+
74+
# After: Postwalk with custom node function (27 μs on no-op input, 2500x slower)
75+
exp_to_trig(x::BasicSymbolic) = Postwalk(PassThrough(_exp_to_trig_node))(x)
76+
```
77+
78+
The extreme regression on no-op input (expression with no `exp` nodes) happens because
79+
`Postwalk` visits every node in the tree. The manual code sees `isadd` at the top level,
80+
recurses into children, sees they're all `cos`/`sin` terms (not `exp`), and returns
81+
immediately.
82+
83+
## Why It's Slower
84+
85+
For QuestBase's typical expressions (5-50 nodes):
86+
87+
1. **`Postwalk` visits every node** — even when only a few nodes match. Manual code
88+
skips irrelevant subtrees via `_apply_termwise`.
89+
2. **Pattern matching overhead**`@rule` builds closures with continuation-passing
90+
style and `ImmutableDict` bindings. For a simple `ispow && isexp` check, this is
91+
much heavier than two boolean tests.
92+
3. **`Fixpoint` re-traverses** — When rules interact, `Fixpoint(Postwalk(...))` walks
93+
the full tree again. Manual code handles interactions in a single pass.
94+
4. **`@acrule` tries permutations** — For `exp(a)*exp(b)`, it tries all argument
95+
orderings up to `COMM_CHECKS_LIMIT` (default 10).
96+
97+
The rule system is designed for Symbolics.jl's simplifier, which applies hundreds of
98+
rules to large expression trees. The overhead is amortized there but dominates for
99+
QuestBase's small, targeted transformations.
100+
101+
`Postwalk` is still used where it works well: `expand_all` and `add_div`, where the
102+
rewriter needs to visit all nodes anyway.

src/Symbolics/Symbolics_utils.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ function expand_fraction(x::BasicSymbolic)
1414
_apply_termwise(expand_fraction, x)
1515
elseif isdiv(x)
1616
args = arguments(x)
17-
sum([arg / args[2] for arg in arguments(args[1])])
17+
sum(arg / args[2] for arg in arguments(args[1]))
1818
else
1919
x
2020
end

src/Symbolics/exponentials.jl

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,54 @@
1-
expand_exp_power(expr::Num) = expand_exp_power(unwrap(expr))
2-
simplify_exp_products(x::Num) = simplify_exp_products(unwrap(x))
3-
41
"Returns true if expr is an exponential"
52
isexp(expr) = isterm(expr) && operation(expr) === exp
63

7-
"Expand powers of exponential such that exp(x)^n => exp(x*n) "
4+
"Expand powers of exponential such that exp(x)^n => exp(x*n)"
85
function expand_exp_power(expr::BasicSymbolic)
96
if isadd(expr)
107
sum(expand_exp_power(arg) for arg in arguments(expr))
118
elseif ismul(expr)
129
prod(expand_exp_power(arg) for arg in arguments(expr))
10+
elseif ispow(expr) && isexp(arguments(expr)[1])
11+
exp(arguments(arguments(expr)[1])[1] * arguments(expr)[2])
1312
else
14-
if ispow(expr) && isexp(arguments(expr)[1])
15-
exp(arguments(arguments(expr)[1])[1] * arguments(expr)[2])
16-
else
17-
expr
18-
end
13+
expr
1914
end
2015
end
16+
expand_exp_power(expr::Num) = expand_exp_power(unwrap(expr))
2117
expand_exp_power(expr) = expr
2218

23-
"Simplify products of exponentials such that exp(a)*exp(b) => exp(a+b)
24-
This is included in SymbolicUtils as of 17.0 but the method here avoid other simplify calls"
19+
"Simplify products of exponentials such that exp(a)*exp(b) => exp(a+b).
20+
Also expands exp(x)^n => exp(x*n) and simplifies exp(0) => 1."
2521
function simplify_exp_products(expr::BasicSymbolic)
2622
if isadd(expr)
2723
_apply_termwise(simplify_exp_products, expr)
2824
elseif isdiv(expr)
2925
_apply_termwise(simplify_exp_products, expr)
3026
elseif ismul(expr)
31-
simplify_exp_products_mul(expr)
27+
_simplify_exp_products_mul(expr)
28+
elseif ispow(expr) && isexp(arguments(expr)[1])
29+
# exp(x)^n => exp(x*n) — fixes bug where exp powers were left unexpanded
30+
exp(arguments(arguments(expr)[1])[1] * arguments(expr)[2])
3231
else
3332
expr
3433
end
3534
end
35+
36+
function _simplify_exp_products_mul(expr)
37+
args = arguments(expr)
38+
ind = findall(isexp, args)
39+
rest_ind = setdiff(1:length(args), ind)
40+
rest = isempty(rest_ind) ? 1 : prod(args[rest_ind])
41+
total = isempty(ind) ? 0 : sum(arguments(args[i])[1] for i in ind)
42+
if is_literal_number(total)
43+
iszero(unwrap_const(total)) && return rest
44+
end
45+
return isempty(ind) ? rest : rest * exp(total)
46+
end
47+
48+
simplify_exp_products(x::Num) = simplify_exp_products(unwrap(x))
3649
function simplify_exp_products(x::Complex{Num})
3750
return Complex{Num}(
3851
simplify_exp_products(unwrap(x.re)), simplify_exp_products(unwrap(x.im))
3952
)
4053
end
41-
function simplify_exp_products_mul(expr)
42-
ind = findall(x -> isexp(x), arguments(expr))
43-
rest_ind = setdiff(1:length(arguments(expr)), ind)
44-
rest = isempty(rest_ind) ? 1 : prod(arguments(expr)[rest_ind])
45-
total = isempty(ind) ? 0 : sum(getindex.(arguments.(arguments(expr)[ind]), 1))
46-
if is_literal_number(total)
47-
(iszero(unwrap_const(total)) && return rest)
48-
else
49-
return rest * exp(total)
50-
end
51-
end
5254
simplify_exp_products(x) = x

src/Symbolics/fourier.jl

Lines changed: 50 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,54 @@ function trig_to_exp(x::BasicSymbolic)
191191
return Symbolics.substitute(x, Dict(rules))
192192
end
193193

194+
"Convert a single exp(arg) node to trig form with sign normalization.
195+
Returns `nothing` if the node is not an exp call (for use with PassThrough)."
196+
function _exp_to_trig_node(x::BasicSymbolic)
197+
!(isterm(x) && operation(x) === exp) && return nothing
198+
199+
arg = first(arguments(x))
200+
201+
# exp(0) = 1: handle literal zero argument
202+
arg_val = arg isa BasicSymbolic ? unwrap_const(arg) : arg
203+
if arg_val isa Number && iszero(arg_val)
204+
return 1
205+
end
206+
207+
trigarg = Symbolics.expand(-im * arg) # the argument of the to-be trig function
208+
trigarg = simplify_complex(trigarg)
209+
210+
# After expansion and simplification, check if trigarg is zero (exp(0) = 1)
211+
ta_val = trigarg isa BasicSymbolic ? unwrap_const(trigarg) : trigarg
212+
if ta_val isa Number && iszero(ta_val)
213+
return 1
214+
end
215+
216+
# put arguments of trigs into a standard form such that sin(x) = -sin(-x), cos(x) = cos(-x) are recognized
217+
if isadd(trigarg)
218+
first_symbol = minimum(
219+
cat(
220+
string.(sorted_arguments(trigarg)),
221+
string.(sorted_arguments(-trigarg));
222+
dims=1,
223+
),
224+
)
225+
226+
# put trigarg => -trigarg the lowest alphabetic argument of trigarg is lower than that of -trigarg
227+
# this is a meaningless key but gives unique signs to all sums
228+
is_first = minimum(string.(sorted_arguments(trigarg))) == first_symbol
229+
return if is_first
230+
cos(-trigarg) - im * sin(-trigarg)
231+
else
232+
cos(trigarg) + im * sin(trigarg)
233+
end
234+
end
235+
return if ismul(trigarg) && _has_negative_coefficient(trigarg)
236+
cos(-trigarg) - im * sin(-trigarg)
237+
else
238+
cos(trigarg) + im * sin(trigarg)
239+
end
240+
end
241+
194242
"""
195243
exp_to_trig(x::BasicSymbolic)
196244
exp_to_trig(x)
@@ -210,51 +258,9 @@ trigonometric arguments for consistent simplification.
210258
function exp_to_trig(x::BasicSymbolic)
211259
if isadd(x) || isdiv(x) || ismul(x)
212260
return _apply_termwise(exp_to_trig, x)
213-
elseif isterm(x) && operation(x) === exp
214-
arg = first(arguments(x))
215-
216-
# exp(0) = 1: handle literal zero argument
217-
arg_val = arg isa BasicSymbolic ? unwrap_const(arg) : arg
218-
if arg_val isa Number && iszero(arg_val)
219-
return 1
220-
end
221-
222-
trigarg = Symbolics.expand(-im * arg) # the argument of the to-be trig function
223-
trigarg = simplify_complex(trigarg)
224-
225-
# After expansion and simplification, check if trigarg is zero (exp(0) = 1)
226-
ta_val = trigarg isa BasicSymbolic ? unwrap_const(trigarg) : trigarg
227-
if ta_val isa Number && iszero(ta_val)
228-
return 1
229-
end
230-
231-
# put arguments of trigs into a standard form such that sin(x) = -sin(-x), cos(x) = cos(-x) are recognized
232-
if isadd(trigarg)
233-
first_symbol = minimum(
234-
cat(
235-
string.(sorted_arguments(trigarg)),
236-
string.(sorted_arguments(-trigarg));
237-
dims=1,
238-
),
239-
)
240-
241-
# put trigarg => -trigarg the lowest alphabetic argument of trigarg is lower than that of -trigarg
242-
# this is a meaningless key but gives unique signs to all sums
243-
is_first = minimum(string.(sorted_arguments(trigarg))) == first_symbol
244-
return if is_first
245-
cos(-trigarg) - im * sin(-trigarg)
246-
else
247-
cos(trigarg) + im * sin(trigarg)
248-
end
249-
end
250-
return if ismul(trigarg) && _has_negative_coefficient(trigarg)
251-
cos(-trigarg) - im * sin(-trigarg)
252-
else
253-
cos(trigarg) + im * sin(trigarg)
254-
end
255-
else
256-
return x
257261
end
262+
result = _exp_to_trig_node(x)
263+
return isnothing(result) ? x : result
258264
end
259265

260266
"Check if a Mul expression has a negative leading coefficient"

0 commit comments

Comments
 (0)