Skip to content

[IRBuilder] Hoist input gets across none exprs - #9338

Open
tlively wants to merge 3 commits into
ir-builder-tunnel-paramsfrom
ir-builder-hoist-input-gets
Open

tlively wants to merge 3 commits into
ir-builder-tunnel-paramsfrom
ir-builder-hoist-input-gets

Conversation

@tlively

@tlively tlively commented Nov 1, 2026

Copy link
Copy Markdown
Member

Input scratch locals are never modified while their initial local.get expressions remain on the expression stack, so hoistLastValue can move them directly across subsequent none-typed expressions without allocating a new scratch local or creating a synthetic block. Track whether a local is an input local of the current scope via ScopeCtx::isInputLocal and use the same check when tunneling inputs in pushScope.

Note that optimizing input parameters in IRBuilder cannot disturb tests that expect the parsed IR to closely mirror the input WAT, because Binaryen IR does not have control flow input parameters and such tests therefore do not use them.

Input scratch locals are never modified while their initial `local.get` expressions remain on the expression stack, so `hoistLastValue` can move them directly across subsequent `none`-typed expressions without allocating a new scratch local or creating a synthetic block. Track whether a local is an input local of the current scope via `ScopeCtx::isInputLocal` and use the same check when tunneling inputs in `pushScope`.

Note that optimizing input parameters in `IRBuilder` cannot disturb tests that expect the parsed IR to closely mirror the input WAT, because Binaryen IR does not have control flow input parameters and such tests therefore do not use them.
@tlively
tlively requested a review from a team as a code owner November 1, 2026 07:30
@tlively
tlively requested review from kripken and removed request for a team November 1, 2026 07:30

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request optimizes WebAssembly IR building by hoisting input scratch locals directly across none-typed expressions, avoiding unnecessary scratch local allocations. It also simplifies the scope-pushing logic by checking if a local is an input local directly. The review feedback suggests replacing std::find in the header with a range-based loop to avoid an <algorithm> dependency, and guarding against potential out-of-bounds indexing on scope.inputType when processing inputs.

Comment thread src/wasm-ir-builder.h
Comment on lines +514 to +517
bool isInputLocal(Index local) const {
return std::find(inputLocals.begin(), inputLocals.end(), local) !=
inputLocals.end();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using std::find in the header file introduces a dependency on <algorithm>. Since inputLocals is typically a very small vector, we can replace it with a simple range-based for loop to avoid this dependency and keep the header self-contained.

    bool isInputLocal(Index local) const {
      for (auto inputLocal : inputLocals) {
        if (inputLocal == local) {
          return true;
        }
      }
      return false;
    }

Comment on lines +933 to 941
auto* get = expr->dynCast<LocalGet>();
bool isInputGet = get && get->type == scope.inputType[i] &&
getScope().isInputLocal(get->index);
bool isPop = isUnnamedBlock && inputs.size() == 1 && expr->is<Pop>();
if (isInputGet) {
assert(expr->cast<LocalGet>()->index == parentLocals[parentIndex]);
scope.inputLocals.push_back(parentLocals[parentIndex]);
scope.inputLocals.push_back(get->index);
} else if (!isPop) {
auto scratch = addScratchLocal(scope.inputType[i]);
CHECK_ERR(scratch);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When hasInput is false, scope.inputType is Type::none (which has a size of 0). If inputs is populated (e.g., in the isUnnamedBlock and Pop case), accessing scope.inputType[i] results in an out-of-bounds indexing on Type. Although this is currently short-circuited because get is nullptr for Pop, it is extremely fragile. We should safely guard the indexing by checking the bounds of scope.inputType.

      auto* get = expr->dynCast<LocalGet>();
      Type type = i < scope.inputType.size() ? scope.inputType[i] : Type::none;
      bool isInputGet = get && get->type == type &&
                        getScope().isInputLocal(get->index);
      bool isPop = isUnnamedBlock && inputs.size() == 1 && expr->is<Pop>();
      if (isInputGet) {
        scope.inputLocals.push_back(get->index);
      } else if (!isPop) {
        auto scratch = addScratchLocal(type);
        CHECK_ERR(scratch);

get && getScope().isInputLocal(get->index)) {
// Input scratch locals are never modified while their initial `local.get`s
// remain on the stack, so we can hoist them directly across subsequent
// none-typed expressions without allocating a new scratch local.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

  1. Perhaps it makes sense to document the invariant depended on here in wasm-ir-builder.h?
  2. This invariant seems right to me, but I asked Gemini to verify, and it came up with... this:
(module
  (func (export "test") (result i32)
    i32.const 10
    block (param i32) (result i32)
      i32.const 99
      local.set 0 ;; (or `local.set $scratch` in WAT) — accepted & passes validation!
    end
  )
)
$ bin/wasm-opt a.wat --print
(module
 (type $0 (func (result i32)))
 (export "test" (func $0))
 (func $0 (result i32)
  (local $scratch i32)
  (local $scratch_1 i32)
  (local.set $scratch
   (i32.const 10)
  )
  (block (result i32)
   (local.set $scratch_1
    (local.get $scratch)
   )
   (local.set $scratch
    (i32.const 99)
   )
   (local.get $scratch_1)
  )
 )
)

This should not even validate (seems like a bug) but it manages to write to a scratch local in an unexpected way. Not to the live local, but still, this looks concerning...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Oh yeah, we shouldn't allow accessing scratch locals (introduced for any reason) by index like that. But fixing that mostly-unrelated bug will restore this invariant.

  1. Perhaps it makes sense to document the invariant depended on here in wasm-ir-builder.h?

This is all internal implementation details of the control flow input lowering, so I'm not sure why the header would be the right location for a comment.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fair enough about a comment. Otherwise lgtm, but I think we need to fix that bug before this lands, as the fuzzer should be able to error until then.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't think the fuzzer would be affected. It should never be parsing modules that incorrectly access scratch locals via out-of-bounds local indices.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm, I guess it would take a text-level fuzzer, yeah, good point. Needs to be an invalid module.

This branch has not been deployed

No deployments
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.

2 participants