Skip to content

[WIP] opt: do not fold !! of non bool - #3560

Open
aleksisch wants to merge 8 commits into
masterfrom
aleksisch/no-folding-for-non-bool
Open

[WIP] opt: do not fold !! of non bool#3560
aleksisch wants to merge 8 commits into
masterfrom
aleksisch/no-folding-for-non-bool

Conversation

@aleksisch

Copy link
Copy Markdown
Collaborator

It's incorrect to fold !!x if x of any other type than bool. For example it can be user provided type.

@aleksisch
aleksisch force-pushed the aleksisch/no-folding-for-non-bool branch 2 times, most recently from 976bcc3 to e886888 Compare July 27, 2026 14:42
It's incorrect to fold !!x if x of any other type than bool.
For example it can be user provided type.
@aleksisch
aleksisch force-pushed the aleksisch/no-folding-for-non-bool branch from e886888 to ee03cc1 Compare July 27, 2026 15:58
aleksisch and others added 3 commits July 27, 2026 19:01
We support it in same way it works in aot.
We can't test it in CI, however das allow das_config to be configured
with various standard libraries and various setups. Some of them
have stronger requirements.
An expression-bodied callee takes the pure-graft path, which handed the body
rewriter an empty rename map on the grounds that "expression bodies declare no
locals". They declare no locals, but a block literal inside one declares
ARGUMENTS, and after the graft those sit in the caller's scope chain. infer
re-resolves every ExprVar by name, so an unrenamed argument captures a
substituted parameter expression that happens to spell the same name:

    def callee(squad_team : int) : int {
        return apply_team() $ [shadowable] (team : int) : int {
            return squad_team * 100 + team
        }
    }
    def caller(team : int) : int { return callee(team) }   // 505, want 705

    // spliced: return (team * 100) + team - both operands are the block's team

The statement path already renames block arguments for exactly this reason
(LocalNameCollect::preVisitBlockArgument); only the graft path skipped it. Run
the same collector there.

