Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "NumExpr"
uuid = "005f7402-6e25-4d9a-960d-a0ddd50a2fba"
version = "1.0.0"
version = "1.1.0"

[compat]
julia = "1.8"
3 changes: 3 additions & 0 deletions benchmark/Project.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[deps]
BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
NumExpr = "005f7402-6e25-4d9a-960d-a0ddd50a2fba"
118 changes: 118 additions & 0 deletions benchmark/benchmarks.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using BenchmarkTools
using NumExpr

const SUITE = BenchmarkGroup()

# ─────────────────────────────────────────────────────────────────────────────
# Shared test data
# ─────────────────────────────────────────────────────────────────────────────

const BENCH_VARS = Dict{String,Float64}(
"a" => 1.5, "b" => 2.3, "c" => 0.7, "d" => 4.1, "e" => 5.9,
"f" => 3.2, "g" => 7.8, "h" => 6.4, "i" => 8.1, "j" => 9.3,
"k" => 2.7, "x" => 1.2,
)

NumExpr.eval_expr(var::NumExpr.Variable) = get(BENCH_VARS, var[], NaN)

const BENCH_EXPRS = [
"constant" => "42",
"simple" => "a + b",
"medium" => "a + b * c - d / e",
"trig" => "sin(x) ^ 2 + cos(x) ^ 2",
"func_chain" => "sqrt(abs(a * b - c))",
"complex" => "sin(a) * cos(b) + exp(c) / log(d + 1) - sqrt(abs(e))",
"large" => "a + b * c + d * e + f * g + h * i + j * k",
]

# ─────────────────────────────────────────────────────────────────────────────
# 1. Parse
# ─────────────────────────────────────────────────────────────────────────────

SUITE["parse"] = BenchmarkGroup(["parsing"])

for (label, expr_str) in BENCH_EXPRS
SUITE["parse"][label] = @benchmarkable parse_expr($expr_str)
end

# ─────────────────────────────────────────────────────────────────────────────
# 2. Compile (parse + compile)
# ─────────────────────────────────────────────────────────────────────────────

SUITE["compile"] = BenchmarkGroup(["compilation"])

for (label, expr_str) in BENCH_EXPRS
SUITE["compile"][label] = @benchmarkable compile_expr($expr_str, VarContext())
end

# ─────────────────────────────────────────────────────────────────────────────
# 3. Eval — tree walk
# ─────────────────────────────────────────────────────────────────────────────

SUITE["eval"] = BenchmarkGroup(["evaluation"])
SUITE["eval"]["tree"] = BenchmarkGroup(["tree-walk"])

for (label, expr_str) in BENCH_EXPRS
node = parse_expr(expr_str)
SUITE["eval"]["tree"][label] = @benchmarkable eval_expr($node)
end

# ─────────────────────────────────────────────────────────────────────────────
# 4. Eval — bytecode VM (pre-allocated stack)
# ─────────────────────────────────────────────────────────────────────────────

SUITE["eval"]["vm"] = BenchmarkGroup(["bytecode", "vm"])

for (label, expr_str) in BENCH_EXPRS
ctx = VarContext()
compiled = compile_expr(expr_str, ctx)
values = zeros(Float64, length(ctx))
for (name, val) in BENCH_VARS
haskey(ctx, name) && (values[ctx[name]] = val)
end
stack = Vector{Float64}(undef, compiled.max_stack)
SUITE["eval"]["vm"][label] = @benchmarkable eval_compiled($compiled, $values, $stack)
end

# ─────────────────────────────────────────────────────────────────────────────
# 5. Eval — bytecode VM (auto stack)
# ─────────────────────────────────────────────────────────────────────────────

SUITE["eval"]["vm_auto"] = BenchmarkGroup(["bytecode", "vm", "auto-stack"])

for (label, expr_str) in BENCH_EXPRS
ctx = VarContext()
compiled = compile_expr(expr_str, ctx)
values = zeros(Float64, length(ctx))
for (name, val) in BENCH_VARS
haskey(ctx, name) && (values[ctx[name]] = val)
end
SUITE["eval"]["vm_auto"][label] = @benchmarkable eval_compiled($compiled, $values)
end

