Skip to content

Commit

Permalink
Merge pull request #65 from jverzani/mystery_alloc
Browse files Browse the repository at this point in the history
* rework how we call things (substitute then call) to avoid some allocations. Speeds things up quite a bit. 

* Update docs to match. 

*Add `map_matched` from CallableExpressions
  • Loading branch information
jverzani authored Jan 8, 2025
2 parents 94c9beb + de4fbda commit a3e4c82
Show file tree
Hide file tree
Showing 12 changed files with 289 additions and 164 deletions.
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "SimpleExpressions"
uuid = "deba94f7-f32a-40ad-b45e-be020a5ded2f"
authors = ["jverzani <[email protected]> and contributors"]
version = "1.1.7"
version = "1.1.8"

[deps]
CallableExpressions = "391672e0-bbe4-4ab4-8bc9-b89a79cbc2f0"
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ The [`@symbolic`](@ref) macro, the lone export, can create a symbolic variable a

The expressions can be evaluated as a univariate function, `u(x)`, a univariate function with parameter, `u(x, p)`, or as a bivariate function, `u(x,y)` (with `y` being a parameter). These are all typical calling patterns when a function is passed to a numeric routine. For expressions without a symbolic value (as can happen through substitution) `u()` will evaluate the value.

To substitute in for either the variable or the parameter, leaving a symbolic expression, we have the calling patterns `u(:,p)`, `u(x,:)` to substitute in for the parameter and variable respectively. The colon can also be `nothing` or `missing`. There are also methods for `replace` that allow more complicated substitutions.
To substitute in for either the variable or the parameter, leaving a symbolic expression, we have the calling patterns `u(:,p)`, `u(x,:)` to substitute in for the parameter and variable respectively. The colon can also be `nothing` or `missing`.

When using positional arguments in a call, as above, all symbolic variables are treated identically, as are all symbolic parameters.

There are also methods for `replace` that allow more complicated substitutions. For `replace`, symbolic objects are returned. For `replace`, variables are distinct and identified by their symbol. Pairs may be specified to the call notation as a convenience for `replace`.

There are no performance claims, this package is all about convenience. Similar convenience is available in some form with `SymPy`, `SymEngine`, `Symbolics`, etc. As well, placeholder syntax is available in `Underscores.jl`, `Chain.jl`, `DataPipes.jl` etc., This package only has value in that it is very lightweight and, hopefully, intuitively simple.

Performance is good though, as `CallableExpressions` is performant. A benchmark case of finding a zero of a function runs without allocations in `0.000003 seconds`, with a symbolic expression in `0.000036` seconds with 275 allocations (one order of magnitude slower), `SymEngine` is two orders of magnitude slower, and SymPy is about four orders slower, as it takes `0.067234` seconds with 82.94 k allocations.
Performance is good though, as `CallableExpressions` is performant. A benchmark case of finding a zero of a function runs without allocations in `1.099 μs` with `0` allocations, with a symbolic expression in `1.231 μs` with `0` allocations, `SymEngine` is two orders of magnitude slower (`302.329 μs` with `1731` allocations), and SymPy is about four orders slower (and with `80k` allocations).

Extensions are provided for `SpecialFunctions`, `AbstractTrees`, `Latexify`, and `RecipesBase`.

