Skip to content
Merged
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
8 changes: 4 additions & 4 deletions benchmarks/sql/LINQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ Notation: `—` means the variant is not applicable for this benchmark (operator

| Benchmark | Shape | m3f_old | m3f (Phase 2A) | Delta |
|---|---|---:|---:|---|
| count_aggregate | `where → count` | 5 | 5 | parity (same counter loop) |
| chained_where | `where → where → count` | 17 | 8 | **2.1× faster** (fuses chained wheres into single `&&` predicate) |
| select_count | `select → count` | 15 | 2 | **7.5× faster** (counter lane evaluates projection per iteration to preserve side effects; optimizer DCEs pure projections, no array materialization) |
| to_array_filter | `where → select → to_array` | 11 | 11 | parity (after `each(<array>)` peel + reserve + workhorse `push`) |
| count_aggregate | `where → count` | 5 | 4 | parity-ish (1ns improvement from `each(<array>)` peel) |
| chained_where | `where → where → count` | 17 | 6 | **2.8× faster** (fuses chained wheres into single `&&` predicate; small gain from peel + const-ref param) |
| select_count | `select → count` | 15 | 0 | ** faster** — when the projection is pure (`has_sideeffects == false`) and the source has length, the counter lane shortcuts to `length(src)` and elides the loop entirely. See [macro_boost::has_sideeffects](../../daslib/macro_boost.das) and `linq_fold.das:plan_loop_or_count` |
| to_array_filter | `where → select → to_array` | 11 | 10 | parity (after `each(<array>)` peel + reserve + workhorse `push`) |

Shapes outside Phase 2A scope now compile to plain linq (`m3f ≈ m3`). This is an intentional regression vs the historical `_old_fold` numbers — Boris's call ("we let it fall through unfolded, and we see performance issues. im ok being slower until we fix") as the forcing function for Phase 2B+. The previous "m3f = m3f_old (identical by construction)" baseline assumed `_fold` would dispatch to `_old_fold` on the unmatched path; Phase 2A drops that dispatch.

Expand Down
96 changes: 76 additions & 20 deletions daslib/linq_fold.das
Original file line number Diff line number Diff line change
Expand Up @@ -534,18 +534,30 @@ def private type_has_length(t : TypeDecl?) : bool {
}

[macro_function]
def private peel_each_length_source(var top : Expression?) : Expression? {
// If `top` is `each(<x>)` and `<x>` has a length-supporting type, return `<x>` so
// the emitted loop iterates the underlying container directly — lets the array-lane
// reserve fire and avoids the iterator wrapper. Iteration semantics are preserved
// (`for (it in each(arr))` and `for (it in arr)` yield the same element refs).
// Restricted to length-supporting types to keep `reserve(length(src))` valid.
def private is_each_call(call : ExprCall?) : bool {
//! `each` in daslib/builtin.das is generic, so the resolved `func.name` on a typed
//! call is the mangled instance name (e.g. `builtin\`each\`30908...`). The generic's
//! original name lives in `func.fromGeneric.name`. Match either.
if (call == null || call.func == null) return false
return (call.func.name == "each"
|| (call.func.fromGeneric != null && call.func.fromGeneric.name == "each"))
}

[macro_function]
def private peel_each(var top : Expression?) : Expression? {
// Unwrap `each(<arr>)` to `<arr>` when `<arr>` is a true array (or fixed-size array).
// Iteration semantics are preserved: `for it in <arr>` implicitly re-wraps via the
// same `each` overload. We gate on array-ness because peeling an iterator-typed
// argument (e.g. `each(range(10))`, `each(generator())`) would put the iterator in
// place — the downstream length shortcut and reserve-by-length hints assume an
// indexable source. Only peel when we can prove that's true.
if (!(top is ExprCall)) return top
var topCall = top as ExprCall
if (topCall.func == null || topCall.func.name != "each"
|| topCall.arguments |> length != 1
|| !type_has_length(topCall.arguments[0]._type)) return top
return clone_expression(topCall.arguments[0])
if (!is_each_call(topCall) || topCall.arguments |> length != 1) return top
let argExpr = topCall.arguments[0]
if ((argExpr == null || argExpr._type == null)
|| (!argExpr._type.isGoodArrayType && !argExpr._type.isArray)) return top
return clone_expression(argExpr)
}

[macro_function]
Expand All @@ -556,7 +568,7 @@ def private plan_loop_or_count(var expr : Expression?) : Expression? {
// with a plain for-loop. Returns null for anything else — caller falls through unfolded.
var (top, calls) = flatten_linq(expr)
if (empty(calls)) return null
top = peel_each_length_source(top)
top = peel_each(top)
let lastName = calls.back()._1.name
if (lastName != "count" && lastName != "where_" && lastName != "select") return null
let counterLane = lastName == "count"
Expand All @@ -569,6 +581,7 @@ def private plan_loop_or_count(var expr : Expression?) : Expression? {
var projection : Expression?
var intermediateBinds : array<Expression?>
var seenSelect = false
var allProjectionsPure = true
var elementType = clone_type(top._type.firstType)
var lastBindName = itName
for (i in 0 .. intermediateCount) {
Expand All @@ -593,6 +606,9 @@ def private plan_loop_or_count(var expr : Expression?) : Expression? {
if (projection != null) {
let prevWorkhorse = projection._type != null && projection._type.isWorkhorseType
if (!prevWorkhorse) return null // chained non-workhorse selects — Phase 2B
if (has_sideeffects(projection)) {
allProjectionsPure = false
}
let bindName = "`v`{at.line}`{at.column}`{length(intermediateBinds)}"
intermediateBinds |> push <| qmacro_expr() {
var $i(bindName) = $e(projection)
Expand All @@ -606,6 +622,26 @@ def private plan_loop_or_count(var expr : Expression?) : Expression? {
return null
}
}
if (projection != null && has_sideeffects(projection)) {
allProjectionsPure = false
}
Comment thread
borisbat marked this conversation as resolved.
// Counter-lane shortcut: when there's no filter and every projection in the chain is
// pure, the count is simply `length(source)`. Skip the loop entirely — no per-element
// increments, no per-element side-effect evaluation. Gated on `type_has_length` so we
// only emit `length(src)` when it's statically resolvable.
if (counterLane && whereCond == null && allProjectionsPure
&& type_has_length(top._type)) {
var topExpr = clone_expression(top)
topExpr.genFlags.alwaysSafe = true
var res = qmacro(invoke($($i(srcName) : typedecl($e(topExpr))) {
return length($i(srcName))
}, $e(topExpr)))
res.force_at(at)
res.force_generated(true)
let blk = (res as ExprInvoke).arguments[0] as ExprMakeBlock
(blk._block as ExprBlock).arguments[0].flags.can_shadow = true
return res
}
Comment thread
borisbat marked this conversation as resolved.
// Build the per-element loop body.
var loopBody : Expression?
if (counterLane) {
Expand All @@ -618,7 +654,10 @@ def private plan_loop_or_count(var expr : Expression?) : Expression? {
for (b in intermediateBinds) {
sideEffectStmts |> push(b)
}
if (projection != null) {
// Bind the final projection only when it might have side effects. Pure projections
// (the common case — `_._field * 2`) can be elided entirely; no need to rely on
// the optimizer to DCE a dead store afterwards.
if (projection != null && has_sideeffects(projection)) {
let finalBindName = "`vfinal`{at.line}`{at.column}"
sideEffectStmts |> push <| qmacro_expr() {
var $i(finalBindName) = $e(projection)
Expand Down Expand Up @@ -713,14 +752,31 @@ def private plan_loop_or_count(var expr : Expression?) : Expression? {
var topExpr = clone_expression(top)
topExpr.genFlags.alwaysSafe = true
var res : Expression?
// Pick the block-parameter typedecl modifier by source shape:
// - iterator (rvalue, e.g. `each(range(10))`) — strip `-const` so the body can
// consume the iterator. Without the strip, daslang's typer reports
// "can't iterate over const iterator".
// - container with length (array/table/string/range/fixed-array) — keep modifiers
// so a `const&` source (e.g. `let arr <-`) matches the param exactly.
let topIsIter = top._type != null && top._type.isIterator
if (counterLane) {
res = qmacro(invoke($($i(srcName) : typedecl($e(topExpr)) - const) {
var $i(accName) = 0
for ($i(itName) in $i(srcName)) {
$e(loopBody)
}
return $i(accName)
}, $e(topExpr)))
if (topIsIter) {
res = qmacro(invoke($($i(srcName) : typedecl($e(topExpr)) - const) {
var $i(accName) = 0
for ($i(itName) in $i(srcName)) {
$e(loopBody)
}
return $i(accName)
}, $e(topExpr)))
} else {
res = qmacro(invoke($($i(srcName) : typedecl($e(topExpr))) {
var $i(accName) = 0
for ($i(itName) in $i(srcName)) {
$e(loopBody)
}
return $i(accName)
}, $e(topExpr)))
}
} else {
let isIter = expr._type.isIterator
// Pre-reserve the accumulator to the source's length when the source has a known
Expand All @@ -736,7 +792,7 @@ def private plan_loop_or_count(var expr : Expression?) : Expression? {
return <- $i(accName).to_sequence_move()
}, $e(topExpr)))
} elif (sourceHasLength) {
res = qmacro(invoke($($i(srcName) : typedecl($e(topExpr)) - const) {
res = qmacro(invoke($($i(srcName) : typedecl($e(topExpr))) {
var $i(accName) : array<$t(elementType)>
$i(accName) |> reserve(length($i(srcName)))
for ($i(itName) in $i(srcName)) {
Expand Down
163 changes: 163 additions & 0 deletions daslib/macro_boost.das
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,166 @@ def public collect_labels(expr : ExpressionPtr) {
return <- res
}

[macro_function]
def public has_sideeffects(expr : Expression?) : bool {
//! Conservative side-effect detection. Returns true when the expression has — or
//! might have — side effects. Returns false ONLY when provably pure (no function
//! calls, no heap allocation, no container mutation).
//!
//! Intended for macro-time elision of discardable evaluations.
//! Callers treat false as a promise; true is the safe default — when in doubt, true.
// null / compiler-tagged-pure / variable reads / constant literals — leaf, safe.
if (expr == null || expr.flags.noSideEffects
|| expr is ExprVar
|| expr is ExprConstInt || expr is ExprConstInt8 || expr is ExprConstInt16
|| expr is ExprConstInt64 || expr is ExprConstUInt || expr is ExprConstUInt8
|| expr is ExprConstUInt16 || expr is ExprConstUInt64 || expr is ExprConstFloat
|| expr is ExprConstDouble || expr is ExprConstBool || expr is ExprConstString
|| expr is ExprConstPtr || expr is ExprConstRange || expr is ExprConstURange
|| expr is ExprConstRange64 || expr is ExprConstURange64
|| expr is ExprConstEnumeration || expr is ExprConstBitfield) return false
// Member access — recurse into operand.
if (expr is ExprField) return has_sideeffects((expr as ExprField).value)
if (expr is ExprSafeField) return has_sideeffects((expr as ExprSafeField).value)
if (expr is ExprSwizzle) return has_sideeffects((expr as ExprSwizzle).value)
// Pointer / reference artifacts.
if (expr is ExprRef2Value) return has_sideeffects((expr as ExprRef2Value).subexpr)
if (expr is ExprRef2Ptr) return has_sideeffects((expr as ExprRef2Ptr).subexpr)
if (expr is ExprPtr2Ref) return has_sideeffects((expr as ExprPtr2Ref).subexpr)
if (expr is ExprAddr) return false
Comment thread
borisbat marked this conversation as resolved.
// Type / variant checks.
if (expr is ExprIs) return has_sideeffects((expr as ExprIs).subexpr)
if (expr is ExprIsVariant) return has_sideeffects((expr as ExprIsVariant).value)
if (expr is ExprAsVariant) return has_sideeffects((expr as ExprAsVariant).value)
if (expr is ExprSafeAsVariant) return has_sideeffects((expr as ExprSafeAsVariant).value)
// Cast — recurse.
if (expr is ExprCast) return has_sideeffects((expr as ExprCast).subexpr)
// Compile-time meta.
if (expr is ExprTypeInfo || expr is ExprTypeDecl || expr is ExprTag) return false
// Subscripts.
if (expr is ExprAt) {
let at_e = expr as ExprAt
// tables auto-insert on missing key — unsafe; arrays/strings safe (read-only).
if (at_e.subexpr == null || at_e.subexpr._type == null
|| at_e.subexpr._type.isGoodTableType) return true
return has_sideeffects(at_e.subexpr) || has_sideeffects(at_e.index)
}
if (expr is ExprSafeAt) {
let sat = expr as ExprSafeAt
return has_sideeffects(sat.subexpr) || has_sideeffects(sat.index)
}
// Null coalescing.
if (expr is ExprNullCoalescing) {
let nc = expr as ExprNullCoalescing
return has_sideeffects(nc.subexpr) || has_sideeffects(nc.defaultValue)
}
// String builder — string heap allocation is no-op by compiler; recurse into operands.
if (expr is ExprStringBuilder) {
let sb = expr as ExprStringBuilder
for (e in sb.elements) {
if (has_sideeffects(e)) return true
}
return false
}
// key_exists is a pure container read.
if (expr is ExprKeyExists) {
let ke = expr as ExprKeyExists
for (a in ke.arguments) {
if (has_sideeffects(a)) return true
}
return false
}
// Function-call-shaped expressions: ExprCall (regular call) and ExprOp1/ExprOp2/ExprOp3
// (operators, which also resolve to a function). Two-layer check:
//
// 1. Mutation ops (`++`, `--`, `+=`, `-=`, …) are unconditionally unsafe —
// blacklisted up front, regardless of how the resolved builtin happens to be
// flagged. Catches builtins that the C++ side forgot to mark with
// `knownSideEffects`/`unsafeOperation`.
// 2. Trust `func.flags` when `func != null` — covers user-defined operator
// overloads (e.g. `struct Foo { def operator +(...) }`), which fall through
// `func_has_sideeffects` as non-builtin → unsafe. Fall back to the op-name
// allowlist only when `func == null` (typer left it unresolved, e.g. after
// partial constant folding). `/` and `%` stay UNSAFE (div-by-zero panic;
// design decision).
//
// `is`/`as` on handled types is EXACT-rtti (see CLAUDE.md), so each shape needs its
// own branch — can't cast ExprOp2 to ExprCallFunc even though the C++ class inherits.
if (expr is ExprOp1) {
let e1 = expr as ExprOp1
// func != null → trust func flags (catches user overloads); func == null → fall
// back to op-name allowlist (handles partial-folding artifacts). Mutation ops
// are unconditionally unsafe (in case a C++ builtin missed the side-effect flag).
if (is_mutation_op1(e1.op)
|| (e1.func != null && func_has_sideeffects(e1.func))
|| (e1.func == null && !is_safe_op1(e1.op))) return true
return has_sideeffects(e1.subexpr)
}
if (expr is ExprOp2) {
let e2 = expr as ExprOp2
if (is_mutation_op2(e2.op) || e2.op == "/" || e2.op == "%"
|| (e2.func != null && func_has_sideeffects(e2.func))
|| (e2.func == null && !is_safe_op2(e2.op))) return true
return has_sideeffects(e2.left) || has_sideeffects(e2.right)
}
if (expr is ExprOp3) {
let e3 = expr as ExprOp3
// ExprOp3 is the only ternary `?:` in daslang — pure if operands pure.
return has_sideeffects(e3.subexpr) || has_sideeffects(e3.left) || has_sideeffects(e3.right)
}
if (expr is ExprCall) {
let ec = expr as ExprCall
if (func_has_sideeffects(ec.func)) return true
for (a in ec.arguments) {
if (has_sideeffects(a)) return true
}
return false
}
// Default: unknown → unsafe.
return true
}

[macro_function]
def private func_has_sideeffects(f : Function?) : bool {
//! True when calling `f` may have side effects. Allowlists builtins
//! (`flags.builtIn`) without `knownSideEffects` or `unsafeOperation`.
return (f == null || !f.flags.builtIn
|| f.flags.knownSideEffects || f.flags.unsafeOperation)
}
Comment thread
borisbat marked this conversation as resolved.

[macro_function]
def private is_safe_op1(op : das_string) : bool {
//! Unary operators that are pure on workhorse types — no overflow trap, no mutation.
//! Excludes `++` / `--` (handled by is_mutation_op1).
return op == "-" || op == "!" || op == "~" || op == "+"
Comment thread
borisbat marked this conversation as resolved.
}

[macro_function]
def private is_safe_op2(op : das_string) : bool {
//! Binary operators that are pure on workhorse types. Excludes `/`, `%` (div-by-zero
//! panic — design decision) and all compound-assignment ops (handled by is_mutation_op2).
return (op == "+" || op == "-" || op == "*"
|| op == "==" || op == "!=" || op == "<" || op == "<=" || op == ">" || op == ">="
|| op == "&" || op == "|" || op == "^" || op == "<<" || op == ">>"
|| op == "&&" || op == "||")
}

[macro_function]
def private is_mutation_op1(op : das_string) : bool {
//! Unary operators that mutate their operand. Unconditionally unsafe — bypasses any
//! flag check on the resolved builtin (in case the C++ side forgot to mark it).
//! `++` / `--` are prefix; `+++` / `---` are the daslang AST op-strings for postfix
//! increment/decrement (the trailing-plus / trailing-minus naming).
return op == "++" || op == "--" || op == "+++" || op == "---"
}

[macro_function]
def private is_mutation_op2(op : das_string) : bool {
//! Compound-assignment operators (mutate the left operand). Same unconditional-unsafe
//! treatment as is_mutation_op1.
return (op == "+=" || op == "-=" || op == "*=" || op == "/=" || op == "%="
|| op == "&=" || op == "|=" || op == "^="
|| op == "<<=" || op == ">>="
|| op == "&&=" || op == "||=" || op == "^^=")
}

Loading
Loading