# ─────────────────────────────────────────────────────────────────────────────
# 6. Throughput — bulk evaluation
# ─────────────────────────────────────────────────────────────────────────────

SUITE["throughput"] = BenchmarkGroup(["bulk", "throughput"])

let
ctx = VarContext()
formulas_1k = [compile_expr("$(rand()) + $(rand()) * $(rand())", ctx) for _ in 1:1_000]
formulas_10k = [compile_expr("$(rand()) + $(rand()) * $(rand())", ctx) for _ in 1:10_000]
values = Float64[]
stack_1k = Vector{Float64}(undef, maximum(f.max_stack for f in formulas_1k))
stack_10k = Vector{Float64}(undef, maximum(f.max_stack for f in formulas_10k))

SUITE["throughput"]["1k_formulas"] = @benchmarkable begin
@inbounds for f in $formulas_1k
eval_compiled(f, $values, $stack_1k)
end
end

SUITE["throughput"]["10k_formulas"] = @benchmarkable begin
@inbounds for f in $formulas_10k
eval_compiled(f, $values, $stack_10k)
end
end
end
10 changes: 9 additions & 1 deletion src/NumExpr.jl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ module NumExpr
export parse_expr,
eval_expr,
isglobal_scope,
islocal_scope
islocal_scope,
var_has_tags,
VarContext,
compile_expr,
eval_compiled

#__ exceptions

Expand Down Expand Up @@ -48,5 +52,9 @@ struct LocalScope <: AbstractScope end
include("utils.jl")
include("parser.jl")
include("eval.jl")
include("opcodes.jl")
include("context.jl")
include("compiler.jl")
include("vm.jl")

end
221 changes: 221 additions & 0 deletions src/compiler.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
# compiler

"""
CompiledExpr

Compact bytecode representation of a numeric expression.
Created by [`compile_expr`](@ref) and evaluated by [`eval_compiled`](@ref).

## Fields
- `code::Vector{UInt8}`: Bytecode in postfix (RPN) order.
- `constants::Vector{Float64}`: Constant pool for numeric literals.
- `nvars::Int`: Maximum variable index referenced.
- `max_stack::Int`: Stack depth required for evaluation.
"""
struct CompiledExpr
code::Vector{UInt8}
constants::Vector{Float64}
nvars::Int
max_stack::Int
end

function Base.show(io::IO, expr::CompiledExpr)
print(
io,
"CompiledExpr(",
length(expr.code), " bytes, ",
length(expr.constants), " consts, ",
"stack=", expr.max_stack, ")",
)
end

#__ emitter

mutable struct Emitter
const code::Vector{UInt8}
const constants::Vector{Float64}
const const_index::Dict{Float64,Int}
const ctx::VarContext
nvars::Int
depth::Int
max_depth::Int

Emitter(ctx::VarContext) = new(UInt8[], Float64[], Dict{Float64,Int}(), ctx, 0, 0, 0)
end

function emit!(e::Emitter, op::UInt8)::Nothing
push!(e.code, op)
return nothing
end

function emit_u16!(e::Emitter, val::Int)::Nothing
push!(e.code, UInt8(val & 0xFF))
push!(e.code, UInt8((val >> 8) & 0xFF))
return nothing
end

function push_depth!(e::Emitter)::Nothing
e.depth += 1
e.depth > e.max_depth && (e.max_depth = e.depth)
return nothing
end

function pop_depth!(e::Emitter)::Nothing
e.depth -= 1
return nothing
end

function add_const!(e::Emitter, val::Float64)::Int
idx = get(e.const_index, val, 0)
if idx == 0
push!(e.constants, val)
idx = length(e.constants)
idx > typemax(UInt16) && error("constant pool exceeds UInt16 limit: $idx")
e.const_index[val] = idx
end
return idx
end

#__ compile dispatch