Expand Down
92 changes: 56 additions & 36 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,28 @@ This package leverages the [`CallableExpressions`](https://gitlab.com/nsajko/Cal

## Rationale

`Julia` has easy-to-use "anonymous" functions defined through the pattern `(args) -> body` using `->`, notation which mirrors common math notation. However, for students the distinction between an expression, such as defines the "`body`" and a function is sometimes not made, whereas in `Julia` or other computer languages, the distinction is forced. The `SymPy` package, as well as other symbolic packages in `Julia` like `Symbolics` and `SymEngine`, allows symbolic expressions to be created naturally from symbolic variables. This package does just this (and does not provide the many other methods for manipulating symbolic expressions that make using a CAS so powerful). The symbolic expressions subtype `Function`, so can be used where functions are expected.

The envisioned usage is within resource-constrained environments, such as `binder.org`.

To keep things as simple as possible, there are only a few types of symbolic values: symbolic numbers, symbolic variables, symbolic parameters, symbolic expressions, and symbolic equations.

Symbolic variables and parameters are created with the `@symbolic` macro. For the `@symbolic` macro, the first argument names the symbolic variable, the optional second names the symbolic parameter.
`Julia` has easy-to-use "anonymous" functions defined through the
pattern `(args) -> body` using `->`, notation which mirrors common
math notation. However, for students the distinction between an
expression, such as defines the "`body`" and a function is sometimes
not made, whereas in `Julia` or other computer languages, the
distinction is forced. The `SymPy` package, as well as other symbolic
packages in `Julia` like `Symbolics` and `SymEngine`, allows callable
symbolic expressions to be created naturally from symbolic
variables. This package does just this (but does not provide the many
other compelling features of a CAS). The symbolic expressions subtype
`Function`, so can be used where functions are expected.

The envisioned usage is within resource-constrained environments, such
as `binder.org`.

To keep things as simple as possible, there are only a few types of
symbolic values: symbolic numbers, symbolic variables, symbolic
parameters, symbolic expressions, and symbolic equations.

Symbolic variables and parameters are created with the `@symbolic`
macro. For the `@symbolic` macro, the first argument names the
symbolic variable, the optional second argument names the symbolic parameter.

Symbolic expressions are built up naturally by using these two types of objects.

Expand Down Expand Up @@ -47,7 +62,7 @@ f(2)
The main difference being, `u` can subsequently be algebraically manipulated.


The parameter can also be used:
The parameter can also be used to form an expression:

```@example expressions
u = cos(x) - p * x
Expand All @@ -60,49 +75,63 @@ The variable or parameter can be substituted in for:
u(pi/4,:), u(:, 4)
```

Or, the expression can be evaluated directly

```@example expressions
u(pi/4, 4)
```


## Evaluation

The calling pattern for a symbolic expression `ex` is simple: the first positional argument is for the symbolic value, the second for the symbolic parameter. Leading to:
The basic calling pattern for a symbolic expression `ex` is simple: the first positional argument is for the symbolic value, the second for the symbolic parameter.

Leading to these rules:

* `ex(x)` to evaluate the expression of just the variable with the value of `x`; an error is thrown if the expression has both a variable and a parameter.
* `ex(x, p)` to evaluate an expression of both a variable and a parameter; if there is no parameter the value of `p` is ignored.
* `ex(*, p)` to evaluate an expression of just a parameter. The `*` in the `x` slot can be any valid identifier (except for `:`, `nothing`, or `missing`, as they are used for substitution), it is just ignored.
* `ex(x, p)` to evaluate an expression of both a variable and a parameter; if there is no parameter the value of the second argument is ignored.
* `ex(*, p)` to evaluate an expression of just a parameter. The `*` in the `x` slot can be any valid identifier (except for `:`, `nothing`, or `missing`, as they are used for substitution); the value of the first argument is just ignored.
* `ex()` to evaluate an expression that involves neither a symbolic variable or a parameter.

## Substitution

Evaluation leaves a non-symbolic value. For substitution, the result is still symbolic. The syntax for substitution is:
Evaluation leaves a non-symbolic value. For substitution, the result is still symbolic.

The basic syntax for substitution is:

* `ex(:, p)` to substitute in for the parameter.
* `ex(x, :)` to substitute in for the variable.
* `replace(ex, args::Pair...)` to substitute in for either a variable, parameter, expression head, or symbolic expression (possibly with a wildcard). The pairs are specified as `variable_name => replacement_value`.
* `ex(args::Pair...)` redirects to `replace(ex, args::Pair...)`

The use of `:` to indicate the remaining value is borrowed from Julia's array syntax; it can also be either `nothing` or `missing`.

The design of `SimpleExpressions` is to only allow one variable and one parameter in a given expression and to assign these variables to positional arguments. This is just to simplify the usage. The underlying `CallableExpressions` package allows greater flexibility.
For evaluation and substitution using positional arguments, all instances of symbolic variables and all instances of symbolic parameters are treated identically. To work with multiple symbolic parameters or variables, `replace` can be used to substitute in values for a specific variable.

* `replace(ex, args::Pair...)` to substitute in for either a variable, parameter, expression head, or symbolic expression (possibly with a wildcard). The pairs are specified as `variable_name => replacement_value`.
* `ex(args::Pair...)` redirects to `replace(ex, args::Pair...)`

Two or more variables can be used, as here:
To illustrate, two or more variables can be used, as here:

```@example expressions
@symbolic y
@symbolic x
@symbolic y # both symbolic variables
u = x^2 - y^2
```

Evaluating `u` with a value in the `x` position will error. This is a deliberate design limitation; it can be worked around via `replace`:
Evaluating `u` with a value in the `x` position will evaluate both `x` and `y` with that value:

```julia
u(1,2) # ERROR: more than one variable
```@example expressions
u(1) # always 0
u(1,2) # not 1^2 - 2^2, the second argument is ignored here
```

Whereas
As indicated, this is a deliberate design limitation to simplify usage. It can be worked around via `replace`:

```@example expressions
v = replace(u, x=>1, y=>2) # the symbolic value ((1^2)-(2^2))
v() # evaluates to -3
```

The `replace` method is a bit more involved. The `key => value` pairs have different dispatches depending on the value of the key. Above, the key is a `SymbolicVariable`, but the key can be
The `replace` method is a bit more involved than illustrate. The `key => value` pairs have different dispatches depending on the value of the key. Above, the key is a `SymbolicVariable`, but the key can be

* A `SymbolicVariable` or `SymbolicParameter` in which case the simple substitution is applied, as just illustrated.
* A function, like `sin`. In this case, a matching operation head is replaced by the replacement head. Eg. `sin => cos` will replace a `sin` call with a `cos` call.
Expand Down Expand Up @@ -169,7 +198,7 @@ The package grew out of a desire to have a simpler approach to solving `f(x) = g
Symbolic equations are specified using `~`, a notation borrowed from `Symbolics` for `SymPy` and now on loan to `SimpleExpressions`. Of course `=` is assignment, and `==` and `===` are used for comparisons, so some other syntax is necessary and `~` plays the role of distinguishing the left- and right-hand sides of an equation.

By default, when calling a symbolic equation the difference of the left- and right-hand sides is used, so, in this case, symbolic equations can be passed directly to the `find_zero` method from `Roots`:

```@example expressions
using Roots
@symbolic x p
Expand All @@ -188,18 +217,18 @@ solve(cos(x) ~ p*x, (0, pi/2), p=3)
```@example expressions
@symbolic a A
@symbolic b B
solve(sin(A)/a ~ sin(B)/b, A)
solve(sin(A)/a ~ sin(B)/b, A) # solve not exported, but is imported with Roots above
```

This example shows "inverse" functions are applied (without concern for domain/range restrictions) when possible.

### Plotting

For plotting a symbolic equation, `eq`, the values `eq.lhs` and `eq.rhs` may be used separately to produce a pair of traces. With `Plots`, where a vector of functions may be plotted, `plot([eq...], a, b)` will plot each side with separate trace. Though with `Plots` there is a recipe to plot a symbolic equation as two separate functions; it does not plot the difference of the two functions.
For plotting a symbolic equation, `eq`, the values `eq.lhs` and `eq.rhs` may be used separately to produce a pair of traces. With `Plots`, where a vector of functions may be plotted, `plot([eq...], a, b)` will plot each side with separate trace. Though with `Plots` there is a recipe to plot a symbolic equation as two separate functions.

### Derivatives

Symbolic expressions can be easily differentiated, though the operator is not exported. A variable to differentiate by should be specified, though when missing it is assumed there is a lone symbolic variable to differentiate by. The operator differentiates with respect to thevariable assuming it represents a scalar quantity:
Symbolic expressions can be easily differentiated, though the operator is not exported. A variable to differentiate by should be specified, though when missing it is assumed there is a lone symbolic variable to differentiate by. The operator differentiates with respect to the variable assuming it represents a scalar quantity:

```@example expressions
import SimpleExpressions: D
Expand Down Expand Up @@ -228,13 +257,4 @@ u = D(exp(x) * (sin(3x) + sin(101x)), x)

#### Simplification

No simplification is done so the expressions can quickly become unwieldy. There is `TermInterface` support, so--in theory--rewriting of expressions, as is possible with the `Metatheory.jl` package is possible.

The scaffolding is in place for `Metatheory` support once the development version is tagged. With this, the following pattern, say, can factor out `exp(x)`:

```
using Metatheory # need 3.0 to use with TermInterface v"2.0"
r = @rule (~x * ~a + ~x * ~b --> ~x * (~a + ~b))
r(u)
```

No simplification is done so the expressions can quickly become unwieldy. There is `TermInterface` support, so--in theory--rewriting of expressions, as is possible with the `Metatheory.jl` package. The scaffolding is in place, but waits for the development version to be tagged.
2 changes: 1 addition & 1 deletion src/SimpleExpressions.jl
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import TermInterface
import TermInterface: iscall, operation, arguments, sorted_arguments,
maketerm, is_operation, metadata
using CommonEq
export , , Eq, , , , , # \ll, \leqq, \Equal,\lessgtr, \gtrless, \gg,\geqq
# export ≪, ≦, Eq, ⩵, ≶, ≷, ≫, ≧ # \ll, \leqq, \Equal,\lessgtr, \gtrless, \gg,\geqq


export @symbolic
Expand Down
78 changes: 47 additions & 31 deletions src/call.jl
Original file line number Diff line number Diff line change
Expand Up @@ -32,35 +32,59 @@


function (ex::SymbolicExpression)(x)
𝑥,𝑝 = xp(ex)
_call(ex, operation(ex), (𝑥,), x)
_call(ex, operation(ex), x)
end

function (ex::SymbolicExpression)(x,p)
𝑥,𝑝 = xp(ex)
_call(ex, operation(ex), (𝑥,𝑝), x, p)
_call(ex, operation(ex), x, p)
end

# these **assume** no more than one SymbolicVariable or SymbolicParameter
# are in expression. See `replace` for more general substitution
# substitute for 𝑥
function _substitutex(u, x)
pred = x -> isa(x, StaticVariable)
mapping = _ -> DynamicConstant(x)
expression_map_matched(pred, mapping, u)
end

# substitute for 𝑝
function _substitutep(u, p)
pred = p -> isa(p, DynamicVariable)
mapping = _ -> DynamicConstant(p)
expression_map_matched(pred, mapping, u)
end

function _call(ex, ::Any, x::T) where T
# allocates less than creating u((𝑥=x,))
u = (ex)
u₁ = _substitutex(u, x)
return u₁(NamedTuple{}())
end

function _call(ex, ::Any, x::T, p::S) where {T,S}
# allocates less than creating u((𝑥=x,𝑝=p))
u = (ex)
u₁ = _substitutex(u, x)
u₂ = _substitutep(u₁, p)
return u₂(NamedTuple{}())
end

_call(ex, ::Any, 𝑥, x) = ((ex))(NamedTuple{𝑥}((x,)))
_call(ex, ::Any, 𝑥𝑝, x, p) = ((ex))(NamedTuple{𝑥𝑝}((x,p)))

function _call(ex, ::typeof(Base.broadcasted), 𝑥, x)
((ex))(NamedTuple{𝑥}((x,))) |> Base.materialize
function _call(ex, ::typeof(Base.broadcasted), x::T) where T
Base.materialize(_call(ex, nothing, x))
end

function _call(ex, ::typeof(Base.broadcasted), 𝑥𝑝, x, p)
((ex))(NamedTuple{tuple(𝑥𝑝...)}((x,p))) |> Base.materialize
function _call(ex, ::typeof(Base.broadcasted), x::T, p::S) where {T,S}
Base.materialize(_call(ex, nothing, x, p))
end

# directly call with kwargs.
# direct call can be quite more performant but requires
# specification of the variable/parameter name in the call.
(𝑥::SymbolicVariable)(;kwargs...) = ((𝑥))(NamedTuple(kwargs))
(𝑝::SymbolicParameter)(;kwargs...) = ((𝑝))(NamedTuple(kwargs))
## This handles case of symbolic expressions which are numeric
## This also handles case of symbolic expressions which are numeric
## have value given by ex()
(ex::SymbolicExpression)(;kwargs...) = ((ex))(NamedTuple(kwargs))

Expand All @@ -86,27 +110,19 @@ const MISSING = Union{Nothing, Missing, typeof(:)}
(𝑝::SymbolicParameter)(x,::MISSING) = 𝑝
(𝑝::SymbolicParameter)(::Missing,::MISSING) = 𝑝

(ex::SymbolicExpression)(::MISSING, p) = substitutep(ex, p)
(ex::SymbolicExpression)(x,::MISSING) = substitutex(ex, x)
(ex::SymbolicExpression)(::MISSING, ::Missing) = ex
function (ex::SymbolicExpression)(::MISSING, p)
u = (ex)
u₁ = _substitutep(u, p)
SymbolicExpression(u₁)
end
function (ex::SymbolicExpression)(x,::MISSING)
u = (ex)
u₁ = _substitutex(u, x)
SymbolicExpression(u₁)
end

(ex::SymbolicExpression)(::MISSING, ::MISSING) = ex

(X::SymbolicEquation)(::MISSING,p) = tilde(X.lhs(:, p), X.rhs(:, p))
(X::SymbolicEquation)(x,::MISSING) = tilde(X.lhs(x, :), X.rhs(x, :))
(X::SymbolicEquation)(::Missing,::MISSING) = X


# these **assume** no more than one SymbolicVariable or SymbolicParameter
# are in expression. See `replace` for more general substitution
# substitute for x
function substitutex(ex, x)
pred = x -> isa(x, StaticVariable)
mapping = _ -> DynamicConstant(x)
SymbolicExpression(expression_map_matched(pred, mapping, (ex)))
end

# substitute for p
function substitutep(ex, p)
pred = p -> isa(p, DynamicVariable)
mapping = _ -> DynamicConstant(p)
SymbolicExpression(expression_map_matched(pred, mapping, (ex)))
end
Loading

2 comments on commit a3e4c82

@jverzani
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JuliaRegistrator
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registration pull request created: JuliaRegistries/General/122631

Tip: Release Notes

Did you know you can add release notes too? Just add markdown formatted text underneath the comment after the text
"Release notes:" and it will be added to the registry PR, and if TagBot is installed it will also be added to the
release that TagBot creates. i.e.

@JuliaRegistrator register

Release notes:

## Breaking changes

- blah

To add them here just re-invoke and the PR will be updated.

Tagging

After the above pull request is merged, it is recommended that a tag is created on this repository for the registered package version.

This will be done automatically if the Julia TagBot GitHub Action is installed, or can be done manually through the github interface, or via:

git tag -a v1.1.8 -m "<description of version>" a3e4c828ce0719acd632ed9806204e87373eea1e
git push origin v1.1.8

Please sign in to comment.