Skip to content

Always inline trivial calls that always shrink #7669

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from

Conversation

osa1
Copy link
Contributor

@osa1 osa1 commented Jun 19, 2025

Currently a "trivial call" is a function that just calls another function.

(func $foo ...
  (call $target ...))

Where the arguments to $target are all flat instructions like local.get, or
consts.

Currently we inline these functions always only when not optimizing for code
size.

When optimizing for code size, these functions can always be inlined when

  1. The arguments to $target are all function argument locals.
  2. Each local is used once.
  3. In the order they appear in $foo's signature.

When these hold, inlining $foo never increases code size as it doesn't cause
introducing more locals (or drops etc.) at the call sites.

$foo above when these hold looks like this:

(func $foo (param $arg1 ...) (param $arg2 ...)
  (call $target (local.get $arg1) (local.get $arg2)))

Update FunctionInfo type and FunctionInfoScanner to annotate functions with
more detailed "trivial call" information that also contains whether inlining
shrinks code size.

If a function shrinks when inlined always inline it even with -Os.

Otherwise inline it as before, i.e. when not optimizing for code size.

A "trivial call" is a function that just calls another function.

    (func $foo ...
      (call $target ...))

Currently we inline these functions always only when not optimizing for
code size.

When optimizing for code size, these functions can always be inlined
when

1. The arguments to `$target` are all function argument locals.
2. The locals are not used more than once
3. The locals are used in the same order they appear in the function
   arguments.

When these hold, inlining `$foo` never increases code size as it doesn't
cause introducing more locals at call sites.

Improve `FunctionInfo` type and `FunctionInfoScanner` to annotate
functions with "trivial call" information that also contains whether
inlining shrinks code size.

If a function shrinks when inlined always inline it even with `-Os`.

Otherwise inline it as before, i.e. when not optimizing for code size.
@osa1 osa1 changed the title Always inline trivial calls that always shrinks Always inline trivial calls that always shrink Jun 19, 2025
;; CHECK-NEXT: (local.get $17)
;; CHECK-NEXT: )
;; CHECK-NEXT: )
;; CHECK-NEXT: )
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a little bit verbose. Should we run a simple pass to clean this up? (eliminate blocks and locals)

Copy link
Member

@kripken kripken Jun 20, 2025

Choose a reason for hiding this comment

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

We have --inlining-optimizing for that, and that is what the normal pass pipeline uses. --inlining without optimizing is mainly useful for debugging and tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks. --inlining-optimizing simplifies the wat in this test quite a bit. Should I use it in these tests, or do we prefer testing smaller units of code with just --inlining?

@osa1 osa1 marked this pull request as ready for review June 20, 2025 13:59
Comment on lines 70 to 71
// Whether a function just calls another function with only `local.get`s as
// arguments.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Whether a function just calls another function with only `local.get`s as
// arguments.
// Whether a function just calls another function with only trivial
// (`local.get` or constant) arguments.

I think handling constants is important too, and the old code did so (by the heuristic that not having children - that's not precise, but it's probably good enough?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wouldn't that increase code size when you have more than one call site, by duplicating the constants at the call sites?

Where did the old code do this? I'm trying

(module
 (type $0 (func (param i32 i32 i32)))

 (type $1 (func))

 (type $2 (func))

 (import "env" "foo" (func $imported-foo (type $0) (param i32 i32 i32)))

 (func $call-foo (type $1)
  (call $imported-foo
   (i32.const 1)
   (i32.const 2)
   (i32.const 3)))

 (func $main (type $2)
  (call $call-foo)
  (call $call-foo)
  (call $call-foo)))

with wasm-opt -all --inlining -S -o - < test.wat but it doesn't inline call-foo. (trying with main branch)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, I see the old code where we check the size now. However I'm unable to make wasm-opt inline function calls with const arguments, using the main branch. I'll investigate.

I think the point about binary size increase still applies though..

Copy link
Contributor Author

@osa1 osa1 Jun 23, 2025

Choose a reason for hiding this comment

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

I figured it out, it actually works the same way as before. I updated the comments to clarify, and added a test. PTAL.

Copy link
Member

Choose a reason for hiding this comment

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

Unresolving as I don't follow. Are you saying we did not inline constants before, that is, the old comment about us doing so was wrong? If so, where was the bug, as we measured size, and the size of a const is 1, same as local.get?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The old code that made us inline const arguments is still there and running as before, on lines 268-271 on the new file. I added a test to check const argument inlining. It works the same way before and after this PR.

The way it works in new code is that if we know that the code definitely shrinks when inlined, we have an early return. If we don't know that, then the old code that checks the size still runs as before and marks the function as MayNotShrink. In which case we only inline with -O3 as before.

Copy link
Member

Choose a reason for hiding this comment

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

Ok, sounds good. In that case, the comments on new lines 77-83 can be updated to reflect that not only local.gets are allowed.

Copy link
Member

Choose a reason for hiding this comment

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

Oh, and also where this comment is, 70-71.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I updated only the MayNotShrink documentation:

  • The TrivialCall type documentation does not mention the argument types:

    // Whether a function just calls another function in a way that always shrinks
    // when the calling function is inlined.
    

    The argument types (constant or local) depend on the values so they're explained in the documentations of variants.

  • The Shrinks variant is only generated when the arguments are all locals. We don't generate it for constant because constants can be a a few bytes and increase binary sizes. The documentation:

    // Function just calls another function, with `local.get`s as arguments, and
    // with each `local` is used exactly once, and in the order they appear in the
    // argument list.
    //
    // In this case, inlining the function generates smaller code, and it is also
    // good for runtime.
    Shrinks,
    

    This is in sync with the code.

  • The MayNotShrink variant did not mention constants before, I now updated it to mention constants. Current documentation:

    // Function just calls another function, but maybe with constant arguments, or
    // maybe some locals are used more than once. In these cases code size does
    // not always shrink: at the call sites, omitted locals can create `drop`
    // instructions, a local used multiple times can create new locals, and
    // encoding of constants may be larger than just a `local.get` with a small
    // index. In these cases we still want to inline with `-O3`, but the code size
    // may increase when inlined.
    MayNotShrink,
    

PTAL.

@osa1
Copy link
Contributor Author

osa1 commented Jun 23, 2025

@kripken It looks like this revealed an existing undefined behavior in this line:

maskLeft = (1ULL << leftMaxBits) - 1;

Apparently leftMaxBits can be 64, there are a few code paths in getMaxBits that return 64, e.g.

binaryen/src/ir/bits.h

Lines 267 to 270 in 8470f1b

case RotLInt64:
case RotRInt64:
case SubInt64:
return 64;

Is this a blocker? It looks like an existing issue that getMaxBits returns 64, which is used in a left shift.

@tlively
Copy link
Member

tlively commented Jun 24, 2025

I've also been running into that UB (which is not related to any of the PRs it shows up on) and I've fixed it in #7676.

(func $main (type $2)
(call $call-foo)
(call $call-foo)
(call $call-foo)))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@kripken I found a test that checks inlining with -O3 when arguments are all constants:

(func $middle2 (param $x i32) (param $y i32) (param $z i32)
;; Also trivial, even though the order of params is different and we have a
;; const.
(call $top
(local.get $z)
(i32.const 42)
(local.get $x)
)
)

Should I remove this new test?

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.

3 participants