function compile_node!(e::Emitter, val::NumVal{Float64})::Nothing
x = val[]
if isnan(x)
emit!(e, OP_LOAD_NAN)
elseif x === 0.0
emit!(e, OP_LOAD_ZERO)
elseif x === 1.0
emit!(e, OP_LOAD_ONE)
else
emit!(e, OP_LOAD_CONST)
emit_u16!(e, add_const!(e, x))
end
push_depth!(e)
return nothing
end

function compile_node!(e::Emitter, val::NumVal{Bool})::Nothing
emit!(e, val[] ? OP_LOAD_TRUE : OP_LOAD_FALSE)
push_depth!(e)
return nothing
end

function compile_node!(e::Emitter, var::Variable)::Nothing
idx = get_or_create!(e.ctx, var[])
idx > typemax(UInt16) && error("variable index exceeds UInt16 limit: $idx")
idx > e.nvars && (e.nvars = idx)
emit!(e, OP_LOAD_VAR)
emit_u16!(e, idx)
push_depth!(e)
return nothing
end

function compile_node!(::Emitter, ::StrVal)::Nothing
error("String operations are not supported in compiled mode. Use eval_expr instead.")
end

function compile_node!(e::Emitter, node::ExprNode)::Nothing
head = node.head
args = node.args
n = length(args)

# Unary minus
if head isa Arithmetic{:-} && n == 1
compile_node!(e, args[1])
emit!(e, OP_NEG)
return nothing
end

# Unary plus (identity)
if head isa Arithmetic{:+} && n == 1
compile_node!(e, args[1])
return nothing
end

# Functions (unary, binary, ternary)
if head isa AbstractFuncOperator
op = opcode(head)
if op == OP_MEAN
n < 1 && error("mean requires at least 1 argument, got $n")
n > 255 && error("mean supports at most 255 arguments, got $n")
for i in 1:n
compile_node!(e, args[i])
end
emit!(e, op)
push!(e.code, UInt8(n))
for _ in 2:n
pop_depth!(e)
end
return nothing
elseif op == OP_IFELSE
n != 3 && error("ifelse requires exactly 3 arguments, got $n")
compile_node!(e, args[1])
compile_node!(e, args[2])
compile_node!(e, args[3])
emit!(e, op)
pop_depth!(e)
pop_depth!(e)
return nothing
elseif op in (OP_MAX2, OP_MIN2, OP_GET, OP_ROUND, OP_ISLESS, OP_DIV_INT, OP_REM)
n != 2 && error("function $(head) requires exactly 2 arguments, got $n")
compile_node!(e, args[1])
compile_node!(e, args[2])
emit!(e, op)
pop_depth!(e)
return nothing
else
n != 1 && error("compiled mode supports only unary function $(head), got $n arguments")
compile_node!(e, args[1])
emit!(e, op)
return nothing
end
end

# Binary / n-ary operators (left-associative fold)
n < 2 && error("operator $(head) requires at least 2 operands, got $n")
op = opcode(head)
compile_node!(e, args[1])
for i in 2:n
compile_node!(e, args[i])
emit!(e, op)
pop_depth!(e)
end
return nothing
end

#__ public API

"""
compile_expr(node::Union{AbstractExpr,ExprNode}, ctx::VarContext) -> CompiledExpr

Compile a parsed expression tree into compact bytecode.
All formulas compiled with the same `ctx` share variable indices.

"""
function compile_expr(node::Union{AbstractExpr,ExprNode}, ctx::VarContext)::CompiledExpr
e = Emitter(ctx)
compile_node!(e, node)
return CompiledExpr(e.code, e.constants, e.nvars, e.max_depth)
end

"""
compile_expr(str::AbstractString, ctx::VarContext) -> CompiledExpr

Parse and compile a string expression into compact bytecode.

## Examples

```julia-repl
julia> ctx = VarContext()
VarContext(0 variables)

julia> f = compile_expr("a + b * sin(c)", ctx)
CompiledExpr(13 bytes, 0 consts, stack=2)

julia> ctx
VarContext(3 variables)
```
"""
function compile_expr(str::AbstractString, ctx::VarContext)::CompiledExpr
return compile_expr(parse_expr(str), ctx)
end
Loading
Loading