Renaming rather than declining to splice matters, because the capture has three
faces and a candidacy gate only reaches one of them:

  * a can_shadow block argument (host query blocks - dagor's ecs marks every
    [es] argument - and daslib/linq_boost's generated blocks) is silently
    wrong, since can_shadow is exactly what switches error 30701 off. dagor hit
    `squad_team != team` emitted as `team != team`
  * an explicit [inline] callee of the same shape is equally wrong, and no
    heuristic gate applies to it - the author opted into splicing, not into
    name capture
  * the same shape WITHOUT can_shadow does not compile at all: error[30701]
    "block argument 'team' is shadowed by function argument 'team'", pointing
    at the callee's block and blaming a caller argument

All three are probe-verified, and the added tests cover them. Renaming also
keeps every splice that a gate would have declined - nested splices re-prefix
correctly, four levels of colliding `team` yielding
__inl0_l___inl0_l___inl0_l___inl0_l_team and still evaluating to 705.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aleksisch
aleksisch force-pushed the aleksisch/no-folding-for-non-bool branch from ee03cc1 to 3e59b84 Compare July 27, 2026 16:05
aleksisch and others added 4 commits July 27, 2026 19:24
The splice substitutes the caller's argument expression for a parameter.
That breaks when the body passes the parameter on to a REF parameter:
the substituted value has no storage, so the inner call stops matching
("can't pass non-ref to ref"), and a program that compiled before the
pass now does not.

Record, per callee parameter, whether it is read as the argument of a ref
parameter, and bind a temp for those instead of substituting - for the
constant case as well as the computed one. A by-value parameter is a copy
in the real call anyway, so the temp is what the un-inlined code did.

Worse than a diagnostic when the substituted value is constant: the
folder then evaluates the native with a value where the C++ signature
wants `const T &` and dereferences storage that was never materialized,
which segfaults the compiler:

    das::FoldingVisitor::evalAndFold -> Context::evalWithCatch -> <native>

The added test covers that shape - a private (so budget-exempt) callee
whose parameter goes to an implicit const-ref native, called with an
expression built from const locals. It crashed the compiler before this
change.

Found in dagor's enlisted: traceray_normalized in shell_fx_common.das
reported as no matching function after get_floor_material_name spliced.
Invoke devirtualization splices the block literal in place and consumes the
holder local's only read. The patch slot runs before lint, and optimize -
which reaps the now-dead `let` - runs after it, so lint saw a variable with
no access flag left and reported it unused:

    def apply_via_call(x : int) : int
      let add_two = $(v : int) : int { return v + 2 }
      return apply_block(add_two, x)    // LINT002: unused variable add_two

At lint time add_two carries only access_init, while a function-pointer local
in the same position carries access_get|access_init|access_pass. That
asymmetry makes the gap look specific to block-typed variables, but it is not
- @@fn simply never devirtualizes. `options disable_auto_inline` compiles the
same file clean, which is what pins this on the splice rather than on passing
a variable as an argument.

The devirtualizer now marks the holder marked_used, the same "a transform ate
the reads" contract the unused-argument checks in ast_lint.cpp already honor,
and daslib/lint.das honors that flag for locals as it already did for
arguments. Optimize still reaps the dead `let`, so there is no codegen cost.

Found in dagor, where the reported variables could not be renamed away
without breaking the build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The folder evaluates a builtin call at compile time when the callee is pure,
returns a foldable type and every argument is constant. That is wrong when
the body is not really there: an AOT compiler build (DAS_AOT_COMPILER)
replaces C++ bound bodies with an asserting stub, so folding such a call runs
the stub and aborts the compiler. dagor hit this on

    rel_hp_float_to_fixed_floor(1.0)

which only became constant after a small wrapper was inlined at a constant
call site.

Add BuiltInFunction::noFolding with a chainable noFold(), and test it at the
four builtin fold gates, so a binding that must not run at compile time can
say so:

    DAS_ADD_FUN_BIND("rel_hp_float_to_fixed_floor", none, ...)->noFold();

Nothing is marked here. Folding stays on by default for every function.

The flag sits on BuiltInFunction because both Function flag words are full,
and builtins are registered by the host in every process, so nothing has to
be serialized. Mark at the binding rather than under DAS_AOT_COMPILER: the
same TU is compiled into both the AOT compiler and the runtime, so both fold
the same way and the AOT hashes keep matching.
register_<Module> is extern "C" so C and D hosts can call it unmangled
between das_initialize_modules() and das_initialize_finalize(). Two side
effects of that linkage broke every C++ host that pulls modules.

The macro can no longer declare the entry point: it expands inside a function
body, a linkage specification is only valid at namespace scope, and declaring
it without one names a different, C++ mangled symbol that nothing defines. So
each host had to add a file scope DECLARE_MODULE per module, boilerplate the
macro already has the name for. REGISTER_MODULE and
REGISTER_MODULE_IN_NAMESPACE now also emit das_pull_<Module>, a C++ linkage
tail call to register_<Module>, and NEED_MODULE declares that instead, which
at block scope is both legal and correct.

The macro also stopped ending in a semicolon, so two bare uses on consecutive
lines spliced into one expression and the second leading * became a
multiplication:

    error: invalid operands to binary expression ('unsigned int' and
           'DasThreadLocal<unsigned int, ...>')

Put the semicolon back. A call site that writes its own gets an empty
statement.

Pulling a module from C++ needs no declaration again:

    void pull_game_das() {
        NEED_MODULE(SomeModule)
    }

The C side is untouched: register_<Module> and jit_register_<Module> keep C
linkage and their unmangled names, so the C API embedding flow works exactly
as documented. DECLARE_MODULE and PULL_MODULE keep working for callers that
would rather declare at file scope.
@aleksisch
aleksisch force-pushed the aleksisch/no-folding-for-non-bool branch from 3e59b84 to dc7a7fe Compare July 27, 2026 16:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant